734 lines
26 KiB
Rust
734 lines
26 KiB
Rust
//! A tiny, self-contained PDF 1.7 writer for GridCraft's print and PDF export.
|
||
//!
|
||
//! - The 14 standard fonts (no embedding) with WinAnsiEncoding, plus approximate width tables
|
||
//! ([`text_width`]) so callers can measure, align and wrap text.
|
||
//! - Vector drawing: rectangles, lines (solid or dashed), polygons, circles (Bézier), clipping.
|
||
//! - RGBA images as RGB image XObjects with a soft mask for alpha (run-length encoded).
|
||
//! - Coordinates are points with a **top-left origin** (y grows down); text `y` is the baseline.
|
||
//!
|
||
//! Streams are written uncompressed (images use the trivial RunLengthDecode filter) so the crate
|
||
//! needs no dependencies.
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
#![forbid(unsafe_code)]
|
||
|
||
mod metrics;
|
||
|
||
use std::fmt::Write as _;
|
||
|
||
/// RGB colour, 0–255 per channel.
|
||
pub type Rgb = [u8; 3];
|
||
|
||
/// The standard 14 fonts minus Symbol and ZapfDingbats.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub enum Font {
|
||
#[default]
|
||
Helvetica,
|
||
HelveticaBold,
|
||
HelveticaOblique,
|
||
HelveticaBoldOblique,
|
||
TimesRoman,
|
||
TimesBold,
|
||
TimesItalic,
|
||
TimesBoldItalic,
|
||
Courier,
|
||
CourierBold,
|
||
CourierOblique,
|
||
CourierBoldOblique,
|
||
}
|
||
|
||
/// A font family for [`Font::styled`].
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||
pub enum Family {
|
||
#[default]
|
||
Sans,
|
||
Serif,
|
||
Mono,
|
||
}
|
||
|
||
impl Font {
|
||
pub const ALL: [Font; 12] = [
|
||
Font::Helvetica,
|
||
Font::HelveticaBold,
|
||
Font::HelveticaOblique,
|
||
Font::HelveticaBoldOblique,
|
||
Font::TimesRoman,
|
||
Font::TimesBold,
|
||
Font::TimesItalic,
|
||
Font::TimesBoldItalic,
|
||
Font::Courier,
|
||
Font::CourierBold,
|
||
Font::CourierOblique,
|
||
Font::CourierBoldOblique,
|
||
];
|
||
|
||
/// PostScript base font name.
|
||
pub fn base_name(self) -> &'static str {
|
||
match self {
|
||
Font::Helvetica => "Helvetica",
|
||
Font::HelveticaBold => "Helvetica-Bold",
|
||
Font::HelveticaOblique => "Helvetica-Oblique",
|
||
Font::HelveticaBoldOblique => "Helvetica-BoldOblique",
|
||
Font::TimesRoman => "Times-Roman",
|
||
Font::TimesBold => "Times-Bold",
|
||
Font::TimesItalic => "Times-Italic",
|
||
Font::TimesBoldItalic => "Times-BoldItalic",
|
||
Font::Courier => "Courier",
|
||
Font::CourierBold => "Courier-Bold",
|
||
Font::CourierOblique => "Courier-Oblique",
|
||
Font::CourierBoldOblique => "Courier-BoldOblique",
|
||
}
|
||
}
|
||
|
||
fn index(self) -> usize {
|
||
Font::ALL.iter().position(|f| *f == self).unwrap_or(0)
|
||
}
|
||
|
||
/// The face of `family` with the given weight and slant.
|
||
pub fn styled(family: Family, bold: bool, italic: bool) -> Font {
|
||
match (family, bold, italic) {
|
||
(Family::Sans, false, false) => Font::Helvetica,
|
||
(Family::Sans, true, false) => Font::HelveticaBold,
|
||
(Family::Sans, false, true) => Font::HelveticaOblique,
|
||
(Family::Sans, true, true) => Font::HelveticaBoldOblique,
|
||
(Family::Serif, false, false) => Font::TimesRoman,
|
||
(Family::Serif, true, false) => Font::TimesBold,
|
||
(Family::Serif, false, true) => Font::TimesItalic,
|
||
(Family::Serif, true, true) => Font::TimesBoldItalic,
|
||
(Family::Mono, false, false) => Font::Courier,
|
||
(Family::Mono, true, false) => Font::CourierBold,
|
||
(Family::Mono, false, true) => Font::CourierOblique,
|
||
(Family::Mono, true, true) => Font::CourierBoldOblique,
|
||
}
|
||
}
|
||
|
||
/// Picks a standard family for a font name: serif faces map to Times, monospaced ones to
|
||
/// Courier, everything else to Helvetica.
|
||
pub fn family_for(name: &str) -> Family {
|
||
let n = name.to_ascii_lowercase();
|
||
const SERIF: [&str; 10] = ["times", "cambria", "georgia", "garamond", "serif", "book", "palatino", "baskerville", "tinos", "caladea"];
|
||
const MONO: [&str; 8] = ["courier", "consolas", "mono", "menlo", "lucida console", "cousine", "code", "typewriter"];
|
||
if MONO.iter().any(|m| n.contains(m)) {
|
||
Family::Mono
|
||
} else if SERIF.iter().any(|s| n.contains(s)) && !n.contains("sans") {
|
||
Family::Serif
|
||
} else {
|
||
Family::Sans
|
||
}
|
||
}
|
||
|
||
/// Advance width of a character in 1/1000 em.
|
||
pub fn char_width(self, c: char) -> u16 {
|
||
let table: Option<&[u16; 95]> = match self {
|
||
Font::Helvetica | Font::HelveticaOblique => Some(&metrics::HELVETICA),
|
||
Font::HelveticaBold | Font::HelveticaBoldOblique => Some(&metrics::HELVETICA_BOLD),
|
||
Font::TimesRoman | Font::TimesItalic => Some(&metrics::TIMES),
|
||
Font::TimesBold | Font::TimesBoldItalic => Some(&metrics::TIMES_BOLD),
|
||
_ => None,
|
||
};
|
||
let Some(table) = table else { return 600 };
|
||
let ascii = |c: char| -> Option<u16> {
|
||
let i = (c as u32).checked_sub(32)? as usize;
|
||
table.get(i).copied()
|
||
};
|
||
if let Some(w) = ascii(c) {
|
||
return w;
|
||
}
|
||
match c {
|
||
'—' | '…' | '‰' | '™' => 1000,
|
||
_ => metrics::fold(c).and_then(ascii).unwrap_or(match self {
|
||
Font::TimesRoman | Font::TimesItalic | Font::TimesBold | Font::TimesBoldItalic => 500,
|
||
_ => 556,
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Width of `text` in points when set in `font` at `size` points.
|
||
pub fn text_width(font: Font, size: f32, text: &str) -> f32 {
|
||
// u64: a u32 sum overflows for ~8 million characters.
|
||
let units: u64 = text.chars().map(|c| u64::from(font.char_width(c))).sum();
|
||
units as f32 * size / 1000.0
|
||
}
|
||
|
||
/// Unicode → WinAnsiEncoding (CP1252) byte; unmappable characters become `?`.
|
||
pub fn win_ansi(c: char) -> u8 {
|
||
let u = c as u32;
|
||
if (0x20..0x7F).contains(&u) || (0xA0..=0xFF).contains(&u) {
|
||
return u as u8;
|
||
}
|
||
match c {
|
||
'€' => 0x80,
|
||
'‚' => 0x82,
|
||
'ƒ' => 0x83,
|
||
'„' => 0x84,
|
||
'…' => 0x85,
|
||
'†' => 0x86,
|
||
'‡' => 0x87,
|
||
'ˆ' => 0x88,
|
||
'‰' => 0x89,
|
||
'Š' => 0x8A,
|
||
'‹' => 0x8B,
|
||
'Œ' => 0x8C,
|
||
'Ž' => 0x8E,
|
||
'‘' => 0x91,
|
||
'’' => 0x92,
|
||
'“' => 0x93,
|
||
'”' => 0x94,
|
||
'•' => 0x95,
|
||
'–' => 0x96,
|
||
'—' => 0x97,
|
||
'˜' => 0x98,
|
||
'™' => 0x99,
|
||
'š' => 0x9A,
|
||
'›' => 0x9B,
|
||
'œ' => 0x9C,
|
||
'ž' => 0x9E,
|
||
'Ÿ' => 0x9F,
|
||
'\t' => b' ',
|
||
_ => b'?',
|
||
}
|
||
}
|
||
|
||
/// A PDF literal string `( … )` for `text` in WinAnsiEncoding.
|
||
fn pdf_string(text: &str) -> String {
|
||
let mut s = String::with_capacity(text.len() + 2);
|
||
s.push('(');
|
||
for c in text.chars() {
|
||
let b = win_ansi(c);
|
||
match b {
|
||
b'(' | b')' | b'\\' => {
|
||
s.push('\\');
|
||
s.push(b as char);
|
||
}
|
||
0x20..=0x7E => s.push(b as char),
|
||
_ => {
|
||
let _ = write!(s, "\\{b:03o}");
|
||
}
|
||
}
|
||
}
|
||
s.push(')');
|
||
s
|
||
}
|
||
|
||
/// A number for a content stream: finite, short.
|
||
fn n(v: f32) -> String {
|
||
let v = if v.is_finite() { v.clamp(-1.0e7, 1.0e7) } else { 0.0 };
|
||
let r = (v * 1000.0).round() / 1000.0;
|
||
if r == r.trunc() {
|
||
format!("{}", r as i64)
|
||
} else {
|
||
let s = format!("{r:.3}");
|
||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||
}
|
||
}
|
||
|
||
/// True when every value is finite (NaN and infinities are rejected).
|
||
fn finite(v: &[f32]) -> bool {
|
||
v.iter().all(|x| x.is_finite())
|
||
}
|
||
|
||
/// A positive, finite extent (a width, height or radius).
|
||
fn extent(v: f32) -> bool {
|
||
v > 0.0 && v.is_finite()
|
||
}
|
||
|
||
/// A stroke width: negative, NaN and infinite become 0 (the thinnest line).
|
||
fn stroke_width(w: f32) -> f32 {
|
||
if w.is_finite() { w.max(0.0) } else { 0.0 }
|
||
}
|
||
|
||
/// The entries of a dash array (`[on, off, …]`, at most 8). A pattern the PDF specification
|
||
/// forbids (any non-finite entry, or no entry that survives rounding to a positive number) is
|
||
/// replaced by an empty array, i.e. a solid line.
|
||
fn dash_values(dash: &[f32]) -> String {
|
||
let d: Vec<f32> = dash.iter().take(8).map(|v| if v.is_finite() { v.max(0.0) } else { f32::NAN }).collect();
|
||
if !finite(&d) || !d.iter().any(|v| (v.min(1.0e7) * 1000.0).round() > 0.0) {
|
||
return String::new();
|
||
}
|
||
d.iter().map(|v| n(*v)).collect::<Vec<_>>().join(" ")
|
||
}
|
||
|
||
fn rgb_op(c: Rgb, stroke: bool) -> String {
|
||
let f = |v: u8| n(v as f32 / 255.0);
|
||
format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" })
|
||
}
|
||
|
||
struct ImageData {
|
||
width: u32,
|
||
height: u32,
|
||
rgb: Vec<u8>,
|
||
alpha: Option<Vec<u8>>,
|
||
}
|
||
|
||
/// A page under construction. Obtain one with [`PdfDoc::add_page`].
|
||
pub struct Page {
|
||
width: f32,
|
||
height: f32,
|
||
content: String,
|
||
fonts: [bool; 12],
|
||
images: Vec<ImageData>,
|
||
depth: u32,
|
||
}
|
||
|
||
/// Most bytes of content per page before further drawing is dropped (keeps hostile inputs from
|
||
/// exhausting memory).
|
||
const MAX_PAGE_CONTENT: usize = 64 * 1024 * 1024;
|
||
/// Largest image (pixels) embedded.
|
||
const MAX_IMAGE_PIXELS: u64 = 40_000_000;
|
||
|
||
impl Page {
|
||
pub fn width(&self) -> f32 {
|
||
self.width
|
||
}
|
||
pub fn height(&self) -> f32 {
|
||
self.height
|
||
}
|
||
fn y(&self, y: f32) -> f32 {
|
||
self.height - y
|
||
}
|
||
fn full(&self) -> bool {
|
||
self.content.len() > MAX_PAGE_CONTENT
|
||
}
|
||
fn op(&mut self, s: &str) {
|
||
if !self.full() {
|
||
self.content.push_str(s);
|
||
self.content.push('\n');
|
||
}
|
||
}
|
||
|
||
/// Saves the graphics state (pair with [`Page::restore`]).
|
||
pub fn save(&mut self) {
|
||
self.depth = self.depth.saturating_add(1);
|
||
self.content.push_str("q\n");
|
||
}
|
||
/// Restores the graphics state saved by [`Page::save`]; extra calls are ignored.
|
||
pub fn restore(&mut self) {
|
||
if self.depth > 0 {
|
||
self.depth -= 1;
|
||
self.content.push_str("Q\n");
|
||
}
|
||
}
|
||
/// Intersects the clip with a rectangle (until the next [`Page::restore`]).
|
||
pub fn clip_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
|
||
let s = format!("{} {} {} {} re W n", n(x), n(self.y(y + h)), n(w.max(0.0)), n(h.max(0.0)));
|
||
self.op(&s);
|
||
}
|
||
|
||
pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) {
|
||
if !(extent(w) && extent(h) && finite(&[x, y])) {
|
||
return;
|
||
}
|
||
let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h));
|
||
self.op(&s);
|
||
}
|
||
|
||
pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) {
|
||
if !finite(&[x, y, w, h]) {
|
||
return;
|
||
}
|
||
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(stroke_width(width)), n(x), n(self.y(y + h)), n(w), n(h));
|
||
self.op(&s);
|
||
}
|
||
|
||
pub fn stroke_line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb) {
|
||
self.stroke_line_dashed(x0, y0, x1, y1, width, color, &[]);
|
||
}
|
||
|
||
/// A line with a dash pattern (`[on, off, …]` in points; empty = solid).
|
||
pub fn stroke_line_dashed(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dash: &[f32]) {
|
||
if !finite(&[x0, y0, x1, y1]) {
|
||
return;
|
||
}
|
||
let s = format!(
|
||
"{}\n{} w [{}] 0 d\n{} {} m {} {} l S",
|
||
rgb_op(color, true),
|
||
n(stroke_width(width)),
|
||
dash_values(dash),
|
||
n(x0),
|
||
n(self.y(y0)),
|
||
n(x1),
|
||
n(self.y(y1))
|
||
);
|
||
self.op(&s);
|
||
}
|
||
|
||
/// An open polyline.
|
||
pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) {
|
||
if pts.len() < 2 || !pts.iter().all(|p| finite(p)) {
|
||
return;
|
||
}
|
||
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(stroke_width(width)), dash_values(dash));
|
||
self.path(&mut s, pts, false);
|
||
s.push_str(" S");
|
||
self.op(&s);
|
||
}
|
||
|
||
fn path(&self, s: &mut String, pts: &[[f32; 2]], close: bool) {
|
||
for (i, p) in pts.iter().enumerate() {
|
||
let _ = write!(s, "{} {} {} ", n(p[0]), n(self.y(p[1])), if i == 0 { "m" } else { "l" });
|
||
}
|
||
if close {
|
||
s.push('h');
|
||
}
|
||
}
|
||
|
||
/// A closed polygon, filled and/or stroked.
|
||
pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) {
|
||
return;
|
||
}
|
||
let mut s = String::new();
|
||
if let Some(f) = fill {
|
||
s.push_str(&rgb_op(f, false));
|
||
s.push('\n');
|
||
}
|
||
if let Some((c, w)) = stroke {
|
||
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(stroke_width(w)));
|
||
}
|
||
self.path(&mut s, pts, true);
|
||
s.push_str(match (fill.is_some(), stroke.is_some()) {
|
||
(true, true) => " B",
|
||
(true, false) => " f",
|
||
_ => " S",
|
||
});
|
||
self.op(&s);
|
||
}
|
||
|
||
/// A circle approximated by four cubic Béziers.
|
||
pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||
if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) {
|
||
return;
|
||
}
|
||
const K: f32 = 0.552_284_8;
|
||
let k = r * K;
|
||
let y = self.y(cy);
|
||
let mut s = String::new();
|
||
if let Some(f) = fill {
|
||
s.push_str(&rgb_op(f, false));
|
||
s.push('\n');
|
||
}
|
||
if let Some((c, w)) = stroke {
|
||
let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(stroke_width(w)));
|
||
}
|
||
let _ = write!(
|
||
s,
|
||
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h",
|
||
n(cx + r),
|
||
n(y),
|
||
n(cx + r),
|
||
n(y + k),
|
||
n(cx + k),
|
||
n(y + r),
|
||
n(cx),
|
||
n(y + r),
|
||
n(cx - k),
|
||
n(y + r),
|
||
n(cx - r),
|
||
n(y + k),
|
||
n(cx - r),
|
||
n(y),
|
||
n(cx - r),
|
||
n(y - k),
|
||
n(cx - k),
|
||
n(y - r),
|
||
n(cx),
|
||
n(y - r),
|
||
n(cx + k),
|
||
n(y - r),
|
||
n(cx + r),
|
||
n(y - k),
|
||
n(cx + r),
|
||
n(y)
|
||
);
|
||
s.push_str(match (fill.is_some(), stroke.is_some()) {
|
||
(true, true) => " B",
|
||
(true, false) => " f",
|
||
_ => " S",
|
||
});
|
||
self.op(&s);
|
||
}
|
||
|
||
/// Text with its baseline starting at `(x, y)`.
|
||
pub fn text(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str) {
|
||
self.text_rotated(x, y, size, font, color, text, 0.0);
|
||
}
|
||
|
||
/// Text rotated by `angle` radians (clockwise on the page, like the y-down coordinate
|
||
/// system) about its baseline origin.
|
||
pub fn text_rotated(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str, angle: f32) {
|
||
if text.is_empty() || !extent(size) || !finite(&[x, y]) {
|
||
return;
|
||
}
|
||
let fi = font.index();
|
||
if let Some(f) = self.fonts.get_mut(fi) {
|
||
*f = true;
|
||
}
|
||
let (sn, cs) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) };
|
||
// Clockwise in y-down = counter-clockwise negated in PDF's y-up space.
|
||
let s = format!(
|
||
"BT\n{}\n/F{} {} Tf\n{} {} {} {} {} {} Tm\n{} Tj\nET",
|
||
rgb_op(color, false),
|
||
fi + 1,
|
||
n(size.min(10_000.0)),
|
||
n(cs),
|
||
n(-sn),
|
||
n(sn),
|
||
n(cs),
|
||
n(x),
|
||
n(self.y(y)),
|
||
pdf_string(text)
|
||
);
|
||
self.op(&s);
|
||
}
|
||
|
||
/// Draws an RGBA image (`pw`×`ph` pixels, row-major, straight alpha) into the box
|
||
/// `(x, y, w, h)`. Malformed or oversized buffers are ignored.
|
||
pub fn image_rgba(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: &[u8], pw: u32, ph: u32) {
|
||
let px = pw as u64 * ph as u64;
|
||
if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) {
|
||
return;
|
||
}
|
||
let px = px as usize;
|
||
let mut rgb = Vec::with_capacity(px * 3);
|
||
let mut alpha = Vec::with_capacity(px);
|
||
for p in rgba.as_chunks::<4>().0.iter().take(px) {
|
||
rgb.extend_from_slice(&[p[0], p[1], p[2]]);
|
||
alpha.push(p[3]);
|
||
}
|
||
let alpha = if alpha.iter().all(|a| *a == 255) { None } else { Some(alpha) };
|
||
let idx = self.images.len();
|
||
self.images.push(ImageData { width: pw, height: ph, rgb, alpha });
|
||
let s = format!("q {} 0 0 {} {} {} cm /Im{} Do Q", n(w), n(h), n(x), n(self.y(y + h)), idx + 1);
|
||
self.op(&s);
|
||
}
|
||
}
|
||
|
||
/// Document metadata.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct Info {
|
||
pub title: String,
|
||
pub author: String,
|
||
pub creator: String,
|
||
}
|
||
|
||
/// A PDF document under construction.
|
||
#[derive(Default)]
|
||
pub struct PdfDoc {
|
||
pages: Vec<Page>,
|
||
pub info: Info,
|
||
}
|
||
|
||
/// PackBits-style run-length encoding (PDF `RunLengthDecode`).
|
||
fn run_length(data: &[u8]) -> Vec<u8> {
|
||
let mut out = Vec::with_capacity(data.len() / 2 + 16);
|
||
let mut i = 0;
|
||
while i < data.len() {
|
||
let b = data[i];
|
||
let mut run = 1;
|
||
while i + run < data.len() && run < 128 && data[i + run] == b {
|
||
run += 1;
|
||
}
|
||
if run >= 2 {
|
||
out.push((257 - run) as u8);
|
||
out.push(b);
|
||
i += run;
|
||
continue;
|
||
}
|
||
// Literal run until the next repeat of 3+ or 128 bytes.
|
||
let start = i;
|
||
let mut len = 0;
|
||
while i < data.len() && len < 128 {
|
||
if i + 2 < data.len() && data[i] == data[i + 1] && data[i] == data[i + 2] {
|
||
break;
|
||
}
|
||
i += 1;
|
||
len += 1;
|
||
}
|
||
if len == 0 {
|
||
// Next is a repeat; loop will handle it.
|
||
continue;
|
||
}
|
||
out.push((len - 1) as u8);
|
||
out.extend_from_slice(&data[start..start + len]);
|
||
}
|
||
out.push(128);
|
||
out
|
||
}
|
||
|
||
/// Longest Info string written, in characters; longer values are cut on a character boundary.
|
||
const MAX_INFO_CHARS: usize = 1024;
|
||
|
||
/// A PDF text string (document Info entries) for `text`: a literal `( … )` for printable ASCII,
|
||
/// otherwise UTF-16BE with a byte-order mark as a hex string (ISO 32000-1 7.9.2.2).
|
||
fn pdf_text_string(text: &str) -> String {
|
||
let text: String = text.chars().take(MAX_INFO_CHARS).collect();
|
||
if text.chars().all(|c| matches!(c, ' '..='~')) {
|
||
return pdf_string(&text);
|
||
}
|
||
let mut s = String::with_capacity(text.len() * 4 + 6);
|
||
s.push_str("<FEFF");
|
||
for unit in text.encode_utf16() {
|
||
let _ = write!(s, "{unit:04X}");
|
||
}
|
||
s.push('>');
|
||
s
|
||
}
|
||
|
||
impl PdfDoc {
|
||
pub fn new() -> PdfDoc {
|
||
PdfDoc::default()
|
||
}
|
||
|
||
/// Adds a page of `width_pt` × `height_pt` points and returns it for drawing.
|
||
pub fn add_page(&mut self, width_pt: f32, height_pt: f32) -> &mut Page {
|
||
let width = if width_pt.is_finite() { width_pt.clamp(3.0, 14_400.0) } else { 612.0 };
|
||
let height = if height_pt.is_finite() { height_pt.clamp(3.0, 14_400.0) } else { 792.0 };
|
||
self.pages.push(Page { width, height, content: String::new(), fonts: [false; 12], images: Vec::new(), depth: 0 });
|
||
let i = self.pages.len() - 1;
|
||
&mut self.pages[i]
|
||
}
|
||
|
||
pub fn page_count(&self) -> usize {
|
||
self.pages.len()
|
||
}
|
||
|
||
/// The page at `i`, to keep drawing on it.
|
||
pub fn page_mut(&mut self, i: usize) -> Option<&mut Page> {
|
||
self.pages.get_mut(i)
|
||
}
|
||
|
||
/// Serializes the document. A document without pages gets one blank Letter page.
|
||
pub fn finish(mut self) -> Vec<u8> {
|
||
if self.pages.is_empty() {
|
||
self.add_page(612.0, 792.0);
|
||
}
|
||
// Object numbering: 1 catalog, 2 page tree, 3 info, then fonts, then per page.
|
||
let mut used = [false; 12];
|
||
for p in &self.pages {
|
||
for (u, f) in used.iter_mut().zip(p.fonts.iter()) {
|
||
*u |= *f;
|
||
}
|
||
}
|
||
let mut next = 4u32;
|
||
let mut font_ids = [0u32; 12];
|
||
for (i, u) in used.iter().enumerate() {
|
||
if *u {
|
||
font_ids[i] = next;
|
||
next += 1;
|
||
}
|
||
}
|
||
let mut objects: Vec<(u32, Vec<u8>)> = Vec::new();
|
||
let mut kids = Vec::new();
|
||
let mut font_dict = String::new();
|
||
for (i, id) in font_ids.iter().enumerate() {
|
||
if *id != 0 {
|
||
let _ = write!(font_dict, "/F{} {} 0 R ", i + 1, id);
|
||
let name = Font::ALL.get(i).copied().unwrap_or_default().base_name();
|
||
let enc = if name.starts_with("Symbol") { "" } else { " /Encoding /WinAnsiEncoding" };
|
||
objects.push((*id, format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name}{enc} >>").into_bytes()));
|
||
}
|
||
}
|
||
for page in &mut self.pages {
|
||
while page.depth > 0 {
|
||
page.restore();
|
||
}
|
||
let mut xobjects = String::new();
|
||
for (k, img) in page.images.iter().enumerate() {
|
||
let img_id = next;
|
||
next += 1;
|
||
let smask = match &img.alpha {
|
||
Some(a) => {
|
||
let id = next;
|
||
next += 1;
|
||
let data = run_length(a);
|
||
let mut o = format!(
|
||
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /RunLengthDecode /Length {} >>\nstream\n",
|
||
img.width,
|
||
img.height,
|
||
data.len()
|
||
)
|
||
.into_bytes();
|
||
o.extend_from_slice(&data);
|
||
o.extend_from_slice(b"\nendstream");
|
||
objects.push((id, o));
|
||
format!(" /SMask {id} 0 R")
|
||
}
|
||
None => String::new(),
|
||
};
|
||
let data = run_length(&img.rgb);
|
||
let mut o = format!(
|
||
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceRGB /BitsPerComponent 8{} /Filter /RunLengthDecode /Length {} >>\nstream\n",
|
||
img.width,
|
||
img.height,
|
||
smask,
|
||
data.len()
|
||
)
|
||
.into_bytes();
|
||
o.extend_from_slice(&data);
|
||
o.extend_from_slice(b"\nendstream");
|
||
objects.push((img_id, o));
|
||
let _ = write!(xobjects, "/Im{} {} 0 R ", k + 1, img_id);
|
||
}
|
||
let content_id = next;
|
||
let page_id = next + 1;
|
||
next += 2;
|
||
let body = std::mem::take(&mut page.content);
|
||
let mut o = format!("<< /Length {} >>\nstream\n", body.len()).into_bytes();
|
||
o.extend_from_slice(body.as_bytes());
|
||
o.extend_from_slice(b"endstream");
|
||
objects.push((content_id, o));
|
||
let xo = if xobjects.is_empty() { String::new() } else { format!(" /XObject << {xobjects}>>") };
|
||
objects.push((
|
||
page_id,
|
||
format!(
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << {}>>{} >> /Contents {} 0 R >>",
|
||
n(page.width),
|
||
n(page.height),
|
||
font_dict,
|
||
xo,
|
||
content_id
|
||
)
|
||
.into_bytes(),
|
||
));
|
||
kids.push(page_id);
|
||
}
|
||
objects.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()));
|
||
let kids_s: Vec<String> = kids.iter().map(|k| format!("{k} 0 R")).collect();
|
||
objects.push((2, format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids_s.join(" "), kids.len()).into_bytes()));
|
||
let creator = if self.info.creator.is_empty() { "GridCraft" } else { &self.info.creator };
|
||
let mut info = format!("<< /Producer {} /Creator {}", pdf_text_string("GridCraft PDF"), pdf_text_string(creator));
|
||
if !self.info.title.is_empty() {
|
||
let _ = write!(info, " /Title {}", pdf_text_string(&self.info.title));
|
||
}
|
||
if !self.info.author.is_empty() {
|
||
let _ = write!(info, " /Author {}", pdf_text_string(&self.info.author));
|
||
}
|
||
info.push_str(" >>");
|
||
objects.push((3, info.into_bytes()));
|
||
objects.sort_by_key(|(id, _)| *id);
|
||
|
||
let mut out: Vec<u8> = Vec::new();
|
||
out.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
|
||
let mut offsets = vec![0usize; next as usize];
|
||
for (id, body) in &objects {
|
||
if let Some(slot) = offsets.get_mut(*id as usize) {
|
||
*slot = out.len();
|
||
}
|
||
out.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||
out.extend_from_slice(body);
|
||
out.extend_from_slice(b"\nendobj\n");
|
||
}
|
||
let xref = out.len();
|
||
let mut x = format!("xref\n0 {}\n0000000000 65535 f \n", next);
|
||
for off in offsets.iter().skip(1) {
|
||
let _ = writeln!(x, "{off:010} 00000 n ");
|
||
}
|
||
let _ = write!(x, "trailer\n<< /Size {} /Root 1 0 R /Info 3 0 R >>\nstartxref\n{}\n%%EOF\n", next, xref);
|
||
out.extend_from_slice(x.as_bytes());
|
||
out
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|