forked from 1b-agent/opencadstudio-upstream
PDF underlays: attach prompts and definitions, display adjustments and clipping, Properties, snapping, PDFCLIP and PDFIMPORT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc9d5edbff
commit
0c50013ca7
50 files changed
+2770
-325
No files matched your search
@@ -186,16 +186,22 @@ impl ImageModel {
|
||||
impl ImageModel {
|
||||
/// Build an ImageModel from a PDF UNDERLAY + its definition object.
|
||||
///
|
||||
/// The page rasterises through `pdf_raster` (system pdftoppm, cached);
|
||||
/// the world quad sizes from the page's physical size — 1 underlay unit =
|
||||
/// 1 PDF inch — times the entity's scale, placed at the insertion with
|
||||
/// the entity's rotation. `None` when the underlay is off, non-PDF, or
|
||||
/// the page can't be rendered (caller keeps the outline placeholder).
|
||||
/// The page rasterises through `pdf_raster` with no page background, so
|
||||
/// only the drawn content covers the drawing; contrast, monochrome and
|
||||
/// the background adjustment recolour that raster and fade lowers its
|
||||
/// opacity. The world quad is the page size in inches (1 drawing unit per
|
||||
/// page inch) times the entity scale, placed at the insertion with the
|
||||
/// entity rotation. A clip boundary (on, 2+ vertices, in underlay units)
|
||||
/// limits the drawn area to its inside, or to the page outside it when
|
||||
/// inverted. `None` when the underlay is off, non-PDF, or the page can't
|
||||
/// be rendered (caller keeps the outline placeholder).
|
||||
pub fn from_underlay(
|
||||
u: &acadrust::entities::Underlay,
|
||||
def: &acadrust::entities::UnderlayDefinition,
|
||||
background: [f32; 4],
|
||||
) -> Option<Self> {
|
||||
use acadrust::entities::{UnderlayDisplayFlags, UnderlayType};
|
||||
use super::pdf_raster::{self, PageAdjust};
|
||||
if !u.flags.contains(UnderlayDisplayFlags::ON) {
|
||||
return None;
|
||||
}
|
||||
@@ -207,12 +213,25 @@ impl ImageModel {
|
||||
} else {
|
||||
def.page_name.trim()
|
||||
};
|
||||
let raster = super::pdf_raster::rasterize_page(&def.file_path, page)?;
|
||||
let (page_w, page_h) = pdf_raster::page_size_inches(&def.file_path, page)?;
|
||||
let raster = pdf_raster::rasterize_page_display(&def.file_path, page)?;
|
||||
let bg_lum = 0.299 * background[0] + 0.587 * background[1] + 0.114 * background[2];
|
||||
let pixels = pdf_raster::adjusted_pixels(
|
||||
&def.file_path,
|
||||
page,
|
||||
&raster,
|
||||
PageAdjust {
|
||||
contrast: u.contrast.min(100),
|
||||
monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME),
|
||||
adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND),
|
||||
dark_background: bg_lum <= 0.5,
|
||||
},
|
||||
);
|
||||
|
||||
// Page size in drawing units (1 unit per PDF inch), entity scale applied.
|
||||
let w_du = raster.width as f64 / raster.dpi as f64 * u.x_scale;
|
||||
let h_du = raster.height as f64 / raster.dpi as f64 * u.y_scale;
|
||||
if w_du <= 0.0 || h_du <= 0.0 {
|
||||
let w_du = page_w * u.x_scale;
|
||||
let h_du = page_h * u.y_scale;
|
||||
if w_du.abs() <= 0.0 || h_du.abs() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (c, s) = (u.rotation.cos(), u.rotation.sin());
|
||||
@@ -238,35 +257,7 @@ impl ImageModel {
|
||||
];
|
||||
let corners_low = [[oxl, oyl, ozl]; 4];
|
||||
|
||||
// Visible region: the clip polygon (vertices in underlay units,
|
||||
// scaled like the quad) fan-triangulated, or the full page. Inverted
|
||||
// clips fall back to the full page (rare; better whole than hidden).
|
||||
let clipping = u.flags.contains(UnderlayDisplayFlags::CLIPPING)
|
||||
&& !u.clip_inverted
|
||||
&& u.clip_boundary_vertices.len() >= 3;
|
||||
let tris_uv: Vec<[f64; 2]> = if clipping {
|
||||
let pts: Vec<[f64; 2]> = u
|
||||
.clip_boundary_vertices
|
||||
.iter()
|
||||
.map(|p| [p.x * u.x_scale / w_du, p.y * u.y_scale / h_du])
|
||||
.collect();
|
||||
let mut tris = Vec::with_capacity((pts.len().saturating_sub(2)) * 3);
|
||||
for i in 1..pts.len() - 1 {
|
||||
tris.push(pts[0]);
|
||||
tris.push(pts[i]);
|
||||
tris.push(pts[i + 1]);
|
||||
}
|
||||
tris
|
||||
} else {
|
||||
vec![
|
||||
[0.0, 0.0],
|
||||
[1.0, 0.0],
|
||||
[1.0, 1.0],
|
||||
[0.0, 0.0],
|
||||
[1.0, 1.0],
|
||||
[0.0, 1.0],
|
||||
]
|
||||
};
|
||||
let tris_uv = underlay_visible_uv(u, page_w, page_h);
|
||||
let verts: Vec<ImageQuadVertex> = tris_uv
|
||||
.iter()
|
||||
.map(|&[fu, fv]| {
|
||||
@@ -282,10 +273,10 @@ impl ImageModel {
|
||||
Some(Self {
|
||||
render_instance: None,
|
||||
file_path: def.file_path.clone(),
|
||||
pixels: raster.pixels.clone(),
|
||||
pixels,
|
||||
width: raster.width,
|
||||
height: raster.height,
|
||||
opacity: 1.0 - u.fade as f32 / 100.0,
|
||||
opacity: 1.0 - u.fade.min(100) as f32 / 100.0,
|
||||
corners,
|
||||
corners_low,
|
||||
draw_depth: 0.0,
|
||||
@@ -294,6 +285,29 @@ impl ImageModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible region of an underlay page as triangles in page UV (0..1, y up):
|
||||
/// the whole page, the clip polygon, or — for an inverted clip — the page
|
||||
/// with the polygon cut out.
|
||||
fn underlay_visible_uv(u: &acadrust::entities::Underlay, page_w: f64, page_h: f64) -> Vec<[f64; 2]> {
|
||||
use acadrust::entities::UnderlayDisplayFlags;
|
||||
let page = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||||
let whole = || vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||||
let clip = crate::entities::underlay::clip_polygon_local(u);
|
||||
if !u.flags.contains(UnderlayDisplayFlags::CLIPPING) || clip.len() < 3 {
|
||||
return whole();
|
||||
}
|
||||
let ring: Vec<[f64; 2]> = clip.iter().map(|p| [p[0] / page_w, p[1] / page_h]).collect();
|
||||
let (points, triangles) = if u.clip_inverted {
|
||||
cadkernel::geom2d::triangulate(&page, &[ring])
|
||||
} else {
|
||||
cadkernel::geom2d::triangulate(&ring, &[])
|
||||
};
|
||||
triangles
|
||||
.into_iter()
|
||||
.flat_map(|t| t.map(|i| points[i]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl ImageModel {
|
||||
/// Build an ImageModel from an OLE2FRAME's embedded presentation.
|
||||
/// The blob's compound file is parsed for the native raster or the cached
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod instance_model;
|
||||
pub mod material_model;
|
||||
pub mod ole_pres;
|
||||
pub mod pdf_raster;
|
||||
pub mod pdf_vector;
|
||||
pub mod mesh_model;
|
||||
pub mod solid_model;
|
||||
pub mod solid_history;
|
||||
|
||||
+132
-10
@@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||
use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
|
||||
use hayro::{RenderCache, RenderSettings};
|
||||
|
||||
/// One rasterised PDF page. `dpi` ties its pixel size to its physical size.
|
||||
@@ -18,7 +18,8 @@ pub struct PdfPage {
|
||||
|
||||
const RASTER_DPI: f32 = 150.0;
|
||||
|
||||
type PageKey = (String, String, u32);
|
||||
/// Source path, page, DPI bits and whether the page background is transparent.
|
||||
type PageKey = (String, String, u32, bool);
|
||||
|
||||
fn page_cache() -> &'static Mutex<HashMap<PageKey, Option<Arc<PdfPage>>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<PageKey, Option<Arc<PdfPage>>>>> = OnceLock::new();
|
||||
@@ -40,7 +41,11 @@ pub fn register_source(path: &str, bytes: Arc<Vec<u8>>) {
|
||||
page_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.retain(|(cached_path, _, _), _| cached_path != path);
|
||||
.retain(|(cached_path, _, _, _), _| cached_path != path);
|
||||
adjusted_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.retain(|key, _| key.0 != path);
|
||||
}
|
||||
|
||||
/// Rasterise a 1-based PDF page, memoised by source path and page name.
|
||||
@@ -51,7 +56,18 @@ pub fn rasterize_page(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
||||
/// Rasterise a 1-based PDF page at a caller-chosen DPI, memoised per source
|
||||
/// path, page and DPI (print quality differs from the on-screen 150 DPI).
|
||||
pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
|
||||
let key = (path.to_string(), page.to_string(), dpi.to_bits());
|
||||
rasterize_cached(path, page, dpi, false)
|
||||
}
|
||||
|
||||
/// Rasterise a page for display: no page background (transparent pixels
|
||||
/// outside the drawn content) and straight, not premultiplied, alpha — the
|
||||
/// page is drawn over the drawing background like its vector content.
|
||||
pub fn rasterize_page_display(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
||||
rasterize_cached(path, page, RASTER_DPI, true)
|
||||
}
|
||||
|
||||
fn rasterize_cached(path: &str, page: &str, dpi: f32, transparent: bool) -> Option<Arc<PdfPage>> {
|
||||
let key = (path.to_string(), page.to_string(), dpi.to_bits(), transparent);
|
||||
if let Some(hit) = page_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
@@ -61,7 +77,7 @@ pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option<Arc<Pdf
|
||||
return hit;
|
||||
}
|
||||
|
||||
let built = rasterize_uncached(path, page, dpi);
|
||||
let built = rasterize_uncached(path, page, dpi, transparent);
|
||||
page_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
@@ -73,10 +89,10 @@ pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option<Arc<Pdf
|
||||
/// print job, whose 300 DPI page would otherwise stay cached for the whole
|
||||
/// session (tens of megabytes per sheet).
|
||||
pub fn rasterize_page_at_dpi_uncached(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
|
||||
rasterize_uncached(path, page, dpi)
|
||||
rasterize_uncached(path, page, dpi, false)
|
||||
}
|
||||
|
||||
fn rasterize_uncached(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
|
||||
fn rasterize_uncached(path: &str, page: &str, dpi: f32, transparent: bool) -> Option<Arc<PdfPage>> {
|
||||
let bytes = source_bytes(path)?;
|
||||
let pdf = Pdf::new(bytes).ok()?;
|
||||
let page_no = page.trim().parse::<usize>().unwrap_or(1).max(1);
|
||||
@@ -89,21 +105,127 @@ fn rasterize_uncached(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>>
|
||||
&RenderSettings {
|
||||
x_scale: scale,
|
||||
y_scale: scale,
|
||||
bg_color: WHITE,
|
||||
bg_color: if transparent { TRANSPARENT } else { WHITE },
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let width = u32::from(pixmap.width());
|
||||
let height = u32::from(pixmap.height());
|
||||
let mut pixels = pixmap.data_as_u8_slice().to_vec();
|
||||
if transparent {
|
||||
unpremultiply(&mut pixels);
|
||||
}
|
||||
Some(Arc::new(PdfPage {
|
||||
pixels: Arc::new(pixmap.data_as_u8_slice().to_vec()),
|
||||
pixels: Arc::new(pixels),
|
||||
width,
|
||||
height,
|
||||
dpi,
|
||||
}))
|
||||
}
|
||||
|
||||
fn source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
|
||||
fn unpremultiply(pixels: &mut [u8]) {
|
||||
for px in pixels.chunks_exact_mut(4) {
|
||||
let a = px[3] as u32;
|
||||
if a != 0 && a != 255 {
|
||||
for c in &mut px[..3] {
|
||||
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of pages in a PDF source.
|
||||
pub fn page_count(path: &str) -> Option<usize> {
|
||||
let bytes = source_bytes(path)?;
|
||||
let pdf = Pdf::new(bytes).ok()?;
|
||||
Some(pdf.pages().len())
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
let bytes = source_bytes(path)?;
|
||||
let pdf = Pdf::new(bytes).ok()?;
|
||||
let page_no = page.trim().parse::<usize>().ok()?;
|
||||
let page = pdf.pages().get(page_no.checked_sub(1)?)?;
|
||||
let (w, h) = page.render_dimensions();
|
||||
Some((w as f64 / 72.0, h as f64 / 72.0))
|
||||
}
|
||||
|
||||
/// Display adjustments of an underlay page, applied to its display raster.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct PageAdjust {
|
||||
/// 0..=100; 100 keeps the colours, lower values pull them to mid grey.
|
||||
pub contrast: u8,
|
||||
pub monochrome: bool,
|
||||
/// Lighten dark, unsaturated content on a dark background (black lines
|
||||
/// and text become white), keeping coloured content.
|
||||
pub adjust_for_background: bool,
|
||||
pub dark_background: bool,
|
||||
}
|
||||
|
||||
type AdjustKey = (String, String, PageAdjust);
|
||||
|
||||
fn adjusted_cache() -> &'static Mutex<HashMap<AdjustKey, Arc<Vec<u8>>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<AdjustKey, Arc<Vec<u8>>>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// The display raster with the underlay's contrast / monochrome / background
|
||||
/// adjustments applied, memoised per source, page and adjustment.
|
||||
pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdjust) -> Arc<Vec<u8>> {
|
||||
let identity = adjust.contrast >= 100 && !adjust.monochrome && !adjust.adjust_for_background;
|
||||
if identity {
|
||||
return raster.pixels.clone();
|
||||
}
|
||||
let key = (path.to_string(), page.to_string(), adjust);
|
||||
if let Some(hit) = adjusted_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.get(&key)
|
||||
.cloned()
|
||||
{
|
||||
return hit;
|
||||
}
|
||||
let mut pixels = raster.pixels.as_ref().clone();
|
||||
for px in pixels.chunks_exact_mut(4) {
|
||||
if px[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut rgb = [px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0];
|
||||
if adjust.monochrome {
|
||||
let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
|
||||
rgb = [l; 3];
|
||||
}
|
||||
if adjust.adjust_for_background {
|
||||
let max = rgb[0].max(rgb[1]).max(rgb[2]);
|
||||
let min = rgb[0].min(rgb[1]).min(rgb[2]);
|
||||
let saturation = if max > 0.0 { (max - min) / max } else { 0.0 };
|
||||
let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
|
||||
// ponytail: greyscale inversion of low-saturation content only;
|
||||
// hue-preserving remap if coloured content must adapt too.
|
||||
let clash = if adjust.dark_background { l < 0.5 } else { l > 0.5 };
|
||||
if saturation < 0.25 && clash {
|
||||
rgb = rgb.map(|c| 1.0 - c);
|
||||
}
|
||||
}
|
||||
if adjust.contrast < 100 {
|
||||
let k = adjust.contrast as f32 / 100.0;
|
||||
rgb = rgb.map(|c| 0.5 + (c - 0.5) * k);
|
||||
}
|
||||
for (dst, c) in px[..3].iter_mut().zip(rgb) {
|
||||
*dst = (c.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
let pixels = Arc::new(pixels);
|
||||
adjusted_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.insert(key, pixels.clone());
|
||||
pixels
|
||||
}
|
||||
|
||||
pub(crate) fn source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
|
||||
if let Some(bytes) = source_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
//! Vector content of a PDF page — paths and text runs in page inches (y up,
|
||||
//! origin at the page's lower-left corner) — read through the PDF
|
||||
//! interpreter. Drives snapping to an underlay's geometry and PDFIMPORT.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use hayro::hayro_interpret::font::Glyph;
|
||||
use hayro::hayro_interpret::util::TransformExt;
|
||||
use hayro::hayro_interpret::{
|
||||
interpret_page, BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache,
|
||||
InterpreterSettings, Paint, PathDrawMode, SoftMask,
|
||||
};
|
||||
use hayro::hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro::hayro_syntax::object::Name;
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape};
|
||||
|
||||
/// One segment of a path, in page inches.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Segment {
|
||||
Line([f64; 2], [f64; 2]),
|
||||
Cubic([f64; 2], [f64; 2], [f64; 2], [f64; 2]),
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
pub fn start(&self) -> [f64; 2] {
|
||||
match self {
|
||||
Segment::Line(a, _) | Segment::Cubic(a, ..) => *a,
|
||||
}
|
||||
}
|
||||
pub fn end(&self) -> [f64; 2] {
|
||||
match self {
|
||||
Segment::Line(_, b) | Segment::Cubic(_, _, _, b) => *b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SubPath {
|
||||
pub segments: Vec<Segment>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PdfPath {
|
||||
pub subpaths: Vec<SubPath>,
|
||||
/// Stroke colour (RGB) and width in points, for stroked paths.
|
||||
pub stroke: Option<([u8; 3], f64)>,
|
||||
/// Fill colour (RGB), for filled paths.
|
||||
pub fill: Option<[u8; 3]>,
|
||||
}
|
||||
|
||||
/// A run of glyphs on one baseline in one font and size.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PdfText {
|
||||
pub text: String,
|
||||
/// Base font name without a subset prefix ("Helvetica").
|
||||
pub font: String,
|
||||
/// Baseline start, page inches.
|
||||
pub origin: [f64; 2],
|
||||
/// Height of the capital letters, inches.
|
||||
pub cap_height: f64,
|
||||
/// Advance width of the run, inches.
|
||||
pub width: f64,
|
||||
/// Baseline direction, radians.
|
||||
pub rotation: f64,
|
||||
pub color: [u8; 3],
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct PageVectors {
|
||||
pub paths: Vec<PdfPath>,
|
||||
pub texts: Vec<PdfText>,
|
||||
}
|
||||
|
||||
type Key = (String, String);
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<Key, Option<Arc<PageVectors>>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<Key, Option<Arc<PageVectors>>>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Paths and text of a 1-based page, memoised per source and page.
|
||||
pub fn page_vectors(path: &str, page: &str) -> Option<Arc<PageVectors>> {
|
||||
let key = (path.to_string(), page.to_string());
|
||||
if let Some(hit) = cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.get(&key)
|
||||
.cloned()
|
||||
{
|
||||
return hit;
|
||||
}
|
||||
let built = read_page(path, page).map(Arc::new);
|
||||
cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.insert(key, built.clone());
|
||||
built
|
||||
}
|
||||
|
||||
fn read_page(path: &str, page: &str) -> Option<PageVectors> {
|
||||
let bytes = super::pdf_raster::source_bytes(path)?;
|
||||
let pdf = Pdf::new(bytes).ok()?;
|
||||
let page_no = page.trim().parse::<usize>().ok()?;
|
||||
let page = pdf.pages().get(page_no.checked_sub(1)?)?;
|
||||
let (w, h) = page.render_dimensions();
|
||||
// Page points with y up → inches.
|
||||
let initial = Affine::scale(1.0 / 72.0) * page.initial_transform(false).to_kurbo();
|
||||
let cache = InterpreterCache::new();
|
||||
let mut context = Context::new(
|
||||
initial,
|
||||
Rect::new(0.0, 0.0, w as f64, h as f64),
|
||||
&cache,
|
||||
page.xref(),
|
||||
InterpreterSettings::default(),
|
||||
);
|
||||
let mut device = Collector::default();
|
||||
interpret_page(page, &mut context, &mut device);
|
||||
device.finish_text();
|
||||
|
||||
// Base font names in the order the content stream selects them; glyph
|
||||
// fonts are matched to them in their order of first use.
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut ops = page.typed_operations();
|
||||
while let Some(op) = ops.next() {
|
||||
if let TypedInstruction::TextFont(font) = op {
|
||||
let name = page
|
||||
.resources()
|
||||
.get_font(font.0)
|
||||
.and_then(|dict| dict.get::<Name>(b"BaseFont"))
|
||||
.map(|name| String::from_utf8_lossy(&name).into_owned())
|
||||
.unwrap_or_default();
|
||||
let name = name.split_once('+').map(|(_, rest)| rest.to_string()).unwrap_or(name);
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut texts = device.texts;
|
||||
for text in &mut texts {
|
||||
let index = device.font_order.iter().position(|key| *key == text.font_key);
|
||||
text.text.font = index
|
||||
.and_then(|i| names.get(i).cloned())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| "Text".to_string());
|
||||
}
|
||||
Some(PageVectors {
|
||||
paths: device.paths,
|
||||
texts: texts.into_iter().map(|t| t.text).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
struct PendingText {
|
||||
text: PdfText,
|
||||
font_key: u128,
|
||||
/// Baseline end, inches, for joining the next glyph.
|
||||
end: [f64; 2],
|
||||
em: f64,
|
||||
/// Tops of the capital letters seen so far, inches.
|
||||
caps: Vec<f64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Collector {
|
||||
paths: Vec<PdfPath>,
|
||||
texts: Vec<PendingText>,
|
||||
current: Option<PendingText>,
|
||||
font_order: Vec<u128>,
|
||||
}
|
||||
|
||||
impl Collector {
|
||||
fn finish_text(&mut self) {
|
||||
if let Some(text) = self.current.take() {
|
||||
if !text.text.text.trim().is_empty() {
|
||||
self.texts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb(paint: &Paint<'_>) -> [u8; 3] {
|
||||
match paint {
|
||||
Paint::Color(color) => {
|
||||
let c = color.to_rgba().to_rgba8();
|
||||
[c[0], c[1], c[2]]
|
||||
}
|
||||
Paint::Pattern(_) => [0, 0, 0],
|
||||
}
|
||||
}
|
||||
|
||||
fn pt(p: Point) -> [f64; 2] {
|
||||
[p.x, p.y]
|
||||
}
|
||||
|
||||
fn subpaths(path: &BezPath, transform: Affine) -> Vec<SubPath> {
|
||||
let mut out: Vec<SubPath> = 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;
|
||||
out.push(SubPath { segments: Vec::new(), closed: false });
|
||||
start = p;
|
||||
last = p;
|
||||
}
|
||||
PathEl::LineTo(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 c1 = last + (c - last) * (2.0 / 3.0);
|
||||
let c2 = p + (c - p) * (2.0 / 3.0);
|
||||
if let Some(sp) = out.last_mut() {
|
||||
sp.segments.push(Segment::Cubic(pt(last), pt(c1), pt(c2), pt(p)));
|
||||
}
|
||||
last = p;
|
||||
}
|
||||
PathEl::CurveTo(c1, c2, 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)));
|
||||
}
|
||||
last = p;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if let Some(sp) = out.last_mut() {
|
||||
if (last - start).hypot() > 1e-9 {
|
||||
sp.segments.push(Segment::Line(pt(last), pt(start)));
|
||||
}
|
||||
sp.closed = true;
|
||||
}
|
||||
last = start;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.retain(|sp| !sp.segments.is_empty());
|
||||
// A path drawn back to its start without a close operator is closed.
|
||||
for sp in &mut out {
|
||||
let (first, last) = (sp.segments[0].start(), sp.segments[sp.segments.len() - 1].end());
|
||||
if !sp.closed && (first[0] - last[0]).hypot(first[1] - last[1]) < 1e-9 {
|
||||
sp.closed = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl<'a> Device<'a> for Collector {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'a>, mode: &PathDrawMode) {
|
||||
self.finish_text();
|
||||
let subpaths = subpaths(path, transform);
|
||||
if subpaths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (stroke, fill) = match mode {
|
||||
PathDrawMode::Stroke(props) => {
|
||||
// Width in points: the user-space width through the transform
|
||||
// (which is in inches), back to points.
|
||||
let scale = transform.determinant().abs().sqrt() * 72.0;
|
||||
(Some((rgb(paint), props.line_width as f64 * scale)), None)
|
||||
}
|
||||
PathDrawMode::Fill(_) => (None, Some(rgb(paint))),
|
||||
};
|
||||
self.paths.push(PdfPath { subpaths, stroke, fill });
|
||||
}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
|
||||
fn draw_glyph(
|
||||
&mut self,
|
||||
glyph: &Glyph<'a>,
|
||||
transform: Affine,
|
||||
glyph_transform: Affine,
|
||||
paint: &Paint<'a>,
|
||||
_: &GlyphDrawMode,
|
||||
) {
|
||||
let Glyph::Outline(outline) = glyph else {
|
||||
return;
|
||||
};
|
||||
let full = transform * glyph_transform;
|
||||
let origin = full * Point::ZERO;
|
||||
// Outlines are in 1000 units per em.
|
||||
let em_vec = full * Point::new(0.0, 1000.0) - origin;
|
||||
let em = em_vec.hypot();
|
||||
if em <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let advance = outline.advance_width().unwrap_or(0.0) as f64;
|
||||
let adv_vec = full * Point::new(advance, 0.0) - origin;
|
||||
let run_dir = full * Point::new(1000.0, 0.0) - origin;
|
||||
let rotation = run_dir.y.atan2(run_dir.x);
|
||||
let text = outline
|
||||
.as_unicode()
|
||||
.map(|s| match s {
|
||||
hayro::hayro_interpret::hayro_cmap::BfString::Char(c) => c.to_string(),
|
||||
hayro::hayro_interpret::hayro_cmap::BfString::String(s) => s,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let key = outline.font_cache_key();
|
||||
if !self.font_order.contains(&key) {
|
||||
self.font_order.push(key);
|
||||
}
|
||||
let bounds = outline.outline().bounding_box();
|
||||
let glyph_top = text
|
||||
.chars()
|
||||
.any(|c| c.is_uppercase())
|
||||
.then(|| bounds.y1 / 1000.0 * em);
|
||||
let origin = pt(origin);
|
||||
let joins = self.current.as_ref().is_some_and(|run| {
|
||||
run.font_key == key
|
||||
&& (run.em - em).abs() < 1e-6
|
||||
&& (run.text.rotation - rotation).abs() < 1e-6
|
||||
&& ((run.end[0] - origin[0]).powi(2) + (run.end[1] - origin[1]).powi(2)).sqrt()
|
||||
< 0.3 * em
|
||||
});
|
||||
if !joins {
|
||||
self.finish_text();
|
||||
self.current = Some(PendingText {
|
||||
text: PdfText {
|
||||
text: String::new(),
|
||||
font: String::new(),
|
||||
origin,
|
||||
cap_height: 0.0,
|
||||
width: 0.0,
|
||||
rotation,
|
||||
color: rgb(paint),
|
||||
},
|
||||
font_key: key,
|
||||
end: origin,
|
||||
em,
|
||||
caps: Vec::new(),
|
||||
});
|
||||
}
|
||||
if let Some(run) = self.current.as_mut() {
|
||||
run.text.text.push_str(&text);
|
||||
if let Some(top) = glyph_top {
|
||||
run.caps.push(top);
|
||||
// Capital height: the median capital top, so round letters'
|
||||
// overshoot (O, G) does not lift it.
|
||||
let mut caps = run.caps.clone();
|
||||
caps.sort_by(f64::total_cmp);
|
||||
run.text.cap_height = caps[caps.len() / 2];
|
||||
}
|
||||
run.end = [origin[0] + adv_vec.x, origin[1] + adv_vec.y];
|
||||
run.text.width = ((run.end[0] - run.text.origin[0]).powi(2)
|
||||
+ (run.end[1] - run.text.origin[1]).powi(2))
|
||||
.sqrt();
|
||||
}
|
||||
}
|
||||
fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
|
||||
// ── Snapping ────────────────────────────────────────────────────────────────
|
||||
|
||||
static PDF_OSNAP: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// PDFOSNAP: whether object snaps find the geometry inside PDF underlays.
|
||||
pub fn pdf_osnap() -> bool {
|
||||
PDF_OSNAP.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn set_pdf_osnap(on: bool) {
|
||||
PDF_OSNAP.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Most snap points one underlay contributes.
|
||||
// ponytail: a flat cap; a spatial index over the page segments if large
|
||||
// drawings need every point.
|
||||
const MAX_SNAP_POINTS: usize = 20_000;
|
||||
|
||||
/// Endpoint and midpoint snaps of the underlay's PDF geometry, in world
|
||||
/// space, when PDFOSNAP is on and the page is shown.
|
||||
pub fn underlay_snap_points(
|
||||
u: &acadrust::entities::Underlay,
|
||||
document: &acadrust::CadDocument,
|
||||
) -> Vec<(glam::DVec3, crate::scene::model::wire_model::SnapHint)> {
|
||||
use crate::scene::model::wire_model::SnapHint;
|
||||
if !pdf_osnap() || !u.flags.contains(acadrust::entities::UnderlayDisplayFlags::ON) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(def) = crate::entities::underlay::definition(u, document) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(vectors) = page_vectors(&def.file_path, crate::entities::underlay::page_of(def)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let world = |p: [f64; 2]| {
|
||||
let w = crate::entities::underlay::local_to_world(u, p);
|
||||
glam::DVec3::new(w[0], w[1], w[2])
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
'paths: for path in &vectors.paths {
|
||||
for sp in &path.subpaths {
|
||||
for seg in &sp.segments {
|
||||
if out.len() >= MAX_SNAP_POINTS {
|
||||
break 'paths;
|
||||
}
|
||||
out.push((world(seg.start()), SnapHint::Endpoint));
|
||||
out.push((world(seg.end()), SnapHint::Endpoint));
|
||||
if let Segment::Line(a, b) = seg {
|
||||
out.push((
|
||||
world([(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]),
|
||||
SnapHint::Midpoint,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in new issue
Block a user