Files
pdfcraft/crates/create/src/lib.rs
T
f88a59f762 M10.2: Create PDF from multiple files, edited as pages in the Organize grid (#361)
* 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>
2026-10-08 17:42:13 -04:00

567 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! pdfcraft-create — create PDFs from nothing, images or text (L4). See the README.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
mod extract;
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file};
use pdfcraft_fonts::{literal, win_ansi, wrap};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum CreateError {
#[error("{0}: {1}")]
Image(String, String),
#[error("{0}")]
Invalid(String),
}
/// US Letter in points.
pub const LETTER: (f64, f64) = (612.0, 792.0);
/// The largest page side PDF allows (ISO 32000-2 Annex C: 14 400 units).
const MAX_SIDE: f64 = 14_400.0;
fn add_page(doc: &mut Document, w: f64, h: f64, resources: Dict, content: Option<Vec<u8>>) -> Result<ObjRef, CreateError> {
// `Document::new_empty` always has a page tree.
let pages = doc
.root()
.and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages")))
.ok_or_else(|| CreateError::Invalid("the new document has no page tree".into()))?;
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(pages));
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), Object::Real(w), Object::Real(h)]));
page.set(b"Resources".to_vec(), Object::Dict(resources));
if let Some(c) = content {
let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &c)));
page.set(b"Contents".to_vec(), Object::Ref(s));
}
let r = doc.add(page);
let _ = doc.update_dict(pages, |d| {
let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default();
kids.push(Object::Ref(r));
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
d.set(b"Kids".to_vec(), Object::Array(kids));
});
Ok(r)
}
fn set_title(doc: &mut Document, title: &str) {
let mut info = Dict::new();
info.set(b"Title".to_vec(), PdfString::text(title));
info.set(b"Producer".to_vec(), PdfString::text("PdfCraft"));
let r = doc.add(info);
doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r));
}
/// A document of `pages` empty pages of `width × height` points.
pub fn blank(width: f64, height: f64, pages: usize) -> Result<Document, CreateError> {
if !(width.is_finite() && height.is_finite() && (3.0..=MAX_SIDE).contains(&width) && (3.0..=MAX_SIDE).contains(&height))
|| pages == 0
|| pages > 10_000
{
return Err(CreateError::Invalid("invalid page size or count".into()));
}
let mut doc = Document::new_empty();
for _ in 0..pages {
add_page(&mut doc, width, height, Dict::new(), None)?;
}
Ok(doc)
}
// ── images ──────────────────────────────────────────────────────────────────────────────────
/// An image ready to embed: its XObject dictionary, encoded data, optional soft mask, and size
/// in pixels and dots per inch.
struct Embedded {
dict: Dict,
data: Vec<u8>,
filtered: bool,
smask: Option<(Dict, Vec<u8>)>,
px: (u32, u32),
dpi: (f64, f64),
}
const JP2_SIGNATURE: &[u8] = &[0, 0, 0, 0x0C, b'j', b'P', b' ', b' ', 0x0D, 0x0A, 0x87, 0x0A];
fn be32(b: &[u8], at: usize) -> Option<u32> {
b.get(at..at + 4).map(|x| u32::from_be_bytes([x[0], x[1], x[2], x[3]]))
}
fn be16(b: &[u8], at: usize) -> Option<u16> {
b.get(at..at + 2).map(|x| u16::from_be_bytes([x[0], x[1]]))
}
/// The boxes of a JP2 box sequence: (type, payload).
fn jp2_boxes(b: &[u8]) -> Vec<([u8; 4], &[u8])> {
let mut out = Vec::new();
let mut i = 0usize;
while i + 8 <= b.len() {
let len = be32(b, i).unwrap_or(0) as u64;
let ty = [b[i + 4], b[i + 5], b[i + 6], b[i + 7]];
let (head, len) = match len {
0 => (8, (b.len() - i) as u64),
1 => match b.get(i + 8..i + 16) {
Some(x) => (16, u64::from_be_bytes([x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7]])),
None => break,
},
n => (8, n),
};
let end = i.saturating_add(len as usize).min(b.len());
if len < head as u64 || end <= i {
break;
}
out.push((ty, &b[i + head..end]));
i = end;
}
out
}
/// A JPEG 2000 image (JP2 file or raw codestream), embedded as is (`/JPXDecode`; the colour
/// space comes from the file).
fn jpx(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
let bad = |m: &str| CreateError::Image(name.into(), m.into());
let mut dpi = (72.0, 72.0);
let (w, h) = if bytes.starts_with(JP2_SIGNATURE) {
let header =
jp2_boxes(bytes).into_iter().find(|(t, _)| t == b"jp2h").map(|(_, p)| p).ok_or_else(|| bad("a JPEG 2000 file without a header"))?;
let inner = jp2_boxes(header);
let ihdr = inner.iter().find(|(t, _)| t == b"ihdr").map(|(_, p)| *p).ok_or_else(|| bad("a JPEG 2000 file without image size"))?;
// Capture resolution (pixels per metre), when given.
if let Some((_, res)) = inner.iter().find(|(t, _)| t == b"res ")
&& let Some((_, r)) = jp2_boxes(res).into_iter().find(|(t, _)| t == b"resc" || t == b"resd")
&& r.len() >= 10
{
let part = |n: u16, d: u16, e: i8| if d == 0 { 0.0 } else { n as f64 / d as f64 * 10f64.powi(e as i32) * 0.0254 };
let (v, hz) = (
part(be16(r, 0).unwrap_or(0), be16(r, 2).unwrap_or(0), r[8] as i8),
part(be16(r, 4).unwrap_or(0), be16(r, 6).unwrap_or(0), r[9] as i8),
);
if v > 1.0 && hz > 1.0 {
dpi = (hz, v);
}
}
(be32(ihdr, 4).ok_or_else(|| bad("bad image header"))?, be32(ihdr, 0).ok_or_else(|| bad("bad image header"))?)
} else {
// SIZ: Lsiz Rsiz Xsiz Ysiz XOsiz YOsiz …
let (x, y, xo, yo) = (be32(bytes, 8), be32(bytes, 12), be32(bytes, 16), be32(bytes, 20));
match (x, y, xo, yo) {
(Some(x), Some(y), Some(xo), Some(yo)) if x > xo && y > yo => (x - xo, y - yo),
_ => return Err(bad("a damaged JPEG 2000 codestream")),
}
};
if w == 0 || h == 0 || w > 100_000 || h > 100_000 {
return Err(bad("unusual JPEG 2000 image size"));
}
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("JPXDecode"));
Ok(Embedded { dict: d, data: bytes.to_vec(), filtered: true, smask: None, px: (w, h), dpi })
}
fn jpeg(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
let bad = |m: &str| CreateError::Image(name.into(), m.into());
if bytes.len() < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 {
return Err(bad("not a JPEG file"));
}
let (mut i, mut size, mut comps, mut dpi, mut adobe) = (2usize, None, 0u8, (72.0, 72.0), false);
while i + 4 <= bytes.len() {
if bytes[i] != 0xFF {
i += 1;
continue;
}
let marker = bytes[i + 1];
if marker == 0xD8 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
i += 2;
continue;
}
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
let seg = bytes.get(i + 4..i + 2 + len).ok_or_else(|| bad("truncated"))?;
match marker {
// APP0 JFIF density.
0xE0 if seg.starts_with(b"JFIF\0") && seg.len() >= 12 => {
let (unit, x, y) = (seg[7], u16::from_be_bytes([seg[8], seg[9]]) as f64, u16::from_be_bytes([seg[10], seg[11]]) as f64);
if x > 0.0 && y > 0.0 {
dpi = match unit {
1 => (x, y),
2 => (x * 2.54, y * 2.54),
_ => dpi,
};
}
}
0xEE if seg.starts_with(b"Adobe") => adobe = true,
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => {
if seg.len() < 6 {
return Err(bad("bad frame header"));
}
size = Some((u16::from_be_bytes([seg[3], seg[4]]) as u32, u16::from_be_bytes([seg[1], seg[2]]) as u32));
comps = seg[5];
break;
}
0xDA => break,
_ => {}
}
i += 2 + len;
}
let (w, h) = size.filter(|(w, h)| *w > 0 && *h > 0).ok_or_else(|| bad("no image size"))?;
let mut d = Dict::new();
let cs = match comps {
1 => "DeviceGray",
3 => "DeviceRGB",
4 => {
if adobe {
// Adobe writes CMYK JPEGs inverted.
d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0, 1, 0].iter().map(|v| Object::Int(*v)).collect()));
}
"DeviceCMYK"
}
n => return Err(bad(&format!("{n} colour components are not supported"))),
};
d.set(b"ColorSpace".to_vec(), Object::name(cs));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
Ok(Embedded { dict: d, data: bytes.to_vec(), filtered: true, smask: None, px: (w, h), dpi })
}
fn png_image(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
let bad = |m: String| CreateError::Image(name.into(), m);
let mut dec = png::Decoder::new(std::io::Cursor::new(bytes));
dec.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = dec.read_info().map_err(|e| bad(e.to_string()))?;
let dpi = match reader.info().pixel_dims {
Some(png::PixelDimensions { xppu, yppu, unit: png::Unit::Meter }) if xppu > 0 && yppu > 0 => (xppu as f64 * 0.0254, yppu as f64 * 0.0254),
_ => (72.0, 72.0),
};
let mut buf = vec![0; reader.output_buffer_size().ok_or_else(|| bad("image too large".into()))?];
let frame = reader.next_frame(&mut buf).map_err(|e| bad(e.to_string()))?;
let (w, h) = (frame.width, frame.height);
let data = &buf[..frame.buffer_size()];
let (channels, cs) = match frame.color_type {
png::ColorType::Grayscale => (1, "DeviceGray"),
png::ColorType::GrayscaleAlpha => (2, "DeviceGray"),
png::ColorType::Rgb => (3, "DeviceRGB"),
png::ColorType::Rgba => (4, "DeviceRGB"),
png::ColorType::Indexed => return Err(bad("unexpected palette output".into())),
};
let has_alpha = channels == 2 || channels == 4;
let colour_n = if has_alpha { channels - 1 } else { channels };
let mut colour = Vec::with_capacity(w as usize * h as usize * colour_n);
let mut alpha = Vec::new();
for px in data.chunks_exact(channels) {
colour.extend_from_slice(&px[..colour_n]);
if has_alpha {
alpha.push(px[colour_n]);
}
}
let mut d = Dict::new();
d.set(b"ColorSpace".to_vec(), Object::name(cs));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
let smask = (has_alpha && alpha.iter().any(|a| *a != 255)).then(|| {
let mut m = Dict::new();
m.set(b"Type".to_vec(), Object::name("XObject"));
m.set(b"Subtype".to_vec(), Object::name("Image"));
m.set(b"Width".to_vec(), Object::Int(w as i64));
m.set(b"Height".to_vec(), Object::Int(h as i64));
m.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
m.set(b"BitsPerComponent".to_vec(), Object::Int(8));
(m, alpha)
});
Ok(Embedded { dict: d, data: colour, filtered: false, smask, px: (w, h), dpi })
}
/// 8-bit RGBA pixels → an image (gray when every pixel is, with a soft mask when any pixel is
/// not opaque).
fn rgba_image(rgba: &[u8], (w, h): (u32, u32), dpi: (f64, f64)) -> Embedded {
let gray = rgba.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]);
let opaque = rgba.as_chunks::<4>().0.iter().all(|p| p[3] == 255);
let colour: Vec<u8> = if gray {
rgba.as_chunks::<4>().0.iter().map(|p| p[0]).collect()
} else {
rgba.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect()
};
let mut d = Dict::new();
d.set(b"ColorSpace".to_vec(), Object::name(if gray { "DeviceGray" } else { "DeviceRGB" }));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
let smask = (!opaque).then(|| {
let mut m = Dict::new();
m.set(b"Type".to_vec(), Object::name("XObject"));
m.set(b"Subtype".to_vec(), Object::name("Image"));
m.set(b"Width".to_vec(), Object::Int(w as i64));
m.set(b"Height".to_vec(), Object::Int(h as i64));
m.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
m.set(b"BitsPerComponent".to_vec(), Object::Int(8));
(m, rgba.as_chunks::<4>().0.iter().map(|p| p[3]).collect())
});
Embedded { dict: d, data: colour, filtered: false, smask, px: (w, h), dpi }
}
/// BMP and GIF (the first frame), through the `image` decoders. Their resolution is not read:
/// 72 dpi, as for images without one.
fn decoded(name: &str, bytes: &[u8], format: image::ImageFormat) -> Result<Embedded, CreateError> {
let img = image::load_from_memory_with_format(bytes, format).map_err(|e| CreateError::Image(name.into(), e.to_string()))?;
let rgba = img.to_rgba8();
Ok(rgba_image(rgba.as_raw(), rgba.dimensions(), (72.0, 72.0)))
}
/// Every page of a TIFF (multi-page scans become multi-page PDFs).
fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
use tiff::ColorType as C;
use tiff::decoder::{Decoder, DecodingResult};
use tiff::tags::Tag;
let bad = |m: String| CreateError::Image(name.into(), m);
let mut dec = Decoder::new(std::io::Cursor::new(bytes)).map_err(|e| bad(e.to_string()))?;
let mut out = Vec::new();
loop {
let (w, h) = dec.dimensions().map_err(|e| bad(e.to_string()))?;
let ct = dec.colortype().map_err(|e| bad(e.to_string()))?;
let unit = dec.get_tag_u32(Tag::ResolutionUnit).unwrap_or(2);
let res = |t: Tag, dec: &mut Decoder<std::io::Cursor<&[u8]>>| -> f64 {
// Resolutions are rationals.
let v = match dec.get_tag(t) {
Ok(tiff::decoder::ifd::Value::Rational(n, d)) if d != 0 => f64::from(n) / f64::from(d),
Ok(other) => other.into_f64().unwrap_or(72.0),
Err(_) => 72.0,
};
let v = if unit == 3 { v * 2.54 } else { v };
if v.is_finite() && v >= 1.0 { v } else { 72.0 }
};
let dpi = (res(Tag::XResolution, &mut dec), res(Tag::YResolution, &mut dec));
let data = match dec.read_image().map_err(|e| bad(e.to_string()))? {
DecodingResult::U8(v) => v,
// 16-bit samples: keep the high byte.
DecodingResult::U16(v) => v.iter().map(|x| (x >> 8) as u8).collect(),
_ => return Err(bad("this TIFF sample format isn't supported".into())),
};
let page = match ct {
C::Gray(1) => {
// Bilevel scans stay 1 bit per pixel (0 = black after the decoder's inversion).
let mut d = Dict::new();
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
Embedded { dict: d, data, filtered: false, smask: None, px: (w, h), dpi }
}
C::Gray(8 | 16) => {
let mut d = Dict::new();
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
Embedded { dict: d, data, filtered: false, smask: None, px: (w, h), dpi }
}
C::RGB(8 | 16) => {
let mut d = Dict::new();
d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
Embedded { dict: d, data, filtered: false, smask: None, px: (w, h), dpi }
}
C::CMYK(8 | 16) => {
let mut d = Dict::new();
d.set(b"ColorSpace".to_vec(), Object::name("DeviceCMYK"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
Embedded { dict: d, data, filtered: false, smask: None, px: (w, h), dpi }
}
C::RGBA(8 | 16) => rgba_image(&data, (w, h), dpi),
C::GrayA(8 | 16) => {
let rgba: Vec<u8> = data.as_chunks::<2>().0.iter().flat_map(|p| [p[0], p[0], p[0], p[1]]).collect();
rgba_image(&rgba, (w, h), dpi)
}
other => return Err(bad(format!("{other:?} TIFF images aren't supported yet"))),
};
out.push(page);
if !dec.more_images() {
break;
}
dec.next_image().map_err(|e| bad(e.to_string()))?;
}
Ok(out)
}
/// What a file picked for Create is, by its bytes (and, for text, its name).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SourceKind {
Pdf,
Image,
Text,
}
/// File extensions Create converts to PDF (images and plain text).
pub const CONVERTIBLE: [&str; 12] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "txt", "text"];
fn is_image(bytes: &[u8]) -> bool {
bytes.starts_with(&[0xFF, 0xD8])
|| bytes.starts_with(b"\x89PNG\r\n\x1a\n")
|| bytes.starts_with(b"II*\0")
|| bytes.starts_with(b"MM\0*")
|| bytes.starts_with(b"GIF8")
// JPEG 2000: a JP2 file or a raw codestream.
|| bytes.starts_with(JP2_SIGNATURE)
|| bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51])
// BMP: "BM" and a known header size (so text starting with "BM" stays text).
|| (bytes.starts_with(b"BM")
&& bytes.get(14..18).and_then(|h| <[u8; 4]>::try_from(h).ok()).is_some_and(|h| matches!(u32::from_le_bytes(h), 12 | 40 | 52 | 56 | 108 | 124)))
}
/// Whether `bytes` named `name` is a PDF, an image Create can embed, or plain text (a `.txt` or
/// `.text` file). `None` for anything else.
pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
let head = bytes.get(..bytes.len().min(1024)).unwrap_or_default();
if head.windows(5).any(|w| w == b"%PDF-") {
return Some(SourceKind::Pdf);
}
if is_image(bytes) {
return Some(SourceKind::Image);
}
let lower = name.to_ascii_lowercase();
(lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text)
}
/// Detect the image format from its bytes; a TIFF may hold several pages.
fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
if bytes.starts_with(&[0xFF, 0xD8]) {
Ok(vec![jpeg(name, bytes)?])
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Ok(vec![png_image(name, bytes)?])
} else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
tiff_pages(name, bytes)
} else if bytes.starts_with(b"BM") {
Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?])
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?])
} else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) {
Ok(vec![jpx(name, bytes)?])
} else {
Err(CreateError::Image(name.into(), "use a PNG, JPEG, JPEG 2000, TIFF, GIF or BMP image".into()))
}
}
/// Embed an image file (its first page, for TIFFs) as an image XObject in `doc`. Returns the
/// object and the image's natural size in points (from its resolution).
pub fn image_xobject(doc: &mut Document, name: &str, bytes: &[u8]) -> Result<(ObjRef, (f64, f64)), CreateError> {
let img = embed(name, bytes)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?;
let size = (img.px.0 as f64 * 72.0 / img.dpi.0, img.px.1 as f64 * 72.0 / img.dpi.1);
let mut d = img.dict;
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(img.px.0 as i64));
d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64));
if let Some((m, alpha)) = img.smask {
let mr = doc.add(Object::Stream(Stream::flate(m, &alpha)));
d.set(b"SMask".to_vec(), Object::Ref(mr));
}
let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) };
Ok((doc.add(Object::Stream(stream)), size))
}
/// Resolution used to size PDF pages. Image pixels are never resampled.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum ImageResolution {
/// Embedded resolution, falling back to 72 dpi when absent.
#[default]
Embedded,
/// Override both axes with a finite resolution between 1 and 1200 dpi.
Dpi(f64),
}
/// One page per image, each the size of its image at the image's resolution.
pub fn from_images(images: &[(String, Vec<u8>)]) -> Result<Document, CreateError> {
from_images_with_resolution(images, ImageResolution::Embedded)
}
/// Create image pages with embedded resolution or a fixed dpi (72 gives one point per pixel).
pub fn from_images_with_resolution(images: &[(String, Vec<u8>)], resolution: ImageResolution) -> Result<Document, CreateError> {
if let ImageResolution::Dpi(dpi) = resolution
&& (!dpi.is_finite() || !(1.0..=1200.0).contains(&dpi))
{
return Err(CreateError::Invalid("image DPI must be finite and between 1 and 1200".into()));
}
if images.is_empty() {
return Err(CreateError::Invalid("no images".into()));
}
let mut doc = Document::new_empty();
for img in images.iter().map(|(name, bytes)| embed(name, bytes)).collect::<Result<Vec<_>, _>>()?.into_iter().flatten() {
let dpi = match resolution {
ImageResolution::Embedded => img.dpi,
ImageResolution::Dpi(dpi) => (dpi, dpi),
};
let (mut w, mut h) = (img.px.0 as f64 * 72.0 / dpi.0, img.px.1 as f64 * 72.0 / dpi.1);
// Keep huge images within the largest page PDF allows.
let k = (MAX_SIDE / w.max(h)).min(1.0);
w *= k;
h *= k;
let mut d = img.dict;
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(img.px.0 as i64));
d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64));
if let Some((m, alpha)) = img.smask {
let mr = doc.add(Object::Stream(Stream::flate(m, &alpha)));
d.set(b"SMask".to_vec(), Object::Ref(mr));
}
let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) };
let xr = doc.add(Object::Stream(stream));
let mut xobj = Dict::new();
xobj.set(b"Im0".to_vec(), Object::Ref(xr));
let mut res = Dict::new();
res.set(b"XObject".to_vec(), Object::Dict(xobj));
let content = format!("q {w:.3} 0 0 {h:.3} 0 0 cm /Im0 Do Q\n").into_bytes();
add_page(&mut doc, w, h, res, Some(content))?;
}
if let Some((name, _)) = images.first() {
set_title(&mut doc, name.rsplit_once('.').map_or(name.as_str(), |(s, _)| s));
}
Ok(doc)
}
// ── text ────────────────────────────────────────────────────────────────────────────────────
/// Plain text set in Helvetica on pages of `page` size with 1-inch margins.
pub fn from_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<Document, CreateError> {
let size = if font_size.is_finite() && font_size > 0.0 { font_size.clamp(4.0, 72.0) } else { 11.0 };
let (w, h) = page;
let margin = 72.0;
let line_h = size * 1.25;
let width = (w - 2.0 * margin).max(36.0);
let lines: Vec<String> = text
.replace("\r\n", "\n")
.replace('\t', " ")
.split('\u{c}')
.flat_map(|p| wrap(p, size, width).into_iter().chain(std::iter::once("\u{c}".to_string())))
.collect();
let per_page = (((h - 2.0 * margin) / line_h).floor() as usize).max(1);
let mut doc = Document::new_empty();
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let fr = doc.add(font);
let mut page_lines: Vec<Vec<String>> = vec![Vec::new()];
for l in lines.iter().take(lines.len().saturating_sub(1)) {
if l == "\u{c}" || page_lines.last().is_some_and(|p| p.len() >= per_page) {
page_lines.push(Vec::new());
if l == "\u{c}" {
continue;
}
}
if let Some(p) = page_lines.last_mut() {
p.push(l.clone());
}
}
for lines in page_lines {
let mut c = format!("BT /F1 {size} Tf {line_h:.3} TL {margin} {:.3} Td\n", h - margin - size).into_bytes();
for l in lines {
c.extend(literal(&win_ansi(&l)));
c.extend_from_slice(b" Tj T*\n");
}
c.extend_from_slice(b"ET\n");
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Ref(fr));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
add_page(&mut doc, w, h, res, Some(c))?;
}
set_title(&mut doc, title);
Ok(doc)
}
#[cfg(test)]
mod tests;