PDF: import every page as an artboard; export a page per artboard

The PDF importer renders each page with PDFium at the import DPI (default 300), lays the
pages out side by side and wraps each in an artboard, so a 2-page PDF opens as 2 artboards
and saves back as a 2-page PDF (one page per artboard, page-sized, labelled). The import
caps at 16 pages for now.

The PDF writer now takes a list of pages: shared font/ICC/selection objects, one image per
page, and the existing one-page export is the single-page case. Artboards with vector/text
layers are rasterised in this path.

Tested: artboards_export_as_one_page_each; convert sample.pdf app-multi.pdf (2 pages),
rendered back with the probe.
This commit is contained in:
Triwarr committed 2026-11-06 19:15:08 +01:00
1 parent 95c9d3c9eb
commit 399cd6ea4d
3 files changed
+204 -87

No files matched your search

+112 -57
View File
@@ -109,10 +109,12 @@ fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String {
/// A one-page PDF.
pub fn print_pdf(page: &PrintPage) -> Vec<u8> {
print_pdf_with_vectors(page, "", &selection::SelectionFont::default())
print_pdf_pages(std::slice::from_ref(page), &[String::new()], &selection::SelectionFont::default())
}
fn print_pdf_with_vectors(page: &PrintPage, vectors: &str, selection_font: &selection::SelectionFont) -> Vec<u8> {
/// A PDF with one page per entry; `vectors[i]` (when present) is drawn over page `i`.
fn print_pdf_pages(pages: &[PrintPage], vectors: &[String], selection_font: &selection::SelectionFont) -> Vec<u8> {
let Some(first) = pages.first() else { return Vec::new() };
let mut out: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();
let mut offsets: Vec<usize> = Vec::new();
let mut obj = |out: &mut Vec<u8>, body: &[u8]| {
@@ -121,58 +123,78 @@ fn print_pdf_with_vectors(page: &PrintPage, vectors: &str, selection_font: &sele
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
let img = &page.image;
let (pw, ph) = page.paper;
let (x, y, w, h) = page.rect;
let selection_base = if img.icc.is_some() { 8 } else { 7 };
// 1 catalog, 2 pages, 3 page, 4 contents, 5 image, 6 font, 7 ICC (optional).
let n = pages.len();
// 1 catalog, 2 pages, then per page: page, contents, image; then the shared font/ICC/selection.
let font_obj = 3 + n * 3;
let icc = first.image.icc.as_ref();
let icc_obj = icc.map(|_| font_obj + 1);
let selection_base = font_obj + if icc.is_some() { 2 } else { 1 };
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
obj(
&mut out,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R /FSelect {selection_base} 0 R >> >> /Contents 4 0 R >>"
let kids = (0..n).map(|i| format!("{} 0 R", 3 + i * 3)).collect::<Vec<_>>().join(" ");
obj(&mut out, format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").as_bytes());
for (i, page) in pages.iter().enumerate() {
let page_obj = 3 + i * 3;
let content_obj = page_obj + 1;
let image_obj = page_obj + 2;
let (pw, ph) = page.paper;
let (x, y, w, h) = page.rect;
obj(
&mut out,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 {image_obj} 0 R >> /Font << /F1 {font_obj} 0 R /FSelect {selection_base} 0 R >> >> /Contents {content_obj} 0 R >>"
)
.as_bytes(),
);
// An explicit white page, so every viewer (and rasteriser) shows paper, not transparency.
let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n");
if let Some(v) = vectors.get(i) {
content.push_str(v);
}
content.push_str(&marks_ops(page.marks, page.rect));
let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration;
let pad = if any_marks { 30.0 } else { 6.0 };
if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) {
content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l)));
}
if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) {
content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d)));
}
let c = deflate(content.as_bytes());
let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes();
body.extend_from_slice(&c);
body.extend_from_slice(b"\nendstream");
obj(&mut out, &body);
let img = &page.image;
let device = match img.channels {
1 => "/DeviceGray",
4 => "/DeviceCMYK",
_ => "/DeviceRGB",
};
let cs = match icc_obj {
Some(obj) => format!("[/ICCBased {obj} 0 R]"),
None => device.to_string(),
};
let z = deflate(&img.data);
let mut body = format!(
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {cs} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n",
img.width,
img.height,
z.len()
)
.as_bytes(),
);
// An explicit white page, so every viewer (and rasteriser) shows paper, not transparency.
let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n");
content.push_str(vectors);
content.push_str(&marks_ops(page.marks, page.rect));
let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration;
let pad = if any_marks { 30.0 } else { 6.0 };
if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) {
content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l)));
.into_bytes();
body.extend_from_slice(&z);
body.extend_from_slice(b"\nendstream");
obj(&mut out, &body);
}
if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) {
content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d)));
}
let c = deflate(content.as_bytes());
let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes();
body.extend_from_slice(&c);
body.extend_from_slice(b"\nendstream");
obj(&mut out, &body);
let device = match img.channels {
1 => "/DeviceGray",
4 => "/DeviceCMYK",
_ => "/DeviceRGB",
};
let cs = if img.icc.is_some() { "[/ICCBased 7 0 R]".to_string() } else { device.to_string() };
let z = deflate(&img.data);
let mut body = format!(
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {cs} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n",
img.width,
img.height,
z.len()
)
.into_bytes();
body.extend_from_slice(&z);
body.extend_from_slice(b"\nendstream");
obj(&mut out, &body);
obj(&mut out, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
if let Some(icc) = &img.icc {
if let Some(icc) = icc {
let device = match first.image.channels {
1 => "/DeviceGray",
4 => "/DeviceCMYK",
_ => "/DeviceRGB",
};
let z = deflate(icc);
let mut body = format!("<< /N {} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", img.channels, z.len()).into_bytes();
let mut body = format!("<< /N {} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", first.image.channels, z.len()).into_bytes();
body.extend_from_slice(&z);
body.extend_from_slice(b"\nendstream");
obj(&mut out, &body);
@@ -261,9 +283,10 @@ fn collect_vectors(doc: &Document) -> (Vec<(usize, String)>, selection::Selectio
(layers, selection_font)
}
/// File › Save As / Save a Copy › PDF (#3401): the flattened document, at its print size
/// (pixels ÷ resolution × 72 pt), as a one-page PDF. Layers and transparency are dropped — the
/// warning says so — and the embedded profile travels with the image.
/// File › Save As / Save a Copy › PDF (#3401): the flattened document at its print size
/// (pixels ÷ resolution × 72 pt). One page per artboard when the document has artboards, else one
/// page for the whole document. Layers and transparency are dropped — the warning says so — and
/// the embedded profile travels with the images.
pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
let (w, h) = (usize::try_from(doc.size.width).unwrap_or(0), usize::try_from(doc.size.height).unwrap_or(0));
if w == 0 || h == 0 {
@@ -277,6 +300,41 @@ pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
};
let fmt = doc.pixel_format();
let n = fmt.channels().max(1);
let dpi = f64::from(doc.resolution_dpi.max(1.0));
let paper = if doc.mode == ColorMode::Cmyk { 0.0 } else { 1.0 };
let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let icc = doc.icc_profile.as_ref().map(|p| p.to_vec());
let artboards = doc.artboards();
if !artboards.is_empty() {
// One flattened pass of the whole document; each artboard is a crop of it.
let surface = photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None);
let mut pages = Vec::with_capacity(artboards.len());
for (_, name, artboard) in &artboards {
let rect = artboard.rect;
let (aw, ah) = (rect.width(), rect.height());
if aw == 0 || ah == 0 {
continue;
}
let values = surface.read_region(rect);
let mut data = Vec::with_capacity((aw as usize * ah as usize).saturating_mul(device));
for px in values.chunks(n) {
let a = px.last().copied().unwrap_or(1.0).clamp(0.0, 1.0);
for v in px.iter().take(device) {
data.push(to8(v * a + paper * (1.0 - a)));
}
}
let image = PrintImage { width: aw, height: ah, channels: device, data, icc: icc.clone() };
let (pw, ph) = (f64::from(aw) * 72.0 / dpi, f64::from(ah) * 72.0 / dpi);
pages.push(PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: Some((*name).to_string()) });
}
if pages.is_empty() {
return Err(IoError::Unsupported("PDF: the document's artboards have no area".into()));
}
let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()];
warnings.push(format!("PDF exported {} artboards as {} pages; vector/text layers on artboards are rasterised", artboards.len(), pages.len()));
let vectors = vec![String::new(); pages.len()];
return Ok(ExportResult { bytes: print_pdf_pages(&pages, &vectors, &selection::SelectionFont::default()), warnings });
}
let canvas = photocraft_geom::Rect::new(0, 0, w as i32, h as i32);
// Simple topmost shapes and text layers can be drawn over the flattened backdrop without changing
// compositing. Stop at the first unsupported visible layer, since it may depend on the
@@ -290,10 +348,8 @@ pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
}
let surface = photocraft_compose::flatten_to_surface(&backdrop, PixelFormat::new(fmt.mode, fmt.sample, true), None);
let values = surface.read_region(canvas);
let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
// Straight alpha, composited here over paper: white in RGB/Gray (1 = full light), zero ink
// in CMYK (0 = paper).
let paper = if doc.mode == ColorMode::Cmyk { 0.0 } else { 1.0 };
let mut data = Vec::with_capacity((w * h).saturating_mul(device));
for px in values.chunks(n) {
let a = px.last().copied().unwrap_or(1.0).clamp(0.0, 1.0);
@@ -301,8 +357,7 @@ pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
data.push(to8(v * a + paper * (1.0 - a)));
}
}
let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: doc.icc_profile.as_ref().map(|p| p.to_vec()) };
let dpi = f64::from(doc.resolution_dpi.max(1.0));
let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: icc.clone() };
let (pw, ph) = (f64::from(doc.size.width) * 72.0 / dpi, f64::from(doc.size.height) * 72.0 / dpi);
let page = PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: None };
let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()];
@@ -315,7 +370,7 @@ pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
vectors.push_str(ops);
}
vectors.push_str("Q\n");
Ok(ExportResult { bytes: print_pdf_with_vectors(&page, &vectors, &selection_font), warnings })
Ok(ExportResult { bytes: print_pdf_pages(&[page], &[vectors], &selection_font), warnings })
}
#[cfg(test)]
+26
View File
@@ -393,3 +393,29 @@ fn leading_newline_stays_in_the_first_selection_span() {
let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap();
assert!(content.contains("/ActualText <FEFF000A0041>"), "the first span retains the leading newline:\n{content}");
}
#[test]
fn artboards_export_as_one_page_each() {
use photocraft_doc::{Artboard, Group};
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
let mut d = Document::new("pages.pdf", Size { width: 40, height: 30 }, ColorMode::Rgb, SampleType::F32);
d.resolution_dpi = 72.0;
for (rect, px) in [(Rect::new(0, 0, 20, 30), [1.0, 0.0, 0.0, 1.0]), (Rect::new(20, 0, 40, 30), [0.0, 0.0, 1.0, 1.0])] {
let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, rect.x0, rect.y0, rect.x1, rect.y1, &px)));
let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None };
d.layers.push(Layer::new("Page", LayerContent::Group(group)));
}
let out = export(&d).unwrap();
check_structure(&out.bytes);
let parsed = lopdf::Document::load_mem(&out.bytes).unwrap();
let pages = parsed.get_pages();
assert_eq!(pages.len(), 2);
for id in pages.values() {
let page = parsed.get_dictionary(*id).unwrap();
let media = page.get(b"MediaBox").unwrap().as_array().unwrap();
let w = media[2].as_float().or_else(|_| media[2].as_i64().map(|v| v as f32)).unwrap();
let h = media[3].as_float().or_else(|_| media[3].as_i64().map(|v| v as f32)).unwrap();
assert!((w - 20.0).abs() < 0.01 && (h - 30.0).abs() < 0.01, "page {w}x{h}");
}
assert_eq!(out.bytes.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 2);
}
+66 -30
View File
@@ -1,10 +1,12 @@
//! PDF import: PDFium renders the first page to a PNG and the flat importer builds the document.
//! PDF import: PDFium renders every page and each page becomes an artboard.
//!
//! The page's print size is preserved by setting the document resolution to the import DPI:
//! pixels ÷ DPI × 72 pt is the PDF page's size again. Multi-page import, vector/text extraction
//! and page management are the next steps (see PLAN.md in the local PDF project).
//! The document resolution is the import DPI, so pixels ÷ DPI × 72 pt is the PDF page's size
//! again. Pages are laid out side by side with a small gap; more than [`MAX_PAGES`] pages are
//! capped so the union canvas stays bounded.
use crate::{ImportOptions, ImportResult, IoError};
use photocraft_doc::{Artboard, Document, Group, Layer, LayerContent};
use photocraft_geom::Rect;
/// `%PDF-` magic. `.ai` files are PDFs too, but the dispatch keeps them out of this importer.
pub fn is_pdf(bytes: &[u8]) -> bool {
@@ -17,41 +19,75 @@ pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions) -> Result<Impor
Err(IoError::Unsupported("PDF import needs the desktop build".into()))
}
/// Renders the PDF's first page at [`ImportOptions::pdf_dpi`] and opens it as a flat document.
/// Renders every PDF page at [`ImportOptions::pdf_dpi`] and opens it as one artboard per page.
#[cfg(not(target_arch = "wasm32"))]
pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result<ImportResult, IoError> {
use image::ImageEncoder;
use pdfium_render::prelude::*;
/// The most pages one import renders; more would make the union canvas enormous.
const MAX_PAGES: usize = 16;
/// Gap between the artboards, in document pixels.
const GAP: u32 = 80;
let dpi = if opts.pdf_dpi.is_finite() && opts.pdf_dpi > 0.0 { opts.pdf_dpi } else { 300.0 };
let pdfium = bind()?;
let document = pdfium.load_pdf_from_byte_slice(bytes, None).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?;
let page_count = document.pages().len();
let page = document.pages().get(0).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?;
let width_pt = f64::from(page.width().value);
// A page can be arbitrarily large; cap the raster so a hostile PDF cannot request a huge
// allocation. 20,000 px is far above a poster at 300 dpi.
let target_width = (width_pt * f64::from(dpi) / 72.0).round().clamp(1.0, 20_000.0) as i32;
let config = PdfRenderConfig::new().set_target_width(target_width);
let bitmap = page.render_with_config(&config).map_err(|e| IoError::Unsupported(format!("PDF render: {e}")))?;
let (width, height) = (bitmap.width().max(0) as u32, bitmap.height().max(0) as u32);
let rgba = bitmap.as_rgba_bytes();
let image = image::RgbaImage::from_raw(width, height, rgba)
.ok_or_else(|| IoError::Unsupported("PDF: the rendered bitmap does not match its buffer".into()))?;
// Reuse the well-tested flat importer (the PNG still carries the alpha and colour exactly).
let mut png = Vec::new();
image::codecs::png::PngEncoder::new(&mut png)
.write_image(image.as_raw(), width, height, image::ExtendedColorType::Rgba8)
.map_err(|e| IoError::Unsupported(format!("PDF: encoding the rendered page: {e}")))?;
let mut result = crate::flat::import_flat(name, &png)?;
result.document.resolution_dpi = dpi;
result.document.name = name.to_string();
if page_count > 1 {
result
.warnings
.push(format!("PDF has {page_count} pages; this build imports page 1 (multi-page import is the next step)"));
let total = document.pages().len().max(0) as usize;
let count = total.min(MAX_PAGES);
if count == 0 {
return Err(IoError::Unsupported("PDF has no pages".into()));
}
Ok(result)
// Render first, so a malformed later page fails before any document is built.
let mut rendered = Vec::with_capacity(count);
for index in 0..count {
let page = document.pages().get(index as i32).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?;
let width_pt = f64::from(page.width().value);
// A page can be arbitrarily large; cap the raster so a hostile PDF cannot request a huge
// allocation. 20,000 px is far above a poster at 300 dpi.
let target_width = (width_pt * f64::from(dpi) / 72.0).round().clamp(1.0, 20_000.0) as i32;
let config = PdfRenderConfig::new().set_target_width(target_width);
let bitmap = page.render_with_config(&config).map_err(|e| IoError::Unsupported(format!("PDF render: {e}")))?;
let (width, height) = (bitmap.width().max(0) as u32, bitmap.height().max(0) as u32);
rendered.push((width, height, bitmap.as_rgba_bytes()));
}
let doc_width = rendered.iter().map(|(w, _, _)| *w).sum::<u32>() + GAP * (count as u32 - 1);
let doc_height = rendered.iter().map(|(_, h, _)| *h).max().unwrap_or(1);
let mut doc: Option<Document> = None;
let mut x = 0u32;
for (index, (width, height, rgba)) in rendered.into_iter().enumerate() {
// Reuse the well-tested flat importer: the page is composited into the union canvas at its
// artboard position, encoded as PNG, and decoded into a document-wide raster layer.
let mut canvas = image::RgbaImage::new(doc_width, doc_height);
let page = image::RgbaImage::from_raw(width, height, rgba)
.ok_or_else(|| IoError::Unsupported("PDF: the rendered bitmap does not match its buffer".into()))?;
image::imageops::overlay(&mut canvas, &page, i64::from(x), 0);
let mut png = Vec::new();
image::codecs::png::PngEncoder::new(&mut png)
.write_image(canvas.as_raw(), doc_width, doc_height, image::ExtendedColorType::Rgba8)
.map_err(|e| IoError::Unsupported(format!("PDF: encoding the rendered page: {e}")))?;
let mut result = crate::flat::import_flat(name, &png)?;
let layer = result.document.layers.pop().ok_or_else(|| IoError::Unsupported("PDF: the page decoded to an empty layer".into()))?;
let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32);
let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None };
let artboard = Layer::new(format!("Page {}", index + 1), LayerContent::Group(group));
match &mut doc {
None => {
result.document.layers = vec![artboard];
doc = Some(result.document);
}
Some(d) => d.layers.push(artboard),
}
x += width + GAP;
}
let mut doc = doc.ok_or_else(|| IoError::Unsupported("PDF has no pages".into()))?;
doc.resolution_dpi = dpi;
doc.name = name.to_string();
let mut warnings = Vec::new();
if total > count {
warnings.push(format!("PDF has {total} pages; imported the first {count} (the limit is {MAX_PAGES})"));
}
Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false })
}
/// Loads the PDFium shared library from the executable's folder (`PDFIUM_DIR` overrides it).