mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
- craft-pdf (the shared Rust/Hayro reader in craft-libs, rev c5a1cbf) renders each page; no shared library is loaded or shipped, and every desktop platform works. - Raster pages only: editable text/vector import and the pdf_text/pdf_vectors options are removed (they can return later through craft-pdf's interpret device). Page geometry, artboards, page range, budgets, cancellation and warnings are kept; the effective DPI now respects craft-pdf's 8192 px side and 64M px page limits. - Drops pdfium-render, the PDFium download in CI, the build-script copy and the packaging/licence staging; records the Hayro/fonts/CMaps/ICC assets in ATTRIBUTION.md and NOTICE. - \.ai\ files starting with %PDF- now take the PDF importer, matching the comment. - Attribution, architecture and file-format-parity docs updated.
240 lines
12 KiB
Rust
240 lines
12 KiB
Rust
//! PDF import: craft-pdf (a Rust PDF reader and renderer built on Hayro) rasterizes the
|
|
//! selected pages, and each page becomes one artboard.
|
|
//!
|
|
//! Raster pages only: editable text and vector import would need craft-pdf's `interpret`
|
|
//! device and is not part of this importer. The renderer's hard output bounds (8192 px per
|
|
//! side and 64M px per page) and a document-wide strip budget are respected by lowering the
|
|
//! effective DPI, so each page keeps its physical size: pixels / resolution * 72 pt.
|
|
//!
|
|
//! The renderer is pure Rust and runs on every desktop platform: no shared library is loaded
|
|
//! or shipped, and a malformed file is reported as an error rather than taking the process down.
|
|
|
|
use crate::{ImportOptions, ImportResult, IoError};
|
|
use craft_pdf::interpret::InterpreterSettings;
|
|
use craft_pdf::raster::{self, RenderCache};
|
|
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
|
use photocraft_doc::{Artboard, Document, Group, Layer, LayerContent};
|
|
use photocraft_geom::{Rect, Size};
|
|
use photocraft_raster::{Interrupt, Surface};
|
|
|
|
/// `%PDF-` magic. `.ai` files that start with it are PDFs and render through this importer.
|
|
pub fn is_pdf(bytes: &[u8]) -> bool {
|
|
bytes.starts_with(b"%PDF-")
|
|
}
|
|
|
|
/// PDF import is not available in the web build.
|
|
#[cfg(target_arch = "wasm32")]
|
|
pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions, _ctl: &Interrupt) -> Result<ImportResult, IoError> {
|
|
Err(IoError::Unsupported("PDF import needs the desktop build".into()))
|
|
}
|
|
|
|
/// Renders the selected PDF pages and opens each as one artboard.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -> Result<ImportResult, IoError> {
|
|
/// The most pages one import renders.
|
|
const MAX_PAGES: usize = 16;
|
|
/// Gap between the artboards, in document pixels.
|
|
const GAP: u32 = 80;
|
|
/// The document-wide raster budget across every imported page, in pixels.
|
|
const MAX_UNION_PIXELS: u64 = 200_000_000;
|
|
/// The default pixel-memory budget when the caller sets none: a zero
|
|
/// [`ImportOptions::flat_max_pixel_bytes`] still bounds the import.
|
|
const DEFAULT_MAX_PIXEL_BYTES: u64 = 2 << 30;
|
|
|
|
ctl.check().map_err(|_| IoError::Cancelled)?;
|
|
let requested = if opts.pdf_dpi.is_finite() && opts.pdf_dpi > 0.0 { f64::from(opts.pdf_dpi) } else { 300.0 };
|
|
let pdf = craft_pdf::open_slice_with_options(bytes, None, &craft_pdf::OpenOptions::default()).map_err(open_error)?;
|
|
let total = pdf.pages().len();
|
|
if total == 0 {
|
|
return Err(IoError::Unsupported("PDF has no pages".into()));
|
|
}
|
|
let (first, last) = match opts.pdf_pages {
|
|
Some((a, b)) => {
|
|
let a = a.max(1);
|
|
let b = if b == 0 { u32::MAX } else { b.max(a) };
|
|
if u64::from(a) > total as u64 {
|
|
return Err(IoError::Unsupported(format!("PDF page range starts at {a}, but the PDF has {total} pages")));
|
|
}
|
|
((a as usize).min(total), (b as usize).min(total))
|
|
}
|
|
None => (1, total),
|
|
};
|
|
let page_count = last - first + 1;
|
|
let count = page_count.min(MAX_PAGES);
|
|
|
|
// Page sizes in points first: the effective DPI must fit the renderer's bounds without
|
|
// changing any page's physical size.
|
|
let mut sizes = Vec::with_capacity(count);
|
|
for index in 0..count {
|
|
let page = &pdf.pages()[first - 1 + index];
|
|
let (width, height) = page.render_dimensions();
|
|
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
|
return Err(IoError::Unsupported(format!("PDF page {} has no area", first + index)));
|
|
}
|
|
sizes.push((f64::from(width), f64::from(height)));
|
|
ctl.check().map_err(|_| IoError::Cancelled)?;
|
|
}
|
|
let dpi = effective_dpi(requested, &sizes, u64::from(raster::MAX_SIDE), raster::MAX_PIXELS, MAX_UNION_PIXELS);
|
|
|
|
let mut doc_width: u64 = 0;
|
|
let mut doc_height: u64 = 0;
|
|
let mut retained: u64 = 0;
|
|
for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() {
|
|
let width = page_pixels(width_pt, dpi);
|
|
let height = page_pixels(height_pt, dpi);
|
|
doc_width = doc_width.saturating_add(u64::from(width)).saturating_add(if index + 1 < count { u64::from(GAP) } else { 0 });
|
|
doc_height = doc_height.max(u64::from(height));
|
|
let tiles_x = (u64::from(width)).div_ceil(256);
|
|
let tiles_y = (u64::from(height)).div_ceil(256);
|
|
retained = retained.saturating_add(tiles_x.saturating_mul(tiles_y).saturating_mul(256 * 256 * 4));
|
|
}
|
|
if doc_width == 0 || doc_height == 0 {
|
|
return Err(IoError::Unsupported("PDF pages have no area".into()));
|
|
}
|
|
if doc_width > u64::from(u32::MAX) || doc_height > u64::from(u32::MAX) {
|
|
return Err(IoError::Unsupported("PDF import is too large at this DPI; lower the import DPI".into()));
|
|
}
|
|
// Retained tiles plus the converting page's peak: craft-pdf's RGBA8 raster (4 B/px) and the
|
|
// f32 document buffer (16 B/px).
|
|
let max_page = sizes.iter().map(|&(w, h)| u64::from(page_pixels(w, dpi)) * u64::from(page_pixels(h, dpi))).max().unwrap_or(0);
|
|
let estimate = retained.saturating_add(max_page.saturating_mul(20));
|
|
let budget = if opts.flat_max_pixel_bytes > 0 { opts.flat_max_pixel_bytes } else { DEFAULT_MAX_PIXEL_BYTES };
|
|
if estimate > budget {
|
|
return Err(IoError::Unsupported(format!(
|
|
"PDF import needs about {} MB of pixel memory; the limit is {} MB (lower the import DPI)",
|
|
estimate >> 20,
|
|
budget >> 20
|
|
)));
|
|
}
|
|
|
|
let mut warnings = Vec::new();
|
|
if first > 1 || last < total || count < page_count {
|
|
let shown_last = first + count - 1;
|
|
let reason = if count < page_count { " (the page limit is 16)" } else { "" };
|
|
warnings.push(format!("PDF has {total} pages; imported pages {first}-{shown_last}{reason}"));
|
|
}
|
|
if dpi + 0.5 < requested {
|
|
warnings.push(format!("PDF was rendered at {:.0} dpi to fit the import limits", dpi.max(1.0)));
|
|
}
|
|
|
|
let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8);
|
|
doc.resolution_dpi = dpi as f32;
|
|
let mut x: u32 = 0;
|
|
for index in 0..count {
|
|
ctl.check().map_err(|_| IoError::Cancelled)?;
|
|
let page_number = first + index;
|
|
let page = &pdf.pages()[page_number - 1];
|
|
let (width_pt, height_pt) = sizes[index];
|
|
let width = page_pixels(width_pt, dpi);
|
|
let height = page_pixels(height_pt, dpi);
|
|
// The DPI solve keeps these inside the renderer's bounds; re-check rather than panic if
|
|
// rounding on an edge moves one pixel past them.
|
|
if width > raster::MAX_SIDE || height > raster::MAX_SIDE || u64::from(width) * u64::from(height) > raster::MAX_PIXELS {
|
|
return Err(IoError::Unsupported(format!("PDF page {page_number} is too large at {dpi:.0} dpi; lower the import DPI")));
|
|
}
|
|
let cache = RenderCache::new();
|
|
let settings = InterpreterSettings::default();
|
|
let options = raster::Options {
|
|
scale: [(dpi / 72.0) as f32; 2],
|
|
size: Some([width, height]),
|
|
offset: [0.0, 0.0],
|
|
// PDF pages composite onto white, as every PDF viewer and the previous importer did.
|
|
background: [255, 255, 255, 255],
|
|
color_replacement: None,
|
|
};
|
|
let rendered = raster::render(page, &cache, &settings, &options).map_err(|error| render_error(page_number, dpi, error))?;
|
|
if rendered.diagnostics.image_decode_failed {
|
|
warnings.push(format!("PDF page {page_number}: an image could not be decoded and is missing"));
|
|
}
|
|
if rendered.diagnostics.content_truncated {
|
|
warnings.push(format!("PDF page {page_number}: some content was omitted (a decode or memory limit)"));
|
|
}
|
|
ctl.check().map_err(|_| IoError::Cancelled)?;
|
|
|
|
let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
|
|
let bytes = rendered.bytes();
|
|
let mut data = Vec::new();
|
|
if data.try_reserve_exact(bytes.len()).is_err() {
|
|
return Err(IoError::Unsupported(format!("PDF page {page_number} needs more memory than is available")));
|
|
}
|
|
for chunk in bytes.as_chunks::<4>().0 {
|
|
for &value in &chunk[..3] {
|
|
data.push(f32::from(value) / 255.0);
|
|
}
|
|
data.push(f32::from(chunk[3]) / 255.0);
|
|
}
|
|
surface.write_region(Rect::from_xywh(x as i32, 0, width, height), &data);
|
|
let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32);
|
|
let layer = Layer::new(format!("Page {page_number}"), LayerContent::Raster(surface));
|
|
let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None };
|
|
doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group)));
|
|
x = x.saturating_add(width).saturating_add(GAP);
|
|
ctl.progress((index + 1) as f32 / count as f32);
|
|
}
|
|
|
|
Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false })
|
|
}
|
|
|
|
/// One page's raster width or height in pixels at `dpi`, at least one.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn page_pixels(points: f64, dpi: f64) -> u32 {
|
|
(points * dpi / 72.0).round().max(1.0) as u32
|
|
}
|
|
|
|
/// The highest DPI not above `requested` that keeps every page inside the renderer's side and
|
|
/// pixel bounds and the whole strip inside `max_union_pixels`. Rendering at that DPI keeps each
|
|
/// page's physical size unchanged; unreachable bounds report 1 DPI and the render call then fails
|
|
/// with a clear message.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn effective_dpi(requested: f64, sizes: &[(f64, f64)], max_side: u64, max_page_pixels: u64, max_union_pixels: u64) -> f64 {
|
|
let mut dpi = requested.clamp(1.0, 2400.0);
|
|
loop {
|
|
let mut union = 0u64;
|
|
let mut fits = true;
|
|
for &(width_pt, height_pt) in sizes {
|
|
let width = u64::from(page_pixels(width_pt, dpi));
|
|
let height = u64::from(page_pixels(height_pt, dpi));
|
|
if width > max_side || height > max_side || width.saturating_mul(height) > max_page_pixels {
|
|
fits = false;
|
|
break;
|
|
}
|
|
union = union.saturating_add(width.saturating_mul(height));
|
|
}
|
|
if fits && union <= max_union_pixels {
|
|
return dpi;
|
|
}
|
|
if dpi <= 1.0 {
|
|
return 1.0;
|
|
}
|
|
// The strip's gaps contribute a constant width, so a geometric step converges without
|
|
// needing a closed form per page.
|
|
dpi = (dpi * 0.9).max(1.0);
|
|
}
|
|
}
|
|
|
|
/// Maps a craft-pdf open failure to the importer's error.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn open_error(error: craft_pdf::OpenError) -> IoError {
|
|
use craft_pdf::OpenError;
|
|
match error {
|
|
OpenError::InputLimit { limit } => IoError::Unsupported(format!("PDF is larger than the {limit}-byte input limit")),
|
|
OpenError::Allocation => IoError::Unsupported("PDF needs more memory than is available".into()),
|
|
OpenError::BackendPanic => IoError::Unsupported("PDF reader failed on this file; it may be damaged".into()),
|
|
OpenError::Load(error) => IoError::Unsupported(format!("PDF: {error:?}")),
|
|
}
|
|
}
|
|
|
|
/// Maps a craft-pdf render failure to the importer's error.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn render_error(page: usize, dpi: f64, error: raster::Error) -> IoError {
|
|
use raster::Error;
|
|
match error {
|
|
Error::Cancelled => IoError::Cancelled,
|
|
Error::OutputLimit => IoError::Unsupported(format!("PDF page {page} is too large at {dpi:.0} dpi; lower the import DPI")),
|
|
Error::InvalidPage => IoError::Unsupported(format!("PDF page {page} has an empty or invalid page box")),
|
|
Error::Allocation => IoError::Unsupported(format!("PDF page {page} needs more memory than is available")),
|
|
Error::BackendPanic => IoError::Unsupported(format!("PDF renderer failed on page {page}; it may be damaged")),
|
|
Error::InvalidOptions | Error::BufferLength => IoError::Unsupported(format!("PDF page {page} could not be rendered")),
|
|
}
|
|
}
|