From 87d8deaced5542bd29bbe3fb5077177ce11450f5 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 12 Nov 2026 21:28:38 +0800 Subject: [PATCH 1/3] fix(measure): read compound and fractional number formats (ISO 32000-2 12.9.2) Scale::read refused number format arrays of more than one unit (feet and inches) and the /F, /T formats, so measuring inside an Acrobat-calibrated viewport failed and such saved measurements were listed as unsupported. Read them into Scale::formats, label readings with them (whole units, then the rest as decimals, a fraction, rounded or cut off; a last unit that rounds up carries) and write them back when a measurement is added with that scale. Single-unit decimal scales read and label exactly as before. --- crates/measure/README.md | 2 +- crates/measure/src/lib.rs | 103 ++++-- crates/measure/src/number_format.rs | 510 ++++++++++++++++++++++++++++ crates/measure/src/tests.rs | 51 ++- 4 files changed, 628 insertions(+), 38 deletions(-) create mode 100644 crates/measure/src/number_format.rs diff --git a/crates/measure/README.md b/crates/measure/README.md index 2e438d7a1e..292fcc4a3c 100644 --- a/crates/measure/README.md +++ b/crates/measure/README.md @@ -10,6 +10,6 @@ The desktop Measure objects panel exposes all three tools, live values and coord Snapping interprets painted paths and nested Form XObjects, including graphics-state transforms. Cubic paths are flattened to a maximum deviation of 0.2 user units. Raster edges, glyph outlines, clipping and optional-content visibility are not interpreted. Extraction stops at 8,000,000 segments, 24,000,000 endpoint and midpoint targets, 512 MiB of decoded content, 4,096 streams, 32 nested forms or 256 nested `q`; intersection searches use at most 256 nearby segments. Extraction is lenient: a content stream that can't be decoded is skipped and counted, and the rest of the page still snaps. The UI and automation report these limits. -Imported single-unit decimal rectilinear scales support differing X and Y conversion factors when CYX is present. Unit labels are text strings and may be non-ASCII ("m²"). Compound units (such as feet and inches), fraction formats and non-rectilinear scales are not supported: `list` reports such measurements, and ones with invalid geometry, under `unsupported` with a reason instead of failing, and leaves their PDF data untouched. A polygon whose last vertex repeats the first is treated as closed. Interoperability with Acrobat-authored measurements is untested. Geospatial and 3D measuring are outside this component. +Imported single-unit decimal rectilinear scales support differing X and Y conversion factors when CYX is present. Unit labels are text strings and may be non-ASCII ("m²"). Imported compound units (such as feet and inches, up to 8 units), fractions and values cut off to whole units (ISO 32000-2 §12.9.2 number format arrays) are read too: readings are labelled with them (`3 ft 6 1/2 in`, a last unit that rounds up carrying into the one before) and a measurement added with such a scale writes them back. Authoring them, differing coordinate and distance units, and non-rectilinear scales are not supported: `list` reports such measurements, and ones with invalid geometry, under `unsupported` with a reason instead of failing, and leaves their PDF data untouched. A polygon whose last vertex repeats the first is treated as closed. Interoperability with Acrobat-authored measurements is untested. Geospatial and 3D measuring are outside this component. Synthetic tests cover geometry, calibration, viewport precedence, coordinate transforms, saved annotations and captions, snapping targets, cyclic forms, malformed arguments, permissions, undo, save/reopen, rendering and spreadsheet-safe CSV. No sample PDF or font assets are added. diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index bd60a192c1..d8bfda587d 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -3,7 +3,9 @@ //! annotations use the measurement intents of line, polygon and polyline annotations (§12.5.6). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod number_format; pub mod snap; +use number_format::{Formats, NumberFormat}; use pdfcraft_annot::{Meta, NewAnnotation, Shape, Style}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; use serde::Serialize; @@ -69,6 +71,13 @@ pub struct Scale { pub area_unit: String, pub precision: u8, pub ratio: String, + /// The number format arrays of an imported scale whose units are compound (feet and inches) + /// or whose values show as fractions or cut off to whole units (ISO 32000-2 §12.9.2): + /// readings are labelled with them, and a measurement added with this scale writes them + /// back. `unit`, `area_unit` and the factors are their first units'. `None` for single-unit + /// decimal scales, which `unit`, `area_unit` and `precision` describe. + #[serde(skip_serializing_if = "Option::is_none")] + pub formats: Option, } impl Default for Scale { fn default() -> Self { @@ -81,6 +90,7 @@ impl Default for Scale { area_unit: "in^2".into(), precision: 2, ratio: "1 in = 1 in".into(), + formats: None, } } } @@ -95,6 +105,7 @@ impl Scale { area_unit: format!("{unit}^2"), precision, ratio: format!("1 pt = {units_per_user_unit} {unit}"), + formats: None, }; out.validate()?; Ok(out) @@ -142,6 +153,9 @@ impl Scale { if self.ratio.len() > 256 || self.ratio.chars().any(char::is_control) { return Err(invalid("invalid scale ratio")); } + if let Some(formats) = &self.formats { + formats.validate().map_err(invalid)?; + } Ok(()) } pub fn dictionary(&self) -> Result { @@ -159,10 +173,25 @@ impl Scale { d.set(b"SS".to_vec(), PdfString::text("")); Object::Array(vec![Object::Dict(d)]) }; + // An imported array is written as read, its first unit given this scale's label and factor. + let imported = |formats: &[NumberFormat], unit: &str, factor: f64| { + let first = formats.first().map(|f| NumberFormat { unit: unit.into(), factor, ..f.clone() }); + Object::Array(first.iter().chain(formats.iter().skip(1)).map(NumberFormat::dictionary).collect()) + }; let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Measure")); d.set(b"Subtype".to_vec(), Object::name("RL")); 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)); + } + 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)); + return Ok(d); + } d.set(b"X".to_vec(), format(&self.unit, self.x)); if self.x != self.y { d.set(b"Y".to_vec(), format(&self.unit, self.y)); @@ -172,50 +201,51 @@ impl Scale { d.set(b"A".to_vec(), format(&self.area_unit, self.area_factor)); Ok(d) } - /// Single-unit decimal scales are editable. Compound/fractional and non-rectilinear - /// imports are refused explicitly; their dictionaries and appearance remain untouched. + /// Single-unit decimal scales are editable. Compound units (feet and inches), fractions and + /// values cut off to whole units read too, keeping their number format arrays in + /// [`Scale::formats`] (ISO 32000-2 §12.9.2). Non-rectilinear scales and formats that can't be + /// shown are refused explicitly; their dictionaries and appearance remain untouched. pub fn read(doc: &Document, object: &Object) -> Result { let o = doc.resolve(object); let d = o.as_dict().ok_or_else(|| invalid("invalid measure dictionary"))?; if d.get(b"Subtype").map(|o| doc.resolve(o)).is_some_and(|o| !matches!(*o, Object::Null) && o.as_name() != Some(b"RL")) { return Err(invalid("only rectilinear measurement scales are supported")); } - let format = |key: &[u8]| -> Result<(String, f64, u8)> { - let a = doc.resolve(d.get(key).ok_or_else(|| invalid("measurement scale lacks a number format"))?); - let a = a.as_array().ok_or_else(|| invalid("invalid measurement number format"))?; - if a.len() != 1 { - return Err(invalid("compound measurement units are not supported yet")); + let format = |key: &[u8]| -> Result> { + let a = d.get(key).ok_or_else(|| invalid("measurement scale lacks a number format"))?; + number_format::read(doc, a).map_err(invalid) + }; + // The unit and factor a number format array converts to first. + let first = |a: &[NumberFormat]| a.first().map(|f| (f.unit.clone(), f.factor)).ok_or_else(|| invalid("empty number format")); + 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 { + 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 o = doc.resolve(a.first().ok_or_else(|| invalid("empty number format"))?); - let f = o.as_dict().ok_or_else(|| invalid("invalid number format dictionary"))?; - let fmt = f.get(b"F").map(|o| doc.resolve(o)); - let precision = match fmt.as_deref().and_then(Object::as_name) { - Some(b"R") => 0, - None | Some(b"D") => { - let denom = f.get(b"D").and_then(|o| doc.resolve(o).as_int()).unwrap_or(100); - (0..=6).find(|p| 10_i64.pow(*p) == denom).ok_or_else(|| invalid("unsupported measurement precision"))? - } - _ => return Err(invalid("fractional measurement formats are not supported yet")), - }; - Ok(( - text(doc, f, b"U"), - f.get(b"C").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("missing conversion factor"))?, - precision as u8, - )) + None => x, }; - let (unit, x, _) = format(b"X")?; - let y = if d.contains(b"Y") { - let (_, y, _) = format(b"Y")?; - y * d.get(b"CYX").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))? - } else { - x - }; - let (distance_unit, distance_factor, precision) = format(b"D")?; + let distances = format(b"D")?; + let (distance_unit, distance_factor) = first(&distances)?; if unit != distance_unit { return Err(invalid("different coordinate and distance units are not supported yet")); } - let (area_unit, area_factor, _) = format(b"A")?; - let out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(doc, d, b"R") }; + let areas = format(b"A")?; + let (area_unit, area_factor) = first(&areas)?; + let plain = [&xs, &distances, &areas].into_iter().chain(ys.as_ref()).all(|a| number_format::plain_precision(a).is_some()); + // The decimals a distance shows: of its only unit, or of its last one when compound. + let precision = number_format::plain_precision(&distances) + .or_else(|| { + distances + .last() + .filter(|f| f.remainder == number_format::Remainder::Decimal) + .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 out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(doc, d, b"R"), formats }; out.validate()?; Ok(out) } @@ -319,10 +349,13 @@ pub fn reading(kind: Kind, points: &[Point], scale: &Scale) -> Result { length *= scale.distance_factor; let value = if kind == Kind::Area { area } else { length }; let unit = if kind == Kind::Area { scale.area_unit.clone() } else { scale.unit.clone() }; - let label = format!("{value:.precision$} {unit}", precision = usize::from(scale.precision)); if !value.is_finite() { return Err(invalid("measurement overflows its scale")); } + let label = match &scale.formats { + Some(f) => number_format::format(value, if kind == Kind::Area { &f.area } else { &f.distance }), + None => format!("{value:.precision$} {unit}", precision = usize::from(scale.precision)), + }; Ok(Reading { kind, value, unit, label, delta_x: delta[0], delta_y: delta[1], angle: delta[1].atan2(delta[0]).to_degrees(), length, area }) } #[derive(Clone, Debug, PartialEq)] @@ -479,7 +512,7 @@ const MAX_LISTED_BYTES: usize = 32 << 20; /// The bytes a listed measurement keeps in points and text. fn retained(m: &Measurement) -> usize { let s = &m.scale; - [m.id.len(), m.author.len(), m.label.len(), s.unit.len(), s.area_unit.len(), s.ratio.len()] + [m.id.len(), m.author.len(), m.label.len(), s.unit.len(), s.area_unit.len(), s.ratio.len(), s.formats.as_ref().map_or(0, Formats::held_bytes)] .into_iter() .fold(m.points.len().saturating_mul(std::mem::size_of::()), usize::saturating_add) } diff --git a/crates/measure/src/number_format.rs b/crates/measure/src/number_format.rs new file mode 100644 index 0000000000..a4396a9717 --- /dev/null +++ b/crates/measure/src/number_format.rs @@ -0,0 +1,510 @@ +//! Number format arrays (ISO 32000-2 §12.9.2, number format dictionaries). +//! +//! A rectilinear measure dictionary's `X`, `Y`, `D` and `A` entries are arrays of number format +//! dictionaries. The first element's `C` converts to the largest unit; each later element's `C` +//! is how many of its (smaller) unit make one of the previous element's. A value shows as whole +//! units of every element but the last, then what is left in the last unit, shown as its `F` +//! says: decimals to `1/D` (`/D`), a fraction in `D`ths (`/F`), or whole units rounded (`/R`) or +//! cut off (`/T`). With miles, feet, and inches in eighths, 1.4505 mi shows as +//! "1 mi 2,378 ft 7 5/8 in". + +use pdfcraft_cos::{Dict, Document, Object, PdfString}; +use serde::Serialize; + +/// Most elements one number format array is read with (miles, yards, feet and inches are four). +pub const MAX_UNITS: usize = 8; +/// Longest unit label or separator, in characters, as for a scale's own unit labels. +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. +const NUDGE: f64 = 1e-6; + +/// How the part of the last unit below a whole one shows (`F`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Remainder { + /// `/D`: decimals, to `1 / denominator`. + Decimal, + /// `/F`: a fraction in `denominator`ths. + Fraction, + /// `/R`: rounded to whole units. + Round, + /// `/T`: cut off to whole units. + Truncate, +} + +/// One number format dictionary. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct NumberFormat { + /// `U`: the unit's label. + pub unit: String, + /// `C`: this unit in one of the previous element's unit (or, for the first element, in what + /// the measure dictionary's entry converts from). + pub factor: f64, + /// `F`. + pub remainder: Remainder, + /// `D`: the decimal precision (a power of ten) or the fraction's denominator; 1 for whole units. + pub denominator: i64, + /// `FD`: decimals keep their trailing zeros and fractions are not reduced. + pub exact: bool, + /// `RT`: between groups of thousands. + pub thousands: String, + /// `RD`: the decimal point. + pub decimal: String, + /// `PS`: before the label. + pub before: String, + /// `SS`: after the label. + pub after: String, + /// `O /P`: the label goes before the number instead of after it. + pub prefix: bool, +} + +impl NumberFormat { + /// Steps of this unit counted by its remainder: `1/denominator` for decimals and fractions. + fn steps(&self) -> f64 { + match self.remainder { + Remainder::Decimal | Remainder::Fraction => self.denominator.max(1) as f64, + Remainder::Round | Remainder::Truncate => 1.0, + } + } + + /// `count` steps of this unit (whole, finite and not negative) as text. + fn number(&self, count: f64) -> String { + let denominator = self.denominator.max(1); + match self.remainder { + Remainder::Round | Remainder::Truncate => grouped(&format!("{count:.0}"), &self.thousands), + Remainder::Decimal => { + let places = denominator.ilog10() as usize; + let text = format!("{:.places$}", count / denominator as f64); + let (whole, part) = text.split_once('.').unwrap_or((&text, "")); + let part = if self.exact { part } else { part.trim_end_matches('0') }; + let whole = grouped(whole, &self.thousands); + if part.is_empty() { whole } else { format!("{whole}{}{part}", self.decimal) } + } + Remainder::Fraction => { + // The numerator is below the denominator however large and inexact the count is. + let numerator = (count.rem_euclid(denominator as f64) as i64).clamp(0, denominator - 1); + let whole = ((count - numerator as f64) / denominator as f64).round().max(0.0); + let text = grouped(&format!("{whole:.0}"), &self.thousands); + if numerator == 0 { + return text; + } + let g = if self.exact { 1 } else { gcd(numerator, denominator) }; + let fraction = format!("{}/{}", numerator / g, denominator / g); + if whole == 0.0 { fraction } else { format!("{text} {fraction}") } + } + } + } + + /// This format as a number format dictionary, every entry written out. + pub(crate) fn dictionary(&self) -> Object { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("NumberFormat")); + d.set(b"U".to_vec(), PdfString::text(&self.unit)); + d.set(b"C".to_vec(), Object::Real(self.factor)); + let f = match self.remainder { + Remainder::Decimal => "D", + Remainder::Fraction => "F", + Remainder::Round => "R", + Remainder::Truncate => "T", + }; + d.set(b"F".to_vec(), Object::name(f)); + if matches!(self.remainder, Remainder::Decimal | Remainder::Fraction) { + d.set(b"D".to_vec(), Object::Int(self.denominator)); + } + d.set(b"FD".to_vec(), Object::Bool(self.exact)); + d.set(b"RT".to_vec(), PdfString::text(&self.thousands)); + d.set(b"RD".to_vec(), PdfString::text(&self.decimal)); + d.set(b"PS".to_vec(), PdfString::text(&self.before)); + d.set(b"SS".to_vec(), PdfString::text(&self.after)); + d.set(b"O".to_vec(), Object::name(if self.prefix { "P" } else { "S" })); + Object::Dict(d) + } +} + +/// The number format arrays of an imported scale that a single-unit decimal scale can't stand +/// for: compound units (feet and inches), fractions, or values cut off to whole units. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Formats { + pub x: Vec, + pub y: Option>, + pub distance: Vec, + pub area: Vec, +} + +impl Formats { + /// Every array, `y` after `x` when there is one. + fn arrays(&self) -> impl Iterator> { + [&self.x, &self.distance, &self.area].into_iter().chain(self.y.as_ref()) + } + + /// Checks every array as [`crate::Scale::validate`] checks a scale's own fields. + pub(crate) fn validate(&self) -> Result<(), &'static str> { + self.arrays().try_for_each(|a| validate(a)) + } + + /// Bytes held, elements and their text, for a listing's bound on what it keeps. + pub(crate) fn held_bytes(&self) -> usize { + self.arrays() + .flatten() + .flat_map(|f| [&f.unit, &f.thousands, &f.decimal, &f.before, &f.after]) + .fold(0usize, |n, s| n.saturating_add(s.len())) + .saturating_add(self.arrays().map(Vec::len).sum::().saturating_mul(std::mem::size_of::())) + } +} + +/// The decimal places of a single-element decimal (`/D`) or rounded (`/R`) array: what a +/// single-unit decimal scale's `precision` describes. `None` for anything else. +pub(crate) fn plain_precision(formats: &[NumberFormat]) -> Option { + match formats { + [f] if f.remainder == Remainder::Decimal => u8::try_from(f.denominator.max(1).ilog10()).ok(), + [f] if f.remainder == Remainder::Round => Some(0), + _ => None, + } +} + +/// Reads the number format array `object` (resolving references), refusing a structure it can't +/// read or a precision it can't show. Labels, separators and later units' factors are checked +/// only when they are shown ([`Formats::validate`]): a single-unit decimal array is read for its +/// unit, factor and precision alone, as it always was. +pub(crate) fn read(doc: &Document, object: &Object) -> Result, &'static str> { + let a = doc.resolve(object); + let a = a.as_array().ok_or("invalid measurement number format")?; + if a.is_empty() { + return Err("empty number format"); + } + if a.len() > MAX_UNITS { + return Err("number formats of more than 8 units are not supported"); + } + a.iter().map(|o| read_one(doc, &doc.resolve(o))).collect() +} + +fn read_one(doc: &Document, o: &Object) -> Result { + let f = o.as_dict().ok_or("invalid number format dictionary")?; + let get = |k: &[u8]| f.get(k).map(|v| doc.resolve(v)); + let text = |k: &[u8], default: &str| get(k).and_then(|v| v.as_string().map(PdfString::to_text)).unwrap_or_else(|| default.into()); + let remainder = match get(b"F").as_deref().and_then(Object::as_name) { + None | Some(b"D") => Remainder::Decimal, + Some(b"F") => Remainder::Fraction, + Some(b"R") => Remainder::Round, + Some(b"T") => Remainder::Truncate, + Some(_) => return Err("unknown measurement number format"), + }; + let denominator = match remainder { + Remainder::Decimal => get(b"D").and_then(|v| v.as_int()).unwrap_or(100), + Remainder::Fraction => get(b"D").and_then(|v| v.as_int()).unwrap_or(16), + Remainder::Round | Remainder::Truncate => 1, + }; + let denominator_ok = match remainder { + Remainder::Decimal => (0..=6).any(|p| 10_i64.pow(p) == denominator), + Remainder::Fraction => (1..=MAX_DENOMINATOR).contains(&denominator), + Remainder::Round | Remainder::Truncate => true, + }; + if !denominator_ok { + return Err("unsupported measurement precision"); + } + // An empty RD means the default decimal point. + let decimal = Some(text(b"RD", ".")).filter(|s| !s.is_empty()).unwrap_or_else(|| ".".into()); + Ok(NumberFormat { + unit: text(b"U", ""), + factor: get(b"C").and_then(|v| v.as_f64()).ok_or("missing conversion factor")?, + remainder, + denominator, + exact: matches!(get(b"FD").as_deref(), Some(Object::Bool(true))), + thousands: text(b"RT", ","), + decimal, + before: text(b"PS", " "), + after: text(b"SS", " "), + prefix: get(b"O").as_deref().and_then(Object::as_name) == Some(b"P".as_slice()), + }) +} + +/// Checks an array: 1 to [`MAX_UNITS`] elements, factors as a scale's, labels and separators of +/// at most 24 printable characters, a decimal precision of 0 to 6 places and a denominator of 1 +/// to 65,536. +fn validate(formats: &[NumberFormat]) -> Result<(), &'static str> { + if formats.is_empty() || formats.len() > MAX_UNITS { + return Err("number formats have 1 to 8 units"); + } + for f in formats { + if !f.factor.is_finite() || !(1e-12..=1e12).contains(&f.factor) { + return Err("scale factors must be finite and between 1e-12 and 1e12"); + } + if f.unit.trim().is_empty() || f.unit.chars().count() > MAX_TEXT || f.unit.chars().any(char::is_control) { + return Err("unit labels must be 1 to 24 printable characters"); + } + if [&f.thousands, &f.decimal, &f.before, &f.after].iter().any(|s| s.chars().count() > MAX_TEXT || s.chars().any(char::is_control)) { + return Err("number format separators must be at most 24 printable characters"); + } + let denominator_ok = match f.remainder { + Remainder::Decimal => (0..=6).any(|p| 10_i64.pow(p) == f.denominator), + Remainder::Fraction => (1..=MAX_DENOMINATOR).contains(&f.denominator), + Remainder::Round | Remainder::Truncate => f.denominator == 1, + }; + if !denominator_ok { + return Err("unsupported measurement precision"); + } + } + Ok(()) +} + +/// `value`, in the first element's unit, as `formats` show it (ISO 32000-2 §12.9.2): whole units +/// of every element but the last, then the rest in the last element's unit. A last unit that +/// rounds up to a whole one of the unit before carries into it ("1 ft 0 in", not "0 ft 12 in"). +/// Not-a-number and infinite values show as nothing. +pub(crate) fn format(value: f64, formats: &[NumberFormat]) -> String { + let Some(last) = formats.last() else { return String::new() }; + if !value.is_finite() { + return String::new(); + } + 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(); + 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(); + if value < 0.0 && (count > 0.0 || wholes.iter().any(|w| *w > 0.0)) { + out.push('-'); + } + for (whole, f) in wholes.iter().zip(formats) { + push_segment(&mut out, &grouped(&format!("{whole:.0}"), &f.thousands), f); + } + push_segment(&mut out, &last.number(count), last); + out.trim().to_string() +} + +/// Carries a last unit's `count` of `steps` that reached a whole one of the unit before into +/// it, and then each whole count of a unit into the unit before that. Only whole conversion +/// factors carry: a unit that isn't a whole number of the next has no whole count to carry. +fn carry(count: &mut f64, steps: f64, wholes: &mut [f64], formats: &[NumberFormat]) { + let full = formats.last().map_or(0.0, |f| f.factor * steps); + let Some(previous) = wholes.last_mut() else { return }; + if !is_whole(full) || *count < full { + return; + } + *count -= full; + *previous += 1.0; + for i in (1..wholes.len()).rev() { + let per = formats.get(i).map_or(0.0, |f| f.factor); + let (Some(&w), true) = (wholes.get(i), is_whole(per)) else { continue }; + if w >= per + && let Some([before, here]) = wholes.get_mut(i - 1..=i) + { + *here -= per; + *before += 1.0; + } + } +} + +fn is_whole(v: f64) -> bool { + v.is_finite() && v >= 1.0 && v.fract() == 0.0 +} + +fn push_segment(out: &mut String, number: &str, f: &NumberFormat) { + let label = [f.before.as_str(), &f.unit, &f.after].concat(); + if f.prefix { + out.push_str(&label); + out.push_str(number); + } else { + out.push_str(number); + out.push_str(&label); + } +} + +/// The ASCII digits of a whole number with `separator` between groups of three, from the right. +fn grouped(digits: &str, separator: &str) -> String { + let n = digits.len(); + let mut out = String::with_capacity(n + n / 3 * separator.len()); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (n - i).is_multiple_of(3) { + out.push_str(separator); + } + out.push(c); + } + out +} + +fn gcd(mut a: i64, mut b: i64) -> i64 { + while b != 0 { + (a, b) = (b, a % b); + } + a.max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + fn unit(unit: &str, factor: f64, remainder: Remainder, denominator: i64) -> NumberFormat { + NumberFormat { + unit: unit.into(), + factor, + remainder, + denominator, + exact: false, + thousands: ",".into(), + decimal: ".".into(), + before: " ".into(), + after: " ".into(), + prefix: false, + } + } + + fn feet_inches(denominator: i64) -> Vec { + vec![unit("ft", 1.0 / 864.0, Remainder::Decimal, 100), unit("in", 12.0, Remainder::Fraction, denominator)] + } + + /// A file of a catalog, an empty page tree and `objects`, numbered from 3. + fn doc_with(objects: &[&str]) -> Document { + let pdf = pdfcraft_testkit::Pdf::new().obj("<< /Type /Catalog /Pages 2 0 R >>").obj("<< /Type /Pages /Kids [] /Count 0 >>").objs(objects); + Document::open(Arc::new(pdf.build())).unwrap() + } + + /// Reads `array`, stored by reference as the object after `objects` (numbered from 3). + fn parsed(objects: &[&str], array: &str) -> Result, &'static str> { + let doc = doc_with(&[objects, &[array]].concat()); + let num = u32::try_from(objects.len() + 3).unwrap(); + read(&doc, &Object::Ref(pdfcraft_cos::ObjRef { num, generation: 0 })) + } + + /// The miles, feet and eighths of inches that show "1 mi 2,378 ft 7 5/8 in". + #[test] + fn compound_units_show_whole_units_then_the_rest() { + let formats = + vec![unit("mi", 1.0, Remainder::Decimal, 100), unit("ft", 5280.0, Remainder::Decimal, 100), unit("in", 12.0, Remainder::Fraction, 8)]; + assert_eq!(format(1.4505, &formats), "1 mi 2,378 ft 7 5/8 in"); + // A remainder reduces (4/8 is 1/2) unless FD keeps its denominator. + assert_eq!(format(3.0 + 6.5 / 12.0, &feet_inches(8)), "3 ft 6 1/2 in"); + let mut exact = feet_inches(8); + exact[1].exact = true; + assert_eq!(format(3.0 + 6.5 / 12.0, &exact), "3 ft 6 4/8 in"); + // No fractional part: the whole number alone; under one unit: the fraction alone. + assert_eq!(format(2.5, &feet_inches(16)), "2 ft 6 in"); + assert_eq!(format(1.0 / 96.0, &feet_inches(16)), "0 ft 1/8 in"); + } + + /// A remainder that rounds up to a whole one of the unit before carries into it, through + /// every unit that becomes whole in turn. + #[test] + fn a_remainder_that_rounds_up_carries() { + assert_eq!(format(1.0 - 1e-4, &feet_inches(16)), "1 ft 0 in"); + let formats = vec![unit("yd", 1.0, Remainder::Decimal, 100), unit("ft", 3.0, Remainder::Decimal, 100), unit("in", 12.0, Remainder::Round, 1)]; + assert_eq!(format(2.0 - 1e-4, &formats), "2 yd 0 ft 0 in"); + // A factor that isn't whole has no whole count to carry: the rounded rest stays. + let odd = vec![unit("a", 1.0, Remainder::Decimal, 100), unit("b", 2.5, Remainder::Round, 1)]; + 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"); + // 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"); + assert_eq!(format(3.49, &cut), "3 ft 5 in"); + } + + /// Single-unit decimals, rounding and cutting, with the spec's separators and label position. + #[test] + fn decimals_separators_and_label_position() { + let mut decimal = unit("m", 1.0, Remainder::Decimal, 1000); + assert_eq!(format(1234.5, std::slice::from_ref(&decimal)), "1,234.5 m"); + decimal.exact = true; + assert_eq!(format(1234.5, std::slice::from_ref(&decimal)), "1,234.500 m"); + let euro = NumberFormat { thousands: ".".into(), decimal: ",".into(), ..decimal.clone() }; + assert_eq!(format(1_234_567.25, std::slice::from_ref(&euro)), "1.234.567,250 m"); + assert_eq!(format(7.0, &[unit("m", 1.0, Remainder::Decimal, 100)]), "7 m"); + assert_eq!(format(7.6, &[unit("m", 1.0, Remainder::Round, 1)]), "8 m"); + assert_eq!(format(7.6, &[unit("m", 1.0, Remainder::Truncate, 1)]), "7 m"); + let prefixed = NumberFormat { prefix: true, before: String::new(), after: String::new(), ..unit("$", 1.0, Remainder::Decimal, 100) }; + assert_eq!(format(12.5, &[prefixed]), "$12.5"); + let tight = vec![ + NumberFormat { before: String::new(), after: String::new(), ..unit("'", 1.0, Remainder::Decimal, 100) }, + NumberFormat { before: String::new(), after: String::new(), ..unit("\"", 12.0, Remainder::Fraction, 4) }, + ]; + assert_eq!(format(12.0 + 3.25 / 12.0, &tight), "12'3 1/4\""); + // Negative values keep their sign; nothing shows for none and for non-finite values. + assert_eq!(format(-2.5, &feet_inches(16)), "-2 ft 6 in"); + assert_eq!(format(-0.0, &feet_inches(16)), "0 ft 0 in"); + assert_eq!(format(1.0, &[]), ""); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(format(bad, &feet_inches(16)), ""); + } + // The largest values a scale allows show in full, without an exponent. + let huge = format(1e30, &feet_inches(16)); + assert!(huge.starts_with("1,000,000,000,000,000,019,884,624,838,656 ft"), "{huge}"); + // A fraction of a value too large to be exact still has a numerator below its denominator. + let fraction = format(1e30, &[unit("in", 1.0, Remainder::Fraction, 3)]); + let tokens: Vec<&str> = fraction.split_whitespace().collect(); + assert!(matches!(tokens.as_slice(), [_, "in"] | [_, "1/3" | "2/3", "in"]), "{fraction}"); + } + + /// Every entry is read with its default, references resolved, and written back as read. + #[test] + fn arrays_read_with_defaults_and_write_back() { + let source = "[<< /U (ft) /C 0.5 >> << /U 3 0 R /C 4 0 R /F 5 0 R /D 8 /FD true /RT (.) /RD () /PS () /SS (-) /O /P >>]"; + let read_back = parsed(&["(in)", "12", "/F"], source).unwrap(); + assert_eq!(read_back[0], unit("ft", 0.5, Remainder::Decimal, 100)); + let inches = &read_back[1]; + assert_eq!((inches.unit.as_str(), inches.factor, inches.remainder, inches.denominator), ("in", 12.0, Remainder::Fraction, 8)); + assert!(inches.exact && inches.prefix); + assert_eq!((inches.thousands.as_str(), inches.decimal.as_str(), inches.before.as_str(), inches.after.as_str()), (".", ".", "", "-")); + assert_eq!(parsed(&[], "[<< /U (in) /C 1 /F /F >>]").unwrap()[0].denominator, 16); + // Written out and read again, nothing changes. + let written = Object::Array(read_back.iter().map(NumberFormat::dictionary).collect()); + assert_eq!(read(&doc_with(&[]), &written).unwrap(), read_back); + assert_eq!(plain_precision(&read_back), None); + assert_eq!(plain_precision(&read_back[..1]), Some(2)); + assert_eq!(plain_precision(&[unit("m", 1.0, Remainder::Round, 1)]), Some(0)); + assert_eq!(plain_precision(&[unit("m", 1.0, Remainder::Fraction, 16)]), None); + assert_eq!(plain_precision(&[unit("m", 1.0, Remainder::Truncate, 1)]), None); + } + + /// What can't be shown faithfully is refused with a reason, before anything is formatted. + #[test] + fn unreadable_arrays_are_refused() { + let units = |n: usize| format!("[{}]", vec!["<< /U (u) /C 1 >>"; n].join(" ")); + let nine = units(9); + for (source, reason) in [ + ("[]", "empty number format"), + ("<< /U (ft) /C 1 >>", "invalid measurement number format"), + (nine.as_str(), "number formats of more than 8 units are not supported"), + ("[(ft)]", "invalid number format dictionary"), + ("[<< /U (ft) /C 1 /F /X >>]", "unknown measurement number format"), + ("[<< /U (ft) >>]", "missing conversion factor"), + ("[<< /U (ft) /C 0 >>]", "scale factors must be finite and between 1e-12 and 1e12"), + ("[<< /C 1 >>]", "unit labels must be 1 to 24 printable characters"), + ("[<< /U (ft) /C 1 /D 12 >>]", "unsupported measurement precision"), + ("[<< /U (ft) /C 1 /D 10000000 >>]", "unsupported measurement precision"), + ("[<< /U (ft) /C 1 /F /F /D 0 >>]", "unsupported measurement precision"), + ("[<< /U (ft) /C 1 /F /F /D 65537 >>]", "unsupported measurement precision"), + ("[<< /U (ft) /C 1 /SS (aaaaaaaaaaaaaaaaaaaaaaaaa) >>]", "number format separators must be at most 24 printable characters"), + ] { + assert_eq!(parsed(&[], source).and_then(|f| validate(&f).map(|()| f)), Err(reason), "{source}"); + } + assert_eq!(parsed(&[], "[<< /U (ft) /C 1 /F /F /D 65536 >>]").unwrap()[0].denominator, 65536); + assert_eq!(parsed(&[], &units(MAX_UNITS)).map(|f| f.len()), Ok(MAX_UNITS)); + } + + /// What formats hold is counted for the listing bound. + #[test] + fn formats_count_their_text() { + let f = Formats { + x: feet_inches(16), + y: Some(feet_inches(16)), + distance: feet_inches(16), + area: vec![unit("ft²", 1.0, Remainder::Decimal, 100)], + }; + // Each element holds its label and "," "." " " " " (4 bytes). + 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 { 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 80d30fdefe..902ac34a4b 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -283,13 +283,14 @@ fn unsupported_measurements_are_reported_not_fatal() { let mut d = fixture("", "", &[]); add(&mut d, &new(Kind::Distance, vec![[0.0, 0.0], [3.0, 4.0]]), &Meta::default()).unwrap(); add(&mut d, &new(Kind::Distance, vec![[10.0, 0.0], [13.0, 4.0]]), &Meta::default()).unwrap(); - // An imported compound feet-and-inches format (two NumberFormat dictionaries). + // An imported format this crate can't show (an unknown /F). let (r, _) = annot(&d, 0, 1).unwrap(); let format = |unit: &str, c: f64| { let mut f = Dict::new(); f.set(b"Type".to_vec(), Object::name("NumberFormat")); f.set(b"U".to_vec(), PdfString::text(unit)); f.set(b"C".to_vec(), Object::Real(c)); + f.set(b"F".to_vec(), Object::name("X")); Object::Dict(f) }; d.update_dict(r, |a| { @@ -302,7 +303,7 @@ fn unsupported_measurements_are_reported_not_fatal() { assert_eq!(listing.measurements.len(), 1); assert_eq!(listing.unsupported.len(), 1); assert_eq!((listing.unsupported[0].page, listing.unsupported[0].index), (0, 1)); - assert!(listing.unsupported[0].reason.contains("compound"), "{}", listing.unsupported[0].reason); + assert_eq!(listing.unsupported[0].reason, "unknown measurement number format"); // Invalid geometry on an imported annotation is reported the same way. let (r, _) = annot(&d, 0, 0).unwrap(); d.update_dict(r, |a| a.set(b"L".to_vec(), nums([0.0, 0.0, f64::NAN, 1.0]))).unwrap(); @@ -632,3 +633,49 @@ fn indirect_scale_fields_and_closed_area_edges_are_handled() { assert_eq!(item.label, "Floor Plan"); assert_eq!(item.scale, Scale { y: 3.0, ..Scale::new(2.0, "m", 3).unwrap() }); } + +/// An Acrobat-style drawing scale of 1 in = 1 ft whose distances show in feet and sixteenths of +/// inches (ISO 32000-2 §12.9.2): a viewport and a saved measurement using it read, are labelled +/// in feet and inches, and a measurement added inside the viewport keeps the formats. +#[test] +fn compound_and_fractional_scales_read_label_and_carry_into_new_measurements() { + let feet_inches = "[<< /Type /NumberFormat /U (ft) /C 0.0138888888888889 /D 100 >> << /U (in) /C 12 /F /F /D 16 >>]"; + let measure = format!( + "<< /Type /Measure /Subtype /RL /R (1 in = 1 ft) /X {feet_inches} /D [<< /U (ft) /C 1 >> << /U (in) /C 12 /F /F /D 16 >>] /A [<< /U (sq ft) /C 1 /F /T >>] >>" + ); + let mut d = fixture("", &format!("/VP [<< /Type /Viewport /BBox [0 0 612 792] /Measure {measure} >>]"), &[]); + let scale = scale_at(&d, 0, [10.0, 10.0]).unwrap(); + assert_eq!((scale.unit.as_str(), scale.area_unit.as_str(), scale.precision), ("ft", "sq ft", 0)); + close(scale.x, 1.0 / 72.0); + let formats = scale.formats.as_ref().unwrap(); + assert_eq!(formats.distance.len(), 2); + // 3 ft 6 1/2 in at 1 in = 1 ft is 3 in and 6.5 / 12 in on the page: 216 + 39 points. + let distance = reading(Kind::Distance, &[[0.0, 0.0], [0.0, 255.0]], &scale).unwrap(); + close(distance.value, 255.0 / 72.0); + assert_eq!(distance.label, "3 ft 6 1/2 in"); + // Just short of 2 ft carries into the feet; an area of 2.9 sq ft is cut off to 2. + assert_eq!(reading(Kind::Distance, &[[0.0, 0.0], [143.99, 0.0]], &scale).unwrap().label, "2 ft 0 in"); + let side = (2.9f64).sqrt() * 72.0; + assert_eq!(reading(Kind::Area, &[[0.0, 0.0], [side, 0.0], [side, side], [0.0, side]], &scale).unwrap().label, "2 sq ft"); + + // Added inside the viewport, as the Measure tool and measure_distance do, the measurement + // keeps the compound formats and reads the same after a save. + let mut m = new(Kind::Distance, vec![[0.0, 0.0], [0.0, 255.0]]); + m.scale = scale.clone(); + add(&mut d, &m, &Meta { date: None, id: "plan".into() }).unwrap(); + let (_, dict) = annot(&d, 0, 0).unwrap(); + assert_eq!(text(&d, &dict, b"Contents"), "3 ft 6 1/2 in"); + let saved = d.resolve(dict.get(b"Measure").unwrap()); + let saved_d = d.resolve(saved.as_dict().unwrap().get(b"D").unwrap()); + let inches = saved_d.as_array().unwrap()[1].as_dict().unwrap().clone(); + assert_eq!(inches.get(b"F").and_then(Object::as_name), Some(b"F".as_slice())); + assert_eq!(inches.get(b"D").and_then(Object::as_int), Some(16)); + let reopened = Document::open(Arc::new(write_full(&d, &SaveOptions::default()).unwrap())).unwrap(); + let listing = list(&reopened); + assert!(listing.unsupported.is_empty(), "{:?}", listing.unsupported); + assert_eq!(listing.measurements[0].reading.label, "3 ft 6 1/2 in"); + close(listing.measurements[0].scale.x, scale.x); + assert_eq!(listing.measurements[0].scale.formats.as_ref().map(|f| (f.x.len(), f.distance.len(), f.area.len())), Some((2, 2, 1))); + // 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); +} From c2355b0b0eeafd3db920674eb6b70739f9db50d9 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 12 Nov 2026 21:36:03 +0800 Subject: [PATCH 2/3] 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)); +} From 4439794769106561e1a293dc1b18c50aedaada08 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 12 Nov 2026 22:05:28 +0800 Subject: [PATCH 3/3] fix(measure): validate written Y factor and check /F and /D on last format only --- crates/measure/src/lib.rs | 6 +++ crates/measure/src/number_format.rs | 75 +++++++++++++++++------------ crates/measure/src/tests.rs | 7 +++ 3 files changed, 56 insertions(+), 32 deletions(-) diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index 111594a84a..67efab1a9a 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -157,6 +157,12 @@ impl Scale { } if let Some(formats) = &self.formats { formats.validate().map_err(invalid)?; + if formats.y.is_some() { + let yf = self.y / formats.cyx; + if !yf.is_finite() || !(1e-12..=1e12).contains(&yf) { + return Err(invalid("scale factors must be finite and between 1e-12 and 1e12")); + } + } } Ok(()) } diff --git a/crates/measure/src/number_format.rs b/crates/measure/src/number_format.rs index daafc80b9f..d844d789eb 100644 --- a/crates/measure/src/number_format.rs +++ b/crates/measure/src/number_format.rs @@ -187,33 +187,40 @@ pub(crate) fn read(doc: &Document, object: &Object) -> Result, if a.len() > MAX_UNITS { return Err("number formats of more than 8 units are not supported"); } - a.iter().map(|o| read_one(doc, &doc.resolve(o))).collect() + a.iter().enumerate().map(|(i, o)| read_one(doc, &doc.resolve(o), i + 1 == a.len())).collect() } -fn read_one(doc: &Document, o: &Object) -> Result { +fn read_one(doc: &Document, o: &Object, is_last: bool) -> Result { let f = o.as_dict().ok_or("invalid number format dictionary")?; let get = |k: &[u8]| f.get(k).map(|v| doc.resolve(v)); let text = |k: &[u8], default: &str| get(k).and_then(|v| v.as_string().map(PdfString::to_text)).unwrap_or_else(|| default.into()); - let remainder = match get(b"F").as_deref().and_then(Object::as_name) { - None | Some(b"D") => Remainder::Decimal, - Some(b"F") => Remainder::Fraction, - Some(b"R") => Remainder::Round, - Some(b"T") => Remainder::Truncate, - Some(_) => return Err("unknown measurement number format"), + // ISO 32000-2 §12.9.2: only the last element formats a remainder (`/F`, `/D`, `/FD`); + // earlier elements always format whole units. + let (remainder, denominator, exact) = if is_last { + let remainder = match get(b"F").as_deref().and_then(Object::as_name) { + None | Some(b"D") => Remainder::Decimal, + Some(b"F") => Remainder::Fraction, + Some(b"R") => Remainder::Round, + Some(b"T") => Remainder::Truncate, + Some(_) => return Err("unknown measurement number format"), + }; + let denominator = match remainder { + Remainder::Decimal => get(b"D").and_then(|v| v.as_int()).unwrap_or(100), + Remainder::Fraction => get(b"D").and_then(|v| v.as_int()).unwrap_or(16), + Remainder::Round | Remainder::Truncate => 1, + }; + let denominator_ok = match remainder { + Remainder::Decimal => (0..=6).any(|p| 10_i64.pow(p) == denominator), + Remainder::Fraction => (1..=MAX_DENOMINATOR).contains(&denominator), + Remainder::Round | Remainder::Truncate => true, + }; + if !denominator_ok { + return Err("unsupported measurement precision"); + } + (remainder, denominator, matches!(get(b"FD").as_deref(), Some(Object::Bool(true)))) + } else { + (Remainder::Decimal, 100, false) }; - let denominator = match remainder { - Remainder::Decimal => get(b"D").and_then(|v| v.as_int()).unwrap_or(100), - Remainder::Fraction => get(b"D").and_then(|v| v.as_int()).unwrap_or(16), - Remainder::Round | Remainder::Truncate => 1, - }; - let denominator_ok = match remainder { - Remainder::Decimal => (0..=6).any(|p| 10_i64.pow(p) == denominator), - Remainder::Fraction => (1..=MAX_DENOMINATOR).contains(&denominator), - Remainder::Round | Remainder::Truncate => true, - }; - if !denominator_ok { - return Err("unsupported measurement precision"); - } // An empty RD means the default decimal point. let decimal = Some(text(b"RD", ".")).filter(|s| !s.is_empty()).unwrap_or_else(|| ".".into()); Ok(NumberFormat { @@ -221,7 +228,7 @@ fn read_one(doc: &Document, o: &Object) -> Result { factor: get(b"C").and_then(|v| v.as_f64()).ok_or("missing conversion factor")?, remainder, denominator, - exact: matches!(get(b"FD").as_deref(), Some(Object::Bool(true))), + exact, thousands: text(b"RT", ","), decimal, before: text(b"PS", " "), @@ -231,13 +238,13 @@ fn read_one(doc: &Document, o: &Object) -> Result { } /// Checks an array: 1 to [`MAX_UNITS`] elements, factors as a scale's, labels and separators of -/// at most 24 printable characters, a decimal precision of 0 to 6 places and a denominator of 1 -/// to 65,536. +/// at most 24 printable characters, and on the last element a decimal precision of 0 to 6 places +/// or a fraction denominator of 1 to 65,536. fn validate(formats: &[NumberFormat]) -> Result<(), &'static str> { if formats.is_empty() || formats.len() > MAX_UNITS { return Err("number formats have 1 to 8 units"); } - for f in formats { + for (i, f) in formats.iter().enumerate() { if !f.factor.is_finite() || !(1e-12..=1e12).contains(&f.factor) { return Err("scale factors must be finite and between 1e-12 and 1e12"); } @@ -247,13 +254,15 @@ fn validate(formats: &[NumberFormat]) -> Result<(), &'static str> { if [&f.thousands, &f.decimal, &f.before, &f.after].iter().any(|s| s.chars().count() > MAX_TEXT || s.chars().any(char::is_control)) { return Err("number format separators must be at most 24 printable characters"); } - let denominator_ok = match f.remainder { - Remainder::Decimal => (0..=6).any(|p| 10_i64.pow(p) == f.denominator), - Remainder::Fraction => (1..=MAX_DENOMINATOR).contains(&f.denominator), - Remainder::Round | Remainder::Truncate => f.denominator == 1, - }; - if !denominator_ok { - return Err("unsupported measurement precision"); + if i + 1 == formats.len() { + let denominator_ok = match f.remainder { + Remainder::Decimal => (0..=6).any(|p| 10_i64.pow(p) == f.denominator), + Remainder::Fraction => (1..=MAX_DENOMINATOR).contains(&f.denominator), + Remainder::Round | Remainder::Truncate => f.denominator == 1, + }; + if !denominator_ok { + return Err("unsupported measurement precision"); + } } } Ok(()) @@ -506,6 +515,8 @@ mod tests { assert_eq!(parsed(&[], source).and_then(|f| validate(&f).map(|()| f)), Err(reason), "{source}"); } assert_eq!(parsed(&[], "[<< /U (ft) /C 1 /F /F /D 65536 >>]").unwrap()[0].denominator, 65536); + // Only the last element's `/F` and `/D` format a remainder; earlier elements' `/F` and `/D` are ignored. + assert_eq!(parsed(&[], "[<< /U (ft) /C 1 /F /X /D 12 >> << /U (in) /C 12 /F /F /D 16 >>]").map(|f| f.len()), Ok(2)); assert_eq!(parsed(&[], &units(MAX_UNITS)).map(|f| f.len()), Ok(MAX_UNITS)); } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 8d75d4ddf2..80c09d039f 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -701,4 +701,11 @@ fn a_compound_y_keeps_its_units_and_cyx() { close(again.y, scale.y); let formats = again.formats.unwrap(); assert_eq!((formats.y.unwrap()[1].factor, formats.cyx), (100.0, 3.28084)); + // If `Scale.y` is mutated so `self.y / cyx` exceeds the factor bound, `dictionary()` refuses it. + let mut overflow_y = scale; + if let Some(f) = &mut overflow_y.formats { + f.cyx = 1e-12; + } + overflow_y.y = 2.0; + assert!(overflow_y.dictionary().is_err()); }