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.
This commit is contained in:
jonathanwxh-cell committed 2026-11-12 21:28:38 +08:00
1 parent d749fd8062
commit 87d8deaced
4 files changed
+628 -38

No files matched your search

+1 -1
View File
@@ -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.
+68 -35
View File
@@ -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<Formats>,
}
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<Dict> {
@@ -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<Self> {
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<Vec<NumberFormat>> {
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<Reading> {
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::<Point>()), usize::saturating_add)
}
+510
View File
@@ -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<NumberFormat>,
pub y: Option<Vec<NumberFormat>>,
pub distance: Vec<NumberFormat>,
pub area: Vec<NumberFormat>,
}
impl Formats {
/// Every array, `y` after `x` when there is one.
fn arrays(&self) -> impl Iterator<Item = &Vec<NumberFormat>> {
[&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::<usize>().saturating_mul(std::mem::size_of::<NumberFormat>()))
}
}
/// 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<u8> {
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<Vec<NumberFormat>, &'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<NumberFormat, &'static str> {
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<NumberFormat> {
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<Vec<NumberFormat>, &'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::<NumberFormat>());
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"));
}
}
+49 -2
View File
@@ -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);
}