diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index d693ee941b..71c2818b97 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -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 { - 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 { +/// 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 { + let Some(first) = pages.first() else { return Vec::new() }; let mut out: Vec = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(); let mut offsets: Vec = Vec::new(); let mut obj = |out: &mut Vec, 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::>().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 { 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 { }; 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 { } 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 { 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 { 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)] diff --git a/crates/io/src/pdf/tests.rs b/crates/io/src/pdf/tests.rs index e745623e41..0364fa6e1a 100644 --- a/crates/io/src/pdf/tests.rs +++ b/crates/io/src/pdf/tests.rs @@ -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 "), "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); +} diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index ca9d984993..b35a224dd7 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -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 Result { 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::() + GAP * (count as u32 - 1); + let doc_height = rendered.iter().map(|(_, h, _)| *h).max().unwrap_or(1); + let mut doc: Option = 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).