mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 03:27:21 -06:00
* M10.2: create PDF from multiple files Create a PDF ▸ Multiple files takes PDFs, images and .txt files, lets the user reorder them, and makes one combined PDF (a bookmark and an optional page range per file) or one PDF per file in a chosen folder, never overwriting what is there. - create: source_kind() tells PDFs, images and text apart (moved from the shell) - engine: Session::convert_to_pdf, shared by Open, the dialog and the tool - automation: doc_create_multiple (combine or separate) - ui: Multiple files dialog reusing the Combine files list; translations - parity entry and ROADMAP updated; catalog hashes refreshed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M10.2: multiple files open in the page grid; insert files at any gap Create a PDF ▸ Multiple files now opens the picked files as one unsaved document in the Organize page grid instead of a file-list dialog: pages are removed and reordered there, and Save pages writes what is shown. - grid: a "+" at every gap inserts files right there; Save pages button - insert from file: several files in order, images and text converted, inserted pages selected - the file-list dialog is removed; one PDF per file stays in the doc_create_multiple tool - translations, catalog hashes, parity notes and ROADMAP updated Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M4: insert files by dropping them on the page grid On desktop, PDFs, images and .txt files dropped on the Organize page grid are inserted at the gap under the pointer, in order, and selected; a dropped file still opens as a document everywhere else. winit does not report the pointer while files are dragged over the window on every platform, so a drop waits (up to a second) for the pointer to show up and otherwise appends at the end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M4: files dragged over the page grid follow the pointer (macOS) winit sends no pointer moves while files are dragged over the window, so egui kept the place the pointer entered at: the insertion bar froze there and the files were inserted there. During a file drag the pointer is now read from AppKit (safe objc2 calls, crates already in the tree) and fed to egui, so the bar and a "+" badge follow the pointer and the files go where they are released. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
276 lines
13 KiB
Rust
276 lines
13 KiB
Rust
use std::sync::Arc;
|
||
|
||
use pdfcraft_cos::{SaveOptions, write_full};
|
||
|
||
use super::*;
|
||
|
||
fn reopen(doc: &Document) -> Document {
|
||
let bytes = write_full(doc, &SaveOptions::default()).unwrap();
|
||
hayro_syntax::Pdf::new(bytes.clone()).expect("parses");
|
||
Document::open(Arc::new(bytes)).unwrap()
|
||
}
|
||
|
||
fn pages(doc: &Document) -> Vec<Dict> {
|
||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||
let kids = doc.get(pages).as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap().clone();
|
||
kids.iter().map(|k| doc.resolve(k).as_dict().cloned().unwrap()).collect()
|
||
}
|
||
|
||
fn media(d: &Dict) -> Vec<f64> {
|
||
d.get(b"MediaBox").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect()
|
||
}
|
||
|
||
/// A tiny PNG: 4×2 RGBA with one transparent pixel, 144 dpi.
|
||
fn png_bytes(alpha: bool) -> Vec<u8> {
|
||
let mut out = Vec::new();
|
||
{
|
||
let mut enc = png::Encoder::new(&mut out, 4, 2);
|
||
enc.set_color(if alpha { png::ColorType::Rgba } else { png::ColorType::Rgb });
|
||
enc.set_depth(png::BitDepth::Eight);
|
||
enc.set_pixel_dims(Some(png::PixelDimensions { xppu: 5669, yppu: 5669, unit: png::Unit::Meter }));
|
||
let mut w = enc.write_header().unwrap();
|
||
let n = if alpha { 4 } else { 3 };
|
||
let mut data = vec![200u8; 8 * n];
|
||
if alpha {
|
||
data[3] = 0;
|
||
}
|
||
w.write_image_data(&data).unwrap();
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A minimal baseline JPEG header (SOI, JFIF at 300 dpi, SOF0 3×2 RGB, EOI). Only the headers
|
||
/// matter: the data is embedded as is.
|
||
fn jpeg_bytes() -> Vec<u8> {
|
||
let mut v = vec![0xFF, 0xD8];
|
||
v.extend_from_slice(&[0xFF, 0xE0, 0, 16, b'J', b'F', b'I', b'F', 0, 1, 1, 1, 0x01, 0x2C, 0x01, 0x2C, 0, 0]);
|
||
v.extend_from_slice(&[0xFF, 0xC0, 0, 17, 8, 0, 2, 0, 3, 3, 1, 0x11, 0, 2, 0x11, 1, 3, 0x11, 1]);
|
||
v.extend_from_slice(&[0xFF, 0xD9]);
|
||
v
|
||
}
|
||
|
||
#[test]
|
||
fn blank_documents() {
|
||
let doc = reopen(&blank(612.0, 792.0, 3).unwrap());
|
||
assert_eq!(pages(&doc).len(), 3);
|
||
assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 612.0, 792.0]);
|
||
assert!(blank(1.0, 792.0, 1).is_err() && blank(612.0, 792.0, 0).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn images_become_pages_at_their_resolution() {
|
||
let doc =
|
||
from_images(&[("photo.png".into(), png_bytes(true)), ("scan.jpg".into(), jpeg_bytes()), ("flat.png".into(), png_bytes(false))]).unwrap();
|
||
let doc = reopen(&doc);
|
||
let p = pages(&doc);
|
||
assert_eq!(p.len(), 3);
|
||
// 4×2 px at 144 dpi → 2×1 pt; 3×2 px at 300 dpi → 0.72×0.48 pt.
|
||
let m0 = media(&p[0]);
|
||
assert!((m0[2] - 2.0).abs() < 0.01 && (m0[3] - 1.0).abs() < 0.01, "{m0:?}");
|
||
let m1 = media(&p[1]);
|
||
assert!((m1[2] - 0.72).abs() < 0.01, "{m1:?}");
|
||
let img = |d: &Dict| {
|
||
let x = doc.resolve(d.get(b"Resources").unwrap());
|
||
let xo = doc.resolve(x.as_dict().unwrap().get(b"XObject").unwrap());
|
||
doc.resolve(xo.as_dict().unwrap().get(b"Im0").unwrap()).as_dict().cloned().unwrap()
|
||
};
|
||
assert!(img(&p[0]).contains(b"SMask"), "transparency is kept");
|
||
assert!(!img(&p[2]).contains(b"SMask"));
|
||
assert_eq!(img(&p[1]).name(b"Filter"), Some(&b"DCTDecode"[..]));
|
||
assert_eq!(img(&p[1]).name(b"ColorSpace"), Some(&b"DeviceRGB"[..]));
|
||
let title = doc.resolve(doc.trailer().get(b"Info").unwrap());
|
||
assert_eq!(title.as_dict().unwrap().get(b"Title").unwrap().as_string().unwrap().to_text(), "photo");
|
||
assert!(matches!(from_images(&[("x.gif".into(), b"GIF89a".to_vec())]), Err(CreateError::Image(..))));
|
||
}
|
||
|
||
#[test]
|
||
fn image_resolution_override_changes_size_without_resampling() {
|
||
let images = [("photo.png".into(), png_bytes(true)), ("scan.jpg".into(), jpeg_bytes())];
|
||
for (dpi, sizes) in [(72.0, [(4.0, 2.0), (3.0, 2.0)]), (300.0, [(0.96, 0.48), (0.72, 0.48)])] {
|
||
let doc = reopen(&from_images_with_resolution(&images, ImageResolution::Dpi(dpi)).unwrap());
|
||
for (page, size) in pages(&doc).iter().zip(sizes) {
|
||
let m = media(page);
|
||
assert!((m[2] - size.0).abs() < 0.001 && (m[3] - size.1).abs() < 0.001);
|
||
let res = doc.resolve(page.get(b"Resources").unwrap());
|
||
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
|
||
let img = doc.resolve(xo.as_dict().unwrap().get(b"Im0").unwrap());
|
||
let Object::Stream(stream) = &*img else { panic!("expected an image stream") };
|
||
if stream.dict.name(b"Filter") == Some(&b"DCTDecode"[..]) {
|
||
assert_eq!(*stream.raw, jpeg_bytes());
|
||
} else {
|
||
assert_eq!(stream.dict.int(b"Width"), Some(4));
|
||
assert_eq!(stream.dict.int(b"Height"), Some(2));
|
||
assert!(stream.dict.contains(b"SMask"));
|
||
}
|
||
}
|
||
}
|
||
for dpi in [0.0, -1.0, f64::NAN, f64::INFINITY, 1201.0] {
|
||
assert!(matches!(from_images_with_resolution(&images, ImageResolution::Dpi(dpi)), Err(CreateError::Invalid(_))));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn text_is_wrapped_and_paginated() {
|
||
let long: String = (0..200).map(|i| format!("Line {i} of a plain text file\n")).collect();
|
||
let doc = reopen(&from_text("notes", &format!("{long}\u{c}After a form feed"), LETTER, 11.0).unwrap());
|
||
let p = pages(&doc);
|
||
assert!(p.len() >= 5, "{} pages", p.len());
|
||
let content = |d: &Dict| {
|
||
let c = doc.resolve(d.get(b"Contents").unwrap());
|
||
let Object::Stream(s) = &*c else { panic!() };
|
||
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
|
||
};
|
||
assert!(content(&p[0]).contains("(Line 0 of a plain text file) Tj"));
|
||
assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page");
|
||
}
|
||
|
||
fn image_of(doc: &Document, page: usize) -> Dict {
|
||
let p = &pages(doc)[page];
|
||
let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||
match &*doc.resolve(xo.get(b"Im0").unwrap()) {
|
||
Object::Stream(s) => s.dict.clone(),
|
||
_ => panic!("not an image"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn bmp_gif_and_multi_page_tiff_images() {
|
||
use image::{ImageEncoder, Rgba, RgbaImage};
|
||
// BMP: 3×2 opaque colour.
|
||
let mut bmp = Vec::new();
|
||
image::codecs::bmp::BmpEncoder::new(&mut bmp).write_image(&[10, 20, 30].repeat(6), 3, 2, image::ExtendedColorType::Rgb8).unwrap();
|
||
// GIF: 2×2 with a transparent pixel.
|
||
let mut gif = Vec::new();
|
||
{
|
||
let mut img = RgbaImage::from_pixel(2, 2, Rgba([255, 0, 0, 255]));
|
||
img.put_pixel(0, 0, Rgba([0, 0, 0, 0]));
|
||
let mut enc = image::codecs::gif::GifEncoder::new(&mut gif);
|
||
enc.encode(img.as_raw(), 2, 2, image::ExtendedColorType::Rgba8).unwrap();
|
||
}
|
||
// TIFF: two pages, an 8-bit gray one at 144 dpi and a 1-bit one.
|
||
let mut tif = std::io::Cursor::new(Vec::new());
|
||
{
|
||
let mut enc = tiff::encoder::TiffEncoder::new(&mut tif).unwrap();
|
||
let mut im = enc.new_image::<tiff::encoder::colortype::Gray8>(4, 2).unwrap();
|
||
im.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: 144, d: 1 });
|
||
im.write_data(&[128; 8]).unwrap();
|
||
enc.write_image::<tiff::encoder::colortype::Gray8>(2, 2, &[0, 255, 255, 0]).unwrap();
|
||
}
|
||
let doc = from_images(&[("a.bmp".into(), bmp), ("b.gif".into(), gif), ("scan.tif".into(), tif.into_inner())]).unwrap();
|
||
let doc = reopen(&doc);
|
||
assert_eq!(pages(&doc).len(), 4, "one page per BMP and GIF, two for the TIFF");
|
||
assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 3.0, 2.0]);
|
||
assert_eq!(image_of(&doc, 0).name(b"ColorSpace"), Some(&b"DeviceRGB"[..]));
|
||
assert!(image_of(&doc, 1).contains(b"SMask"), "GIF transparency");
|
||
assert_eq!(media(&pages(&doc)[2]), [0.0, 0.0, 2.0, 1.0], "4×2 px at 144 dpi");
|
||
assert_eq!(image_of(&doc, 2).name(b"ColorSpace"), Some(&b"DeviceGray"[..]));
|
||
assert!(matches!(from_images(&[("x.webp".into(), b"RIFF0000WEBP".to_vec())]), Err(CreateError::Image(..))));
|
||
}
|
||
|
||
#[test]
|
||
fn images_export_as_jpeg_unchanged_and_others_as_png() {
|
||
let doc = reopen(
|
||
&from_images(&[("photo.png".into(), png_bytes(true)), ("scan.jpg".into(), jpeg_bytes()), ("flat.png".into(), png_bytes(false))]).unwrap(),
|
||
);
|
||
let out = extract_images(&doc, &[0, 1, 2], 0);
|
||
assert!(out.skipped.is_empty(), "{:?}", out.skipped);
|
||
let kinds: Vec<(usize, &str, u32, u32)> = out.images.iter().map(|i| (i.page, i.extension, i.width, i.height)).collect();
|
||
assert_eq!(kinds, [(0, "png", 4, 2), (1, "jpg", 3, 2), (2, "png", 4, 2)]);
|
||
assert_eq!(out.images[1].data, jpeg_bytes(), "JPEG data is written as is");
|
||
// The PNG round-trips the pixels, with the soft mask as alpha.
|
||
let dec = png::Decoder::new(std::io::Cursor::new(out.images[0].data.clone()));
|
||
let mut r = dec.read_info().unwrap();
|
||
let mut buf = vec![0; r.output_buffer_size().unwrap()];
|
||
let info = r.next_frame(&mut buf).unwrap();
|
||
assert_eq!(info.color_type, png::ColorType::Rgba);
|
||
assert_eq!(&buf[..8], &[200, 200, 200, 0, 200, 200, 200, 200]);
|
||
// Pages filter; small images can be left out.
|
||
assert_eq!(extract_images(&doc, &[2], 0).images.len(), 1);
|
||
assert!(extract_images(&doc, &[0, 1, 2], 3).images.iter().all(|i| i.width.min(i.height) >= 3));
|
||
}
|
||
|
||
#[test]
|
||
fn indexed_and_one_bit_images_decode() {
|
||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||
// Replace the page's image with a 4×1 indexed image (two colours) and add a 1-bit mask.
|
||
let page = pdfcraft_model::pages(&doc)[0].clone();
|
||
let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||
let (_, r) = xo.iter().next().map(|(k, v)| (k.clone(), v.as_ref().unwrap())).unwrap();
|
||
let mut d = Dict::new();
|
||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
d.set(b"Width".to_vec(), Object::Int(4));
|
||
d.set(b"Height".to_vec(), Object::Int(1));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
|
||
d.set(
|
||
b"ColorSpace".to_vec(),
|
||
Object::Array(vec![
|
||
Object::name("Indexed"),
|
||
Object::name("DeviceRGB"),
|
||
Object::Int(1),
|
||
Object::String(PdfString::literal(vec![255, 0, 0, 0, 0, 255])),
|
||
]),
|
||
);
|
||
doc.set(r, Object::Stream(Stream::from_raw(d, vec![0b0101_0000])));
|
||
let out = extract_images(&doc, &[0], 0);
|
||
let dec = png::Decoder::new(std::io::Cursor::new(out.images[0].data.clone()));
|
||
let mut rd = dec.read_info().unwrap();
|
||
let mut buf = vec![0; rd.output_buffer_size().unwrap()];
|
||
rd.next_frame(&mut buf).unwrap();
|
||
assert_eq!(&buf[..12], &[255, 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 255]);
|
||
}
|
||
|
||
#[test]
|
||
fn jpeg_2000_images_are_embedded_as_is() {
|
||
// A JP2 file: signature, ftyp, jp2h (ihdr 30 × 20, 3 components; resc 5906 px/m ≈ 150 dpi),
|
||
// and a (stub) codestream.
|
||
let bx = |ty: &[u8], payload: &[u8]| {
|
||
let mut v = ((payload.len() + 8) as u32).to_be_bytes().to_vec();
|
||
v.extend_from_slice(ty);
|
||
v.extend_from_slice(payload);
|
||
v
|
||
};
|
||
let mut ihdr = Vec::new();
|
||
ihdr.extend_from_slice(&20u32.to_be_bytes());
|
||
ihdr.extend_from_slice(&30u32.to_be_bytes());
|
||
ihdr.extend_from_slice(&[0, 3, 7, 7, 0, 0]);
|
||
let mut resc = Vec::new();
|
||
for _ in 0..2 {
|
||
resc.extend_from_slice(&5906u16.to_be_bytes());
|
||
resc.extend_from_slice(&1u16.to_be_bytes());
|
||
}
|
||
resc.extend_from_slice(&[0, 0]);
|
||
let jp2h = [bx(b"ihdr", &ihdr), bx(b"res ", &bx(b"resc", &resc))].concat();
|
||
let file = [JP2_SIGNATURE.to_vec(), bx(b"ftyp", b"jp2 \0\0\0\0jp2 "), bx(b"jp2h", &jp2h), bx(b"jp2c", &[0xFF, 0x4F, 0xFF, 0x51])].concat();
|
||
let doc = reopen(&from_images(&[("photo.jp2".into(), file.clone())]).unwrap());
|
||
let p = &pages(&doc)[0];
|
||
let m = media(p);
|
||
assert!((m[2] - 30.0 * 72.0 / 150.0).abs() < 0.2 && (m[3] - 20.0 * 72.0 / 150.0).abs() < 0.2, "{m:?}");
|
||
let out = extract_images(&doc, &[0], 0);
|
||
assert!(out.images.is_empty() && out.skipped.len() == 1, "JPX can't be exported yet: {:?}", out.skipped);
|
||
// A raw codestream: the size comes from SIZ.
|
||
let mut siz = vec![0xFF, 0x4F, 0xFF, 0x51, 0, 41, 0, 0];
|
||
for v in [64u32, 48, 0, 0] {
|
||
siz.extend_from_slice(&v.to_be_bytes());
|
||
}
|
||
let doc = reopen(&from_images(&[("raw.j2k".into(), siz)]).unwrap());
|
||
assert_eq!(media(&pages(&doc)[0])[2], 64.0);
|
||
}
|
||
|
||
#[test]
|
||
fn source_kind_tells_pdfs_images_and_text_apart() {
|
||
assert_eq!(source_kind("a.bin", b"%PDF-1.7\n"), Some(SourceKind::Pdf));
|
||
assert_eq!(source_kind("a.txt", b"junk before\n%PDF-1.4"), Some(SourceKind::Pdf), "a header after leading junk");
|
||
assert_eq!(source_kind("a", &png_bytes(false)), Some(SourceKind::Image));
|
||
assert_eq!(source_kind("a.txt", &jpeg_bytes()), Some(SourceKind::Image), "bytes win over the name");
|
||
assert_eq!(source_kind("notes.TXT", b"hello"), Some(SourceKind::Text));
|
||
assert_eq!(source_kind("notes.text", b""), Some(SourceKind::Text));
|
||
// Text that merely starts like a BMP stays text; a truncated BMP header is not an image.
|
||
assert_eq!(source_kind("b.txt", b"BMW drivers"), Some(SourceKind::Text));
|
||
assert_eq!(source_kind("b.bmp", b"BM"), None);
|
||
assert_eq!(source_kind("a.docx", b"PK\x03\x04"), None);
|
||
assert_eq!(source_kind("", b""), None);
|
||
}
|