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.
This commit is contained in:
jonathanwxh-cell committed 2026-11-12 21:36:03 +08:00
1 parent 87d8deaced
commit c2355b0b0e
3 files changed
+68 -13

No files matched your search

+19 -8
View File
@@ -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)
+26 -5
View File
@@ -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<NumberFormat>,
/// `Y`: changes along the y axis, in its own units, as read.
pub y: Option<Vec<NumberFormat>>,
/// `CYX`: y-axis units in one x-axis unit, as read with `y` (1 without it).
pub cyx: f64,
/// `D`: distances.
pub distance: Vec<NumberFormat>,
/// `A`: areas.
pub area: Vec<NumberFormat>,
}
@@ -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::<NumberFormat>());
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"));
}
+23
View File
@@ -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));
}