mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-14 06:42:20 +00:00
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:
1 parent
d749fd8062
commit
87d8deaced
4 files changed
+628
-38
No files matched your search
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user