diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 1ecad8498..f6ccc0da4 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -749,9 +749,14 @@ impl OpenCADStudio { self.open_blocks_palette(Some(tab)); } - "PDFIMPORT" => { + "PDFIMPORT" | "-PDFIMPORT" => { use crate::command::CadCommand; - let command = crate::modules::insert::pdf_import::PdfImportCommand::new(); + use crate::modules::insert::pdf_import::PdfImportCommand; + let command = if cmd == "-PDFIMPORT" { + PdfImportCommand::command_line() + } else { + PdfImportCommand::new() + }; self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } diff --git a/src/modules/insert/pdf_import.rs b/src/modules/insert/pdf_import.rs index 5fa19bea4..b8eb3c2bd 100644 --- a/src/modules/insert/pdf_import.rs +++ b/src/modules/insert/pdf_import.rs @@ -199,11 +199,15 @@ pub struct PdfFileImport { } impl PdfFileImport { - /// The underlay the page would be as an attachment, for placing it. + /// 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). pub fn placement(&self) -> Underlay { let mut u = Underlay::pdf(); u.insertion_point = Vector3::new(self.insertion.x, self.insertion.y, self.insertion.z); - u.set_scale(self.scale); + let units = crate::scene::model::pdf_raster::page_units_per_inch(&self.path, &self.page) + .unwrap_or(1.0); + u.set_scale(self.scale * units); u.rotation = self.rotation; u } @@ -243,6 +247,13 @@ enum Step { Opposite, Polygon, Mode, + // The File option on the command line (-PDFIMPORT, or FILEDIA 0). + FileName, + Page, + PageList, + Insertion, + Scale, + Rotation, } pub struct PdfImportCommand { @@ -251,6 +262,10 @@ pub struct PdfImportCommand { picked: Option, points: Vec, area: ImportArea, + /// -PDFIMPORT: the file is always asked on the command line. + dash: bool, + file: PdfFileImport, + pages: usize, } impl PdfImportCommand { @@ -271,9 +286,118 @@ impl PdfImportCommand { picked: None, points: Vec::new(), area: ImportArea::All, + dash: false, + file: PdfFileImport { + path: String::new(), + page: "1".to_string(), + scale: 1.0, + rotation: 0.0, + insertion: DVec3::ZERO, + }, + pages: 0, } } + /// -PDFIMPORT. + pub fn command_line() -> Self { + Self { + dash: true, + ..Self::new() + } + } + + /// The File option: the file dialog while FILEDIA is on (PDFIMPORT + /// only), otherwise the file name prompt. + fn file_option(&mut self) -> CmdResult { + if !self.dash && crate::sys::file_dia() { + return CmdResult::Dispatch("_PDFIMPORTFILE".to_string()); + } + self.step = Step::FileName; + CmdResult::NeedPoint + } + + fn file_name(&mut self, text: &str) -> CmdResult { + let typed = text.trim().trim_matches('"'); + if typed.is_empty() { + return CmdResult::ReportError("Filename cannot be blank".to_string()); + } + // A name without an extension is a .pdf file. + let path = if std::path::Path::new(typed).extension().is_none() { + format!("{typed}.pdf") + } else { + typed.to_string() + }; + let shown = crate::entities::underlay::display_path(&path); + let Ok(bytes) = std::fs::read(&path) else { + return CmdResult::ReportError(format!("File {shown} does not exist.")); + }; + 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.")); + }; + self.pages = pages; + self.file.path = path; + self.step = Step::Page; + CmdResult::NeedPoint + } + + fn page(&mut self, text: &str) -> CmdResult { + let token = text.trim(); + if token == "?" { + self.step = Step::PageList; + return CmdResult::NeedPoint; + } + let page = if token.is_empty() { + 1 + } else { + match token.parse::() { + Ok(n) if n > 0 => n as usize, + // Zero is asked again without a message. + Ok(0) => return CmdResult::NeedPoint, + _ => { + 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; + } + self.file.page = page.to_string(); + self.step = Step::Insertion; + CmdResult::NeedPoint + } + + fn page_list(&mut self, text: &str) -> CmdResult { + let token = text.trim(); + self.step = Step::Page; + if token.is_empty() || token == "*" { + 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}.")) + } + _ => CmdResult::ReportMeasurement("No matching page found.".to_string()), + } + } + + fn scale(&mut self, value: f64) -> CmdResult { + if value <= 0.0 { + return CmdResult::ReportError("Value must be positive and nonzero.".to_string()); + } + self.file.scale = value; + self.step = Step::Rotation; + CmdResult::NeedPoint + } + + fn rotation(&mut self, radians: f64) -> CmdResult { + self.file.rotation = radians; + CmdResult::PdfImportFile(self.file.clone()) + } + fn area_choice(&mut self, text: &str) -> CmdResult { match text.trim().to_ascii_uppercase().as_str() { "" | "A" | "ALL" => { @@ -308,7 +432,11 @@ impl PdfImportCommand { impl CadCommand for PdfImportCommand { fn name(&self) -> &'static str { - "PDFIMPORT" + if self.dash { + "-PDFIMPORT" + } else { + "PDFIMPORT" + } } fn prompt(&self) -> String { @@ -324,6 +452,12 @@ impl CadCommand for PdfImportCommand { Step::Mode => { "Keep, Detach or Unload PDF underlay? [Keep/Detach/Unload] :".to_string() } + Step::FileName => "Enter filename for PDF import:".to_string(), + Step::Page => "Enter page number or [?] <1>:".to_string(), + Step::PageList => "Enter page to list <*>:".to_string(), + Step::Insertion => "Specify insertion point <0,0>:".to_string(), + Step::Scale => "Specify scale factor <1.0>:".to_string(), + Step::Rotation => "Specify rotation <0>:".to_string(), } } @@ -347,13 +481,17 @@ impl CadCommand for PdfImportCommand { fn input_kind(&self) -> InputKind { match self.step { - Step::Mode => InputKind::SingleToken, + Step::Mode | Step::Page | Step::PageList => InputKind::SingleToken, + Step::FileName => InputKind::FreeText, _ => InputKind::Point, } } fn point_step_accepts_keywords(&self) -> bool { - matches!(self.step, Step::Select | Step::Area | Step::Polygon) + matches!( + self.step, + Step::Select | Step::Area | Step::Polygon | Step::Scale | Step::Rotation + ) } fn needs_entity_pick(&self) -> bool { @@ -401,6 +539,21 @@ impl CadCommand for PdfImportCommand { self.points.push(pt); CmdResult::NeedPoint } + Step::Insertion => { + self.file.insertion = pt; + self.step = Step::Scale; + CmdResult::NeedPoint + } + // A second point gives the scale, or the angle, from the + // insertion point. + Step::Scale => { + let d = (pt - self.file.insertion).truncate().length(); + self.scale(d) + } + Step::Rotation => { + let d = (pt - self.file.insertion).truncate(); + self.rotation(d.y.atan2(d.x)) + } _ => CmdResult::NeedPoint, } } @@ -408,8 +561,8 @@ impl CadCommand for PdfImportCommand { fn on_text_input(&mut self, text: &str) -> Option { let token = text.trim().to_ascii_uppercase(); Some(match self.step { - Step::Select if token == "F" || token == "FILE" => { - CmdResult::Dispatch("_PDFIMPORTFILE".to_string()) + Step::Select if matches!(token.as_str(), "F" | "FILE" | "_F" | "_FILE") => { + self.file_option() } Step::Area => self.area_choice(&token), Step::Polygon if token == "U" || token == "UNDO" => { @@ -417,13 +570,31 @@ impl CadCommand for PdfImportCommand { CmdResult::NeedPoint } Step::Mode => self.mode_choice(&token), + Step::FileName => self.file_name(text), + Step::Page => self.page(text), + Step::PageList => self.page_list(text), + 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(), + ), + }, + Step::Rotation => match token.parse::() { + Ok(v) => self.rotation(v.to_radians()), + Err(_) if token.is_empty() => self.rotation(0.0), + Err(_) => CmdResult::ReportError( + "Requires valid numeric angle or second point.".to_string(), + ), + }, _ => return None, }) } fn on_enter(&mut self) -> CmdResult { match self.step { - Step::Select => CmdResult::Dispatch("_PDFIMPORTFILE".to_string()), + Step::Select => self.file_option(), Step::Area => self.area_choice(""), Step::Polygon if self.points.len() >= 3 => { self.area = ImportArea::Polygon(self.points.clone()); @@ -433,6 +604,12 @@ impl CadCommand for PdfImportCommand { Step::Polygon => CmdResult::NeedPoint, Step::Mode => self.mode_choice(""), Step::Opposite => CmdResult::NeedPoint, + Step::FileName => self.file_name(""), + Step::Page => self.page(""), + Step::PageList => self.page_list(""), + Step::Insertion => self.on_point(DVec3::ZERO), + Step::Scale => self.scale(1.0), + Step::Rotation => self.rotation(0.0), } } @@ -659,6 +836,312 @@ fn arc_bulge(seg: &Segment) -> Option { Some(2.0 * h / chord) } +/// A subpath cut where its curves are not circular arcs. +enum Piece { + /// Points, bulges (as in [`Stroke`]) and whether it closes. + Run(Vec<[f64; 2]>, Vec, bool), + /// A cubic that is no circular arc. + Curve(Segment), +} + +/// The straight-and-arc runs of a subpath between its other curves. A +/// subpath without other curves is one run (closed when it closes). +fn run_strokes(segments: &[Segment], closed: bool) -> Vec { + let whole = !segments + .iter() + .any(|s| matches!(s, Segment::Cubic(..)) && arc_bulge(s).is_none()); + let mut out = Vec::new(); + let mut points: Vec<[f64; 2]> = Vec::new(); + let mut bulges: Vec = Vec::new(); + let flush = |points: &mut Vec<[f64; 2]>, bulges: &mut Vec, out: &mut Vec| { + if points.len() > 1 { + bulges.push(0.0); + out.push(Piece::Run( + std::mem::take(points), + std::mem::take(bulges), + false, + )); + } + points.clear(); + bulges.clear(); + }; + for seg in segments { + let bulge = match seg { + Segment::Line(..) => Some(0.0), + cubic => arc_bulge(cubic), + }; + match bulge { + Some(b) => { + if points.is_empty() { + points.push(seg.start()); + } + points.push(seg.end()); + bulges.push(b); + } + None => { + flush(&mut points, &mut bulges, &mut out); + out.push(Piece::Curve(seg.clone())); + } + } + } + if whole && closed && points.len() > 2 { + // The closing point is the first again. + points.pop(); + out.push(Piece::Run(points, bulges, true)); + } else { + flush(&mut points, &mut bulges, &mut out); + } + out +} + +/// Circle through three points: centre and radius. +fn circle_through(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> Option<([f64; 2], f64)> { + let d = 2.0 * (a[0] * (b[1] - c[1]) + b[0] * (c[1] - a[1]) + c[0] * (a[1] - b[1])); + if d.abs() < 1e-12 { + return None; + } + let (a2, b2, c2) = ( + a[0] * a[0] + a[1] * a[1], + b[0] * b[0] + b[1] * b[1], + c[0] * c[0] + c[1] * c[1], + ); + let x = (a2 * (b[1] - c[1]) + b2 * (c[1] - a[1]) + c2 * (a[1] - b[1])) / d; + let y = (a2 * (c[0] - b[0]) + b2 * (a[0] - c[0]) + c2 * (b[0] - a[0])) / d; + Some(([x, y], (a[0] - x).hypot(a[1] - y))) +} + +/// An ellipse: centre, unit major direction, semi-axes. +struct Conic { + center: [f64; 2], + major: [f64; 2], + a: f64, + b: f64, +} + +impl Conic { + /// The ellipse through five points (none at their centroid). + fn through(points: &[[f64; 2]; 5]) -> Option { + let o = [ + points.iter().map(|p| p[0]).sum::() / 5.0, + points.iter().map(|p| p[1]).sum::() / 5.0, + ]; + // A x² + B xy + C y² + D x + E y = 1 about the centroid. + let mut m = [[0.0f64; 6]; 5]; + for (row, p) in m.iter_mut().zip(points) { + let (x, y) = (p[0] - o[0], p[1] - o[1]); + *row = [x * x, x * y, y * y, x, y, 1.0]; + } + for col in 0..5 { + let pivot = (col..5).max_by(|i, j| m[*i][col].abs().total_cmp(&m[*j][col].abs()))?; + m.swap(col, pivot); + if m[col][col].abs() < 1e-18 { + return None; + } + for row in 0..5 { + if row != col { + let f = m[row][col] / m[col][col]; + for k in col..6 { + m[row][k] -= f * m[col][k]; + } + } + } + } + let s: Vec = (0..5).map(|i| m[i][5] / m[i][i]).collect(); + let (a, b, c, d, e) = (s[0], s[1], s[2], s[3], s[4]); + let det = 4.0 * a * c - b * b; + if det <= 0.0 { + return None; + } + let x0 = (b * e - 2.0 * c * d) / det; + let y0 = (b * d - 2.0 * a * e) / det; + let f = a * x0 * x0 + b * x0 * y0 + c * y0 * y0 + d * x0 + e * y0 - 1.0; + if f >= 0.0 { + return None; + } + // Eigen-directions of the quadratic part; the smaller value is the + // major axis. + let theta = 0.5 * b.atan2(a - c); + let (ct, st) = (theta.cos(), theta.sin()); + let q1 = a * ct * ct + b * ct * st + c * st * st; + let q2 = a * st * st - b * ct * st + c * ct * ct; + let (r1, r2) = ((-f / q1).sqrt(), (-f / q2).sqrt()); + let (major, ra, rb) = if r1 >= r2 { + ([ct, st], r1, r2) + } else { + ([-st, ct], r2, r1) + }; + Some(Self { + center: [x0 + o[0], y0 + o[1]], + major, + a: ra, + b: rb, + }) + } + + /// (u, v) of a point in the ellipse's own frame, scaled to the unit + /// circle. + fn unit(&self, p: [f64; 2]) -> (f64, f64) { + let (dx, dy) = (p[0] - self.center[0], p[1] - self.center[1]); + let u = dx * self.major[0] + dy * self.major[1]; + let v = -dx * self.major[1] + dy * self.major[0]; + (u / self.a, v / self.b) + } +} + +/// Turning of a run of points about a centre: positive counter-clockwise. +fn sweep_sign(points: &[[f64; 2]], c: [f64; 2]) -> f64 { + points + .windows(2) + .map(|w| (w[0][0] - c[0]) * (w[1][1] - c[1]) - (w[0][1] - c[1]) * (w[1][0] - c[0])) + .sum::() + .signum() +} + +/// Minimum segments of a straight polyline read as a curve. +const CURVE_MIN_SEGMENTS: usize = 9; +/// Share of the vertices that must lie within tolerance of the curve. +const CURVE_INLIERS: f64 = 0.85; +/// Radial tolerance of an arc through the first, middle and last vertex. +const ARC_TOLERANCE: f64 = 0.07; +/// Tolerance of an ellipse through five vertices (in its unit circle). +const ELLIPSE_TOLERANCE: f64 = 0.015; +/// An ellipse at least this round is a circle. +const CIRCLE_RATIO: f64 = 0.96; + +/// A joined stroke read as a curve, in drawing coordinates (`points`): +/// a run of arcs on one circle is an ARC (a CIRCLE when it closes); a +/// straight polyline of nine or more segments whose vertices lie on a +/// circular arc an ARC, on an ellipse a CIRCLE (round) or an ELLIPSE (an +/// elliptical arc when open). +// ponytail: tolerances fitted to measured samples (glyph outlines, noisy +// and exact polygons); vertices off the curve are allowed for 15%. +fn curve_of(points: &[[f64; 2]], bulges: &[f64], closed: bool, z: f64) -> Option { + use std::f64::consts::TAU; + let n = points.len(); + let segs = if closed { n } else { n.saturating_sub(1) }; + if segs == 0 { + return None; + } + let arc = |c: [f64; 2], r: f64, first: [f64; 2], last: [f64; 2], ccw: bool| { + let angle = |p: [f64; 2]| (p[1] - c[1]).atan2(p[0] - c[0]).rem_euclid(TAU); + let (start, end) = if ccw { + (angle(first), angle(last)) + } else { + (angle(last), angle(first)) + }; + EntityType::Arc(Arc::from_center_radius_angles( + Vector3::new(c[0], c[1], z), + r, + start, + end, + )) + }; + // Arcs of one circle, all turning the same way. + if bulges[..segs].iter().all(|b| *b != 0.0) { + let turn = bulges[0].signum(); + let arcs: Vec<_> = (0..segs) + .map(|i| { + crate::entities::common::BulgeArc::from_bulge( + points[i], + points[(i + 1) % n], + bulges[i], + ) + }) + .collect::>()?; + let first = &arcs[0]; + let same = arcs.iter().zip(&bulges[..segs]).all(|(a, b)| { + b.signum() == turn + && (a.radius - first.radius).abs() <= first.radius * 1e-4 + && (a.center[0] - first.center[0]).hypot(a.center[1] - first.center[1]) + <= first.radius * 1e-4 + }); + if !same || segs < 2 { + return None; + } + return Some(if closed { + EntityType::Circle(Circle::from_center_radius( + Vector3::new(first.center[0], first.center[1], z), + first.radius, + )) + } else { + arc(first.center, first.radius, points[0], points[n - 1], turn > 0.0) + }); + } + if bulges.iter().any(|b| *b != 0.0) || segs < CURVE_MIN_SEGMENTS { + return None; + } + let enough = |inliers: usize| inliers as f64 >= CURVE_INLIERS * n as f64; + if !closed { + if let Some((c, r)) = circle_through(points[0], points[n / 2], points[n - 1]) { + let inliers = points + .iter() + .filter(|p| ((p[0] - c[0]).hypot(p[1] - c[1]) - r).abs() <= ARC_TOLERANCE * r) + .count(); + if enough(inliers) { + return Some(arc(c, r, points[0], points[n - 1], sweep_sign(points, c) > 0.0)); + } + } + } + // Five vertices spread along the run: a fifth of a closed loop apart, + // or the ends and quarters of an open one. + let step = if closed { + (n as f64 / 5.0).round() + } else { + (n - 1) as f64 / 4.0 + }; + let idx: Vec = (0..5) + .map(|k| ((k as f64 * step).round() as usize) % n) + .collect(); + let five = [ + points[idx[0]], + points[idx[1]], + points[idx[2]], + points[idx[3]], + points[idx[4]], + ]; + let e = Conic::through(&five)?; + let inliers = points + .iter() + .filter(|p| { + let (u, v) = e.unit(**p); + (u.hypot(v) - 1.0).abs() <= ELLIPSE_TOLERANCE + }) + .count(); + if !enough(inliers) { + return None; + } + if closed && e.b / e.a >= CIRCLE_RATIO { + let (c, r) = circle_through(five[0], five[2], five[4])?; + return Some(EntityType::Circle(Circle::from_center_radius( + Vector3::new(c[0], c[1], z), + r, + ))); + } + let param = |p: [f64; 2]| { + let (u, v) = e.unit(p); + v.atan2(u).rem_euclid(TAU) + }; + let (start, mut end) = if closed { + let s = param(points[0]); + (s, s + TAU) + } else if sweep_sign(points, e.center) > 0.0 { + (param(points[0]), param(points[n - 1])) + } else { + (param(points[n - 1]), param(points[0])) + }; + if end <= start { + end += TAU; + } + let mut ellipse = codec::entities::Ellipse::from_center_axes( + Vector3::new(e.center[0], e.center[1], z), + Vector3::new(e.major[0] * e.a, e.major[1] * e.a, 0.0), + e.b / e.a, + ); + ellipse.start_parameter = start; + ellipse.end_parameter = end; + Some(EntityType::Ellipse(ellipse)) +} + fn same_point(a: [f64; 2], b: [f64; 2]) -> bool { (a[0] - b[0]).abs() < 1e-7 && (a[1] - b[1]).abs() < 1e-7 } @@ -910,42 +1393,44 @@ pub fn convert( (common.layer, common.color, common.line_weight) = (layer.clone(), color, weight); curves.push(circle); - } else if let Some(mut bulges) = sp - .segments - .iter() - .map(|s| match s { - Segment::Line(..) => Some(0.0), - cubic => arc_bulge(cubic), - }) - .collect::>>() - { - let mut points = vec![sp.segments[0].start()]; - points.extend(sp.segments.iter().map(|s| s.end())); - if sp.closed && points.len() > 2 { - points.pop(); - } else { - bulges.push(0.0); - } - let stroke = Stroke { - layer: layer.clone(), - color, - weight, - points, - bulges, - closed: sp.closed, - }; - if settings.join || stroke.straight() || stroke.closed { - strokes.push(stroke); - } else { - // Join off: each arc apart, straight runs between them. - strokes.extend(split_at_arcs(stroke)); - } } else { - let mut spline = EntityType::Spline(spline_of(sp, &world3)); - let common = spline.common_mut(); - (common.layer, common.color, common.line_weight) = - (layer.clone(), color, weight); - curves.push(spline); + // Straight and circular runs become polylines; + // each other curve its own one-span SPLINE. + let all_runs = run_strokes(&sp.segments, sp.closed); + for piece in all_runs { + match piece { + Piece::Run(points, bulges, closed) => { + let stroke = Stroke { + layer: layer.clone(), + color, + weight, + points, + bulges, + closed, + }; + if settings.join || stroke.straight() || stroke.closed + { + strokes.push(stroke); + } else { + // Join off: each arc apart, straight runs + // between them. + strokes.extend(split_at_arcs(stroke)); + } + } + Piece::Curve(seg) => { + let one = SubPath { + segments: vec![seg], + closed: false, + }; + let mut spline = + EntityType::Spline(spline_of(&one, &world3)); + let common = spline.common_mut(); + (common.layer, common.color, common.line_weight) = + (layer.clone(), color, weight); + curves.push(spline); + } + } + } } } } @@ -1037,13 +1522,38 @@ pub fn convert( // The joined strokes, then (when asked) dashes read as one dashed line. let strokes = join_strokes(strokes, settings.join); + // A mirrored placement turns arcs the other way. + let turn = (underlay.x_scale * underlay.y_scale).signum(); + // Joined strokes that trace circles, arcs and ellipses become those. + let strokes: Vec = strokes + .into_iter() + .filter_map(|stroke| { + let points: Vec<[f64; 2]> = stroke + .points + .iter() + .map(|p| { + let w = world2(*p); + [w.x, w.y] + }) + .collect(); + let bulges: Vec = stroke.bulges.iter().map(|b| b * turn).collect(); + match curve_of(&points, &bulges, stroke.closed, z) { + Some(mut curve) => { + let common = curve.common_mut(); + (common.layer, common.color, common.line_weight) = + (stroke.layer, stroke.color, stroke.weight); + result.entities.push(curve); + None + } + None => Some(stroke), + } + }) + .collect(); let strokes: Vec<(Stroke, Option)> = if settings.linetypes { infer_dashes(strokes) } else { strokes.into_iter().map(|s| (s, None)).collect() }; - // A mirrored placement turns arcs the other way. - let turn = (underlay.x_scale * underlay.y_scale).signum(); for (stroke, dash) in strokes { // A lone arc is an ARC. if !stroke.closed && stroke.points.len() == 2 && stroke.bulges[0] != 0.0 { @@ -1206,5 +1716,5 @@ fn fill_entities( } inventory::submit!(crate::command::CommandRegistration { - names: &["PDFIMPORT"] + names: &["PDFIMPORT", "-PDFIMPORT"] }); diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index 36a87e9c1..359164d0f 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -287,6 +287,27 @@ 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. +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) +} + /// Display adjustments of an underlay page, applied to its display raster. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct PageAdjust {