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:
ramox81andClaude Opus 5.5 committed 2026-09-24 16:00:12 +03:00
1 parent cc9d5edbff
commit 0c50013ca7
50 files changed
+2770 -325

No files matched your search

+132 -10
View File
@@ -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())