Measure: add distance, perimeter and area tools (#208)

* M12: add calibrated 2D measurement tools

* M12: harden measurement tools for landing (#208)

Bounded snap targets (endpoints only for accepted segments, page-wide cap),
lenient extraction (deep q nesting and undecodable streams are skipped and
reported), per-page snap cache, measure::list reports unsupported entries
instead of failing, closed polygons with a repeated vertex, printable unit
labels (m²), cached panel listing, WinAnsi captions, indirect /VP kept,
measure.export skipped in the command test like other pickers, missing
translations, honest parity (scale and distance partial) and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ayan 2026-10-08 09:38:57 +05:00 committed by GitHub
parent 77893e8ab3
commit 6974c6550d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2458 additions and 55 deletions

View File

@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "16aafe71a832e881671ef63102b94346717885a14e6e33f41d9d6c0734bc95d6"
sha256 = "b41ad4a0b8d7596cd0e9b731f7b418ad40ce1142213241d4a893419b3bb1bf2c"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0448bc95a4046ec6cee88b02faed799b1917c19fe0f47d30febb9eb6b5c3b3d8"
sha256 = "defdda2a59cfdffd539e5c8905f1b65e98f96dad89e48c843387482353629634"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "b72162877bf38bbbb0849e9a5462b952517d9b4208b0b38c3da019cf1756ce1c"
sha256 = "17c70bf17a9230c73fdc872eb16e1877369c7a8ad718dc40739c40007b066201"
[[asset]]
path = "assets/icons/accessibility.svg"

13
Cargo.lock generated
View File

@ -3583,6 +3583,7 @@ dependencies = [
"pdfcraft-fonts",
"pdfcraft-forms",
"pdfcraft-js",
"pdfcraft-measure",
"pdfcraft-model",
"pdfcraft-ocr",
"pdfcraft-optimize",
@ -3646,6 +3647,18 @@ dependencies = [
"boa_engine",
]
[[package]]
name = "pdfcraft-measure"
version = "0.2.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-model"
version = "0.2.1"

View File

@ -18,6 +18,7 @@ pdfcraft-filters = { path = "crates/filters", version = "0.2.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.2.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.2.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.2.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.2.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.2.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.2.0" }
pdfcraft-model = { path = "crates/model", version = "0.2.0" }

View File

@ -12,15 +12,15 @@ Detailed task lists and acceptance tests are in `plan/execution-plan.md` (local-
The unit is **wall-clock hours of agent work** (Claude Opus 5.5 coding continuously; human review time not included).
**Where we are (2026-10-07, measured by `cargo xtask parity` over 805 tracked Acrobat Pro features; 23 more are Adobe-cloud-only and out of scope):**
**Where we are (2026-10-07, measured by `cargo xtask parity` over 806 tracked Acrobat Pro features; 23 more are Adobe-cloud-only and out of scope):**
| Tier | Features | Shipped | Partial | Shipped % | Weighted % (partial = ½) |
|---|---|---|---|---|---|
| P0 (must-have for 1.0) | 251 | 222 | 25 | 88.4% | 93.4% |
| P1 | 325 | 172 | 30 | 52.9% | 57.5% |
| P2 | 186 | 10 | 2 | 5.4% | 5.9% |
| P3 | 43 | 0 | 0 | 0% | 0% |
| **All** | **805** | **404** | **57** | **50.2%** | **53.7%** |
| P1 | 326 | 174 | 38 | 53.4% | 59.2% |
| P2 | 186 | 14 | 7 | 7.5% | 9.4% |
| P3 | 43 | 1 | 0 | 2.3% | 2.3% |
| **All** | **806** | **411** | **70** | **51.0%** | **55.3%** |
**Effort-weighted parity: ≈ 30–35%.** Feature counts overstate progress: the remaining features include the hardest ones (our own renderer and font engine, editing existing text and reflow, OCR beyond Latin, XFA, PDF/A/X/UA preflight, Office export, long-term signature validation). Weighting each milestone by its estimated size gives about a third of the total work done. See **[Honest assessment](#honest-assessment-2026-10-05)** for what the numbers don't show.
@ -40,8 +40,8 @@ Read this before choosing work. The feature table above counts what exists; this
| Dimension | State | In one line |
|---|---|---|
| Feature count | 50.2% shipped (P0 88%, P1 53%, P2 5%, P3 0%) | A typical viewer, annotator, form-filler or page organizer is mostly covered |
| Effort | ≈ 30–35% | The remaining work is the hardest: M2 15%, M7 17%, M11 18%, M12 20%, M14 0% |
| Feature count | 51.0% shipped (P0 88%, P1 53%, P2 8%, P3 2%) | A typical viewer, annotator, form-filler or page organizer is mostly covered |
| Effort | ≈ 30–35% | The remaining work is the hardest: M2 15%, M7 17%, M11 18%, M12 23%, M14 0% |
| Foundations | Weakest | Rendering is still the bootstrap `hayro`; the inspector is `lopdf`; 18 vendored patches carried |
| Robustness | Early beta | Each 15-minute fuzz run found new out-of-memory crashes or hangs until 2026-10-05 |
| Acrobat fidelity | Unmeasured | No systematic side-by-side comparison with Acrobat, visual or behavioural |
@ -53,7 +53,7 @@ Read this before choosing work. The feature table above counts what exists; this
|---|---|---|
| A Core | 75% | Parser, repair, encryption, incremental saves. Missing: lazy loading (`ByteSource`), own image codecs, PDF 2.0 extras, Arlington validation |
| F Forms | 71% | Filling, authoring, AF scripts, sandboxed JavaScript, data exchange. Missing: XFA, wider JavaScript object model |
| E Comments | 63% | All markup types with appearances, XFDF/FDF, summaries. Missing: replace-text proposals, summary layouts |
| E Comments | 71% | Markup types with appearances, XFDF/FDF, summaries, calibrated 2D measuring (single-unit decimal scales). Missing: replace-text proposals, summary layouts, compound/fractional measurement formats |
| M Accessibility | 62% | Checker (all 32 rules). Missing: autotag, Tags/Order/Content panels, Reading Order tool, keyboard-only operation |
| B View | 55% | Shell, find, panels, tiles, web build. **Rendering is borrowed (`hayro`)**, so the 7 rendering P0s are only partial |
| D Organize | 55% | Pages, combine, split, bookmarks, labels. Missing: replace pages, transitions |
@ -101,12 +101,12 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
| M9 | Signatures (PAdES, validation) | 160–280 | 55% | 70–125 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain signing, certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, Windows store, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print. Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 20% | 160–305 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, measure, search index, XFA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools`, UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese and Simplified Chinese catalogs have ≈1,800 entries each; Simplified Chinese coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. Missing: more catalogs filled in, Windows language detection, installers, performance budgets |
| | **Total (original plan sizing)** | **2,085–3,840** | **≈ 35%** | **≈ 1,350–2,500 at the planned rate; ≈ 600–1,100 at the measured rate** | |
**Overall progress: about 30–35% of the effort (49.5% of features shipped).** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen; rendering itself is still borrowed from `hayro`. See [Honest assessment](#honest-assessment-2026-10-05).
**Overall progress: about 30–35% of the effort (51.0% of features shipped).** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen; rendering itself is still borrowed from `hayro`. See [Honest assessment](#honest-assessment-2026-10-05).
## Critical path
@ -129,6 +129,7 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- **2026-10-08 (M14, Simplified Chinese):** Added 208 translations, bringing the catalog from 1,625 to 1,833 entries. PhotoCraft-style coverage checks protect registered commands, All tools labels and UI `tl!` literals; generated history and diagnostic regressions preserve user values. Chinese font delivery remains incomplete: the release-pinned craft-fonts input has no Hans face and the web build only embeds the Japanese UI face (see #112 and #140). Localization remains partial; overall ≈ 30–35% (unchanged).
- **2026-10-07 (community, #208):** Measure: distance, perimeter and area annotations (standard LineDimension/PolyLineDimension/PolygonDimension with rectilinear /Measure), persistent viewport scales with two-point calibration, vector snapping (endpoints, midpoints, intersections, paths) through nested forms, live readings, CSV export, and eight `measure_*` agent tools. Hardened on landing: snap extraction caps segments and snap targets page-wide and skips undecodable streams and over-deep `q` instead of failing; unsupported imported measurements (compound ft-in, fractional, invalid geometry) are listed as unsupported rather than failing the list; non-ASCII units ("m²") are allowed and rendered in WinAnsi; a closing duplicate polygon vertex counts as closed. Distance and scale are partial (single-unit decimal formats only; Acrobat interop untested). 51.0% shipped; ≈ 30–35% effort.
- **2026-10-07 (community, #190):** Create PDF from Images offers embedded resolution, 72 DPI, or custom DPI (1–1200) without resampling. The engine and doc_create share the choice; Windows image context menus open the chooser through --create-images. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (community):** Cut and stack in Print > Multiple: single-sided n-up sheets can be cut into cell piles and restacked in selected page order, with aligned gutter marks and blanks kept in their cells. Available through doc_print; duplex is refused. Grid-size overflow returns an error. No change to the parity count or the overall estimate (about 30–35%).
@ -219,3 +220,4 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- Robustness sweep (963 of 983 files open, 0 crashes), text layer, find and select, tiles, web build, polish.
- Overall ≈ 3–4%.
- **2026-09-30 (session 1):** planning complete; viewer vertical slice.

View File

@ -181,6 +181,11 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
pub fn build(d: &Dict) -> Option<Stream> {
// Imported measurement appearances may include leaders, captions and formatting that
// this builder cannot reproduce. Preserve them rather than silently losing detail.
if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") {
return None;
}
let subtype = d.name(b"Subtype")?.to_vec();
let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?;
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
@ -582,7 +587,31 @@ pub fn build(d: &Dict) -> Option<Stream> {
}
_ => return None,
}
Some(form(rect, c.as_bytes(), res))
// PdfCraft measurement captions are kept separate from the comment's free-form text.
// A restyle regenerates the path and its value together.
let mut out = c.into_bytes();
if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) {
let value = value.to_text();
if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") {
let size = 10.0;
let x = (rect[0] + rect[2] - text_width(&value, size)) * 0.5;
let y = rect[3] - 12.0;
let col = stroke.unwrap_or([0.0, 0.47, 0.84]);
out.extend(format!("{}BT /Helv {} Tf {} {} Td ", rg(col), n(size), n(x), n(y)).bytes());
// WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8.
out.extend(literal(&win_ansi(&value)));
out.extend_from_slice(b" Tj ET\n");
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
res.set(b"Font".to_vec(), Object::Dict(fonts));
}
}
Some(form(rect, &out, res))
}
/// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold

View File

@ -3,7 +3,7 @@
Agent control for PdfCraft: a headless tool table over the engine, and an opt-in MCP server.
- **Layer:** L7 (architecture §13), but headless. It depends on `engine`, `render` and `organize`, never on a UI toolkit.
- **Status:** over a hundred tools; `pdfcraft-cli tools` lists the current set with their schemas (the count isn't repeated here, so it can't go stale). They cover inspection, rendering, text, page edits and organizing, page labels, bookmarks, comments and their review, form filling and authoring (fields, properties, scripts, data exchange, detection), redaction and sanitizing, password protection and signatures, adding text, images, links and stamps, optimizing, OCR, printing, export, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). The MCP server also serves the open documents as resources, page images included. The running app's UI is driven separately, through the `ui.*` control channel in `pdfcraft-ui-egui`.
- **Status:** over a hundred tools; `pdfcraft-cli tools` lists the current set with their schemas (the count isn't repeated here, so it can't go stale). They cover inspection, rendering, text, page edits and organizing, page labels, bookmarks, comments and their review, form filling and authoring (fields, properties, scripts, data exchange, detection), redaction and sanitizing, password protection and signatures, calibrated 2D measurements and CSV export, adding text, images, links and stamps, optimizing, OCR, printing, export, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). The MCP server also serves the open documents as resources, page images included. The running app's UI is driven separately, through the `ui.*` control channel in `pdfcraft-ui-egui`.
## API

View File

@ -18,6 +18,7 @@ mod forms;
mod links;
#[cfg(feature = "mcp")]
pub mod mcp;
mod measure;
mod printing;
mod redact;
mod signing;
@ -525,6 +526,14 @@ impl Automation {
"doc_print" => self.doc_print(&a)?,
"doc_remove_hidden" => self.doc_remove_hidden(&a)?,
"fill_sign_add" => self.fill_sign_add(&a)?,
"measure_distance" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Distance)?,
"measure_perimeter" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Perimeter)?,
"measure_area" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Area)?,
"measure_info" => self.measurement_info(&a)?,
"measure_list" => self.measurement_list(&a)?,
"measure_scale" => self.measurement_scale(&a)?,
"measure_snap" => self.measurement_snap(&a)?,
"measure_export" => self.measurement_export(&a)?,
"comment_list" => self.comment_list(&a)?,
"comment_add" => self.comment_add(&a)?,
"stamp_custom" => self.stamp_custom(&a)?,

View File

@ -0,0 +1,145 @@
//! Measurement tools share the engine's PDF user-space geometry and scale semantics.
use crate::{Args, Automation, Result, ToolError, failed};
use pdfcraft_engine::{
Edit, Style,
measure::{self, Kind, NewMeasurement, Scale},
};
use serde_json::{Value, json};
impl Automation {
fn measurement_points(&self, a: &Args) -> Result<(usize, Vec<measure::Point>)> {
let page = self.page(a)?;
let arr = a.get("points").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("points must be arrays of [x,y]".into()))?;
if arr.len() > measure::MAX_POINTS {
return Err(ToolError::InvalidArgs("at most 2048 measurement vertices are allowed".into()));
}
let mut points = Vec::new();
for p in arr {
let pair = p.as_array().filter(|p| p.len() == 2).ok_or_else(|| failed("each measurement point needs two coordinates"))?;
let x = pair.first().and_then(Value::as_f64).ok_or_else(|| failed("invalid x coordinate"))?;
let y = pair.get(1).and_then(Value::as_f64).ok_or_else(|| failed("invalid y coordinate"))?;
if !x.is_finite() || !y.is_finite() || x.abs() > 1e9 || y.abs() > 1e9 {
return Err(failed("measurement coordinates must be finite and within 1e9 points"));
}
points.push(self.doc(a)?.measurement_to_user(page, [x, y]).map_err(failed)?);
}
Ok((page, points))
}
pub(crate) fn measurement_add(&mut self, a: &Args, kind: Kind) -> Result<Value> {
let (page, points) = self.measurement_points(a)?;
let at = points.first().copied().ok_or_else(|| failed("measurement needs points"))?;
let scale = self.doc(a)?.measurement_scale(page, at).map_err(failed)?;
let edit = Edit::AddMeasurement(NewMeasurement {
page,
kind,
points,
scale,
style: Style { color: [0.0, 0.47, 0.84], ..Style::default() },
label: a.opt_str("label")?.unwrap_or("").into(),
author: a.opt_str("author")?.unwrap_or(super::comments::DEFAULT_AUTHOR).into(),
});
self.apply(a, edit)
}
pub(crate) fn measurement_info(&self, a: &Args) -> Result<Value> {
let (page, points) = self.measurement_points(a)?;
let kind = Kind::from_name(a.opt_str("type")?.unwrap_or("distance")).ok_or_else(|| failed("type must be distance, perimeter or area"))?;
let at = points.first().copied().ok_or_else(|| failed("measurement needs points"))?;
let scale = self.doc(a)?.measurement_scale(page, at).map_err(failed)?;
let reading = measure::reading(kind, &points, &scale).map_err(failed)?;
Ok(json!({"reading":reading,"scale":scale}))
}
pub(crate) fn measurement_list(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let all = doc.measurements().map_err(failed)?;
let only = a.opt_int("page")?.map(|n| usize::try_from(n.max(1).saturating_sub(1)).unwrap_or(usize::MAX));
let measurements: Vec<Value> = all
.measurements
.iter()
.filter(|m| only.is_none_or(|p| m.page == p))
.map(|m| -> Result<Value> {
let mut v = json!(m);
v["page"] = json!(m.page + 1);
v["index"] = json!(m.index + 1);
let points = m.points.iter().map(|p| doc.measurement_to_view(m.page, *p).map_err(failed)).collect::<Result<Vec<_>>>()?;
v["points"] = json!(points);
Ok(v)
})
.collect::<Result<_>>()?;
let unsupported: Vec<Value> = all
.unsupported
.iter()
.filter(|u| only.is_none_or(|p| u.page == p))
.map(|u| json!({"page":u.page.saturating_add(1),"index":u.index.saturating_add(1),"reason":u.reason}))
.collect();
Ok(json!({"count":measurements.len(),"measurements":measurements,"unsupported":unsupported,"truncated":all.truncated}))
}
pub(crate) fn measurement_scale(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let info = self.doc(a)?.info.pages.get(page).ok_or_else(|| failed("no such page"))?.clone();
let precision = u8::try_from(a.opt_int("precision")?.unwrap_or(2)).map_err(|_| failed("precision must be between 0 and 6"))?;
let unit = a.opt_str("unit")?.unwrap_or("in");
let scale = if a.get("points").is_some() {
if a.get("units_per_point").is_some() {
return Err(failed("pass calibration points or units_per_point, not both"));
}
let (_, points) = self.measurement_points(a)?;
let [from, to] = points.as_slice() else {
return Err(failed("scale calibration needs exactly two points"));
};
let length = a.opt_num("distance")?.ok_or_else(|| failed("calibration needs a real-world distance"))?;
Scale::calibrate(*from, *to, length, unit, precision).map_err(failed)?
} else if let Some(factor) = a.opt_num("units_per_point")? {
{
let from = self.doc(a)?.measurement_to_user(page, [0.0, 0.0]).map_err(failed)?;
let to = self.doc(a)?.measurement_to_user(page, [1.0, 0.0]).map_err(failed)?;
Scale::calibrate(from, to, factor, unit, precision).map_err(failed)?
}
} else {
let at = a.nums::<2>("at")?.unwrap_or([0.0, 0.0]);
let [x, y] = self.doc(a)?.measurement_to_user(page, at).map_err(failed)?;
return Ok(json!({"scale":self.doc(a)?.measurement_scale(page,[x,y]).map_err(failed)?}));
};
let rect = a.nums::<4>("rect")?.unwrap_or([0.0, 0.0, f64::from(info.width), f64::from(info.height)]);
if rect.iter().any(|v| v.abs() > 1e9) {
return Err(failed("viewport coordinates must be within 1e9 points"));
}
let from = self.doc(a)?.measurement_to_user(page, [rect[0], rect[1]]).map_err(failed)?;
let to = self.doc(a)?.measurement_to_user(page, [rect[2], rect[3]]).map_err(failed)?;
let bbox = [from[0].min(to[0]), from[1].min(to[1]), from[0].max(to[0]), from[1].max(to[1])];
let mut out = self
.apply(a, Edit::SetMeasurementScale { page, bbox, name: a.opt_str("name")?.unwrap_or("Drawing scale").into(), scale: scale.clone() })?;
out["scale"] = json!(scale);
Ok(out)
}
pub(crate) fn measurement_snap(&self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let at = a.nums::<2>("at")?.ok_or_else(|| failed("snapping needs at"))?;
let [x, y] = self.doc(a)?.measurement_to_user(page, at).map_err(failed)?;
let geometry = self.doc(a)?.measurement_paths(page).map_err(failed)?;
let options = measure::snap::SnapOptions {
endpoints: a.opt_bool("endpoints")?.unwrap_or(true),
midpoints: a.opt_bool("midpoints")?.unwrap_or(true),
intersections: a.opt_bool("intersections")?.unwrap_or(true),
paths: a.opt_bool("paths")?.unwrap_or(true),
};
let tolerance = a.opt_num("tolerance")?.unwrap_or(6.0);
let adjacent = self.doc(a)?.measurement_to_user(page, [at[0] + tolerance, at[1]]).map_err(failed)?;
let tolerance = measure::distance([x, y], adjacent);
let intersection_limited = options.intersections && geometry.intersection_limited([x, y], tolerance);
let hit = geometry.snap([x, y], tolerance, options).map_err(failed)?;
let snap = hit
.map(|s| -> Result<Value> {
let point = self.doc(a)?.measurement_to_view(page, s.point).map_err(failed)?;
Ok(json!({"point":point,"kind":s.kind,"distance":measure::distance(at,point)}))
})
.transpose()?;
Ok(json!({"snap":snap,"truncated":geometry.truncated,"intersection_limited":intersection_limited,"segments":geometry.segments.len()}))
}
pub(crate) fn measurement_export(&self, a: &Args) -> Result<Value> {
let all = self.doc(a)?.measurements().map_err(failed)?;
let csv = measure::csv(&all.measurements);
let target = self.resolve(a.str("out")?, true)?;
crate::write_atomic(&target, csv.as_bytes())?;
Ok(json!({"path":target,"count":all.measurements.len(),"unsupported":all.unsupported.len(),"truncated":all.truncated}))
}
}

View File

@ -42,6 +42,16 @@ fn point() -> Value {
json!({ "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "[x, y] in points from the top-left of the displayed page." })
}
fn measure_points() -> Value {
json!({"type":"array","items":point(),"minItems":1,"maxItems":2048})
}
fn measure_schema() -> Value {
schema(
json!({"doc":doc(),"page":{"type":"integer","minimum":1},"points":measure_points(),"label":{"type":"string","maxLength":512},"author":{"type":"string","maxLength":512}}),
&["doc", "page", "points"],
)
}
fn color() -> Value {
json!({ "type": "string", "description": "#RRGGBB or a name: yellow, red, orange, green, blue, purple, pink, black, gray, white." })
}
@ -479,6 +489,22 @@ pub fn tools() -> Vec<ToolDef> {
.destructive()
.cmd("edit.remove_links")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("measure_distance", "Measure distance", "Add an undoable two-point distance annotation using the scale of the first point's viewport.")
.cmd("measure.distance").with(measure_schema()),
t("measure_perimeter", "Measure perimeter", "Add an undoable connected-line length annotation. To include a closing edge, repeat the first point at the end.")
.cmd("measure.perimeter").with(measure_schema()),
t("measure_area", "Measure area", "Add an undoable area annotation from a simple polygon. The last edge closes automatically.")
.cmd("measure.area").with(measure_schema()),
t("measure_info", "Read a measurement", "Calculate a live distance, perimeter or area, deltas, angle and scale without adding an annotation. Incomplete paths are allowed.")
.ro().cmd("measure.info").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"points":measure_points(),"type":{"type":"string","enum":["distance","perimeter","area"]}}), &["doc","page","points"])),
t("measure_list", "List measurements", "Saved measurement annotations with calculated values, scale and vertices in display coordinates. Measurements with unsupported imported formats (compound or fractional units, non-rectilinear scales) or invalid geometry are listed under unsupported with a reason.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc"])),
t("measure_scale", "Set or read a measurement scale", "Read the scale at a point, or add a rectangular viewport using units_per_point or two calibration points and their real-world distance. Existing measurements retain their original scales. Undoable.")
.cmd("measure.scale").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"rect":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4},"name":{"type":"string"},"unit":{"type":"string"},"precision":{"type":"integer","minimum":0,"maximum":6},"units_per_point":{"type":"number","exclusiveMinimum":0},"points":measure_points(),"distance":{"type":"number","exclusiveMinimum":0}}), &["doc","page"])),
t("measure_snap", "Snap a measurement vertex", "Snap a point to vector paths, endpoints, midpoints or intersections. Coordinates and tolerance are in display points. Bounded extraction reports truncated geometry.")
.ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])),
t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.")
.cmd("measure.export").with(schema(json!({"doc":doc(),"out":path_arg()}), &["doc","out"])),
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])),

View File

@ -1923,3 +1923,103 @@ fn cut_stack_printing_through_tools() {
assert!(a.call("doc_print", &json!({"doc": doc, "layout": "multiple", "order": "cut-stack", "path": "../escaped.pdf"})).is_err());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn measurements_calibrate_draw_save_reopen_and_export() {
let dir = workdir("measurements");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
let scale = ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"points":[[10,10],[70,10]],"distance":6,"unit":"m","precision":3}));
assert!((scale["scale"]["x"].as_f64().unwrap() - 0.1).abs() < 1e-12);
for (tool, points) in [
("measure_distance", json!([[10, 20], [70, 100]])),
("measure_perimeter", json!([[10, 20], [70, 20], [70, 100]])),
("measure_area", json!([[10, 20], [70, 20], [70, 100], [10, 100]])),
] {
ok(&mut a, tool, json!({"doc":doc,"page":1,"points":points,"label":"Room, \"A\"","author":"Tester"}));
}
let all = ok(&mut a, "measure_list", json!({"doc":doc}));
assert_eq!(all["count"], 3);
assert_eq!(all["unsupported"], json!([]));
assert_eq!(all["truncated"], false);
for (m, value) in all["measurements"].as_array().unwrap().iter().zip([10.0, 14.0, 48.0]) {
assert!((m["reading"]["value"].as_f64().unwrap() - value).abs() < 1e-6);
assert_eq!(m["page"], 1);
assert_eq!(m["label"], "Room, \"A\"");
}
let preview = ok(&mut a, "measure_info", json!({"doc":doc,"page":1,"type":"area","points":[[10,20],[70,20],[70,100],[10,100]]}));
assert!((preview["reading"]["value"].as_f64().unwrap() - 48.0).abs() < 1e-6);
let rendered = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap();
assert!(matches!(rendered.first(), Some(Content::Png { .. })));
ok(&mut a, "edit_undo", json!({"doc":doc}));
assert_eq!(ok(&mut a, "measure_list", json!({"doc":doc}))["count"], 2);
ok(&mut a, "edit_redo", json!({"doc":doc}));
ok(&mut a, "doc_save", json!({"doc":doc,"path":"measured.pdf"}));
let reopened = ok(&mut a, "doc_open", json!({"path":"measured.pdf"}))["doc"].as_u64().unwrap();
let after = ok(&mut a, "measure_list", json!({"doc":reopened}));
assert_eq!(after["measurements"], all["measurements"]);
let exported = ok(&mut a, "measure_export", json!({"doc":reopened,"out":"measurements.csv"}));
assert_eq!((exported["count"].as_u64(), exported["unsupported"].as_u64()), (Some(3), Some(0)));
let csv = std::fs::read_to_string(dir.join("measurements.csv")).unwrap();
assert!(csv.contains("area,48,\"m^2\",\"Room, \"\"A\"\"\""), "{csv}");
assert!(a.call("measure_export", &json!({"doc":doc,"out":"../outside.csv"})).is_err());
// A new viewport changes future readings, without recalibrating saved measurements.
ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"units_per_point":1,"rect":[0,0,50,50],"unit":"cm"}));
assert_eq!(ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"at":[20,20]}))["scale"]["unit"], "cm");
assert_eq!(ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"at":[80,80]}))["scale"]["unit"], "m");
assert_eq!(ok(&mut a, "measure_list", json!({"doc":doc}))["measurements"], all["measurements"]);
// Rotate the page, then measure in its displayed coordinates.
ok(&mut a, "page_rotate", json!({"doc":doc,"pages":[1],"degrees":90}));
ok(&mut a, "measure_distance", json!({"doc":doc,"page":1,"points":[[100,100],[180,160]]}));
let all = ok(&mut a, "measure_list", json!({"doc":doc}));
let last = all["measurements"].as_array().unwrap().last().unwrap();
assert!((last["reading"]["value"].as_f64().unwrap() - 10.0).abs() < 1e-6);
assert_eq!(last["points"], json!([[100.0, 100.0], [180.0, 160.0]]));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn measurements_bad_arguments_leave_document_and_history_unchanged() {
let dir = workdir("measurement-errors");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
let before = ok(&mut a, "doc_list", json!({}));
for (tool, args) in [
("measure_distance", json!({"doc":doc,"page":1,"points":[[0,0]]})),
("measure_distance", json!({"doc":doc,"page":1,"points":[[0,0],[0,0]]})),
("measure_area", json!({"doc":doc,"page":1,"points":[[0,0],[20,20],[0,20],[20,0]]})),
("measure_scale", json!({"doc":doc,"page":1,"points":[[0,0],[0,0]],"distance":10})),
("measure_snap", json!({"doc":doc,"page":1,"at":[1e100,0]})),
("measure_scale", json!({"doc":doc,"page":1,"units_per_point":1,"precision":8})),
] {
assert!(a.call(tool, &args).is_err(), "{tool} {args}");
assert_eq!(ok(&mut a, "doc_list", json!({})), before);
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn measurement_snap_tool_covers_all_targets() {
let dir = workdir("measurement-snap");
let mut a = auto(&dir);
let source = String::from_utf8(fixture(1)).unwrap();
let old = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET";
let drawing = "10 20 m 110 20 l S 60 0 m 60 80 l S";
assert!(drawing.len() <= old.len());
let source = source.replace(old, &format!("{drawing:<width$}", width = old.len()));
std::fs::write(dir.join("drawing.pdf"), source).unwrap();
let doc = ok(&mut a, "doc_open", json!({"path":"drawing.pdf"}))["doc"].as_u64().unwrap();
for (at, kind, point, midpoints) in [
([11, 280], "endpoint", [10, 280], true),
([60, 259], "midpoint", [60, 260], true),
([59, 279], "intersection", [60, 280], false),
([32, 278], "path", [32, 280], true),
] {
let snap = ok(&mut a, "measure_snap", json!({"doc":doc,"page":1,"at":at,"tolerance":3,"midpoints":midpoints}));
assert_eq!(snap["snap"]["kind"], kind);
assert_eq!(snap["snap"]["point"], json!(point.map(f64::from)));
assert_eq!(snap["truncated"], false);
}
assert!(ok(&mut a, "measure_snap", json!({"doc":doc,"page":1,"at":[180,180]}))["snap"].is_null());
let _ = std::fs::remove_dir_all(dir);
}

View File

@ -9,6 +9,7 @@ license.workspace = true
workspace = true
[dependencies]
pdfcraft-measure = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg", "tiff"] }
pdfcraft-render = { workspace = true }
pdfcraft-cos = { workspace = true }

View File

@ -403,11 +403,17 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
icon: "ruler",
hue: PINK,
badge: None,
availability: Planned("M12"),
availability: Ready,
sections: &[ToolSection {
title: "Measure",
items: &[
item("Measuring tool", "ruler", "measure.distance", Planned("M12")),
item("Distance", "ruler", "measure.distance", Ready),
item("Perimeter", "ruler", "measure.perimeter", Ready),
item("Area", "ruler", "measure.area", Ready),
item("Drawing scale", "ruler", "measure.scale", Ready),
item("Measurement information", "ruler", "measure.info", Ready),
item("Snapping", "ruler", "measure.snap", Ready),
item("Export measurements", "file-output", "measure.export", Ready),
item("Geospatial location", "compass", "measure.geo", Planned("M12")),
],
}],

View File

@ -147,6 +147,13 @@ const HELP: Option<&str> = Some("Help");
pub const COMMANDS: &[CommandSpec] = &[
c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"),
c("create.blank", "New blank PDF", FILE, None, Nothing, "file-plus-2"),
c("measure.distance", "Measure distance", None, None, Annotate, "ruler"),
c("measure.perimeter", "Measure perimeter", None, None, Annotate, "ruler"),
c("measure.area", "Measure area", None, None, Annotate, "ruler"),
c("measure.scale", "Set measurement scale", None, None, Annotate, "ruler"),
c("measure.info", "Measurement information", None, None, Document, "ruler"),
c("measure.snap", "Measurement snapping", None, None, Document, "ruler"),
c("measure.export", "Export measurements as CSV", None, None, Document, "file-output"),
c("page.copy", "Copy pages", None, None, Document, "copy"),
c("page.cut", "Cut pages", None, None, Assembly, "scissors"),
c("page.paste", "Paste pages", None, None, Assembly, "clipboard-paste"),

View File

@ -39,6 +39,7 @@ pub use pdfcraft_forms::{
pub use pdfcraft_a11y as a11y;
pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine};
pub use pdfcraft_measure as measure;
pub use pdfcraft_xfa::Report as XfaLayout;
/// A change to an existing page image.
@ -147,7 +148,8 @@ fn scope_of(edit: &Edit) -> Scope {
match edit {
// A file attachment also changes the Attachments list.
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
Edit::AddAnnotation(_)
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::AddCustomStamp { .. }
| Edit::DeleteAnnotation { .. }
| Edit::SetAnnotationContents { .. }
@ -255,6 +257,29 @@ impl Document {
self.editor.as_ref().and_then(|e| pdfcraft_edit::text_blocks(&e.cos, page).ok()).unwrap_or_default()
}
/// Saved measurement annotations, calculated from their geometry and PDF scales, plus
/// the ones that couldn't be read (unsupported formats are skipped, not fatal).
pub fn measurements(&self) -> Result<measure::Listing, String> {
let e = self.editor.as_ref().ok_or("the document can't be read")?;
Ok(measure::list(&e.cos))
}
pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result<measure::Scale, String> {
let e = self.editor.as_ref().ok_or("the document can't be read")?;
measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string())
}
pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result<measure::Point, String> {
let e = self.editor.as_ref().ok_or("the document can't be read")?;
measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string())
}
pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result<measure::Point, String> {
let e = self.editor.as_ref().ok_or("the document can't be read")?;
measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string())
}
pub fn measurement_paths(&self, page: usize) -> Result<measure::snap::Geometry, String> {
let e = self.editor.as_ref().ok_or("the document can't be read")?;
measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string())
}
/// A counter that changes with every edit (for caches of derived data).
pub fn edit_generation(&self) -> u64 {
self.generation
@ -677,6 +702,15 @@ pub enum Edit {
prefix: String,
first: u32,
},
/// Add a calibrated distance, perimeter or area annotation.
AddMeasurement(measure::NewMeasurement),
/// Store a drawing scale for a rectangular viewport (PDF user space).
SetMeasurementScale {
page: usize,
bbox: [f64; 4],
name: String,
scale: measure::Scale,
},
/// Add a comment (sticky note, highlight, shape, drawing, text box…).
AddAnnotation(NewAnnotation),
/// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size
@ -1010,6 +1044,8 @@ impl Edit {
Edit::MoveBookmark { .. } => "Move bookmark".into(),
Edit::SetBookmarkPage { .. } => "Set bookmark destination".into(),
Edit::NumberPages { .. } => "Number pages".into(),
Edit::AddMeasurement(m) => format!("Measure {}", m.kind.name()),
Edit::SetMeasurementScale { .. } => "Set measurement scale".into(),
Edit::AddAnnotation(a) => format!("Add {}", annotation_noun(&a.shape)),
Edit::AddCustomStamp { .. } => "Add stamp".into(),
Edit::DeleteAnnotation { .. } => "Delete comment".into(),
@ -1146,7 +1182,8 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
Err(EditError::NotPermitted("page changes"))
}
}
Edit::AddAnnotation(_)
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::AddCustomStamp { .. }
| Edit::DeleteAnnotation { .. }
| Edit::SetAnnotationContents { .. }
@ -1159,7 +1196,8 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::MoveAnnotation { .. }
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::SetAnnotationInfo { .. } => {
| Edit::SetAnnotationInfo { .. }
| Edit::SetMeasurementScale { .. } => {
if p.annotate() {
Ok(())
} else {
@ -1323,6 +1361,10 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
}
Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?,
Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?,
Edit::AddMeasurement(m) => {
measure::add(doc, m, &cx.meta())?;
}
Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?,
Edit::AddAnnotation(a) => {
pdfcraft_annot::add_annotation(doc, a, &cx.meta())?;
}
@ -1650,6 +1692,8 @@ pub enum EditError {
Bookmark(#[from] pdfcraft_organize::OutlineError),
#[error("{0}")]
Comment(#[from] pdfcraft_annot::AnnotError),
#[error(transparent)]
Measure(#[from] measure::MeasureError),
#[error("{0}")]
Protection(String),
#[error("{0}")]

View File

@ -1411,6 +1411,41 @@ fn guard_turns_a_panic_into_an_error() {
assert_eq!(guard(|| -> u8 { panic!("page {n} is bad") }), Err("page 3 is bad".to_string()));
}
#[test]
fn measurement_edits_are_transactional_and_honour_annotation_permissions() {
let mut session = Session::default();
let id = session.open("plan.pdf", None, Arc::new(fixture(1)), None).unwrap();
let m = measure::NewMeasurement {
page: 0,
kind: measure::Kind::Distance,
points: vec![[10.0, 20.0], [40.0, 60.0]],
scale: measure::Scale::new(0.1, "m", 2).unwrap(),
style: Style::default(),
label: "Wall".into(),
author: "Tester".into(),
};
session.apply(id, Edit::AddMeasurement(m.clone())).unwrap();
let before = session.get(id).unwrap().bytes.clone();
assert_eq!(session.get(id).unwrap().measurements().unwrap().measurements[0].reading.label, "5.00 m");
let mut invalid = m.clone();
invalid.points[1][0] = f64::INFINITY;
assert!(session.apply(id, Edit::AddMeasurement(invalid)).is_err());
assert_eq!(session.get(id).unwrap().bytes, before);
session.undo(id).unwrap();
assert!(session.get(id).unwrap().measurements().unwrap().measurements.is_empty());
session.redo(id).unwrap();
assert_eq!(session.get(id).unwrap().bytes, before);
let p = pdfcraft_cos::Permissions { bits: 0, owner: false };
assert_eq!(check_permission(&Edit::AddMeasurement(m), &p), Err(EditError::NotPermitted("comments")));
assert_eq!(
check_permission(
&Edit::SetMeasurementScale { page: 0, bbox: [0.0, 0.0, 100.0, 100.0], name: "Detail".into(), scale: measure::Scale::default() },
&p
),
Err(EditError::NotPermitted("comments"))
);
}
#[test]
fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() {
let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3)));

17
crates/measure/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "pdfcraft-measure"
description = "Calibrated 2D PDF measurements, viewports and snapping"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-content = { workspace = true }
pdfcraft-annot = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }

15
crates/measure/README.md Normal file
View File

@ -0,0 +1,15 @@
# pdfcraft-measure
Layer L4 implements distance, connected-line perimeter and simple polygon area measurements in PDF user space. It uses the public measurement model in ISO 32000-2 §12.9 (measurement properties: viewports, rectilinear measure and number format dictionaries) and the measurement intents of line, polyline and polygon annotations (§12.5.6).
Measurements are ordinary Line, PolyLine or Polygon annotations with a rectilinear Measure dictionary and a normal appearance containing the numeric caption. Their scale is stored with the annotation, so changing a drawing scale does not change earlier measurements. Moving, restyling, replying to and deleting them uses the existing comment workflow.
Scale::calibrate derives a conversion from two points and a known real-world distance. set_scale appends a named rectangular page viewport, preserving existing viewports. scale_at selects the last viewport containing the first measurement vertex. Default measurements account for UserUnit; display-coordinate conversion accounts for crop boxes and page rotation.
The desktop Measure objects panel exposes all three tools, live values and coordinate deltas, 45-degree constraints, vertex cancellation, snapping, calibration and CSV export. Automation provides measure_distance, measure_perimeter, measure_area, measure_info, measure_list, measure_scale, measure_snap and measure_export. Tool coordinates and explicit units-per-point scales use physical display points.
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 20,000 segments, 40,000 endpoint and midpoint targets, 16 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.
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.

552
crates/measure/src/lib.rs Normal file
View File

@ -0,0 +1,552 @@
//! Calibrated 2D measurements in PDF user space (ISO 32000-2 §12.9, measurement properties).
//! Viewports and annotations carry standard rectilinear /Measure dictionaries; the
//! 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 snap;
use pdfcraft_annot::{Meta, NewAnnotation, Shape, Style};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
use serde::Serialize;
pub type Point = [f64; 2];
pub const MAX_POINTS: usize = 2048;
const MAX_COORD: f64 = 1e9;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum MeasureError {
#[error("{0}")]
Invalid(String),
#[error(transparent)]
Annotation(#[from] pdfcraft_annot::AnnotError),
}
type Result<T> = std::result::Result<T, MeasureError>;
fn invalid(s: &str) -> MeasureError {
MeasureError::Invalid(s.into())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Distance,
Perimeter,
Area,
}
impl Kind {
pub fn name(self) -> &'static str {
match self {
Self::Distance => "distance",
Self::Perimeter => "perimeter",
Self::Area => "area",
}
}
pub fn from_name(s: &str) -> Option<Self> {
match s {
"distance" => Some(Self::Distance),
"perimeter" => Some(Self::Perimeter),
"area" => Some(Self::Area),
_ => None,
}
}
fn intent(self) -> &'static str {
match self {
Self::Distance => "LineDimension",
Self::Perimeter => "PolyLineDimension",
Self::Area => "PolygonDimension",
}
}
}
/// Conversion from PDF user units to real-world units. Y can use a different scale.
/// Distance and area conversion factors are relative to X (the rectilinear measure
/// dictionary, ISO 32000-2 §12.9).
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Scale {
pub x: f64,
pub y: f64,
pub unit: String,
pub distance_factor: f64,
pub area_factor: f64,
pub area_unit: String,
pub precision: u8,
pub ratio: String,
}
impl Default for Scale {
fn default() -> Self {
Self {
x: 1.0 / 72.0,
y: 1.0 / 72.0,
unit: "in".into(),
distance_factor: 1.0,
area_factor: 1.0,
area_unit: "in^2".into(),
precision: 2,
ratio: "1 in = 1 in".into(),
}
}
}
impl Scale {
pub fn new(units_per_user_unit: f64, unit: &str, precision: u8) -> Result<Self> {
let out = Self {
x: units_per_user_unit,
y: units_per_user_unit,
unit: unit.into(),
distance_factor: 1.0,
area_factor: 1.0,
area_unit: format!("{unit}^2"),
precision,
ratio: format!("1 pt = {units_per_user_unit} {unit}"),
};
out.validate()?;
Ok(out)
}
pub fn calibrate(a: Point, b: Point, real_distance: f64, unit: &str, precision: u8) -> Result<Self> {
check_points(&[a, b])?;
if !real_distance.is_finite() || real_distance <= 0.0 {
return Err(invalid("calibration distance must be finite and positive"));
}
let length = distance(a, b);
if length < 1e-6 {
return Err(invalid("calibration needs two distinct points"));
}
Self::new(real_distance / length, unit, precision)
}
pub fn validate(&self) -> Result<()> {
for f in [self.x, self.y, self.distance_factor, self.area_factor] {
if !f.is_finite() || !(1e-12..=1e12).contains(&f) {
return Err(invalid("scale factors must be finite and between 1e-12 and 1e12"));
}
}
if self.precision > 6 {
return Err(invalid("precision must be between 0 and 6 decimal places"));
}
// Units are text strings, so non-ASCII labels such as "m²" are allowed.
for s in [&self.unit, &self.area_unit] {
let n = s.chars().count();
if s.trim().is_empty() || n > 24 || s.chars().any(char::is_control) {
return Err(invalid("unit labels must be 1 to 24 printable characters"));
}
}
if self.ratio.len() > 256 || self.ratio.chars().any(char::is_control) {
return Err(invalid("invalid scale ratio"));
}
Ok(())
}
pub fn dictionary(&self) -> Result<Dict> {
self.validate()?;
let format = |unit: &str, factor: f64| {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("NumberFormat"));
d.set(b"U".to_vec(), PdfString::text(unit));
d.set(b"C".to_vec(), Object::Real(factor));
d.set(b"F".to_vec(), Object::name(if self.precision == 0 { "R" } else { "D" }));
if self.precision > 0 {
d.set(b"D".to_vec(), Object::Int(10_i64.pow(u32::from(self.precision))));
}
d.set(b"FD".to_vec(), Object::Bool(true));
d.set(b"SS".to_vec(), PdfString::text(""));
Object::Array(vec![Object::Dict(d)])
};
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));
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));
d.set(b"CYX".to_vec(), Object::Real(1.0));
}
d.set(b"D".to_vec(), format(&self.unit, self.distance_factor));
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.
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.name(b"Subtype").is_some_and(|n| n != 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 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 precision = match f.name(b"F") {
Some(b"R") => 0,
None | Some(b"D") => {
let denom = f.int(b"D").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(f, b"U"), f.get(b"C").and_then(Object::as_f64).ok_or_else(|| invalid("missing conversion factor"))?, precision as u8))
};
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(Object::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")?;
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(d, b"R") };
out.validate()?;
Ok(out)
}
}
pub(crate) fn check_points(points: &[Point]) -> Result<()> {
if points.len() > MAX_POINTS || points.iter().flatten().any(|v| !v.is_finite() || v.abs() > MAX_COORD) {
return Err(invalid("measurement geometry must be finite, within 1e9 user units, and have at most 2048 vertices"));
}
Ok(())
}
pub fn distance(a: Point, b: Point) -> f64 {
(b[0] - a[0]).hypot(b[1] - a[1])
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Reading {
pub kind: Kind,
pub value: f64,
pub unit: String,
pub label: String,
pub delta_x: f64,
pub delta_y: f64,
pub angle: f64,
pub length: f64,
pub area: f64,
}
/// Live reading allows incomplete paths; adding a saved annotation validates its vertex count.
pub fn reading(kind: Kind, points: &[Point], scale: &Scale) -> Result<Reading> {
check_points(points)?;
scale.validate()?;
let mut length = 0.0;
for w in points.windows(2) {
if let [a, b] = w {
length += ((b[0] - a[0]) * scale.x).hypot((b[1] - a[1]) * scale.y);
}
}
let area = if points.len() >= 3 {
let origin = points.first().copied().unwrap_or_default();
points
.iter()
.zip(points.iter().cycle().skip(1))
.take(points.len())
.map(|(a, b)| ((a[0] - origin[0]) * (b[1] - origin[1]) - (b[0] - origin[0]) * (a[1] - origin[1])) * 0.5)
.sum::<f64>()
.abs()
* scale.x
* scale.y
* scale.area_factor
} else {
0.0
};
let delta = match (points.first(), points.last()) {
(Some(a), Some(b)) => [(b[0] - a[0]) * scale.x, (b[1] - a[1]) * scale.y],
_ => [0.0; 2],
};
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"));
}
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)]
pub struct NewMeasurement {
pub page: usize,
pub kind: Kind,
pub points: Vec<Point>,
pub scale: Scale,
pub style: Style,
pub label: String,
pub author: String,
}
/// An area's vertices without a repeated closing vertex (last == first), which some
/// writers include; the closing edge is implicit.
pub fn open_polygon(points: &[Point]) -> &[Point] {
match points {
[first, rest @ .., last] if rest.len() >= 2 && distance(*first, *last) < 1e-6 => points.get(..points.len() - 1).unwrap_or(points),
_ => points,
}
}
fn validate_geometry(kind: Kind, points: &[Point]) -> Result<()> {
check_points(points)?;
let points = if kind == Kind::Area { open_polygon(points) } else { points };
match kind {
Kind::Distance if points.len() != 2 => return Err(invalid("distance needs exactly two points")),
Kind::Perimeter if points.len() < 2 => return Err(invalid("perimeter needs at least two points")),
Kind::Area if points.len() < 3 => return Err(invalid("area needs at least three points")),
_ => {}
}
if points.windows(2).any(|p| matches!(p,[a,b] if distance(*a,*b)<1e-6)) {
return Err(invalid("adjacent measurement vertices must be distinct"));
}
if kind == Kind::Area {
for (i, (a, b)) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()).enumerate() {
for (j, (c, d)) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()).enumerate().skip(i + 2) {
if i == 0 && j + 1 == points.len() {
continue;
}
if snap::intersection(*a, *b, *c, *d).is_some() {
return Err(invalid("area boundary must not intersect itself"));
}
}
}
}
Ok(())
}
fn text(d: &Dict, k: &[u8]) -> String {
d.get(k).and_then(Object::as_string).map(PdfString::to_text).unwrap_or_default()
}
fn nums(v: impl IntoIterator<Item = f64>) -> Object {
Object::Array(v.into_iter().map(Object::Real).collect())
}
fn page(doc: &Document, index: usize) -> Result<pdfcraft_model::Page> {
pdfcraft_model::pages(doc).get(index).cloned().ok_or_else(|| invalid("measurement page does not exist"))
}
fn annot(doc: &Document, page_index: usize, index: usize) -> Result<(ObjRef, Dict)> {
let p = page(doc, page_index)?;
let list = doc.resolve(p.dict.get(b"Annots").ok_or_else(|| invalid("page has no annotations"))?);
let o = list.as_array().and_then(|a| a.get(index)).ok_or_else(|| invalid("measurement annotation does not exist"))?;
let r = o.as_ref().ok_or_else(|| invalid("inline measurement annotations cannot be edited yet"))?;
let o = doc.get(r);
Ok((r, o.as_dict().cloned().ok_or_else(|| invalid("invalid annotation dictionary"))?))
}
pub fn add(doc: &mut Document, new: &NewMeasurement, meta: &Meta) -> Result<usize> {
validate_geometry(new.kind, &new.points)?;
let info = reading(new.kind, &new.points, &new.scale)?;
if info.value <= 0.0 {
return Err(invalid("measurement must have a positive length or area"));
}
if new.label.len() > 512 || new.author.len() > 512 {
return Err(invalid("measurement label and author must be at most 512 bytes"));
}
let shape = match new.kind {
Kind::Distance => Shape::Line {
from: new.points.first().copied().ok_or_else(|| invalid("missing start"))?,
to: new.points.last().copied().ok_or_else(|| invalid("missing end"))?,
arrow: true,
},
Kind::Perimeter => Shape::PolyLine { vertices: new.points.clone() },
Kind::Area => Shape::Polygon { vertices: new.points.clone(), cloud: false },
};
let index = pdfcraft_annot::add_annotation(
doc,
&NewAnnotation { page: new.page, shape, style: new.style.clone(), contents: info.label.clone(), author: new.author.clone() },
meta,
)?;
let (r, mut d) = annot(doc, new.page, index)?;
d.set(b"IT".to_vec(), Object::name(new.kind.intent()));
d.set(b"Measure".to_vec(), Object::Dict(new.scale.dictionary()?));
d.set(b"Subj".to_vec(), PdfString::text(new.kind.name()));
d.set(b"PCMeasureLabel".to_vec(), PdfString::text(&new.label));
d.set(b"PCMeasureValue".to_vec(), PdfString::text(&info.label));
if new.kind == Kind::Distance {
d.set(b"LE".to_vec(), Object::Array(vec![Object::name("OpenArrow"), Object::name("OpenArrow")]));
d.set(b"Cap".to_vec(), Object::Bool(true));
d.set(b"CP".to_vec(), Object::name("Top"));
}
// Leave room for the readable numeric caption above the geometry.
if let Some(rect) = d.get(b"Rect").and_then(Object::as_array) {
let values: Option<Vec<f64>> = rect.iter().map(Object::as_f64).collect();
if let Some(v) = values.filter(|v| v.len() == 4) {
let width = (info.label.len() as f64 * 6.0).max(40.0);
let cx = (v[0] + v[2]) * 0.5;
d.set(b"Rect".to_vec(), nums([v[0].min(cx - width / 2.0), v[1], v[2].max(cx + width / 2.0), v[3] + 16.0]));
}
}
doc.update_dict(r, |target| *target = d).map_err(|e| invalid(&e.to_string()))?;
pdfcraft_annot::set_appearance(doc, r)?;
Ok(index)
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Measurement {
pub page: usize,
pub index: usize,
pub id: String,
pub author: String,
pub label: String,
pub points: Vec<Point>,
pub scale: Scale,
pub reading: Reading,
}
/// A measurement annotation that couldn't be read (unsupported scale format, invalid
/// geometry). Its PDF data is left untouched.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Unsupported {
pub page: usize,
pub index: usize,
pub reason: String,
}
/// Saved measurements plus the ones that were skipped.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct Listing {
pub measurements: Vec<Measurement>,
pub unsupported: Vec<Unsupported>,
/// The annotation limit stopped enumeration early.
pub truncated: bool,
}
const MAX_ANNOTATIONS: usize = 100_000;
fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize) -> Result<Measurement> {
let scale = Scale::read(doc, d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?)?;
let key = if kind == Kind::Distance { &b"L"[..] } else { b"Vertices" };
let o = doc.resolve(d.get(key).ok_or_else(|| invalid("measurement has no vertices"))?);
let a = o.as_array().ok_or_else(|| invalid("invalid measurement vertices"))?;
if a.len() % 2 != 0 || a.len() > MAX_POINTS * 2 {
return Err(invalid("invalid measurement vertex count"));
}
let v: Vec<f64> = a.iter().map(|n| doc.resolve(n).as_f64().ok_or_else(|| invalid("invalid vertex"))).collect::<Result<_>>()?;
let points: Vec<Point> = v.as_chunks::<2>().0.to_vec();
validate_geometry(kind, &points)?;
let reading = reading(kind, &points, &scale)?;
Ok(Measurement { page, index, id: text(d, b"NM"), author: text(d, b"T"), label: text(d, b"PCMeasureLabel"), points, scale, reading })
}
/// Every measurement annotation. One that can't be read is reported in `unsupported`
/// rather than failing the whole list.
pub fn list(doc: &Document) -> Listing {
let mut out = Listing::default();
let mut count = 0usize;
for (page, p) in pdfcraft_model::pages(doc).iter().enumerate() {
let Some(o) = p.dict.get(b"Annots") else { continue };
let o = doc.resolve(o);
let Some(arr) = o.as_array() else { continue };
for (index, a) in arr.iter().enumerate() {
count = count.saturating_add(1);
if count > MAX_ANNOTATIONS {
out.truncated = true;
return out;
}
let o = doc.resolve(a);
let Some(d) = o.as_dict() else { continue };
let kind = match d.name(b"IT") {
Some(b"LineDimension") => Kind::Distance,
Some(b"PolyLineDimension") => Kind::Perimeter,
Some(b"PolygonDimension") => Kind::Area,
_ => continue,
};
match read_measurement(doc, d, kind, page, index) {
Ok(m) => out.measurements.push(m),
Err(e) => out.unsupported.push(Unsupported { page, index, reason: e.to_string() }),
}
}
}
out
}
/// Last containing viewport wins, using the first point of the measurement (ISO 32000-2 §12.9).
pub fn scale_at(doc: &Document, page_index: usize, at: Point) -> Result<Scale> {
check_points(&[at])?;
let p = page(doc, page_index)?;
if let Some(o) = p.dict.get(b"VP") {
let o = doc.resolve(o);
let arr = o.as_array().ok_or_else(|| invalid("invalid page viewports"))?;
for viewport in arr.iter().rev() {
let o = doc.resolve(viewport);
let Some(d) = o.as_dict() else { continue };
if let Some(bbox) = d.get(b"BBox") {
let o = doc.resolve(bbox);
let b: Option<Vec<f64>> = o.as_array().and_then(|a| a.iter().map(|v| doc.resolve(v).as_f64()).collect());
if let Some(b) = b.filter(|b| b.len() == 4 && b.iter().all(|v| v.is_finite()))
&& at[0] >= b[0].min(b[2])
&& at[0] <= b[0].max(b[2])
&& at[1] >= b[1].min(b[3])
&& at[1] <= b[1].max(b[3])
{
return Scale::read(doc, d.get(b"Measure").ok_or_else(|| invalid("viewport has no measurement scale"))?);
}
}
}
}
let user_unit = p.dict.get(b"UserUnit").and_then(Object::as_f64).unwrap_or(1.0);
Scale::new(user_unit / 72.0, "in", 2)
}
/// Add a named rectangular viewport. Existing viewports are preserved in drawing order.
pub fn set_scale(doc: &mut Document, page_index: usize, bbox: [f64; 4], name: &str, scale: &Scale) -> Result<()> {
check_points(&[[bbox[0], bbox[1]], [bbox[2], bbox[3]]])?;
if bbox[2] <= bbox[0] || bbox[3] <= bbox[1] || name.len() > 256 {
return Err(invalid("viewport needs a positive rectangle and a name of at most 256 bytes"));
}
let p = page(doc, page_index)?;
let vp = p.dict.get(b"VP");
let mut list = match vp {
Some(o) => doc.resolve(o).as_array().cloned().ok_or_else(|| invalid("invalid page viewports"))?,
None => Vec::new(),
};
if list.len() >= 1024 {
return Err(invalid("page has too many measurement viewports"));
}
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Viewport"));
d.set(b"BBox".to_vec(), nums(bbox));
d.set(b"Name".to_vec(), PdfString::text(name));
d.set(b"Measure".to_vec(), Object::Dict(scale.dictionary()?));
list.push(Object::Dict(d));
// An indirect /VP array stays indirect (it may be shared); otherwise the page holds it.
match vp.and_then(Object::as_ref) {
Some(r) => doc.set(r, Object::Array(list)),
None => doc.update_dict(p.obj, |d| d.set(b"VP".to_vec(), Object::Array(list))).map_err(|e| invalid(&e.to_string()))?,
}
Ok(())
}
pub fn csv(measurements: &[Measurement]) -> String {
// Prefix spreadsheet formula triggers; quoting alone does not prevent evaluation.
let field = |s: &str| {
let prefix = if s.trim_start().starts_with(['=', '+', '-', '@']) || s.starts_with(['\t', '\r']) { "'" } else { "" };
format!("\"{prefix}{}\"", s.replace('"', "\"\""))
};
let mut out = "Page,Index,Type,Value,Unit,Label,Author,Scale\r\n".to_string();
for m in measurements {
out.push_str(&format!(
"{},{},{},{},{},{},{},{}\r\n",
m.page.saturating_add(1),
m.index.saturating_add(1),
m.reading.kind.name(),
m.reading.value,
field(&m.reading.unit),
field(&m.label),
field(&m.author),
field(&m.scale.ratio)
));
}
out
}
#[cfg(test)]
mod tests;
/// Display points (top-left, y down, after Rotate/UserUnit) to PDF user space.
pub fn view_to_user(doc: &Document, page_index: usize, at: Point) -> Result<Point> {
check_points(&[at])?;
let page = page(doc, page_index)?;
let unit = page.dict.get(b"UserUnit").and_then(Object::as_f64).unwrap_or(1.0);
if !unit.is_finite() || unit <= 0.0 || unit > 75000.0 {
return Err(invalid("invalid page UserUnit"));
}
let (_, height) = page.display_size(doc);
let m = pdfcraft_content::Matrix(page.view_matrix(doc));
let (x, y) = m.apply(at[0] / unit, height - at[1] / unit);
check_points(&[[x, y]])?;
Ok([x, y])
}
pub fn user_to_view(doc: &Document, page_index: usize, at: Point) -> Result<Point> {
check_points(&[at])?;
let page = page(doc, page_index)?;
let unit = page.dict.get(b"UserUnit").and_then(Object::as_f64).unwrap_or(1.0);
if !unit.is_finite() || unit <= 0.0 || unit > 75000.0 {
return Err(invalid("invalid page UserUnit"));
}
let (_, height) = page.display_size(doc);
let m = pdfcraft_content::Matrix(page.view_matrix(doc)).invert().ok_or_else(|| invalid("invalid page transform"))?;
let (x, y) = m.apply(at[0], at[1]);
Ok([x * unit, (height - y) * unit])
}

391
crates/measure/src/snap.rs Normal file
View File

@ -0,0 +1,391 @@
//! Bounded path extraction and snapping in PDF user space, including nested Form XObjects.
use crate::{Point, Result, check_points, distance, invalid};
use pdfcraft_content::{Matrix, parse};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use serde::Serialize;
use std::collections::HashSet;
const MAX_SEGMENTS: usize = 20_000;
/// Endpoints and midpoints together, across the whole page.
const MAX_TARGETS: usize = 2 * MAX_SEGMENTS;
const MAX_BYTES: usize = 16 * 1024 * 1024;
#[derive(Clone, Debug, Default)]
pub struct Geometry {
pub segments: Vec<[Point; 2]>,
pub endpoints: Vec<Point>,
pub midpoints: Vec<Point>,
/// A limit stopped extraction early (segments, targets, bytes, streams, nesting).
pub truncated: bool,
/// Content streams that couldn't be decoded and were skipped.
pub unreadable: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SnapKind {
Endpoint,
Midpoint,
Intersection,
Path,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct Snap {
pub point: Point,
pub kind: SnapKind,
pub distance: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SnapOptions {
pub endpoints: bool,
pub midpoints: bool,
pub intersections: bool,
pub paths: bool,
}
impl Default for SnapOptions {
fn default() -> Self {
Self { endpoints: true, midpoints: true, intersections: true, paths: true }
}
}
fn dot(a: Point, b: Point) -> f64 {
a[0] * b[0] + a[1] * b[1]
}
fn sub(a: Point, b: Point) -> Point {
[a[0] - b[0], a[1] - b[1]]
}
fn cross(a: Point, b: Point) -> f64 {
a[0] * b[1] - a[1] * b[0]
}
pub fn intersection(a: Point, b: Point, c: Point, d: Point) -> Option<Point> {
let r = sub(b, a);
let s = sub(d, c);
let denominator = cross(r, s);
if !denominator.is_finite() || denominator.abs() < 1e-12 {
return None;
}
let t = cross(sub(c, a), s) / denominator;
let u = cross(sub(c, a), r) / denominator;
if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) { Some([a[0] + t * r[0], a[1] + t * r[1]]) } else { None }
}
fn nearest(p: Point, a: Point, b: Point) -> Point {
let v = sub(b, a);
let length = dot(v, v);
if length < 1e-18 {
return a;
}
let t = (dot(sub(p, a), v) / length).clamp(0.0, 1.0);
[a[0] + v[0] * t, a[1] + v[1] * t]
}
impl Geometry {
/// Dense intersection searches are capped independently from path extraction.
pub fn intersection_limited(&self, at: Point, tolerance: f64) -> bool {
self.segments.iter().filter(|[a, b]| distance(at, nearest(at, *a, *b)) <= tolerance).take(257).count() > 256
}
pub fn snap(&self, at: Point, tolerance: f64, options: SnapOptions) -> Result<Option<Snap>> {
check_points(&[at])?;
if !tolerance.is_finite() || !(0.0..=10_000.0).contains(&tolerance) {
return Err(invalid("snap tolerance must be between 0 and 10000 user units"));
}
let mut best: Option<Snap> = None;
let consider = |best: &mut Option<Snap>, p: Point, kind: SnapKind| {
let distance = distance(at, p);
// Discrete targets take precedence over projections at the same distance.
if distance <= tolerance && best.is_none_or(|b| distance < b.distance - 1e-9) {
*best = Some(Snap { point: p, kind, distance });
}
};
if options.endpoints {
for &p in &self.endpoints {
consider(&mut best, p, SnapKind::Endpoint);
}
}
if options.midpoints {
for &p in &self.midpoints {
consider(&mut best, p, SnapKind::Midpoint);
}
}
// Only segments in the pointer's neighbourhood participate in intersection tests.
// Cap the local quadratic work even on a malicious densely overlapping drawing.
let local: Vec<_> = self.segments.iter().filter(|[a, b]| distance(at, nearest(at, *a, *b)) <= tolerance).take(256).collect();
if options.intersections {
for (i, s) in local.iter().enumerate() {
for t in local.iter().skip(i + 1) {
if let Some(p) = intersection(s[0], s[1], t[0], t[1]) {
consider(&mut best, p, SnapKind::Intersection);
}
}
}
}
if options.paths && best.is_none() {
for [a, b] in &self.segments {
consider(&mut best, nearest(at, *a, *b), SnapKind::Path);
}
}
Ok(best)
}
fn targets(&self) -> usize {
self.endpoints.len().saturating_add(self.midpoints.len())
}
/// Whether the segment was accepted (finite, non-degenerate and within the cap).
fn segment(&mut self, a: Point, b: Point) -> bool {
if self.segments.len() >= MAX_SEGMENTS {
self.truncated = true;
return false;
}
if check_points(&[a, b]).is_ok() && distance(a, b) > 1e-9 {
self.segments.push([a, b]);
return true;
}
false
}
/// Snap targets are recorded only for accepted geometry, and are capped.
fn target(&mut self, ends: [Point; 2], mid: Point) {
if self.targets() >= MAX_TARGETS {
self.truncated = true;
return;
}
self.endpoints.extend(ends);
self.midpoints.push(mid);
}
fn edge(&mut self, a: Point, b: Point) {
if self.segment(a, b) {
self.target([a, b], [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]);
}
}
fn curve(&mut self, points: [Point; 4], depth: usize) {
let [a, b, c, d] = points;
if self.segments.len() >= MAX_SEGMENTS {
self.truncated = true;
return;
}
if depth >= 12 || distance(b, nearest(b, a, d)).max(distance(c, nearest(c, a, d))) <= 0.2 {
self.segment(a, d);
return;
}
let avg = |a: Point, b: Point| [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0];
let (ab, bc, cd) = (avg(a, b), avg(b, c), avg(c, d));
let (abc, bcd) = (avg(ab, bc), avg(bc, cd));
let mid = avg(abc, bcd);
self.curve([a, ab, abc, mid], depth + 1);
self.curve([mid, bcd, cd, d], depth + 1);
}
}
/// Rotate a proposed vertex onto the nearest 45-degree ray from the last vertex.
pub fn constrain(last: Point, point: Point) -> Point {
let v = sub(point, last);
let length = v[0].hypot(v[1]);
let angle = (v[1].atan2(v[0]) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4;
[last[0] + length * angle.cos(), last[1] + length * angle.sin()]
}
struct Walker<'a> {
doc: &'a Document,
geometry: Geometry,
seen: HashSet<ObjRef>,
bytes: usize,
streams: usize,
}
impl Walker<'_> {
fn walk(&mut self, object: &Object, resources: &Dict, initial: Matrix, depth: usize) -> Result<()> {
if depth >= 32 || self.streams >= 4096 || self.geometry.truncated {
self.geometry.truncated = true;
return Ok(());
}
let o = self.doc.resolve(object);
if let Some(arr) = o.as_array() {
// Contents arrays form one stream; q/Q and paths may span stream boundaries.
let mut joined = Vec::new();
for part in arr {
let s = self.doc.resolve(part);
if let Object::Stream(s) = s.as_ref() {
let remaining = MAX_BYTES.saturating_sub(self.bytes).saturating_sub(joined.len());
if remaining == 0 {
self.geometry.truncated = true;
break;
}
// An undecodable part is skipped; the rest of the page still snaps.
match s.decoded_within(remaining) {
Ok(data) => {
joined.extend(data);
joined.push(b'\n');
}
Err(_) => self.geometry.unreadable = self.geometry.unreadable.saturating_add(1),
}
}
}
return self.ops(&joined, resources, initial, depth);
}
let Object::Stream(stream) = o.as_ref() else { return Ok(()) };
let remaining = MAX_BYTES.saturating_sub(self.bytes);
if remaining == 0 {
self.geometry.truncated = true;
return Ok(());
}
match stream.decoded_within(remaining) {
Ok(data) => self.ops(&data, resources, initial, depth),
Err(_) => {
self.geometry.unreadable = self.geometry.unreadable.saturating_add(1);
Ok(())
}
}
}
fn ops(&mut self, data: &[u8], resources: &Dict, initial: Matrix, depth: usize) -> Result<()> {
self.bytes = self.bytes.saturating_add(data.len());
self.streams = self.streams.saturating_add(1);
let mut matrix = initial;
let mut stack = Vec::new();
let mut start = None;
let mut current = None;
let mut path = Geometry::default();
let point = |m: Matrix, x: f64, y: f64| {
let (x, y) = m.apply(x, y);
[x, y]
};
for op in parse(data).ops {
if self.geometry.segments.len().saturating_add(path.segments.len()) >= MAX_SEGMENTS
|| self.geometry.targets().saturating_add(path.targets()) >= MAX_TARGETS
{
self.geometry.truncated = true;
break;
}
match String::from_utf8_lossy(&op.op).as_ref() {
"q" => {
if stack.len() < 256 {
stack.push(matrix);
} else {
// Later transforms can't be tracked reliably; keep what was found.
self.geometry.truncated = true;
break;
}
}
"Q" => {
matrix = stack.pop().unwrap_or(initial);
}
"cm" => {
if let Some(m) = Matrix::from_operands(&op.operands) {
matrix = m.then(&matrix);
}
}
"m" => {
if let Some([x, y]) = op.nums::<2>() {
let p = point(matrix, x, y);
current = Some(p);
start = Some(p);
}
}
"l" => {
if let (Some(a), Some([x, y])) = (current, op.nums::<2>()) {
let b = point(matrix, x, y);
path.edge(a, b);
current = Some(b);
}
}
"c" | "v" | "y" => {
if let Some(a) = current {
let curve = match String::from_utf8_lossy(&op.op).as_ref() {
"c" => op
.nums::<6>()
.map(|[x1, y1, x2, y2, x3, y3]| [a, point(matrix, x1, y1), point(matrix, x2, y2), point(matrix, x3, y3)]),
"v" => op.nums::<4>().map(|[x2, y2, x3, y3]| [a, a, point(matrix, x2, y2), point(matrix, x3, y3)]),
_ => op.nums::<4>().map(|[x1, y1, x3, y3]| {
let d = point(matrix, x3, y3);
[a, point(matrix, x1, y1), d, d]
}),
};
if let Some([a, b, c, d]) = curve
&& check_points(&[a, b, c, d]).is_ok()
{
let before = path.segments.len();
path.curve([a, b, c, d], 0);
if path.segments.len() > before {
path.target([a, d], [(a[0] + 3.0 * b[0] + 3.0 * c[0] + d[0]) / 8.0, (a[1] + 3.0 * b[1] + 3.0 * c[1] + d[1]) / 8.0]);
}
current = Some(d);
}
}
}
"h" => {
if let (Some(a), Some(b)) = (current, start) {
path.edge(a, b);
current = Some(b);
}
}
"re" => {
if let Some([x, y, w, h]) = op.nums::<4>() {
let a = point(matrix, x, y);
let b = point(matrix, x + w, y);
let c = point(matrix, x + w, y + h);
let d = point(matrix, x, y + h);
for (f, t) in [(a, b), (b, c), (c, d), (d, a)] {
path.edge(f, t);
}
current = Some(a);
start = Some(a);
}
}
"S" | "s" | "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" => {
if matches!(String::from_utf8_lossy(&op.op).as_ref(), "s" | "b" | "b*")
&& let (Some(a), Some(b)) = (current, start)
{
path.edge(a, b);
}
self.geometry.segments.append(&mut path.segments);
self.geometry.endpoints.append(&mut path.endpoints);
self.geometry.midpoints.append(&mut path.midpoints);
self.geometry.truncated |= path.truncated;
current = None;
start = None;
}
"n" => {
self.geometry.truncated |= path.truncated;
path = Geometry::default();
current = None;
start = None;
}
"Do" => {
if let Some(name) = op.name(0) {
let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o));
if let Some(o) = xobjects.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(name)) {
let r = o.as_ref();
if r.is_some_and(|r| self.seen.contains(&r)) {
continue;
}
let form = self.doc.resolve(o);
if let Object::Stream(s) = form.as_ref()
&& s.dict.name(b"Subtype") == Some(b"Form")
{
let m = s
.dict
.get(b"Matrix")
.map(|m| self.doc.resolve(m))
.and_then(|o| o.as_array().and_then(|a| Matrix::from_operands(a)))
.unwrap_or_default()
.then(&matrix);
let own = s.dict.get(b"Resources").map(|o| self.doc.resolve(o));
let res = own.as_ref().and_then(|o| o.as_dict()).unwrap_or(resources);
if let Some(r) = r {
self.seen.insert(r);
}
self.walk(o, res, m, depth + 1)?;
if let Some(r) = r {
self.seen.remove(&r);
}
}
}
}
}
_ => {}
}
}
Ok(())
}
}
pub fn geometry(doc: &Document, page_index: usize) -> Result<Geometry> {
let p = crate::page(doc, page_index)?;
let resources = p.dict.get(b"Resources").map(|o| doc.resolve(o));
let empty = Dict::new();
let resources = resources.as_ref().and_then(|o| o.as_dict()).unwrap_or(&empty);
let mut walker = Walker { doc, geometry: Geometry::default(), seen: HashSet::new(), bytes: 0, streams: 0 };
// Extraction is lenient: limits and unreadable streams are reported in the geometry.
if let Some(contents) = p.dict.get(b"Contents") {
walker.walk(contents, resources, Matrix::IDENTITY, 0)?;
}
Ok(walker.geometry)
}

321
crates/measure/src/tests.rs Normal file
View File

@ -0,0 +1,321 @@
use super::*;
use pdfcraft_cos::{SaveOptions, write_full};
use std::sync::Arc;
fn fixture(content: &str, attrs: &str, extra: &[&str]) -> Document {
fixture_with("4 0 R", content, attrs, extra)
}
fn fixture_with(contents: &str, content: &str, attrs: &str, extra: &[&str]) -> Document {
let mut objects = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /XObject << /Nested 5 0 R >> >> {attrs} >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
objects.extend(extra.iter().map(|s| s.to_string()));
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offsets {
bytes.extend(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
Document::open(Arc::new(bytes)).unwrap()
}
fn close(a: f64, b: f64) {
assert!((a - b).abs() < 1e-8, "{a} != {b}");
}
fn new(kind: Kind, points: Vec<Point>) -> NewMeasurement {
NewMeasurement {
page: 0,
kind,
points,
scale: Scale::new(2.0, "m", 3).unwrap(),
style: Style { color: [0.0, 0.47, 0.84], ..Style::default() },
label: "Room 1".into(),
author: "A".into(),
}
}
#[test]
fn calibrated_lengths_concave_areas_and_axis_scales() {
let scale = Scale::calibrate([20.0, 30.0], [23.0, 34.0], 10.0, "m", 3).unwrap();
let distance = reading(Kind::Distance, &[[20.0, 30.0], [23.0, 34.0]], &scale).unwrap();
close(distance.value, 10.0);
close(distance.delta_x, 6.0);
close(distance.delta_y, 8.0);
assert_eq!(distance.label, "10.000 m");
let path = [[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 0.0]];
close(reading(Kind::Perimeter, &path, &scale).unwrap().value, 24.0);
let concave = [[1e8, 1e8], [1e8 + 3.0, 1e8], [1e8 + 3.0, 1e8 + 1.0], [1e8 + 1.0, 1e8 + 1.0], [1e8 + 1.0, 1e8 + 3.0], [1e8, 1e8 + 3.0]];
close(reading(Kind::Area, &concave, &scale).unwrap().value, 20.0);
let reversed: Vec<_> = concave.into_iter().rev().collect();
close(reading(Kind::Area, &reversed, &scale).unwrap().value, 20.0);
let anisotropic = Scale { y: 3.0, ..Scale::new(2.0, "m", 2).unwrap() };
close(reading(Kind::Distance, &[[0.0, 0.0], [3.0, 4.0]], &anisotropic).unwrap().value, 180f64.sqrt());
close(reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [3.0, 4.0]], &anisotropic).unwrap().value, 36.0);
}
#[test]
fn viewport_precedence_first_point_user_unit_and_roundtrip() {
let mut d = fixture("", "/UserUnit 2 /Rotate 90 /CropBox [10 20 210 320]", &[]);
close(scale_at(&d, 0, [50.0, 50.0]).unwrap().x, 2.0 / 72.0);
set_scale(&mut d, 0, [10.0, 20.0, 210.0, 320.0], "large", &Scale::new(1.0, "m", 2).unwrap()).unwrap();
set_scale(&mut d, 0, [20.0, 30.0, 50.0, 60.0], "detail", &Scale::new(10.0, "m", 4).unwrap()).unwrap();
close(scale_at(&d, 0, [25.0, 35.0]).unwrap().x, 10.0);
close(scale_at(&d, 0, [100.0, 100.0]).unwrap().x, 1.0);
// An annotation carries the chosen scale even when its second point leaves the viewport.
let mut m = new(Kind::Distance, vec![[25.0, 35.0], [100.0, 35.0]]);
m.scale = scale_at(&d, 0, m.points[0]).unwrap();
add(&mut d, &m, &Meta { date: None, id: "measure".into() }).unwrap();
let bytes = write_full(&d, &SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(bytes)).unwrap();
let measurements = list(&reopened).measurements;
close(measurements[0].reading.value, 750.0);
assert_eq!(measurements[0].id, "measure");
assert_eq!(scale_at(&reopened, 0, [25.0, 35.0]).unwrap().precision, 4);
}
#[test]
fn standard_annotations_captions_restyle_move_and_unknown_keys() {
let mut d = fixture("", "", &[]);
let meta = Meta { date: None, id: String::new() };
for m in [
new(Kind::Distance, vec![[0.0, 0.0], [3.0, 4.0]]),
new(Kind::Perimeter, vec![[20.0, 0.0], [23.0, 0.0], [23.0, 4.0]]),
new(Kind::Area, vec![[40.0, 0.0], [43.0, 0.0], [43.0, 4.0]]),
] {
add(&mut d, &m, &meta).unwrap();
}
assert_eq!(list(&d).measurements.iter().map(|m| m.reading.value).collect::<Vec<_>>(), vec![10.0, 14.0, 24.0]);
let (r, dict) = annot(&d, 0, 0).unwrap();
assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..]));
d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap();
pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), &meta).unwrap();
let (_, dict) = annot(&d, 0, 0).unwrap();
assert_eq!(dict.int(b"VendorKey"), Some(42));
let ap = d.resolve(dict.get(b"AP").unwrap());
let normal = d.resolve(ap.as_dict().unwrap().get(b"N").unwrap());
let Object::Stream(stream) = normal.as_ref() else { panic!("appearance missing") };
let stream = String::from_utf8(stream.decoded().unwrap()).unwrap();
assert!(stream.contains("(10.000 m) Tj"), "{stream}");
pdfcraft_annot::move_annotation(&mut d, 0, 0, 50.0, 60.0, &meta).unwrap();
assert_eq!(list(&d).measurements[0].points, vec![[50.0, 60.0], [53.0, 64.0]]);
close(list(&d).measurements[0].reading.value, 10.0);
}
#[test]
fn rejects_invalid_geometry_and_unsupported_scales() {
let mut d = fixture("", "", &[]);
for points in [vec![[f64::NAN, 0.0], [2.0, 0.0]], vec![[0.0, 0.0]; 2], vec![[0.0, 0.0], [1e10, 0.0]]] {
assert!(add(&mut d, &new(Kind::Distance, points), &Meta::default()).is_err());
}
assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [2.0, 2.0], [0.0, 2.0], [2.0, 0.0]]), &Meta::default()).is_err());
assert!(Scale::new(f64::INFINITY, "m", 2).is_err());
assert!(Scale::new(1.0, "m", 7).is_err());
assert!(Scale::calibrate([0.0; 2], [0.0; 2], 1.0, "m", 2).is_err());
let mut dict = Scale::default().dictionary().unwrap();
dict.set(b"Subtype".to_vec(), Object::name("GEO"));
assert!(Scale::read(&d, &Object::Dict(dict)).unwrap_err().to_string().contains("rectilinear"));
assert!(set_scale(&mut d, 0, [0.0; 4], "bad", &Scale::default()).is_err());
}
#[test]
fn snap_paths_endpoints_midpoints_and_intersections() {
let d = fixture("10 20 m 110 20 l S 60 0 m 60 80 l S 10 100 100 40 re S", "", &[]);
let paths = snap::geometry(&d, 0).unwrap();
assert_eq!(paths.segments.len(), 6);
let opts = snap::SnapOptions::default();
let hit = paths.snap([11.0, 20.0], 3.0, opts).unwrap().unwrap();
assert_eq!(hit.kind, snap::SnapKind::Endpoint);
assert_eq!(hit.point, [10.0, 20.0]);
let hit = paths.snap([60.0, 41.0], 3.0, opts).unwrap().unwrap();
assert_eq!(hit.kind, snap::SnapKind::Midpoint);
assert_eq!(hit.point, [60.0, 40.0]);
let hit = paths.snap([59.0, 21.0], 3.0, snap::SnapOptions { midpoints: false, ..opts }).unwrap().unwrap();
assert_eq!(hit.kind, snap::SnapKind::Intersection);
assert_eq!(hit.point, [60.0, 20.0]);
let hit = paths.snap([32.0, 22.0], 3.0, opts).unwrap().unwrap();
assert_eq!(hit.kind, snap::SnapKind::Path);
assert_eq!(hit.point, [32.0, 20.0]);
assert!(paths.snap([200.0, 200.0], 3.0, opts).unwrap().is_none());
assert!(paths.snap([f64::NAN, 0.0], 3.0, opts).is_err());
}
#[test]
fn nested_forms_transforms_curves_and_cycles_are_bounded() {
let body = "0 0 m 10 0 l S 0 0 m 0 10 10 10 10 0 c S /Nested Do";
let form = format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Matrix [2 0 0 3 0 0] /Resources << /XObject << /Nested 5 0 R >> >> /Length {} >>\nstream\n{body}\nendstream",
body.len()
);
let d = fixture("q 1 0 0 1 100 200 cm /Nested Do Q", "", &[&form]);
let geometry = snap::geometry(&d, 0).unwrap();
assert_eq!(geometry.segments[0], [[100.0, 200.0], [120.0, 200.0]]);
assert!(geometry.endpoints.contains(&[120.0, 200.0]));
assert!(geometry.midpoints.contains(&[110.0, 222.5]));
assert!(geometry.segments.len() > 4 && geometry.segments.len() < 200);
let d = fixture(&"0 0 m 1 1 l S ".repeat(20_001), "", &[]);
assert!(snap::geometry(&d, 0).unwrap().truncated);
}
#[test]
fn measurement_csv_quotes_newlines_and_blocks_formula_cells() {
let mut d = fixture("", "", &[]);
let mut m = new(Kind::Distance, vec![[0.0, 0.0], [3.0, 4.0]]);
m.label = "=SUM(1,2)\n\"room\"".into();
m.author = " @cmd".into();
add(&mut d, &m, &Meta::default()).unwrap();
let csv = csv(&list(&d).measurements);
assert!(csv.contains("\"'=SUM(1,2)\n\"\"room\"\"\""));
assert!(csv.contains("\"' @cmd\""));
assert!(csv.starts_with("Page,Index,Type,Value"));
}
#[test]
fn coordinates_roundtrip_for_crop_rotation_and_user_unit() {
for rotation in [0, 90, 180, 270] {
let d = fixture("", &format!("/UserUnit 2 /Rotate {rotation} /CropBox [10 20 210 320]"), &[]);
for view in [[0.0, 0.0], [37.125, 81.375], [123.0, 175.0]] {
let user = view_to_user(&d, 0, view).unwrap();
let actual = user_to_view(&d, 0, user).unwrap();
close(actual[0], view[0]);
close(actual[1], view[1]);
}
let a = view_to_user(&d, 0, [10.0, 20.0]).unwrap();
let b = view_to_user(&d, 0, [70.0, 100.0]).unwrap();
let scale = scale_at(&d, 0, a).unwrap();
close(reading(Kind::Distance, &[a, b], &scale).unwrap().value, 100.0 / 72.0);
}
}
#[test]
fn zero_decimal_scale_uses_standard_rounding_format() {
let d = fixture("", "", &[]);
let scale = Scale::new(1.0, "m", 0).unwrap();
let dict = scale.dictionary().unwrap();
let first = dict.get(b"D").unwrap().as_array().unwrap()[0].as_dict().unwrap();
assert_eq!(first.name(b"F"), Some(&b"R"[..]));
assert_eq!(Scale::read(&d, &Object::Dict(dict)).unwrap(), scale);
}
#[test]
fn imported_measurement_appearance_is_not_silently_replaced() {
let mut d = fixture("", "", &[]);
add(&mut d, &new(Kind::Distance, vec![[10.0, 20.0], [40.0, 60.0]]), &Meta::default()).unwrap();
let (r, dict) = annot(&d, 0, 0).unwrap();
let original = dict.get(b"AP").unwrap().clone();
d.update_dict(r, |d| {
d.remove(b"PCMeasureValue");
})
.unwrap();
assert!(pdfcraft_annot::set_appearance(&mut d, r).is_err());
assert_eq!(annot(&d, 0, 0).unwrap().1.get(b"AP"), Some(&original));
close(list(&d).measurements[0].reading.value, 100.0);
}
#[test]
fn degenerate_and_dense_paths_keep_snap_targets_bounded() {
// Rejected (zero-length) segments record no endpoints or midpoints at all.
let d = fixture(&"0 0 0 0 re f ".repeat(100_000), "", &[]);
let g = snap::geometry(&d, 0).unwrap();
assert!(g.segments.is_empty() && g.endpoints.is_empty() && g.midpoints.is_empty(), "{} {}", g.endpoints.len(), g.midpoints.len());
// Every paint drains into the page geometry; the page-wide target cap still holds.
let d = fixture(&"0 0 1 1 re f ".repeat(30_000), "", &[]);
let g = snap::geometry(&d, 0).unwrap();
assert!(g.truncated);
assert!(g.endpoints.len() + g.midpoints.len() <= 40_000 + 12, "{}", g.endpoints.len() + g.midpoints.len());
assert!(g.segments.len() <= 20_000 + 4);
// Unpainted curves and lines are capped too.
let d = fixture(&"0 0 m 5 9 1 9 7 0 c 0 3 l ".repeat(30_000), "", &[]);
let g = snap::geometry(&d, 0).unwrap();
assert!(g.truncated && g.endpoints.is_empty());
}
#[test]
fn deep_graphics_state_and_unreadable_streams_are_skipped_not_fatal() {
let d = fixture(&format!("0 0 m 10 0 l S {}", "q ".repeat(300)), "", &[]);
let g = snap::geometry(&d, 0).unwrap();
assert!(g.truncated);
assert_eq!(g.segments.len(), 1);
// An unsupported filter cannot be decoded at all (corrupt Flate is decoded tolerantly).
let broken = "<< /Length 10 /Filter /NoSuchDecode >>\nstream\nnot-zlib!!\nendstream";
let d = fixture_with("[4 0 R 5 0 R]", "0 0 m 10 0 l S", "", &[broken]);
let g = snap::geometry(&d, 0).unwrap();
assert_eq!(g.unreadable, 1);
assert_eq!(g.segments.len(), 1);
let d = fixture_with("5 0 R", "", "", &[broken]);
assert_eq!(snap::geometry(&d, 0).unwrap().unreadable, 1);
}
#[test]
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).
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));
Object::Dict(f)
};
d.update_dict(r, |a| {
let mut m = Scale::default().dictionary().unwrap();
m.set(b"D".to_vec(), Object::Array(vec![format("ft", 1.0 / 864.0), format("in", 12.0)]));
a.set(b"Measure".to_vec(), Object::Dict(m));
})
.unwrap();
let listing = list(&d);
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);
// 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();
let listing = list(&d);
assert!(listing.measurements.is_empty());
assert_eq!(listing.unsupported.len(), 2);
}
#[test]
fn non_ascii_units_roundtrip_and_render_in_win_ansi() {
let mut d = fixture("", "", &[]);
let mut m = new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0]]);
m.scale = Scale { area_unit: "m²".into(), ..Scale::new(2.0, "m", 1).unwrap() };
add(&mut d, &m, &Meta::default()).unwrap();
let bytes = write_full(&d, &SaveOptions::default()).unwrap();
let d = Document::open(Arc::new(bytes)).unwrap();
let listing = list(&d);
assert!(listing.unsupported.is_empty(), "{:?}", listing.unsupported);
assert_eq!(listing.measurements[0].scale.area_unit, "m²");
assert_eq!(listing.measurements[0].reading.label, "24.0 m²");
let (_, dict) = annot(&d, 0, 0).unwrap();
let ap = d.resolve(dict.get(b"AP").unwrap());
let normal = d.resolve(ap.as_dict().unwrap().get(b"N").unwrap());
let Object::Stream(stream) = normal.as_ref() else { panic!("appearance missing") };
let data = stream.decoded().unwrap();
// "²" is WinAnsi 0xB2, written as a raw byte rather than a UTF-8 replacement character.
assert!(data.windows(5).any(|w| w == b"0 m\xb2)"), "{}", String::from_utf8_lossy(&data));
assert!(!data.windows(3).any(|w| w == "\u{fffd}".as_bytes()));
assert!(Scale::new(1.0, "m\n", 2).is_err());
assert!(Scale::new(1.0, &"x".repeat(25), 2).is_err());
}
#[test]
fn closed_polygon_with_repeated_first_vertex_is_not_self_intersecting() {
let mut d = fixture("", "", &[]);
let points = vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0], [0.0, 0.0]];
add(&mut d, &new(Kind::Area, points.clone()), &Meta::default()).unwrap();
let listing = list(&d);
assert!(listing.unsupported.is_empty(), "{:?}", listing.unsupported);
close(listing.measurements[0].reading.value, 48.0);
assert_eq!(listing.measurements[0].points, points);
// A closing duplicate doesn't turn too few vertices into a polygon.
assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [0.0, 0.0]]), &Meta::default()).is_err());
}
#[test]
fn indirect_viewport_array_stays_indirect() {
let mut d = fixture("", "/VP 5 0 R", &["[]"]);
set_scale(&mut d, 0, [0.0, 0.0, 100.0, 100.0], "plan", &Scale::new(1.0, "m", 2).unwrap()).unwrap();
let page = crate::page(&d, 0).unwrap();
let r = page.dict.get(b"VP").and_then(Object::as_ref).expect("VP stays a reference");
assert_eq!(d.get(r).as_array().map(Vec::len), Some(1));
close(scale_at(&d, 0, [50.0, 50.0]).unwrap().x, 1.0);
}

View File

@ -151,6 +151,7 @@ pub struct DocView {
pub block_drag: Option<crate::edit_text_ui::BlockDrag>,
/// Commenting state: selected comment, gestures, composer.
pub comments: crate::comments::CommentView,
pub measure: crate::measure_ui::MeasureView,
/// Form filling state: the focused field.
pub forms: crate::forms_ui::FormView,
/// Prepare a form: the selected field and the gesture in progress.
@ -262,6 +263,7 @@ impl DocView {
block_drag: None,
pending_action: None,
comments: Default::default(),
measure: Default::default(),
forms: Default::default(),
prepare: Default::default(),
content: Default::default(),
@ -1018,6 +1020,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
QuickTool::Select => !preparing,
QuickTool::Redact => true,
QuickTool::Hand
| QuickTool::Measure(_)
| QuickTool::Crop
| QuickTool::Fill(_)
| QuickTool::Field(_)
@ -1194,6 +1197,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// Comments: tools, selection, moving and resizing come before text selection.
let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed, hidden: comments_hidden };
if let QuickTool::Measure(measure_tool) = tool {
crate::measure_ui::page_input(ui, &resp, doc, view, &pcx, measure_tool);
}
// Form fields take clicks first with the Select tool (as Acrobat fills fields in
// every viewing mode); then comments; then text selection.
if let QuickTool::Stamp(kind) = tool

View File

@ -131,6 +131,7 @@ impl PdfCraftApp {
None => self.theme = next,
}
}
command if command.starts_with("measure.") => crate::measure_ui::command(self, command),
"comment.list" => self.right = Some(RightPanel::Comments),
tool if crate::comments::CommentTool::from_command(tool).is_some() => {
let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return false };

View File

@ -561,6 +561,7 @@ impl Host for crate::PdfCraftApp {
"comment_composer_open": v.comments.composer.is_some(),
})),
"quick_tool": match self.quick_tool {
crate::QuickTool::Measure(t) => format!("measure-{}", t.name()),
crate::QuickTool::Select => "select".to_string(),
crate::QuickTool::Hand => "hand".to_string(),
crate::QuickTool::Crop => "crop".to_string(),

View File

@ -1822,3 +1822,48 @@
Fill & Sign: remove saved initials 入力と署名:保存済みのイニシャルを削除
Change signature 署名を変更
Change initials イニシャルを変更
Measure distance 距離を測定
Measure perimeter 周長を測定
Measure area 面積を測定
Set measurement scale 測定縮尺を設定
Measurement information 測定情報
Measurement snapping 測定のスナップ
Export measurements as CSV 測定結果をCSVに書き出す
Distance 距離
Perimeter 周長
Area 面積
Drawing scale 図面の縮尺
Snapping スナップ
Export measurements 測定結果を書き出す
Open a PDF to measure it. 測定するPDFを開いてください。
Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area. 頂点をクリックします。Enterまたはダブルクリックで周長や面積の測定を終了します。最初の点をクリックすると面積を閉じます。
Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels. Shiftで45度刻みに制限します。Backspaceで頂点を削除し、Escapeで取り消します。
Angle 角度
Click the first point to begin. 最初の点をクリックして開始します。
Snap to drawing 図面にスナップ
Endpoints 端点
Midpoints 中点
Intersections 交点
Paths パス
Sensitivity 感度
Unit 単位
Decimals 小数桁数
Calibrate from two points 2点で校正
Entire page ページ全体
Viewport rectangle in page points (x, y, width, height) ページ上の矩形(ポイント単位:x、y、幅、高さ)
Viewport ビューポート
Apply scale 縮尺を適用
Saved measurements 保存済みの測定
Saved measurements. 測定結果を保存しました。
Endpoint 端点
Midpoint 中点
Intersection 交点
Path パス
This drawing exceeds the snapping limit; only the first paths are used. 図面がスナップの上限を超えているため、最初のパスのみを使用します。
Some page content couldn't be read; snapping uses the rest. ページの内容の一部を読み取れませんでした。残りの内容にスナップします。
Unsupported measurements 未対応の測定
Label ラベル
{n} measurements use an unsupported format and are left out. {n} 件の測定は未対応の形式のため除外されました。
This area has too many overlapping paths; intersection snapping is limited. この領域は重なるパスが多すぎるため、交点へのスナップを制限します。
This measurement needs at least {n} points. この測定には少なくとも{n}点が必要です。

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1634,6 +1634,51 @@
Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. 在 PdfCraft 中打开的文件会显示在这里。将 PDF 拖放到任意位置即可打开。
PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft 可离线工作。无遥测、无账号,除非您自行添加服务商,否则无云端处理。
Measure distance 测量距离
Measure perimeter 测量周长
Measure area 测量面积
Set measurement scale 设置测量比例
Measurement information 测量信息
Measurement snapping 测量吸附
Export measurements as CSV 将测量结果导出为 CSV
Distance 距离
Perimeter 周长
Area 面积
Drawing scale 图面比例
Snapping 吸附
Export measurements 导出测量结果
Open a PDF to measure it. 打开 PDF 以进行测量。
Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area. 单击顶点。按 Enter 或双击以完成周长或面积测量。单击起点以闭合区域。
Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels. Shift 限制为 45 度角。Backspace 移除顶点。Escape 取消。
Angle 角度
Click the first point to begin. 单击起点以开始。
Snap to drawing 吸附到图面
Endpoints 端点
Midpoints 中点
Intersections 交点
Paths 路径
Sensitivity 灵敏度
Unit 单位
Decimals 小数位数
Calibrate from two points 使用两点校准
Entire page 整个页面
Viewport rectangle in page points (x, y, width, height) 以页面点数指定视口矩形(x、y、宽度、高度)
Viewport 视口
Apply scale 应用比例
Saved measurements 已保存的测量
Saved measurements. 已保存测量结果。
Endpoint 端点
Midpoint 中点
Intersection 交点
Path 路径
This drawing exceeds the snapping limit; only the first paths are used. 此图面超过吸附上限;仅使用最前面的路径。
Some page content couldn't be read; snapping uses the rest. 部分页面内容无法读取;将对其余内容进行对齐。
Unsupported measurements 不支持的测量
Label 标签
{n} measurements use an unsupported format and are left out. {n} 个测量使用了不支持的格式,已被排除。
This area has too many overlapping paths; intersection snapping is limited. 此区域有太多重叠路径;交点吸附受到限制。
This measurement needs at least {n} points. 此测量至少需要 {n} 个点。
# Remaining dialogs, generated history labels and diagnostics. Translated from the English meaning.
{0}: {1} {0}:{1}
{dpi} pixels/inch {dpi} 像素/英寸

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -1815,3 +1815,48 @@
Fill & Sign: remove saved initials 填寫與簽署:刪除已儲存的姓名縮寫
Change signature 變更簽名
Change initials 變更姓名縮寫
Measure distance 測量距離
Measure perimeter 測量周長
Measure area 測量面積
Set measurement scale 設定測量比例
Measurement information 測量資訊
Measurement snapping 測量吸附
Export measurements as CSV 將測量結果匯出為 CSV
Distance 距離
Perimeter 周長
Area 面積
Drawing scale 圖面比例
Snapping 吸附
Export measurements 匯出測量結果
Open a PDF to measure it. 開啟 PDF 以進行測量。
Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area. 按一下頂點。按 Enter 或按兩下以完成周長或面積測量。按一下起點以封閉區域。
Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels. Shift 限制為 45 度角。Backspace 移除頂點。Escape 取消。
Angle 角度
Click the first point to begin. 按一下起點以開始。
Snap to drawing 吸附至圖面
Endpoints 端點
Midpoints 中點
Intersections 交點
Paths 路徑
Sensitivity 靈敏度
Unit 單位
Decimals 小數位數
Calibrate from two points 使用兩點校準
Entire page 整個頁面
Viewport rectangle in page points (x, y, width, height) 以頁面點數指定視埠矩形(x、y、寬度、高度)
Viewport 視埠
Apply scale 套用比例
Saved measurements 已儲存的測量
Saved measurements. 已儲存測量結果。
Endpoint 端點
Midpoint 中點
Intersection 交點
Path 路徑
This drawing exceeds the snapping limit; only the first paths are used. 此圖面超過吸附上限;僅使用最前面的路徑。
Some page content couldn't be read; snapping uses the rest. 部分頁面內容無法讀取;將對其餘內容進行貼齊。
Unsupported measurements 不支援的測量
Label 標籤
{n} measurements use an unsupported format and are left out. {n} 個測量使用了不支援的格式,已被排除。
This area has too many overlapping paths; intersection snapping is limited. 此區域有太多重疊路徑;交點吸附受到限制。
This measurement needs at least {n} points. 此測量至少需要 {n} 個點。

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -30,6 +30,7 @@ mod crop;
mod export_ui;
mod js_ui;
mod marks_ui;
mod measure_ui;
mod ocr_ui;
mod optimize_ui;
mod search_ui;
@ -142,6 +143,7 @@ pub enum QuickTool {
Hand,
/// A commenting tool (Add comments).
Comment(comments::CommentTool),
Measure(measure_ui::Tool),
/// Crop pages by dragging a rectangle.
Crop,
/// A Fill & Sign tool.
@ -1205,6 +1207,9 @@ impl PdfCraftApp {
// `--quick select|hand|note|freetext|highlight|underline|strikeout|ink|line|arrow|square|circle`
self.quick_tool = match value {
"select" => QuickTool::Select,
measure if measure.starts_with("measure-") => QuickTool::Measure(
measure_ui::Tool::from_name(measure.trim_start_matches("measure-")).ok_or_else(|| format!("unknown tool {measure}"))?,
),
"hand" => QuickTool::Hand,
"crop" => QuickTool::Crop,
"redact" => QuickTool::Redact,

View File

@ -0,0 +1,479 @@
//! Measure objects: page-space clicks, live readings and persistent drawing scales.
use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, theme::Tokens};
use egui::{Color32, Pos2, Stroke};
use pdfcraft_engine::{
Document, Edit, Style,
measure::{self, Kind, NewMeasurement, Point, Reading, Scale},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tool {
Distance,
Perimeter,
Area,
Calibrate,
}
impl Tool {
pub fn name(self) -> &'static str {
match self {
Self::Distance => "distance",
Self::Perimeter => "perimeter",
Self::Area => "area",
Self::Calibrate => "calibrate",
}
}
pub fn from_name(s: &str) -> Option<Self> {
match s {
"distance" => Some(Self::Distance),
"perimeter" => Some(Self::Perimeter),
"area" => Some(Self::Area),
"calibrate" => Some(Self::Calibrate),
_ => None,
}
}
fn kind(self) -> Kind {
match self {
Self::Area => Kind::Area,
Self::Perimeter => Kind::Perimeter,
_ => Kind::Distance,
}
}
}
pub struct MeasureView {
pub points: Vec<Point>,
pub page: Option<usize>,
pub tool: Option<Tool>,
pub reading: Option<Reading>,
pub scale: Option<Scale>,
pub error: Option<String>,
pub snaps: measure::snap::SnapOptions,
pub snap_enabled: bool,
pub sensitivity: f64,
pub drawing_points: f64,
pub real_distance: f64,
pub unit: String,
pub precision: u8,
pub name: String,
pub whole_page: bool,
pub rect: [f64; 4],
pub label: String,
seeded_page: Option<usize>,
/// Snapping geometry (or why it failed) per (page, edit generation): extraction is not
/// repeated on every hover frame.
paths: Option<(usize, u64, Result<measure::snap::Geometry, String>)>,
/// Saved measurements per edit generation, for the panel.
listing: Option<(u64, Result<measure::Listing, String>)>,
}
impl Default for MeasureView {
fn default() -> Self {
Self {
points: Vec::new(),
page: None,
tool: None,
reading: None,
scale: None,
error: None,
snaps: Default::default(),
snap_enabled: true,
sensitivity: 6.0,
drawing_points: 72.0,
real_distance: 1.0,
unit: "in".into(),
precision: 2,
name: "Drawing scale".into(),
whole_page: true,
rect: [0.0, 0.0, 100.0, 100.0],
label: String::new(),
seeded_page: None,
paths: None,
listing: None,
}
}
}
impl MeasureView {
pub fn cancel(&mut self) {
self.points.clear();
self.page = None;
self.reading = None;
self.scale = None;
}
}
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, doc: &Document, view: &mut DocView, cx: &crate::comments::PageCx<'_>, tool: Tool) {
let page = cx.page;
let xf = cx.xf;
let author = &cx.prefs.author;
if !doc.allows_annotation() {
return;
}
let Some(info) = doc.info.pages.get(page) else { return };
let state = &mut view.measure;
if state.tool != Some(tool) {
state.cancel();
state.tool = Some(tool);
}
// Keys belong to a focused text field (the label, unit or viewport name) when there is one.
let typing = ui.ctx().egui_wants_keyboard_input();
if !typing && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
state.cancel();
}
let screen = |p: Point| {
let v = info.user_to_view(p[0] as f32, p[1] as f32);
xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph)
};
let pos = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p));
let mut hover = None;
let mut snap = None;
if let Some(pos) = pos {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
let (vx, vy) = xf.screen_to_view(pos);
let [x, y] = info.view_to_user(vx, vy);
let mut point = [f64::from(x), f64::from(y)];
if state.snap_enabled {
if state.paths.as_ref().is_none_or(|(p, g, _)| *p != page || *g != doc.edit_generation()) {
state.paths = Some((page, doc.edit_generation(), doc.measurement_paths(page)));
}
let pixels_per_unit = (xf.rect.width() / xf.pw).max(1e-6);
if let Some((_, _, Err(e))) = &state.paths {
state.error = Some(e.clone());
}
if let Some((_, _, Ok(paths))) = &state.paths {
if paths.truncated {
state.error = Some(tl!("This drawing exceeds the snapping limit; only the first paths are used.").into());
} else if paths.unreadable > 0 {
state.error = Some(tl!("Some page content couldn't be read; snapping uses the rest.").into());
}
let tolerance = doc
.measurement_to_user(page, [f64::from(vx) + state.sensitivity / f64::from(pixels_per_unit), f64::from(vy)])
.map(|p| measure::distance(point, p))
.unwrap_or(state.sensitivity / f64::from(pixels_per_unit));
if state.snaps.intersections && paths.intersection_limited(point, tolerance) {
state.error = Some(tl!("This area has too many overlapping paths; intersection snapping is limited.").into());
}
if let Ok(Some(hit)) = paths.snap(point, tolerance, state.snaps) {
point = hit.point;
snap = Some(hit.kind);
}
}
}
if ui.input(|i| i.modifiers.shift)
&& let Some(last) = state.points.last()
{
point = measure::snap::constrain(*last, point);
}
hover = Some(point);
if resp.clicked() {
if state.page != Some(page) {
state.cancel();
state.page = Some(page);
}
if state.points.is_empty() {
match doc.measurement_scale(page, point) {
Ok(scale) => {
state.scale = Some(scale);
state.error = None;
}
Err(e) => {
state.error = Some(e);
return;
}
}
}
let closes =
tool == Tool::Area && state.points.len() >= 3 && state.points.first().is_some_and(|first| screen(*first).distance(pos) <= 6.0);
let repeats = state.points.last().is_some_and(|last| measure::distance(*last, point) < 1e-6);
if !closes && !repeats && state.points.len() < measure::MAX_POINTS {
state.points.push(point);
}
let finishes = closes || matches!(tool, Tool::Distance | Tool::Calibrate) && state.points.len() == 2;
if finishes {
finish(state, &mut view.pending_edit, doc, page, tool, author);
}
}
}
if state.page == Some(page) && !state.points.is_empty() {
if (resp.double_clicked() || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) {
finish(state, &mut view.pending_edit, doc, page, tool, author);
}
if !typing && ui.input(|i| i.key_pressed(egui::Key::Backspace)) {
state.points.pop();
}
let mut points = state.points.clone();
if let Some(p) = hover
&& points.last().is_none_or(|last| measure::distance(*last, p) > 1e-6)
{
points.push(p);
}
if let Some(scale) = &state.scale {
match measure::reading(tool.kind(), &points, scale) {
Ok(reading) => state.reading = Some(reading),
Err(e) => state.error = Some(e.to_string()),
}
}
let positions: Vec<Pos2> = points.iter().map(|&p| screen(p)).collect();
let stroke = Stroke::new(1.5, Color32::from_rgb(0, 120, 214));
for pair in positions.windows(2) {
if let [a, b] = pair {
ui.painter().line_segment([*a, *b], stroke);
}
}
if tool == Tool::Area
&& let (Some(a), Some(b)) = (positions.first(), positions.last())
{
ui.painter().line_segment([*a, *b], stroke);
}
for &p in &positions {
ui.painter().circle_filled(p, 3.0, stroke.color);
}
if let (Some(p), Some(kind)) = (hover, snap) {
ui.painter().circle_stroke(screen(p), 6.0, Stroke::new(1.0, Color32::from_rgb(46, 158, 92)));
ui.painter().text(
screen(p) + egui::vec2(10.0, -10.0),
egui::Align2::LEFT_BOTTOM,
tl!(match kind {
measure::snap::SnapKind::Endpoint => "Endpoint",
measure::snap::SnapKind::Midpoint => "Midpoint",
measure::snap::SnapKind::Intersection => "Intersection",
measure::snap::SnapKind::Path => "Path",
}),
crate::theme::regular(11.0),
stroke.color,
);
}
if let (Some(p), Some(reading)) = (hover, &state.reading) {
ui.painter().text(screen(p) + egui::vec2(12.0, 12.0), egui::Align2::LEFT_TOP, &reading.label, crate::theme::semibold(12.0), stroke.color);
}
}
}
fn finish(state: &mut MeasureView, pending: &mut Option<Edit>, doc: &Document, page: usize, tool: Tool, author: &str) {
if tool == Tool::Calibrate {
if let [a, b] = state.points.as_slice() {
let a = doc.measurement_to_view(page, *a);
let b = doc.measurement_to_view(page, *b);
if let (Ok(a), Ok(b)) = (a, b) {
state.drawing_points = measure::distance(a, b);
}
}
state.cancel();
return;
}
let minimum = if tool == Tool::Area { 3 } else { 2 };
if state.points.len() < minimum {
state.error = Some(crate::i18n::fmt(tl!("This measurement needs at least {n} points."), &[("n", &minimum.to_string())]));
return;
}
if let Some(scale) = state.scale.clone() {
*pending = Some(Edit::AddMeasurement(NewMeasurement {
page,
kind: tool.kind(),
points: state.points.clone(),
scale,
style: Style { color: [0.0, 0.47, 0.84], ..Style::default() },
label: state.label.clone(),
author: author.into(),
}));
state.cancel();
}
}
pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
egui::ScrollArea::vertical().id_salt("measure-panel").show(ui, |ui| panel_body(app, ui, t));
}
fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) {
let Some((index, id)) = app.active_ids() else {
ui.label(tl!("Open a PDF to measure it."));
return;
};
let mut tool = None;
ui.horizontal(|ui| {
for (label, value) in [("Distance", Tool::Distance), ("Perimeter", Tool::Perimeter), ("Area", Tool::Area)] {
if ui.selectable_label(app.quick_tool == QuickTool::Measure(value), tl!(label)).clicked() {
tool = Some(value);
}
}
});
let Some(doc) = app.session.get(id) else { return };
let page = app.views[index].current;
let info = doc.info.pages.get(page).cloned();
let state = &mut app.views[index].measure;
if state.listing.as_ref().is_none_or(|(g, _)| *g != doc.edit_generation()) {
state.listing = Some((doc.edit_generation(), doc.measurements()));
}
if state.seeded_page != Some(page) {
state.seeded_page = Some(page);
if let (Ok(a), Ok(b)) = (doc.measurement_to_user(page, [0.0, 0.0]), doc.measurement_to_user(page, [72.0, 0.0]))
&& let Ok(scale) = doc.measurement_scale(page, a)
&& let Ok(reading) = measure::reading(Kind::Distance, &[a, b], &scale)
{
state.drawing_points = 72.0;
state.real_distance = reading.value;
state.unit = scale.unit;
state.precision = scale.precision;
}
}
ui.label(tl!("Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area."));
ui.label(tl!("Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels."));
ui.separator();
ui.label(tl!("Measurement information"));
if let Some(reading) = &state.reading {
ui.label(egui::RichText::new(&reading.label).strong());
ui.label(format!("X: {:.3} Y: {:.3}", reading.delta_x, reading.delta_y));
ui.label(format!("{}: {:.2}°", tl!("Angle"), reading.angle));
if let Some(scale) = &state.scale {
ui.label(&scale.ratio);
}
} else {
ui.label(tl!("Click the first point to begin."));
}
ui.horizontal(|ui| {
ui.label(tl!("Label"));
ui.add(egui::TextEdit::singleline(&mut state.label).desired_width(130.0).char_limit(128));
});
ui.separator();
ui.checkbox(&mut state.snap_enabled, tl!("Snap to drawing"));
if state.snap_enabled {
ui.horizontal(|ui| {
ui.checkbox(&mut state.snaps.endpoints, tl!("Endpoints"));
ui.checkbox(&mut state.snaps.midpoints, tl!("Midpoints"));
});
ui.horizontal(|ui| {
ui.checkbox(&mut state.snaps.intersections, tl!("Intersections"));
ui.checkbox(&mut state.snaps.paths, tl!("Paths"));
});
ui.add(egui::Slider::new(&mut state.sensitivity, 1.0..=20.0).text(tl!("Sensitivity")));
}
ui.separator();
ui.label(tl!("Drawing scale"));
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut state.drawing_points).range(0.000001..=1e9).prefix("pt "));
ui.label("=");
ui.add(egui::DragValue::new(&mut state.real_distance).range(0.000001..=1e12));
});
ui.horizontal(|ui| {
ui.label(tl!("Unit"));
ui.add(egui::TextEdit::singleline(&mut state.unit).desired_width(64.0).char_limit(12));
ui.add(egui::DragValue::new(&mut state.precision).range(0..=6).prefix(format!("{} ", tl!("Decimals"))));
});
if ui.button(tl!("Calibrate from two points")).clicked() {
tool = Some(Tool::Calibrate);
}
ui.checkbox(&mut state.whole_page, tl!("Entire page"));
if !state.whole_page {
ui.label(tl!("Viewport rectangle in page points (x, y, width, height)"));
for (i, name) in ["X", "Y", "Width", "Height"].iter().enumerate() {
if let Some(v) = state.rect.get_mut(i) {
ui.add(egui::DragValue::new(v).range(0.0..=1e9).prefix(format!("{}: ", tl!(name))));
}
}
}
ui.horizontal(|ui| {
ui.label(tl!("Viewport"));
ui.add(egui::TextEdit::singleline(&mut state.name).desired_width(130.0).char_limit(128));
});
let apply = ui.add_enabled(doc.allows_annotation(), egui::Button::new(tl!("Apply scale"))).clicked();
if let Some(error) = &state.error {
ui.colored_label(Color32::from_rgb(190, 50, 50), error);
}
ui.separator();
let mut select = None;
match state.listing.as_ref().map(|(_, l)| l) {
None => {}
Some(Ok(listing)) => {
ui.label(format!("{}: {}", tl!("Saved measurements"), listing.measurements.len()));
if !listing.unsupported.is_empty() {
let reasons: Vec<String> =
listing.unsupported.iter().map(|u| format!("{} {}: {}", tl!("Page"), u.page.saturating_add(1), u.reason)).collect();
ui.colored_label(Color32::from_rgb(190, 120, 30), format!("{}: {}", tl!("Unsupported measurements"), listing.unsupported.len()))
.on_hover_text(reasons.join("\n"));
}
egui::ScrollArea::vertical().id_salt("measurement-list").max_height(140.0).show(ui, |ui| {
for m in &listing.measurements {
if ui.button(format!("{} {}: {} {}", tl!("Page"), m.page.saturating_add(1), m.reading.label, m.label)).clicked() {
select = Some((m.page, m.index, m.reading.clone(), m.scale.clone()));
}
}
});
}
Some(Err(e)) => {
ui.colored_label(Color32::from_rgb(190, 50, 50), e);
}
}
let export = ui.button(tl!("Export measurements as CSV")).clicked();
if apply && let Some(info) = info {
let result =
doc.measurement_to_user(page, [0.0, 0.0]).and_then(|a| doc.measurement_to_user(page, [1.0, 0.0]).map(|b| (a, b))).and_then(|(a, b)| {
Scale::calibrate(a, b, state.real_distance / state.drawing_points, &state.unit, state.precision).map_err(|e| e.to_string())
});
match result {
Ok(scale) => {
let [x, y, w, h] = if state.whole_page { [0.0, 0.0, f64::from(info.width), f64::from(info.height)] } else { state.rect };
let a = info.view_to_user(x as f32, y as f32);
let b = info.view_to_user((x + w) as f32, (y + h) as f32);
app.views[index].pending_edit = Some(Edit::SetMeasurementScale {
page,
bbox: [f64::from(a[0].min(b[0])), f64::from(a[1].min(b[1])), f64::from(a[0].max(b[0])), f64::from(a[1].max(b[1]))],
name: state.name.clone(),
scale,
});
}
Err(e) => state.error = Some(e.to_string()),
}
}
if let Some((page, annotation, reading, scale)) = select {
app.views[index].current = page;
app.views[index].comments.selected = Some((page, annotation));
app.views[index].measure.reading = Some(reading);
app.views[index].measure.scale = Some(scale);
app.quick_tool = QuickTool::Select;
}
if let Some(tool) = tool {
app.views[index].measure.cancel();
app.quick_tool = QuickTool::Measure(tool);
}
if export {
export_csv(app);
}
}
pub(crate) fn command(app: &mut PdfCraftApp, id: &str) {
app.left = LeftPanel::Tool("measure");
app.left_open = true;
if let Some((index, _)) = app.active_ids()
&& let Some(tool) = id.strip_prefix("measure.").and_then(Tool::from_name)
{
app.views[index].measure.cancel();
app.quick_tool = QuickTool::Measure(tool);
}
if id == "measure.export" {
export_csv(app);
}
}
fn export_csv(app: &mut PdfCraftApp) {
let Some((_, id)) = app.active_ids() else { return };
let result = app.session.get(id).ok_or_else(|| "no such document".to_string()).and_then(|d| d.measurements());
match result {
Err(e) => app.notify(&e),
Ok(listing) => {
let csv = measure::csv(&listing.measurements);
if !listing.unsupported.is_empty() {
app.notify(crate::i18n::fmt(
tl!("{n} measurements use an unsupported format and are left out."),
&[("n", &listing.unsupported.len().to_string())],
));
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = app
.export_dir_override
.as_ref()
.map(|d| std::path::PathBuf::from(d).join("measurements.csv"))
.or_else(|| rfd::FileDialog::new().set_file_name("measurements.csv").add_filter("CSV", &["csv"]).save_file())
{
match crate::editing::write_atomically(&path.to_string_lossy(), csv.as_bytes()) {
Ok(()) => app.notify(tl!("Saved measurements.")),
Err(e) => app.notify(e.to_string()),
}
}
#[cfg(target_arch = "wasm32")]
if let Err(e) = crate::editing::download("measurements.csv", csv.as_bytes()) {
app.notify(&e);
}
}
}
}

View File

@ -128,6 +128,10 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static
);
});
}
if g.id == "measure" {
crate::measure_ui::panel(app, ui, t);
return;
}
// Add a stamp: the palette.
if g.id == "stamp" {
stamp_palette(app, ui, t);

View File

@ -59,6 +59,7 @@ const PICKERS: &[&str] = &[
"export.docx",
"export.html",
"export.rtf",
"measure.export",
];
#[test]

View File

@ -745,3 +745,59 @@ fn control_default_workspace_and_session_override() {
h.state_mut().open_bytes("another.pdf", None, fixture(1)).unwrap();
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read");
}
#[test]
fn measurement_tools_draw_live_calibrate_save_and_export() {
let (mut h, c) = harness_pages(1);
let doc = h.state().views[0].id;
h.state_mut().set_option("zoom", "100").unwrap();
ok(&mut h, &c, "ui.command", json!({"id":"measure.scale"}));
h.run_steps(3);
h.state_mut().views[0].measure.drawing_points = 10.0;
h.state_mut().views[0].measure.real_distance = 1.0;
h.state_mut().views[0].measure.unit = "m".into();
h.get_by_label("Apply scale").click();
h.run_steps(3);
let scale = h.state().session.get(doc).unwrap().measurement_scale(0, [20.0, 20.0]).unwrap();
assert!((scale.x - 0.1).abs() < 1e-10);
let click = |h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, x: f32, y: f32| {
let r = h.state().views[0].page_screen_rect(0).unwrap();
ok(h, c, "ui.click", json!({"x":r.left()+x*r.width()/200.0,"y":r.top()+y*r.height()/300.0}));
h.run_steps(2);
};
for (command, points) in [
("measure.distance", vec![(20.0, 30.0), (80.0, 110.0)]),
("measure.perimeter", vec![(20.0, 130.0), (80.0, 130.0), (80.0, 210.0)]),
("measure.area", vec![(100.0, 130.0), (160.0, 130.0), (160.0, 210.0), (100.0, 210.0)]),
] {
ok(&mut h, &c, "ui.command", json!({"id":command}));
for (x, y) in points {
click(&mut h, &c, x, y);
}
if command != "measure.distance" {
ok(&mut h, &c, "ui.key", json!({"key":"Enter"}));
h.run_steps(3);
}
}
let measurements = h.state().session.get(doc).unwrap().measurements().unwrap().measurements;
assert_eq!(measurements.len(), 3);
for (m, value) in measurements.iter().zip([10.0, 14.0, 48.0]) {
assert!((m.reading.value - value).abs() < 0.01, "{m:?}");
}
ok(&mut h, &c, "ui.command", json!({"id":"edit.undo"}));
h.run_steps(2);
assert_eq!(h.state().session.get(doc).unwrap().measurements().unwrap().measurements.len(), 2);
ok(&mut h, &c, "ui.command", json!({"id":"edit.redo"}));
h.run_steps(2);
let dir = std::env::temp_dir().join(format!("pdfcraft-measure-ui-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
h.state_mut().export_dir_override = Some(dir.to_string_lossy().into());
ok(&mut h, &c, "ui.command", json!({"id":"measure.export"}));
h.run_steps(2);
assert!(std::fs::read_to_string(dir.join("measurements.csv")).unwrap().contains("m^2"));
h.state_mut().set_option("quick", "measure-calibrate").unwrap();
click(&mut h, &c, 30.0, 50.0);
click(&mut h, &c, 130.0, 50.0);
assert!((h.state().views[0].measure.drawing_points - 100.0).abs() < 0.01);
let _ = std::fs::remove_dir_all(dir);
}

View File

@ -4139,11 +4139,11 @@ title = "Distance measuring tool"
area = "E"
tier = "P2"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["measure.distance"]
tools = ["measure_distance"]
evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
notes = "Two-point distance annotations with saved numeric captions and rectilinear scales. Partial: only single-unit decimal formats; compound (ft-in) and fractional formats are not supported (listed as unsupported, data kept), no leader lines or caption placement options, geospatial scales unsupported, and interop with Acrobat-authored measurements (reading theirs, Acrobat reading ours) is untested."
[[feature]]
id = "comment.measure-perimeter"
@ -4151,11 +4151,11 @@ title = "Perimeter measuring tool"
area = "E"
tier = "P2"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["measure.perimeter"]
tools = ["measure_perimeter"]
evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
notes = "Connected-line lengths with click vertices and double-click/Enter finish. Repeat the first point to include a closing edge. Saved standard PolyLineDimension annotations."
[[feature]]
id = "comment.measure-area"
@ -4163,11 +4163,11 @@ title = "Area measuring tool"
area = "E"
tier = "P2"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["measure.area"]
tools = ["measure_area"]
evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
notes = "Simple polygon areas, including concave boundaries and either winding. Self-intersecting boundaries are refused. Saved PolygonDimension annotations; no 3D surface measurement."
[[feature]]
id = "comment.measure-scale"
@ -4175,11 +4175,11 @@ title = "Scale ratio and calibration per viewport"
area = "E"
tier = "P2"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["measure.scale"]
tools = ["measure_scale"]
evidence = ["crates/measure/src/tests.rs::viewport_precedence_first_point_user_unit_and_roundtrip", "crates/measure/src/tests.rs::coordinates_roundtrip_for_crop_rotation_and_user_unit", "crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
notes = "Persistent rectangular viewports with last-containing-viewport precedence, numeric scale and two-point calibration. First vertex selects the scale; prior annotations retain theirs. Partial: single-unit decimal rectilinear scales only; compound (ft-in), fractional and differing distance units can't be authored or imported, no viewport editing/removal UI, and interop with Acrobat's scale ratio dialog and its saved viewports is untested."
[[feature]]
id = "comment.measure-snap"
@ -4187,11 +4187,11 @@ title = "Snap to paths, endpoints, midpoints and intersections"
area = "E"
tier = "P2"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["measure.snap"]
tools = ["measure_snap"]
evidence = ["crates/automation/tests/automation.rs::measurement_snap_tool_covers_all_targets", "crates/measure/src/tests.rs::nested_forms_transforms_curves_and_cycles_are_bounded", "crates/measure/src/tests.rs::snap_paths_endpoints_midpoints_and_intersections"]
notes = "Paths, endpoints, midpoints and intersections of painted vector geometry, including transformed Form XObjects. Cubics flattened to 0.2 user units. Bounded extraction and dense-intersection limits are reported. Raster/text outlines, clipping and optional-content visibility are not interpreted."
[[feature]]
id = "comment.measure-info-panel"
@ -4199,11 +4199,11 @@ title = "Measurement info panel"
area = "E"
tier = "P2"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["measure.info"]
tools = ["measure_info", "measure_list"]
evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
notes = "Live value, units, X/Y deltas, angle and selected scale while drawing; saved measurements can be selected from the panel. Rectilinear 2D readings only."
[[feature]]
id = "comment.measure-export"
@ -4211,11 +4211,11 @@ title = "Export measurements as CSV"
area = "E"
tier = "P3"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["measure.export"]
tools = ["measure_export"]
evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::measurement_csv_quotes_newlines_and_blocks_formula_cells", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"]
notes = "Atomic CSV export of page, annotation index, type, numeric value, unit, label, author and scale. Quotes embedded delimiters and prefixes spreadsheet formula triggers."
[[feature]]
id = "comment.geospatial"