From dae01bdb141aa9d752e52e352789a7c463506ffd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:37:20 +0300 Subject: [PATCH] fix(pdfimport): reference geometry precision, file and page messages, picker automation - The page transform is read as 16.16 fixed point held in single precision and each user-space coordinate as a single-precision value kept to six decimals, so imported coordinates match the reference to the last digit. - Paths wholly outside the page (crop box) or the current clip are left out. - The page's /UserUnit scales the import. - Unreadable files (no trailer, empty, not a PDF) print the "This file cannot be opened." block; encrypted files are asked for again silently. - Page prompt: digits only, "There is no N in ." past the last page; page list prompt keeps asking with "Please enter page number or *.". - Automation: pdf_import_pick (the path the next PDF picker returns) and pdf_import_dialog (page / on_screen / scale / rotation / ok / cancel). Co-Authored-By: Claude Opus 5.5 --- src/app/control/actions.rs | 36 ++++++++++++++++ src/app/update/mod.rs | 6 +++ src/modules/insert/pdf_import.rs | 72 +++++++++++++++++++++++++------- src/scene/model/pdf_raster.rs | 47 +++++++++++++++------ src/scene/model/pdf_vector.rs | 61 ++++++++++++++++++++++++--- src/sys.rs | 12 ++++++ 6 files changed, 199 insertions(+), 35 deletions(-) diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 6b4f0b49c..f2fe1f497 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -109,6 +109,8 @@ pub(super) const NAMES: &[&str] = &[ "view_background", "image_adjust", "import_file_dialog", + "pdf_import_pick", + "pdf_import_dialog", "dgn_import_dialog", "refedit_dialog", "pdf_shx_settings", @@ -2164,6 +2166,40 @@ impl OpenCADStudio { _ => return Err(bad()), } } + // The file the next PDF import picker returns, without showing + // it (value: the path). + "pdf_import_pick" => { + crate::sys::set_pdf_import_pick(string(req, "value")?.into()); + return Ok(Task::none()); + } + // The Import PDF window, one field per call: "page=" (1-based), + // "on_screen=0|1" (insertion point asked after OK), "scale=", + // "rotation=0|90|180|270", "ok" or "cancel". + "pdf_import_dialog" => { + use crate::ui::window::pdf_dialogs::{PdfDialogMsg as P, RotationChoice}; + if self.active_modal != Some(crate::app::ModalKind::PdfImportFile) { + return Err(failure("no_dialog", "The Import PDF window is not open")); + } + let spec = string(req, "value")?; + let (key, value) = spec.split_once('=').unwrap_or((spec, "")); + let bad = || failure("invalid_value", spec); + if key == "cancel" { + return Ok(Task::done(Message::CloseModal)); + } + Message::PdfDialog(match key { + "ok" => P::ImportOk, + "page" => P::ImportPageText(value.to_string()), + "on_screen" => P::ImportInsertOnScreen(value == "1"), + "scale" => P::ImportScale(value.to_string()), + "rotation" => P::ImportRotation( + RotationChoice::ALL + .into_iter() + .find(|r| value.trim().parse() == Ok(r.0)) + .ok_or_else(bad)?, + ), + _ => return Err(bad()), + }) + } // The Import File window, one field per call: "path=", // "format=", "search=", "filter=all|supported", // "recent=", "ok" or "cancel". diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 164e8ad17..2fa967327 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1871,6 +1871,12 @@ impl OpenCADStudio { Message::PdfImportPick => Task::perform( async { + // A file named by automation comes back without the picker. + if let Some(path) = crate::sys::take_pdf_import_pick() { + return std::fs::read(&path) + .map(|bytes| (path, std::sync::Arc::new(bytes))) + .map_err(|e| e.to_string()); + } let handle = crate::sys::file_dialog() .set_title(crate::t!("Select PDF File").as_ref()) .add_filter(crate::t!("PDF Files").as_ref(), &["pdf", "PDF"]) diff --git a/src/modules/insert/pdf_import.rs b/src/modules/insert/pdf_import.rs index b8eb3c2bd..788816c66 100644 --- a/src/modules/insert/pdf_import.rs +++ b/src/modules/insert/pdf_import.rs @@ -201,7 +201,8 @@ pub struct PdfFileImport { impl PdfFileImport { /// Where the page's content goes: one drawing unit per page inch times /// the scale, or, on a page with a viewport measure (a plot), its - /// measured units (a 1:1 millimetre plot comes in at its millimetres). + /// measured units (a 1:1 millimetre plot comes in at its millimetres); + /// a page's user unit multiplies either. pub fn placement(&self) -> Underlay { let mut u = Underlay::pdf(); u.insertion_point = Vector3::new(self.insertion.x, self.insertion.y, self.insertion.z); @@ -256,6 +257,25 @@ enum Step { Rotation, } +/// What a file name that is no readable PDF (damaged, empty, not a PDF) +/// prints before the name is asked for again. +const UNREADABLE_FILE: &str = "This file cannot be opened.\n\ +The problem can be caused by several things:\n\ +- The data in the file is corrupted\n\ +- The file is an earlier format that is no longer used\n\ +- The file format is not supported\n\ +Try to obtain a better copy of the file, recreate the file using different settings, \ +or convert the file to a supported file format."; + +/// A typed page number: digits only (no sign, decimals or ranges); one too +/// large for a 32-bit count is the largest one. +fn page_number(token: &str) -> Option { + if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + Some(token.parse::().map_or(i32::MAX as u64, |n| n.min(i32::MAX as u64)) as usize) +} + pub struct PdfImportCommand { step: Step, handle: Handle, @@ -331,10 +351,24 @@ impl PdfImportCommand { let Ok(bytes) = std::fs::read(&path) else { return CmdResult::ReportError(format!("File {shown} does not exist.")); }; + // A damaged file is repaired only when it still has its trailer (the + // `trailer` dictionary or a cross-reference stream); a cut-off one + // cannot be opened. + // ponytail: byte search; a compressed stream holding these bytes + // would pass the test. + let has = |key: &[u8]| bytes.windows(key.len()).any(|w| w == key); + if !has(b"trailer") && !has(b"/XRef") { + return CmdResult::ReportError(UNREADABLE_FILE.to_string()); + } crate::scene::model::pdf_raster::register_source(&path, std::sync::Arc::new(bytes)); - let Some(pages) = crate::scene::model::pdf_raster::page_count(&path) else { - // ponytail: wording for an unreadable PDF not measured. - return CmdResult::ReportError(format!("{shown} is not a valid PDF file.")); + // An encrypted file is asked for again without a message. + if crate::scene::model::pdf_raster::is_encrypted(&path) { + return CmdResult::NeedPoint; + } + let Some(pages) = + crate::scene::model::pdf_raster::page_count(&path).filter(|&pages| pages > 0) + else { + return CmdResult::ReportError(UNREADABLE_FILE.to_string()); }; self.pages = pages; self.file.path = path; @@ -351,18 +385,18 @@ impl PdfImportCommand { let page = if token.is_empty() { 1 } else { - match token.parse::() { - Ok(n) if n > 0 => n as usize, + match page_number(token) { // Zero is asked again without a message. - Ok(0) => return CmdResult::NeedPoint, - _ => { + Some(0) => return CmdResult::NeedPoint, + Some(n) => n, + None => { return CmdResult::ReportError("Please enter page number or '?'.".to_string()) } } }; - // A page past the last one is asked again without a message. if page > self.pages { - return CmdResult::NeedPoint; + let shown = crate::entities::underlay::display_path(&self.file.path); + return CmdResult::ReportError(format!("There is no {page} in {shown}.")); } self.file.page = page.to_string(); self.step = Step::Insertion; @@ -371,16 +405,23 @@ impl PdfImportCommand { fn page_list(&mut self, text: &str) -> CmdResult { let token = text.trim(); - self.step = Step::Page; if token.is_empty() || token == "*" { + self.step = Step::Page; let all: Vec = (1..=self.pages).map(|n| n.to_string()).collect(); return CmdResult::ReportMeasurement(all.join("\n")); } - match token.parse::() { - Ok(n) if (1..=self.pages).contains(&n) => { - CmdResult::ReportMeasurement(format!("Found page {n}.")) + match page_number(token) { + // Zero, or anything but a page number, is asked again. + Some(0) => CmdResult::NeedPoint, + None => CmdResult::ReportError("Please enter page number or *.".to_string()), + Some(n) => { + self.step = Step::Page; + CmdResult::ReportMeasurement(if n <= self.pages { + format!("Found page {n}.") + } else { + "No matching page found.".to_string() + }) } - _ => CmdResult::ReportMeasurement("No matching page found.".to_string()), } } @@ -576,7 +617,6 @@ impl CadCommand for PdfImportCommand { Step::Scale => match token.parse::() { Ok(v) => self.scale(v), Err(_) if token.is_empty() => self.scale(1.0), - // ponytail: wording for a non-number scale not measured. Err(_) => CmdResult::ReportError( "Requires numeric distance or second point.".to_string(), ), diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index 359164d0f..c2e16ae21 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -276,6 +276,17 @@ pub fn page_count(path: &str) -> Option { Some(pdf.pages().len()) } +/// Whether a registered PDF is encrypted: it does not open with a password +/// no document uses (an unencrypted file ignores the password). +pub fn is_encrypted(path: &str) -> bool { + source_bytes(path).is_some_and(|bytes| { + matches!( + Pdf::new_with_password(bytes, "\u{1}"), + Err(hayro::hayro_syntax::LoadPdfError::Decryption(_)) + ) + }) +} + /// Size of a 1-based page in inches (its rendered width and height at 72 /// points per inch). An underlay is one drawing unit per page inch. pub fn page_size_inches(path: &str, page: &str) -> Option<(f64, f64)> { @@ -287,25 +298,35 @@ pub fn page_size_inches(path: &str, page: &str) -> Option<(f64, f64)> { Some((w as f64 / 72.0, h as f64 / 72.0)) } -/// Drawing units per page inch that a page's measure gives: the X ratio of -/// the first viewport's measure dictionary (`/VP [<< /Measure << /X [<< /C -/// units-per-point >>] >> >>]`, written by plotters with the plot scale), -/// times 72. `None` when the page has no viewport measure. +/// Drawing units per page inch a page imports at: the X ratio of the +/// first viewport's measure dictionary (`/VP [<< /Measure << /X [<< /C +/// units-per-point >>] >> >>]`, written by plotters with the plot scale) +/// times 72, or one unit per inch without one; times the page's +/// `/UserUnit` (points per user-space unit) when it has one. pub fn page_units_per_inch(path: &str, page: &str) -> Option { use hayro::hayro_syntax::object::{Array, Dict, Number}; let bytes = source_bytes(path)?; let pdf = Pdf::new(bytes).ok()?; let page_no = page.trim().parse::().ok()?; let page = pdf.pages().get(page_no.checked_sub(1)?)?; - let viewport = page.raw().get::>(b"VP")?.iter::>().next()?; - let ratio = viewport - .get::>(b"Measure")? - .get::>(b"X")? - .iter::>() - .next()? - .get::(b"C")? - .as_f64(); - (ratio.is_finite() && ratio > 0.0).then_some(ratio * 72.0) + let positive = |v: f64| (v.is_finite() && v > 0.0).then_some(v); + let measured = (|| { + let viewport = page.raw().get::>(b"VP")?.iter::>().next()?; + let ratio = viewport + .get::>(b"Measure")? + .get::>(b"X")? + .iter::>() + .next()? + .get::(b"C")? + .as_f64(); + positive(ratio).map(|r| r * 72.0) + })(); + let user_unit = page + .raw() + .get::(b"UserUnit") + .and_then(|n| positive(n.as_f64())) + .unwrap_or(1.0); + Some(measured.unwrap_or(1.0) * user_unit) } /// Display adjustments of an underlay page, applied to its display raster. diff --git a/src/scene/model/pdf_vector.rs b/src/scene/model/pdf_vector.rs index a88c6c937..58e8d9255 100644 --- a/src/scene/model/pdf_vector.rs +++ b/src/scene/model/pdf_vector.rs @@ -267,6 +267,7 @@ fn read_page_with(path: &str, page: &str, images: bool) -> Option { ); let mut device = Collector { want_images: images, + visible: vec![Rect::new(0.0, 0.0, w as f64 / 72.0, h as f64 / 72.0)], ..Collector::default() }; interpret_page(page, &mut context, &mut device); @@ -331,6 +332,9 @@ struct Collector { want_images: bool, images: Vec, order: Vec, + /// The visible area, in inches: the page, then each clip path's box + /// within the one before it (the last is the current one). + visible: Vec, } impl Collector { @@ -358,14 +362,31 @@ fn pt(p: Point) -> [f64; 2] { [p.x, p.y] } +/// The page transform (user space → inches) at the precision PDF import +/// reads geometry with: the current transformation matrix, in points, as +/// 16.16 fixed point held in single precision — each coefficient times +/// 65536 to the nearest integer, then to the nearest single-precision +/// number (a 0.12 scale is 7864/65536; a 345.678 offset 22654352/65536) — +/// and each user-space coordinate as a single-precision number kept to six +/// decimals (100.3 is 100.300003). +fn import_precision(transform: Affine) -> impl Fn(Point) -> Point { + let points = (Affine::scale(72.0) * transform) + .as_coeffs() + .map(|v| (v * 65536.0).round() as f32 as f64 / 65536.0); + let transform = Affine::scale(1.0 / 72.0) * Affine::new(points); + let six = |v: f64| ((v as f32) as f64 * 1e6).round() / 1e6; + move |p: Point| transform * Point::new(six(p.x), six(p.y)) +} + fn subpaths(path: &BezPath, transform: Affine) -> Vec { + let transform = import_precision(transform); let mut out: Vec = Vec::new(); let mut start = Point::ZERO; let mut last = Point::ZERO; for el in path.elements() { match *el { PathEl::MoveTo(p) => { - let p = transform * p; + let p = transform(p); out.push(SubPath { segments: Vec::new(), closed: false, @@ -374,14 +395,14 @@ fn subpaths(path: &BezPath, transform: Affine) -> Vec { last = p; } PathEl::LineTo(p) => { - let p = transform * p; + let p = transform(p); if let Some(sp) = out.last_mut() { sp.segments.push(Segment::Line(pt(last), pt(p))); } last = p; } PathEl::QuadTo(c, p) => { - let (c, p) = (transform * c, transform * p); + let (c, p) = (transform(c), transform(p)); let c1 = last + (c - last) * (2.0 / 3.0); let c2 = p + (c - p) * (2.0 / 3.0); if let Some(sp) = out.last_mut() { @@ -391,7 +412,7 @@ fn subpaths(path: &BezPath, transform: Affine) -> Vec { last = p; } PathEl::CurveTo(c1, c2, p) => { - let (c1, c2, p) = (transform * c1, transform * c2, transform * p); + let (c1, c2, p) = (transform(c1), transform(c2), transform(p)); if let Some(sp) = out.last_mut() { sp.segments .push(Segment::Cubic(pt(last), pt(c1), pt(c2), pt(p))); @@ -438,6 +459,26 @@ impl<'a> Device<'a> for Collector { if subpaths.is_empty() { return; } + // A path wholly outside the visible area is left out; one that + // reaches into it is kept whole. + if let Some(area) = self.visible.last() { + let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + for seg in subpaths.iter().flat_map(|sp| &sp.segments) { + let points: &[[f64; 2]] = match seg { + Segment::Line(a, b) => &[*a, *b], + Segment::Cubic(a, b, c, d) => &[*a, *b, *c, *d], + }; + for p in points { + for k in 0..2 { + lo[k] = lo[k].min(p[k]); + hi[k] = hi[k].max(p[k]); + } + } + } + if hi[0] < area.x0 || lo[0] > area.x1 || hi[1] < area.y0 || lo[1] > area.y1 { + return; + } + } let (stroke, fill) = match mode { PathDrawMode::Stroke(props) => { // Width in points: the user-space width through the transform @@ -454,7 +495,11 @@ impl<'a> Device<'a> for Collector { fill, }); } - fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_clip_path(&mut self, clip: &ClipPath) { + let bounds = clip.path.bounding_box(); + let area = self.visible.last().map_or(bounds, |a| a.intersect(bounds)); + self.visible.push(area); + } fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} fn draw_glyph( &mut self, @@ -588,7 +633,11 @@ impl<'a> Device<'a> for Collector { self.images.push(image); } } - fn pop_clip_path(&mut self) {} + fn pop_clip_path(&mut self) { + if self.visible.len() > 1 { + self.visible.pop(); + } + } fn pop_transparency_group(&mut self) {} } diff --git a/src/sys.rs b/src/sys.rs index 85ab0916f..a37943959 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -602,6 +602,18 @@ pub fn set_file_dia(on: bool) { FILE_DIA.store(on, std::sync::atomic::Ordering::Relaxed); } +/// A file the next PDF import picker returns without showing itself (set +/// by automation, used once). +static PDF_IMPORT_PICK: std::sync::Mutex> = std::sync::Mutex::new(None); + +pub fn set_pdf_import_pick(path: std::path::PathBuf) { + *PDF_IMPORT_PICK.lock().unwrap_or_else(|e| e.into_inner()) = Some(path); +} + +pub fn take_pdf_import_pick() -> Option { + PDF_IMPORT_PICK.lock().unwrap_or_else(|e| e.into_inner()).take() +} + /// The file type ATTACH's picker was last left on (1-based; 7 = Drawing). static ATTACH_FILTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(7);