Files
pdfcraft/crates/create/src/lib.rs
T

1006 lines
47 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, text or (through LibreOffice) Office documents (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, serialize};
mod extract;
mod text_font;
mod upright_image;
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within};
pub use upright_image::jpeg_exif_orientation;
mod office;
#[cfg(not(target_arch = "wasm32"))]
pub use office::libreoffice;
pub use office::{OFFICE_EXTENSIONS, is_office, office_to_pdf};
use pdfcraft_fonts::wrap_with;
use text_font::TextFont;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum CreateError {
#[error("{0}: {1}")]
Image(String, String),
#[error("{0}")]
Invalid(String),
/// An Office document LibreOffice couldn't convert, or LibreOffice isn't installed.
#[error("{0}")]
Office(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;
/// The page tree of a document being made, with the pages added to it so far: its `/Kids` are
/// written once, by [`Pages::finish`]. Adding each page to the tree in the document would copy every
/// page before it, which a text of a million form feeds makes quadratic.
struct Pages {
tree: ObjRef,
kids: Vec<Object>,
}
impl Pages {
/// The page tree of `doc`, with the pages it has.
fn of(doc: &Document) -> Result<Self, CreateError> {
// `Document::new_empty` always has a page tree.
let tree = 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 kids = doc.get(tree).as_dict().and_then(|d| d.get(b"Kids")).and_then(|k| k.as_array().cloned()).unwrap_or_default();
Ok(Self { tree, kids })
}
fn add(&mut self, doc: &mut Document, w: f64, h: f64, resources: Dict, content: Option<Vec<u8>>) {
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(self.tree));
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));
}
self.kids.push(Object::Ref(doc.add(page)));
}
/// Writes the pages added into the tree.
fn finish(self, doc: &mut Document) -> Result<(), CreateError> {
let count = i64::try_from(self.kids.len()).map_err(|_| CreateError::Invalid("too many pages".into()))?;
doc.update_dict(self.tree, |d| {
d.set(b"Count".to_vec(), Object::Int(count));
d.set(b"Kids".to_vec(), Object::Array(self.kids));
})
.map_err(|e| CreateError::Invalid(e.to_string()))
}
}
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();
let mut tree = Pages::of(&doc)?;
for _ in 0..pages {
tree.add(&mut doc, width, height, Dict::new(), None);
}
tree.finish(&mut doc)?;
Ok(doc)
}
/// A document whose pages hold ready-made content: one `(width, height, resources, content)`
/// tuple per page, in page order. Empty content vectors make blank pages. The OCR Verify
/// screen's editable-text output builds its pages this way.
pub fn from_contents(pages: &[(f64, f64, Dict, Vec<u8>)]) -> Result<Document, CreateError> {
if pages.is_empty() || pages.len() > 10_000 {
return Err(CreateError::Invalid("invalid page count".into()));
}
if pages.iter().any(|(w, h, _, _)| !(w.is_finite() && h.is_finite() && (3.0..=MAX_SIDE).contains(w) && (3.0..=MAX_SIDE).contains(h))) {
return Err(CreateError::Invalid("invalid page size".into()));
}
let mut doc = Document::new_empty();
let mut tree = Pages::of(&doc)?;
for (w, h, resources, content) in pages {
let content = if content.is_empty() { None } else { Some(content.clone()) };
tree.add(&mut doc, *w, *h, resources.clone(), content);
}
tree.finish(&mut doc)?;
Ok(doc)
}
// ── images ──────────────────────────────────────────────────────────────────────────────────
/// An image ready to embed: its XObject dictionary, encoded data, optional soft mask, the ICC
/// profile the file carried (checked against the colour space by `icc_for`), and size in pixels
/// and dots per inch.
struct Embedded {
dict: Dict,
data: Vec<u8>,
filtered: bool,
smask: Option<(Dict, Vec<u8>)>,
icc: Option<Vec<u8>>,
px: (u32, u32),
dpi: (f64, f64),
}
/// `profile` when it can tag an image of `n` colour components: an ICC header (`acsp`) whose
/// colour space is grey, RGB or CMYK to match. Anything else is left out, and the image keeps
/// its device colour space.
fn icc_for(profile: Vec<u8>, n: usize) -> Option<Vec<u8>> {
let space: &[u8] = match n {
1 => b"GRAY",
3 => b"RGB ",
4 => b"CMYK",
_ => return None,
};
(profile.len() >= 128 && profile.get(36..40) == Some(b"acsp") && profile.get(16..20) == Some(space)).then_some(profile)
}
/// A JPEG's ICC profile from its APP2 `ICC_PROFILE` chunks: (sequence number, chunk count,
/// data). `None` unless every chunk from 1 to the count is there exactly once.
fn jpeg_icc(mut chunks: Vec<(u8, u8, &[u8])>) -> Option<Vec<u8>> {
let count = chunks.first()?.1;
chunks.sort_by_key(|c| c.0);
let complete = usize::from(count) == chunks.len() && chunks.iter().zip(1..=count).all(|(c, seq)| c.0 == seq && c.1 == count);
complete.then(|| chunks.iter().flat_map(|c| c.2.iter().copied()).collect())
}
/// The samples of a black-and-white image packed one bit per pixel (1 for white, each row
/// starting on a byte): eight-bit grey (`n` 1) or RGB (`n` 3) whose every pixel is pure black or
/// pure white, or one-bit grey, already packed (a bilevel TIFF). `None` for anything else, found
/// at its first other pixel.
fn bilevel(data: &[u8], (w, h): (u32, u32), n: usize, bits: Option<i64>) -> Option<Vec<u8>> {
let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
let stride = w.div_ceil(8);
let size = stride.checked_mul(h)?;
match (bits, n) {
(Some(1), 1) => data.get(..size).map(<[u8]>::to_vec),
(Some(8), 1 | 3) if w > 0 => {
let samples = data.get(..w.checked_mul(h)?.checked_mul(n)?)?;
let mut packed = vec![0u8; size];
for (i, px) in samples.chunks_exact(n).enumerate() {
let white = match px {
[0] | [0, 0, 0] => false,
[255] | [255, 255, 255] => true,
_ => return None,
};
if white && let Some(b) = packed.get_mut((i / w) * stride + (i % w) / 8) {
*b |= 0x80 >> (i % w % 8);
}
}
Some(packed)
}
_ => None,
}
}
/// Add `img` to `doc` as an image XObject. An ICC profile becomes an `/ICCBased` colour space
/// (with the device space as `/Alternate`); `profiles` shares one object between images that
/// carry the same profile.
///
/// A black-and-white image is stored at one bit per pixel, as CCITT Group 4 when that is smaller
/// than Flate: losslessly, and several times smaller than the eight bits a scan saved as a 1-bit
/// PNG decodes to. RGB keeps its colour space when it has a colour profile.
fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec<u8>, ObjRef)>) -> ObjRef {
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));
let device = d.name(b"ColorSpace").map(<[u8]>::to_vec);
let mut n = match device.as_deref() {
Some(b"DeviceGray") => 1,
Some(b"DeviceRGB") => 3,
Some(b"DeviceCMYK") => 4,
_ => 0,
};
let (mut data, mut filtered) = (img.data, img.filtered);
let profiled_rgb = n == 3 && img.icc.is_some();
if !filtered
&& !profiled_rgb
&& let Some(packed) = bilevel(&data, img.px, n, d.int(b"BitsPerComponent"))
{
if n == 3 {
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
n = 1;
}
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
// The smaller as stored, dictionary included: Group 4's parameters outweigh its saving on
// a tiny image.
let stored = |d: &Dict, data: &[u8]| {
let mut out = Vec::new();
serialize(&Object::Dict(d.clone()), &mut out);
out.len().saturating_add(data.len())
};
let mut flate_dict = d.clone();
flate_dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let flate = pdfcraft_filters::encode_flate(&packed);
(d, data) = match pdfcraft_filters::encode_g4(&packed, img.px.0, img.px.1) {
Ok(g4) => {
let mut parms = Dict::new();
parms.set(b"K".to_vec(), Object::Int(-1));
parms.set(b"Columns".to_vec(), Object::Int(i64::from(img.px.0)));
parms.set(b"Rows".to_vec(), Object::Int(i64::from(img.px.1)));
let mut g4_dict = d.clone();
g4_dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
g4_dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
if stored(&g4_dict, &g4) < stored(&flate_dict, &flate) { (g4_dict, g4) } else { (flate_dict, flate) }
}
Err(_) => (flate_dict, flate),
};
filtered = true;
}
if let (Some(icc), Some(device)) = (img.icc.and_then(|p| icc_for(p, n)), device) {
let r = match profiles.iter().find(|(p, _)| *p == icc) {
Some((_, r)) => *r,
None => {
let mut pd = Dict::new();
pd.set(b"N".to_vec(), Object::Int(n as i64));
pd.set(b"Alternate".to_vec(), Object::Name(device));
let r = doc.add(Object::Stream(Stream::flate(pd, &icc)));
profiles.push((icc, r));
r
}
};
d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("ICCBased"), Object::Ref(r)]));
}
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 filtered { Stream::from_raw(d, data) } else { Stream::flate(d, &data) };
doc.add(Object::Stream(stream))
}
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),
};
// `len as usize` would truncate on 32-bit targets and put the end before the payload.
let end = i.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).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, icc: 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);
let mut icc_chunks = Vec::new();
while let Some(&byte) = bytes.get(i) {
if byte != 0xFF {
i += 1;
continue;
}
i += 1;
// Markers may be preceded by any number of 0xFF fill bytes.
while bytes.get(i) == Some(&0xFF) {
i += 1;
}
let marker = *bytes.get(i).ok_or_else(|| bad("truncated JPEG marker"))?;
i += 1;
// Metadata ends at the first scan; entropy-coded bytes are not segments.
if marker == 0xDA || marker == 0xD9 {
break;
}
if marker == 0xD8 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
continue;
}
let len = usize::from(be16(bytes, i).ok_or_else(|| bad("truncated JPEG segment length"))?);
if len < 2 {
return Err(bad("invalid JPEG segment length"));
}
let end = i.checked_add(len).ok_or_else(|| bad("JPEG segment length overflow"))?;
let seg = bytes.get(i + 2..end).ok_or_else(|| bad("truncated JPEG segment"))?;
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,
// APP2 ICC profile, possibly split over several chunks.
0xE2 => {
if let Some(([seq, count], data)) = seg.strip_prefix(b"ICC_PROFILE\0").and_then(<[u8]>::split_first_chunk::<2>) {
icc_chunks.push((*seq, *count, data));
}
}
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && size.is_none() => {
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];
}
_ => {}
}
i = end;
}
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"));
let icc = jpeg_icc(icc_chunks);
Ok(Embedded { dict: d, data: bytes.to_vec(), filtered: true, smask: None, icc, px: (w, h), dpi })
}
fn png_image(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
let bad = |m: String| CreateError::Image(name.into(), m);
let opts = craft_image_codecs::DecodeOptions {
keep_orientation: true,
limits: craft_image_codecs::Limits { max_alloc: MAX_DECODED_BYTES as u64, ..Default::default() },
};
let img = craft_image_codecs::decode_as_with(craft_image_codecs::Format::Png, bytes, &opts).map_err(|e| match e {
craft_image_codecs::CodecError::LimitExceeded(m) => bad(format!("image too large: {m}")),
other => bad(other.to_string()),
})?;
let dpi = img.meta.dpi.map(|(x, y)| (f64::from(x), f64::from(y))).filter(|&(x, y)| x > 0.0 && y > 0.0).unwrap_or((72.0, 72.0));
let icc = img.icc.clone();
let (w, h) = (img.width(), img.height());
let (channels, cs) = match img.layout() {
craft_image_codecs::ChannelLayout::Gray => (1, "DeviceGray"),
craft_image_codecs::ChannelLayout::GrayA => (2, "DeviceGray"),
craft_image_codecs::ChannelLayout::Rgb => (3, "DeviceRGB"),
craft_image_codecs::ChannelLayout::Rgba => (4, "DeviceRGB"),
_ => return Err(bad("unexpected palette output".into())),
};
let data: std::borrow::Cow<'_, [u8]> = if let Some(u8s) = img.as_u8() {
std::borrow::Cow::Borrowed(u8s)
} else if let Some(u16s) = img.to_u16_samples() {
std::borrow::Cow::Owned(u16s.into_iter().map(|v| (v >> 8) as u8).collect())
} else {
return Err(bad("expected 8-bit or 16-bit PNG samples".into()));
};
let has_alpha = channels == 2 || channels == 4;
let colour_n = if has_alpha { channels - 1 } else { channels };
let mut colour = Vec::new();
colour
.try_reserve_exact((w as usize).saturating_mul(h as usize).saturating_mul(colour_n))
.map_err(|_| bad("not enough memory for this image".into()))?;
let mut alpha = Vec::new();
if has_alpha {
alpha.try_reserve_exact((w as usize).saturating_mul(h as usize)).map_err(|_| bad("not enough memory for this image".into()))?;
}
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, icc, 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, icc: None, px: (w, h), dpi }
}
/// BMP and GIF (the first frame), through `craft-image-codecs`. Their resolution is not read:
/// 72 dpi, as for images without one.
fn decoded(name: &str, bytes: &[u8], format: craft_image_codecs::Format) -> Result<Embedded, CreateError> {
// A GIF's first frame is painted on a canvas the size of its logical screen, however small
// the frame: a few bytes can ask for hundreds of MB.
if format == craft_image_codecs::Format::Gif
&& let Some(&[w0, w1, h0, h1]) = bytes.get(6..10)
&& u64::from(u16::from_le_bytes([w0, w1])) * u64::from(u16::from_le_bytes([h0, h1])) > MAX_GIF_PIXELS
{
return Err(CreateError::Image(name.into(), "the GIF's screen is larger than 4096×4096 pixels".into()));
}
let img = craft_image_codecs::decode_as_with(format, bytes, &craft_image_codecs::DecodeOptions::default())
.map_err(|e| CreateError::Image(name.into(), e.to_string()))?;
let dims = (img.width(), img.height());
let raw = img.to_rgba8();
Ok(rgba_image(&raw, dims, (72.0, 72.0)))
}
fn le16(b: &[u8], at: usize) -> Option<u16> {
b.get(at..at + 2).map(|x| u16::from_le_bytes([x[0], x[1]]))
}
/// A PCX image: a 128-byte header (size at 4-11, resolution at 12 and 14, plane count at 65, bytes
/// per plane row at 66), then run-length coded scanlines, each holding one row of every plane.
fn pcx(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
let bad = |m: &str| CreateError::Image(name.into(), m.into());
let header = bytes.get(..128).ok_or_else(|| bad("a damaged PCX header"))?;
let word = |at: usize| le16(header, at).unwrap_or(0);
let byte = |at: usize| header.get(at).copied().unwrap_or(0);
let (bits, planes, stride) = (byte(3), usize::from(byte(65)), usize::from(word(66)));
let (Some(w), Some(h)) = (word(8).checked_sub(word(4)), word(10).checked_sub(word(6))) else {
return Err(bad("a damaged PCX header"));
};
let (w, h) = (usize::from(w) + 1, usize::from(h) + 1);
if planes == 0 || stride == 0 {
return Err(bad("a damaged PCX header"));
}
if !matches!((bits, planes), (8, 1 | 3) | (1, 1..=4)) {
return Err(bad("this PCX plane layout is not supported"));
}
if stride < (w * usize::from(bits)).div_ceil(8) {
return Err(bad("a damaged PCX header"));
}
let line = planes * stride;
if line.saturating_mul(h) > MAX_DECODED_BYTES || w.saturating_mul(h).saturating_mul(3) > MAX_DECODED_BYTES {
return Err(bad("unusual PCX image size"));
}
// Preserve Create PDF's legacy palette policy, not a second pixel decoder: monochrome
// is black/white and an indexed file without its VGA palette is interpreted as gray.
// The shared reader owns all RLE, plane unpacking and palette application.
let has_vga = bytes.len().checked_sub(769).is_some_and(|at| at >= 128 && bytes.get(at) == Some(&0x0C));
// The pinned decoder predates shared version-4 recognition. Its version byte is
// only a header acceptance check, not a switch for pixel or palette semantics.
// Keep this app's existing version-4 acceptance without replacing the shared pin.
let version_four = byte(1) == 4;
let normalize = version_four || (bits == 1 && planes == 1) || (bits == 8 && planes == 1 && !has_vga);
let mut input = std::borrow::Cow::Borrowed(bytes);
if normalize {
// Normalization must not add an unbounded copy of hostile compressed input.
if bytes.len() > MAX_DECODED_BYTES {
return Err(bad("unusual PCX input size"));
}
let mut copy = Vec::new();
copy.try_reserve_exact(bytes.len()).map_err(|_| bad("not enough memory for this image"))?;
copy.extend_from_slice(bytes);
if version_four && let Some(version) = copy.get_mut(1) {
*version = 5;
}
if bits == 1 && planes == 1 {
if let Some(palette) = copy.get_mut(16..22) {
palette.copy_from_slice(&[0, 0, 0, 255, 255, 255]);
}
} else if bits == 8
&& planes == 1
&& !has_vga
&& let Some(info) = copy.get_mut(68..70)
{
info.copy_from_slice(&2u16.to_le_bytes());
}
input = std::borrow::Cow::Owned(copy);
}
let opts = craft_image_codecs::DecodeOptions {
limits: craft_image_codecs::Limits { max_alloc: MAX_DECODED_BYTES as u64, ..Default::default() },
..Default::default()
};
let image = craft_image_codecs::decode_as_with(craft_image_codecs::Format::Pcx, &input, &opts).map_err(|e| bad(&e.to_string()))?;
// The accepted layouts above are RGB8 in the pinned shared decoder. Refuse an unexpected
// contract rather than interpreting alpha or high-depth bytes as DeviceRGB samples.
if image.layout() != craft_image_codecs::ChannelLayout::Rgb || image.sample_type() != craft_image_codecs::SampleType::U8 {
return Err(bad("unexpected PCX pixel layout"));
}
let data = image.into_data();
let dpi = (f64::from(word(12)), f64::from(word(14)));
let dpi = if dpi.0 > 0.0 && dpi.1 > 0.0 { dpi } else { (72.0, 72.0) };
let mut d = Dict::new();
d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
let px = (u32::try_from(w).unwrap_or(0), u32::try_from(h).unwrap_or(0));
Ok(Embedded { dict: d, data, filtered: false, smask: None, icc: None, px, dpi })
}
/// Largest GIF logical screen, in pixels (4096×4096).
const MAX_GIF_PIXELS: u64 = 1 << 24;
/// Most decoded bytes one TIFF page or PNG may take: room for the 768 MiB CMYK poster in #665,
/// twice over.
const MAX_DECODED_BYTES: usize = 2 << 30;
/// Most decoded bytes all the pages of one TIFF may take together: its pages stay in memory until
/// they are embedded, and many small directories can share one compressed strip. Saturates to the
/// whole address space on 32-bit targets (wasm32), where 4 GiB doesn't fit in `usize`.
const MAX_DECODED_TOTAL: usize = MAX_DECODED_BYTES.saturating_mul(2);
/// Returns whether the TIFF directory at `ifd_offset` is a 1-bit bilevel grayscale image.
fn tiff_page_is_bilevel(bytes: &[u8], ifd_offset: u64) -> bool {
let order = match bytes.get(0..2) {
Some(b"II") => craft_tiff::ByteOrder::Little,
Some(b"MM") => craft_tiff::ByteOrder::Big,
_ => return false,
};
let bigtiff = order.read_u16(bytes, 2) == Some(43);
let Ok((ifd, _)) = craft_tiff::parse_ifd_at(bytes, ifd_offset, order, 0, bigtiff, &craft_tiff::ParseOptions::default()) else {
return false;
};
let Some(bits) = ifd.u64s(craft_tiff::tags::BITS_PER_SAMPLE) else {
return false;
};
bits.len() == 1 && bits[0] == 1
}
/// The first `wanted` pages of a TIFF (multi-page scans become multi-page PDFs).
fn tiff_pages(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, CreateError> {
tiff_pages_within(name, bytes, wanted, MAX_DECODED_TOTAL)
}
/// [`tiff_pages`], refused once the pages kept take more than `budget` bytes.
fn tiff_pages_within(name: &str, bytes: &[u8], wanted: usize, budget: usize) -> Result<Vec<Embedded>, CreateError> {
use craft_image_codecs::ChannelLayout as L;
let bad = |m: String| CreateError::Image(name.into(), m);
let info = craft_image_codecs::tiff_info(bytes).map_err(|e| bad(e.to_string()))?;
let opts = craft_image_codecs::DecodeOptions {
keep_orientation: true,
limits: craft_image_codecs::Limits { max_alloc: MAX_DECODED_BYTES as u64, ..Default::default() },
};
let mut out = Vec::new();
let mut kept = 0usize;
for (idx, p) in info.pages.iter().enumerate() {
if out.len() >= wanted {
break;
}
if p.kind == craft_image_codecs::TiffPageKind::ReducedResolution {
continue;
}
let img = craft_image_codecs::decode_tiff_page(bytes, idx, &opts).map_err(|e| bad(e.to_string()))?;
let (w, h) = (img.width(), img.height());
let dpi = img.meta.dpi.map(|(x, y)| (f64::from(x), f64::from(y))).filter(|&(x, y)| x > 0.0 && y > 0.0).unwrap_or((72.0, 72.0));
let data_buf: std::borrow::Cow<'_, [u8]> = if let Some(u8s) = img.as_u8() {
std::borrow::Cow::Borrowed(u8s)
} else if let Some(u16s) = img.to_u16_samples() {
std::borrow::Cow::Owned(u16s.into_iter().map(|v| (v >> 8) as u8).collect())
} else {
return Err(bad("this TIFF sample format isn't supported".into()));
};
let data = data_buf.as_ref();
let page = match img.layout() {
L::Gray if tiff_page_is_bilevel(bytes, p.ifd_offset) => {
// Bilevel scans stay 1 bit per pixel (0 = black).
let row_bytes = (w as usize).div_ceil(8);
let mut packed = vec![0u8; row_bytes.saturating_mul(h as usize)];
for y in 0..h as usize {
for x in 0..w as usize {
if data[y * w as usize + x] >= 128 {
packed[y * row_bytes + (x >> 3)] |= 0x80 >> (x & 7);
}
}
}
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: packed, filtered: false, smask: None, icc: None, px: (w, h), dpi }
}
L::Gray => {
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: data.to_vec(), filtered: false, smask: None, icc: None, px: (w, h), dpi }
}
L::Rgb => {
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: data.to_vec(), filtered: false, smask: None, icc: None, px: (w, h), dpi }
}
L::Cmyk => {
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: data.to_vec(), filtered: false, smask: None, icc: None, px: (w, h), dpi }
}
L::Rgba => rgba_image(data, (w, h), dpi),
L::GrayA => {
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"))),
};
kept = kept.saturating_add(page.data.len()).saturating_add(page.smask.as_ref().map_or(0, |(_, alpha)| alpha.len()));
if kept > budget {
return Err(bad(format!("the TIFF's pages take more than {} MiB together", budget >> 20)));
}
out.push(page);
}
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,
/// A Word, Excel, PowerPoint or OpenDocument file, converted through LibreOffice.
Office,
}
/// File extensions of the images Create converts to PDF.
pub const IMAGE_EXTENSIONS: [&str; 11] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx"];
/// File extensions of the plain-text files Create converts to PDF.
pub const TEXT_EXTENSIONS: [&str; 2] = ["txt", "text"];
/// File extensions Create converts to PDF itself: [`IMAGE_EXTENSIONS`] and [`TEXT_EXTENSIONS`]
/// (Office documents go through LibreOffice, [`OFFICE_EXTENSIONS`]).
pub const CONVERTIBLE: [&str; 13] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx", "txt", "text"];
/// A PCX file: its manufacturer byte, a version (1 was never used), run-length coding and 1 or 8
/// bits per plane. `pcx` says when the rest of the header isn't one it can read.
fn is_pcx(bytes: &[u8]) -> bool {
bytes.first() == Some(&0x0A) && matches!(bytes.get(1), Some(0 | 2..=5)) && bytes.get(2) == Some(&1) && matches!(bytes.get(3), Some(1 | 8))
}
/// A classic TIFF or a BigTIFF (version 43), in either byte order.
fn is_tiff(bytes: &[u8]) -> bool {
[b"II*\0", b"MM\0*", b"II+\0", b"MM\0+"].iter().any(|m| bytes.starts_with(*m))
}
/// The GIF headers `embed` decodes, so detection and conversion agree.
fn is_gif(bytes: &[u8]) -> bool {
bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")
}
fn is_image(bytes: &[u8]) -> bool {
bytes.starts_with(&[0xFF, 0xD8])
|| bytes.starts_with(b"\x89PNG\r\n\x1a\n")
|| is_tiff(bytes)
|| is_gif(bytes)
|| is_pcx(bytes)
// 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, plain text (a `.txt` or
/// `.text` file) or an Office document ([`is_office`]). `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);
}
if is_office(name, bytes) {
return Some(SourceKind::Office);
}
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, of which the first
/// `wanted` are read.
fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, CreateError> {
if bytes.starts_with(&[0xFF, 0xD8]) {
Ok(vec![jpeg(name, &upright_image::upright(bytes))?])
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Ok(vec![png_image(name, bytes)?])
} else if is_tiff(bytes) {
tiff_pages(name, bytes, wanted)
} else if bytes.starts_with(b"BM") {
Ok(vec![decoded(name, &pdfcraft_xfa::image::bmp_with_pixel_offset(bytes), craft_image_codecs::Format::Bmp)?])
} else if is_gif(bytes) {
Ok(vec![decoded(name, bytes, craft_image_codecs::Format::Gif)?])
} else if is_pcx(bytes) {
Ok(vec![pcx(name, bytes)?])
} 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, 1)?.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);
Ok((add_image(doc, img, &mut Vec::new()), 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();
let mut tree = Pages::of(&doc)?;
let mut profiles = Vec::new();
for img in images.iter().map(|(name, bytes)| embed(name, bytes, usize::MAX)).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 xr = add_image(&mut doc, img, &mut profiles);
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();
tree.add(&mut doc, w, h, res, Some(content));
}
tree.finish(&mut doc)?;
if let Some((name, _)) = images.first() {
set_title(&mut doc, name.rsplit_once('.').map_or(name.as_str(), |(s, _)| s));
}
Ok(doc)
}
// ── text ────────────────────────────────────────────────────────────────────────────────────
/// The text of a plain-text file. A byte order mark picks UTF-8 or UTF-16 (little or big
/// endian) and is dropped; without one the bytes are UTF-8 when they are valid UTF-8, and
/// otherwise the Windows code page they most likely are: 1254 (Turkish) or 1252 (Western).
pub fn decode_text(bytes: &[u8]) -> String {
if let Some((encoding, bom)) = encoding_rs::Encoding::for_bom(bytes) {
return encoding.decode_without_bom_handling(bytes.get(bom..).unwrap_or_default()).0.into_owned();
}
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_string();
}
let encoding = if turkish_code_page(bytes) { encoding_rs::WINDOWS_1254 } else { encoding_rs::WINDOWS_1252 };
encoding.decode_without_bom_handling(bytes).0.into_owned()
}
/// Windows-1254 and 1252 differ only in six bytes: Ğ İ Ş ğ ı ş in 1254 are Ð Ý Þ ð ý þ in 1252.
/// Of the Western languages only Icelandic and Faroese use those, and both also write á í ó ú,
/// which Turkish never does.
fn turkish_code_page(bytes: &[u8]) -> bool {
bytes.iter().any(|b| matches!(b, 0xD0 | 0xDD | 0xDE | 0xF0 | 0xFD | 0xFE))
&& !bytes.iter().any(|b| matches!(b, 0xC1 | 0xCD | 0xD3 | 0xDA | 0xE1 | 0xED | 0xF3 | 0xFA))
}
/// Plain text set in Helvetica on pages of `page` size with 1-inch margins. Text Helvetica can't
/// draw (outside Western European) is set in the bundled Inter face instead, whole, when Inter
/// draws all of it (Latin, Greek and Cyrillic letters); anything else is refused with
/// [`CreateError::Invalid`] naming the line and the first character neither face draws. Text
/// PdfCraft generates goes through [`from_text_escaped`] instead.
pub fn from_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<Document, CreateError> {
// Helvetica in WinAnsiEncoding draws it: set as it always was.
if first_undrawable_line(text).is_none() {
return set_text(title, text, page, font_size, false);
}
// Otherwise the bundled face, or a refusal with where it is, never `?`.
if let Some((line, c)) = text_font::first_missing(text) {
return Err(CreateError::Invalid(text_font::undrawable_message(line, c)));
}
set_text(title, &text.chars().map(text_font::drawn_as).collect::<String>(), page, font_size, true)
}
/// The line (1-based) and character of the first one Helvetica can't draw. Line breaks, tabs and
/// form feeds (new pages) are layout, not drawn.
fn first_undrawable_line(text: &str) -> Option<(usize, char)> {
text.split('\n').enumerate().find_map(|(i, l)| pdfcraft_fonts::first_undrawable(&l.replace('\u{c}', "")).map(|c| (i + 1, c)))
}
/// What a PDF made from text PdfCraft generated says, in its first line, about characters it
/// wrote as `[U+XXXX]`.
pub const ESCAPED_NOTE: &str = "Characters Helvetica can't draw are written as [U+XXXX] code points.";
/// [`from_text`] for text PdfCraft generates from documents (a comment summary, a compare
/// report), which may quote any language. Text the bundled Inter face draws all of is set as
/// [`from_text`] sets it, nothing escaped. Otherwise each character Helvetica can't draw is
/// written as `[U+XXXX]` instead of being refused or set as `?`, so nothing is lost, and the first
/// line says so ([`ESCAPED_NOTE`]). Returns the document and how many characters were written
/// that way.
pub fn from_text_escaped(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<(Document, usize), CreateError> {
if first_undrawable_line(text).is_some() && text_font::first_missing(text).is_none() {
return Ok((set_text(title, &text.chars().map(text_font::drawn_as).collect::<String>(), page, font_size, true)?, 0));
}
let mut escaped = 0usize;
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if matches!(c, '\n' | '\r' | '\u{c}') || pdfcraft_fonts::first_undrawable(c.encode_utf8(&mut [0; 4])).is_none() {
out.push(c);
} else {
escaped = escaped.saturating_add(1);
out.push_str(&format!("[U+{:04X}]", u32::from(c)));
}
}
if escaped > 0 {
out = format!("{ESCAPED_NOTE}\n\n{out}");
}
// The title is a text string in /Info, which holds any character.
Ok((set_text(title, &out, page, font_size, false)?, escaped))
}
/// `text` set on pages of `page` size, in Helvetica or, with `inter`, the bundled Inter face.
fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64, inter: bool) -> 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 mut doc = Document::new_empty();
let font = if inter { TextFont::inter(&mut doc)? } else { TextFont::helvetica(&mut doc) };
let lines: Vec<String> = text
.replace("\r\n", "\n")
.replace('\t', " ")
.split('\u{c}')
.flat_map(|p| wrap_with(p, size, width, |s, size| font.width(s, size)).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 tree = Pages::of(&doc)?;
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(font.show(&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(font.font()));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
tree.add(&mut doc, w, h, res, Some(c));
}
tree.finish(&mut doc)?;
set_title(&mut doc, title);
Ok(doc)
}
#[cfg(test)]
mod tests;