Skip to main content

ravenrs_sheet/pdf/
mod.rs

1//! A tiny, self-contained PDF 1.7 writer for GridCraft's print and PDF export.
2//!
3//! - The 14 standard fonts (no embedding) with WinAnsiEncoding, plus approximate width tables
4//!   ([`text_width`]) so callers can measure, align and wrap text. Characters outside
5//!   WinAnsiEncoding (CJK and other scripts) are set in a predefined, non-embedded Adobe-GB1 CID
6//!   font with the `UniGB-UTF16-H` CMap instead of being replaced by `?`. Symbol uses its own
7//!   built-in encoding ([`symbol_code`]), for Greek letters and mathematical operators.
8//! - Vector drawing: rectangles, lines (solid or dashed), polygons, circles (Bézier), clipping.
9//! - RGBA images as RGB image XObjects with a soft mask for alpha (run-length encoded).
10//! - Coordinates are points with a **top-left origin** (y grows down); text `y` is the baseline.
11//!
12//! Streams are written uncompressed (images use the trivial RunLengthDecode filter) so the crate
13//! needs no dependencies.
14#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
15#![forbid(unsafe_code)]
16
17mod metrics;
18mod symbol;
19
20pub use symbol::{symbol_code, symbol_width};
21
22use std::fmt::Write as _;
23
24/// RGB colour, 0–255 per channel.
25pub type Rgb = [u8; 3];
26
27/// A point in page coordinates, measured from the top-left corner.
28#[derive(Clone, Copy, Debug, Default, PartialEq)]
29pub struct Point {
30    pub x: f32,
31    pub y: f32,
32}
33
34impl Point {
35    pub const fn new(x: f32, y: f32) -> Self {
36        Self { x, y }
37    }
38}
39
40/// A page-space rectangle described by its top-left corner and extent.
41#[derive(Clone, Copy, Debug, Default, PartialEq)]
42pub struct Rect {
43    pub x: f32,
44    pub y: f32,
45    pub width: f32,
46    pub height: f32,
47}
48
49impl Rect {
50    pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
51        Self { x, y, width, height }
52    }
53}
54
55/// The standard 14 fonts minus ZapfDingbats.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
57pub enum Font {
58    #[default]
59    Helvetica,
60    HelveticaBold,
61    HelveticaOblique,
62    HelveticaBoldOblique,
63    TimesRoman,
64    TimesBold,
65    TimesItalic,
66    TimesBoldItalic,
67    Courier,
68    CourierBold,
69    CourierOblique,
70    CourierBoldOblique,
71    /// Greek letters and mathematical symbols, in the font's own encoding.
72    Symbol,
73}
74
75/// How many fonts a document can use.
76const FONT_COUNT: usize = 13;
77
78/// A font family for [`Font::styled`].
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
80pub enum Family {
81    #[default]
82    Sans,
83    Serif,
84    Mono,
85}
86
87impl Font {
88    pub const ALL: [Font; FONT_COUNT] = [
89        Font::Helvetica,
90        Font::HelveticaBold,
91        Font::HelveticaOblique,
92        Font::HelveticaBoldOblique,
93        Font::TimesRoman,
94        Font::TimesBold,
95        Font::TimesItalic,
96        Font::TimesBoldItalic,
97        Font::Courier,
98        Font::CourierBold,
99        Font::CourierOblique,
100        Font::CourierBoldOblique,
101        Font::Symbol,
102    ];
103
104    /// PostScript base font name.
105    pub fn base_name(self) -> &'static str {
106        match self {
107            Font::Helvetica => "Helvetica",
108            Font::HelveticaBold => "Helvetica-Bold",
109            Font::HelveticaOblique => "Helvetica-Oblique",
110            Font::HelveticaBoldOblique => "Helvetica-BoldOblique",
111            Font::TimesRoman => "Times-Roman",
112            Font::TimesBold => "Times-Bold",
113            Font::TimesItalic => "Times-Italic",
114            Font::TimesBoldItalic => "Times-BoldItalic",
115            Font::Courier => "Courier",
116            Font::CourierBold => "Courier-Bold",
117            Font::CourierOblique => "Courier-Oblique",
118            Font::CourierBoldOblique => "Courier-BoldOblique",
119            Font::Symbol => "Symbol",
120        }
121    }
122
123    fn index(self) -> usize {
124        Font::ALL.iter().position(|f| *f == self).unwrap_or(0)
125    }
126
127    /// The face of `family` with the given weight and slant.
128    pub fn styled(family: Family, bold: bool, italic: bool) -> Font {
129        match (family, bold, italic) {
130            (Family::Sans, false, false) => Font::Helvetica,
131            (Family::Sans, true, false) => Font::HelveticaBold,
132            (Family::Sans, false, true) => Font::HelveticaOblique,
133            (Family::Sans, true, true) => Font::HelveticaBoldOblique,
134            (Family::Serif, false, false) => Font::TimesRoman,
135            (Family::Serif, true, false) => Font::TimesBold,
136            (Family::Serif, false, true) => Font::TimesItalic,
137            (Family::Serif, true, true) => Font::TimesBoldItalic,
138            (Family::Mono, false, false) => Font::Courier,
139            (Family::Mono, true, false) => Font::CourierBold,
140            (Family::Mono, false, true) => Font::CourierOblique,
141            (Family::Mono, true, true) => Font::CourierBoldOblique,
142        }
143    }
144
145    /// Picks a standard family for a font name: serif faces map to Times, monospaced ones to
146    /// Courier, everything else to Helvetica.
147    pub fn family_for(name: &str) -> Family {
148        let n = name.to_ascii_lowercase();
149        const SERIF: [&str; 10] = ["times", "cambria", "georgia", "garamond", "serif", "book", "palatino", "baskerville", "tinos", "caladea"];
150        const MONO: [&str; 8] = ["courier", "consolas", "mono", "menlo", "lucida console", "cousine", "code", "typewriter"];
151        if MONO.iter().any(|m| n.contains(m)) {
152            Family::Mono
153        } else if SERIF.iter().any(|s| n.contains(s)) && !n.contains("sans") {
154            Family::Serif
155        } else {
156            Family::Sans
157        }
158    }
159
160    /// Whether this font can show `c` by itself (in its own encoding; for the text faces,
161    /// without the CID fallback).
162    pub fn can_encode(self, c: char) -> bool {
163        match self {
164            Font::Symbol => symbol_code(c).is_some(),
165            _ => c == '?' || win_ansi(c) != b'?',
166        }
167    }
168
169    /// Advance width of a character in 1/1000 em.
170    pub fn char_width(self, c: char) -> u16 {
171        if self == Font::Symbol {
172            return symbol_code(c).and_then(symbol_width).unwrap_or(500);
173        }
174        let table: Option<&[u16; 95]> = match self {
175            Font::Helvetica | Font::HelveticaOblique => Some(&metrics::HELVETICA),
176            Font::HelveticaBold | Font::HelveticaBoldOblique => Some(&metrics::HELVETICA_BOLD),
177            Font::TimesRoman | Font::TimesItalic => Some(&metrics::TIMES),
178            Font::TimesBold | Font::TimesBoldItalic => Some(&metrics::TIMES_BOLD),
179            _ => None,
180        };
181        if needs_cid(c) {
182            return CID_WIDTH;
183        }
184        let Some(table) = table else { return 600 };
185        let ascii = |c: char| -> Option<u16> {
186            let i = (c as u32).checked_sub(32)? as usize;
187            table.get(i).copied()
188        };
189        if let Some(w) = ascii(c) {
190            return w;
191        }
192        match c {
193            '—' | '…' | '‰' | '™' => 1000,
194            _ => metrics::fold(c).and_then(ascii).unwrap_or(match self {
195                Font::TimesRoman | Font::TimesItalic | Font::TimesBold | Font::TimesBoldItalic => 500,
196                _ => 556,
197            }),
198        }
199    }
200}
201
202/// Width of `text` in points when set in `font` at `size` points.
203pub fn text_width(font: Font, size: f32, text: &str) -> f32 {
204    // u64: a u32 sum overflows for ~8 million characters.
205    let units: u64 = text.chars().map(|c| u64::from(font.char_width(c))).sum();
206    units as f32 * size / 1000.0
207}
208
209/// Unicode → WinAnsiEncoding (CP1252) byte; unmappable characters become `?`.
210pub fn win_ansi(c: char) -> u8 {
211    let u = c as u32;
212    if (0x20..0x7F).contains(&u) || (0xA0..=0xFF).contains(&u) {
213        return u as u8;
214    }
215    match c {
216        '€' => 0x80,
217        '‚' => 0x82,
218        'ƒ' => 0x83,
219        '„' => 0x84,
220        '…' => 0x85,
221        '†' => 0x86,
222        '‡' => 0x87,
223        'ˆ' => 0x88,
224        '‰' => 0x89,
225        'Š' => 0x8A,
226        '‹' => 0x8B,
227        'Œ' => 0x8C,
228        'Ž' => 0x8E,
229        '‘' => 0x91,
230        '’' => 0x92,
231        '“' => 0x93,
232        '”' => 0x94,
233        '•' => 0x95,
234        '–' => 0x96,
235        '—' => 0x97,
236        '˜' => 0x98,
237        '™' => 0x99,
238        'š' => 0x9A,
239        '›' => 0x9B,
240        'œ' => 0x9C,
241        'ž' => 0x9E,
242        'Ÿ' => 0x9F,
243        '\t' => b' ',
244        _ => b'?',
245    }
246}
247
248/// Index of the CID font in a page's font flags (after the [`Font::ALL`] faces).
249const CID_FONT: usize = Font::ALL.len();
250/// Number of font resources a page can use.
251const FONT_SLOTS: usize = CID_FONT + 1;
252/// Base font of the CID font used for characters WinAnsiEncoding lacks.
253const CID_BASE_FONT: &str = "STSong-Light";
254/// Default glyph width of the CID font (full-width), in 1/1000 em.
255const CID_WIDTH: u16 = 1000;
256
257/// True when `c` has no WinAnsiEncoding byte and is drawn with the CID font.
258fn needs_cid(c: char) -> bool {
259    !c.is_control() && c != '?' && win_ansi(c) == b'?'
260}
261
262/// A PDF hex string `< … >` of `text` in UTF-16BE (the `UniGB-UTF16-H` encoding).
263fn pdf_utf16_string(text: &str) -> String {
264    let mut s = String::with_capacity(text.len() * 4 + 2);
265    s.push('<');
266    for unit in text.encode_utf16() {
267        let _ = write!(s, "{unit:04X}");
268    }
269    s.push('>');
270    s
271}
272
273/// A PDF literal string `( … )` for `text` in WinAnsiEncoding.
274fn pdf_string(text: &str) -> String {
275    pdf_string_in(text, false)
276}
277
278/// A PDF literal string for `text` in WinAnsiEncoding, or in Symbol's encoding (where a
279/// character Symbol lacks becomes `?`).
280fn pdf_string_in(text: &str, symbol: bool) -> String {
281    let mut s = String::with_capacity(text.len() + 2);
282    s.push('(');
283    for c in text.chars() {
284        let b = if symbol { symbol_code(c).unwrap_or(b'?') } else { win_ansi(c) };
285        match b {
286            b'(' | b')' | b'\\' => {
287                s.push('\\');
288                s.push(b as char);
289            }
290            0x20..=0x7E => s.push(b as char),
291            _ => {
292                let _ = write!(s, "\\{b:03o}");
293            }
294        }
295    }
296    s.push(')');
297    s
298}
299
300/// URI actions use byte strings. Percent-encode non-ASCII UTF-8 before PDF string escaping.
301fn pdf_uri_string(uri: &str) -> String {
302    let mut ascii = String::with_capacity(uri.len());
303    for byte in uri.bytes() {
304        if byte.is_ascii() {
305            ascii.push(byte as char);
306        } else {
307            let _ = write!(ascii, "%{byte:02X}");
308        }
309    }
310    pdf_string(&ascii)
311}
312
313/// A number for a content stream: finite, short.
314fn n(v: f32) -> String {
315    let v = if v.is_finite() { v.clamp(-1.0e7, 1.0e7) } else { 0.0 };
316    let r = (v * 1000.0).round() / 1000.0;
317    if r == r.trunc() {
318        format!("{}", r as i64)
319    } else {
320        let s = format!("{r:.3}");
321        s.trim_end_matches('0').trim_end_matches('.').to_string()
322    }
323}
324
325/// True when every value is finite (NaN and infinities are rejected).
326fn finite(v: &[f32]) -> bool {
327    v.iter().all(|x| x.is_finite())
328}
329
330/// A positive, finite extent (a width, height or radius).
331fn extent(v: f32) -> bool {
332    v > 0.0 && v.is_finite()
333}
334
335/// A stroke width: negative, NaN and infinite become 0 (the thinnest line).
336fn stroke_width(w: f32) -> f32 {
337    if w.is_finite() { w.max(0.0) } else { 0.0 }
338}
339
340/// The entries of a dash array (`[on, off, …]`, at most 8). A pattern the PDF specification
341/// forbids (any non-finite entry, or no entry that survives rounding to a positive number) is
342/// replaced by an empty array, i.e. a solid line.
343fn dash_values(dash: &[f32]) -> String {
344    let d: Vec<f32> = dash.iter().take(8).map(|v| if v.is_finite() { v.max(0.0) } else { f32::NAN }).collect();
345    if !finite(&d) || !d.iter().any(|v| (v.min(1.0e7) * 1000.0).round() > 0.0) {
346        return String::new();
347    }
348    d.iter().map(|v| n(*v)).collect::<Vec<_>>().join(" ")
349}
350
351fn rgb_op(c: Rgb, stroke: bool) -> String {
352    let f = |v: u8| n(v as f32 / 255.0);
353    format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" })
354}
355
356struct ImageData {
357    width: u32,
358    height: u32,
359    rgb: Vec<u8>,
360    alpha: Option<Vec<u8>>,
361}
362
363enum LinkTarget {
364    Uri(String),
365    Page { index: usize, x: f32, y: f32 },
366}
367
368struct LinkAnnotation {
369    /// PDF coordinates, with the origin at the bottom left.
370    rect: [f32; 4],
371    target: LinkTarget,
372}
373
374/// A page under construction. Obtain one with [`PdfDoc::add_page`].
375pub struct Page {
376    width: f32,
377    height: f32,
378    content: String,
379    fonts: [bool; FONT_SLOTS],
380    images: Vec<ImageData>,
381    links: Vec<LinkAnnotation>,
382    depth: u32,
383}
384
385/// Most bytes of content per page before further drawing is dropped (keeps hostile inputs from
386/// exhausting memory).
387const MAX_PAGE_CONTENT: usize = 64 * 1024 * 1024;
388/// Largest image (pixels) embedded.
389const MAX_IMAGE_PIXELS: u64 = 40_000_000;
390const MAX_PAGE_LINKS: usize = 4096;
391const MAX_LINK_URI_BYTES: usize = 4096;
392
393impl Page {
394    pub fn width(&self) -> f32 {
395        self.width
396    }
397    pub fn height(&self) -> f32 {
398        self.height
399    }
400    fn y(&self, y: f32) -> f32 {
401        self.height - y
402    }
403    fn full(&self) -> bool {
404        self.content.len() > MAX_PAGE_CONTENT
405    }
406    fn op(&mut self, s: &str) {
407        if !self.full() {
408            self.content.push_str(s);
409            self.content.push('\n');
410        }
411    }
412
413    fn link_rect(&self, rect: Rect) -> Option<[f32; 4]> {
414        let Rect { x, y, width, height } = rect;
415        if !finite(&[x, y, width, height]) || !extent(width) || !extent(height) || self.links.len() >= MAX_PAGE_LINKS {
416            return None;
417        }
418        let (right, bottom) = (x + width, y + height);
419        if !finite(&[right, bottom]) {
420            return None;
421        }
422        let (left, right) = (x.max(0.0), right.min(self.width));
423        let (top, bottom) = (y.max(0.0), bottom.min(self.height));
424        (right > left && bottom > top).then(|| [left, self.y(bottom), right, self.y(top)])
425    }
426
427    /// Adds an invisible clickable area for an external URI. Invalid or excessive links are ignored.
428    pub fn link_uri(&mut self, x: f32, y: f32, w: f32, h: f32, uri: &str) {
429        let allowed = uri
430            .split_once(':')
431            .is_some_and(|(scheme, rest)| matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https" | "mailto") && !rest.is_empty());
432        if !allowed || uri.len() > MAX_LINK_URI_BYTES || uri.chars().any(char::is_control) {
433            return;
434        }
435        if let Some(rect) = self.link_rect(Rect::new(x, y, w, h)) {
436            self.links.push(LinkAnnotation { rect, target: LinkTarget::Uri(uri.to_string()) });
437        }
438    }
439
440    /// Adds a link to a location on another page of this document (zero-based page index).
441    pub fn link_page(&mut self, rect: Rect, page: usize, target: Point) {
442        if !finite(&[target.x, target.y]) {
443            return;
444        }
445        if let Some(rect) = self.link_rect(rect) {
446            self.links.push(LinkAnnotation { rect, target: LinkTarget::Page { index: page, x: target.x, y: target.y } });
447        }
448    }
449
450    /// Saves the graphics state (pair with [`Page::restore`]).
451    pub fn save(&mut self) {
452        self.depth = self.depth.saturating_add(1);
453        self.content.push_str("q\n");
454    }
455    /// Restores the graphics state saved by [`Page::save`]; extra calls are ignored.
456    pub fn restore(&mut self) {
457        if self.depth > 0 {
458            self.depth -= 1;
459            self.content.push_str("Q\n");
460        }
461    }
462    /// Intersects the clip with a rectangle (until the next [`Page::restore`]).
463    pub fn clip_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
464        let s = format!("{} {} {} {} re W n", n(x), n(self.y(y + h)), n(w.max(0.0)), n(h.max(0.0)));
465        self.op(&s);
466    }
467
468    pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) {
469        if !(extent(w) && extent(h) && finite(&[x, y])) {
470            return;
471        }
472        let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h));
473        self.op(&s);
474    }
475
476    pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) {
477        if !finite(&[x, y, w, h]) {
478            return;
479        }
480        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));
481        self.op(&s);
482    }
483
484    pub fn stroke_line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb) {
485        self.stroke_line_dashed(Point::new(x0, y0), Point::new(x1, y1), width, color, &[]);
486    }
487
488    /// A line with a dash pattern (`[on, off, …]` in points; empty = solid).
489    pub fn stroke_line_dashed(&mut self, start: Point, end: Point, width: f32, color: Rgb, dash: &[f32]) {
490        if !finite(&[start.x, start.y, end.x, end.y]) {
491            return;
492        }
493        let s = format!(
494            "{}\n{} w [{}] 0 d\n{} {} m {} {} l S",
495            rgb_op(color, true),
496            n(stroke_width(width)),
497            dash_values(dash),
498            n(start.x),
499            n(self.y(start.y)),
500            n(end.x),
501            n(self.y(end.y))
502        );
503        self.op(&s);
504    }
505
506    /// An open polyline.
507    pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) {
508        if pts.len() < 2 || !pts.iter().all(|p| finite(p)) {
509            return;
510        }
511        let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(stroke_width(width)), dash_values(dash));
512        self.path(&mut s, pts, false);
513        s.push_str(" S");
514        self.op(&s);
515    }
516
517    fn path(&self, s: &mut String, pts: &[[f32; 2]], close: bool) {
518        for (i, p) in pts.iter().enumerate() {
519            let _ = write!(s, "{} {} {} ", n(p[0]), n(self.y(p[1])), if i == 0 { "m" } else { "l" });
520        }
521        if close {
522            s.push('h');
523        }
524    }
525
526    /// A closed polygon, filled and/or stroked.
527    pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
528        if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) {
529            return;
530        }
531        let mut s = String::new();
532        if let Some(f) = fill {
533            s.push_str(&rgb_op(f, false));
534            s.push('\n');
535        }
536        if let Some((c, w)) = stroke {
537            let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(stroke_width(w)));
538        }
539        self.path(&mut s, pts, true);
540        s.push_str(match (fill.is_some(), stroke.is_some()) {
541            (true, true) => " B",
542            (true, false) => " f",
543            _ => " S",
544        });
545        self.op(&s);
546    }
547
548    /// A circle approximated by four cubic Béziers.
549    pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
550        if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) {
551            return;
552        }
553        const K: f32 = 0.552_284_8;
554        let k = r * K;
555        let y = self.y(cy);
556        let mut s = String::new();
557        if let Some(f) = fill {
558            s.push_str(&rgb_op(f, false));
559            s.push('\n');
560        }
561        if let Some((c, w)) = stroke {
562            let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(stroke_width(w)));
563        }
564        let _ = write!(
565            s,
566            "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h",
567            n(cx + r),
568            n(y),
569            n(cx + r),
570            n(y + k),
571            n(cx + k),
572            n(y + r),
573            n(cx),
574            n(y + r),
575            n(cx - k),
576            n(y + r),
577            n(cx - r),
578            n(y + k),
579            n(cx - r),
580            n(y),
581            n(cx - r),
582            n(y - k),
583            n(cx - k),
584            n(y - r),
585            n(cx),
586            n(y - r),
587            n(cx + k),
588            n(y - r),
589            n(cx + r),
590            n(y - k),
591            n(cx + r),
592            n(y)
593        );
594        s.push_str(match (fill.is_some(), stroke.is_some()) {
595            (true, true) => " B",
596            (true, false) => " f",
597            _ => " S",
598        });
599        self.op(&s);
600    }
601
602    /// Text with its baseline starting at `(x, y)`.
603    pub fn text(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str) {
604        self.text_rotated(Point::new(x, y), size, font, color, text, 0.0);
605    }
606
607    /// Text rotated by `angle` radians (clockwise on the page, like the y-down coordinate
608    /// system) about its baseline origin.
609    pub fn text_rotated(&mut self, origin: Point, size: f32, font: Font, color: Rgb, text: &str, angle: f32) {
610        if text.is_empty() || !extent(size) || !finite(&[origin.x, origin.y]) {
611            return;
612        }
613        let fi = font.index();
614        let size = n(size.min(10_000.0));
615        let (sn, cs) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) };
616        // Clockwise in y-down = counter-clockwise negated in PDF's y-up space.
617        let mut s = format!("BT\n{}\n{} {} {} {} {} {} Tm\n", rgb_op(color, false), n(cs), n(-sn), n(sn), n(cs), n(origin.x), n(self.y(origin.y)));
618        // Symbol text is one run in Symbol's own encoding (the CID font has no maths symbols).
619        if font == Font::Symbol {
620            if let Some(f) = self.fonts.get_mut(fi) {
621                *f = true;
622            }
623            let _ = writeln!(s, "/F{} {} Tf\n{} Tj", fi + 1, size, pdf_string_in(text, true));
624            s.push_str("ET");
625            self.op(&s);
626            return;
627        }
628        // Runs of WinAnsi text use `font`; runs it cannot encode use the CID font. Each `Tj`
629        // advances the text position, so the runs follow each other on the baseline.
630        let mut rest = text;
631        while let Some(first) = rest.chars().next() {
632            let cid = needs_cid(first);
633            let end = rest.char_indices().find(|(_, c)| needs_cid(*c) != cid).map_or(rest.len(), |(i, _)| i);
634            let (run, tail) = rest.split_at(end);
635            let (slot, string) = if cid { (CID_FONT, pdf_utf16_string(run)) } else { (fi, pdf_string(run)) };
636            if let Some(f) = self.fonts.get_mut(slot) {
637                *f = true;
638            }
639            let _ = writeln!(s, "/F{} {} Tf\n{} Tj", slot + 1, size, string);
640            rest = tail;
641        }
642        s.push_str("ET");
643        self.op(&s);
644    }
645
646    /// Draws an RGBA image (`pw`×`ph` pixels, row-major, straight alpha) into the box
647    /// `(x, y, w, h)`. Malformed or oversized buffers are ignored.
648    pub fn image_rgba(&mut self, destination: Rect, rgba: &[u8], pixel_size: [u32; 2]) {
649        let Rect { x, y, width: w, height: h } = destination;
650        let [pw, ph] = pixel_size;
651        let px = pw as u64 * ph as u64;
652        if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) {
653            return;
654        }
655        let px = px as usize;
656        let mut rgb = Vec::with_capacity(px * 3);
657        let mut alpha = Vec::with_capacity(px);
658        for p in rgba.as_chunks::<4>().0.iter().take(px) {
659            rgb.extend_from_slice(&[p[0], p[1], p[2]]);
660            alpha.push(p[3]);
661        }
662        let alpha = if alpha.iter().all(|a| *a == 255) { None } else { Some(alpha) };
663        let idx = self.images.len();
664        self.images.push(ImageData { width: pw, height: ph, rgb, alpha });
665        let s = format!("q {} 0 0 {} {} {} cm /Im{} Do Q", n(w), n(h), n(x), n(self.y(y + h)), idx + 1);
666        self.op(&s);
667    }
668}
669
670/// Document metadata.
671#[derive(Clone, Debug, Default)]
672pub struct Info {
673    pub title: String,
674    pub author: String,
675    pub creator: String,
676}
677
678/// A PDF document under construction.
679#[derive(Default)]
680pub struct PdfDoc {
681    pages: Vec<Page>,
682    pub info: Info,
683}
684
685/// PackBits-style run-length encoding (PDF `RunLengthDecode`).
686fn run_length(data: &[u8]) -> Vec<u8> {
687    let mut out = Vec::with_capacity(data.len() / 2 + 16);
688    let mut i = 0;
689    while i < data.len() {
690        let b = data[i];
691        let mut run = 1;
692        while i + run < data.len() && run < 128 && data[i + run] == b {
693            run += 1;
694        }
695        if run >= 2 {
696            out.push((257 - run) as u8);
697            out.push(b);
698            i += run;
699            continue;
700        }
701        // Literal run until the next repeat of 3+ or 128 bytes.
702        let start = i;
703        let mut len = 0;
704        while i < data.len() && len < 128 {
705            if i + 2 < data.len() && data[i] == data[i + 1] && data[i] == data[i + 2] {
706                break;
707            }
708            i += 1;
709            len += 1;
710        }
711        if len == 0 {
712            // Next is a repeat; loop will handle it.
713            continue;
714        }
715        out.push((len - 1) as u8);
716        out.extend_from_slice(&data[start..start + len]);
717    }
718    out.push(128);
719    out
720}
721
722/// Longest Info string written, in characters; longer values are cut on a character boundary.
723const MAX_INFO_CHARS: usize = 1024;
724
725/// A PDF text string (document Info entries) for `text`: a literal `( … )` for printable ASCII,
726/// otherwise UTF-16BE with a byte-order mark as a hex string (ISO 32000-1 7.9.2.2).
727fn pdf_text_string(text: &str) -> String {
728    let text: String = text.chars().take(MAX_INFO_CHARS).collect();
729    if text.chars().all(|c| matches!(c, ' '..='~')) {
730        return pdf_string(&text);
731    }
732    let mut s = String::with_capacity(text.len() * 4 + 6);
733    s.push_str("<FEFF");
734    for unit in text.encode_utf16() {
735        let _ = write!(s, "{unit:04X}");
736    }
737    s.push('>');
738    s
739}
740
741impl PdfDoc {
742    pub fn new() -> PdfDoc {
743        PdfDoc::default()
744    }
745
746    /// Adds a page of `width_pt` × `height_pt` points and returns it for drawing.
747    pub fn add_page(&mut self, width_pt: f32, height_pt: f32) -> &mut Page {
748        let width = if width_pt.is_finite() { width_pt.clamp(3.0, 14_400.0) } else { 612.0 };
749        let height = if height_pt.is_finite() { height_pt.clamp(3.0, 14_400.0) } else { 792.0 };
750        self.pages.push(Page { width, height, content: String::new(), fonts: [false; FONT_SLOTS], images: Vec::new(), links: Vec::new(), depth: 0 });
751        let i = self.pages.len() - 1;
752        &mut self.pages[i]
753    }
754
755    pub fn page_count(&self) -> usize {
756        self.pages.len()
757    }
758
759    /// The page at `i`, to keep drawing on it.
760    pub fn page_mut(&mut self, i: usize) -> Option<&mut Page> {
761        self.pages.get_mut(i)
762    }
763
764    /// Serializes the document. A document without pages gets one blank Letter page.
765    pub fn finish(mut self) -> Vec<u8> {
766        if self.pages.is_empty() {
767            self.add_page(612.0, 792.0);
768        }
769        // Object numbering: 1 catalog, 2 page tree, 3 info, then fonts, then per page.
770        let mut used = [false; FONT_SLOTS];
771        for p in &self.pages {
772            for (u, f) in used.iter_mut().zip(p.fonts.iter()) {
773                *u |= *f;
774            }
775        }
776        let mut next = 4u32;
777        let mut font_ids = [0u32; FONT_SLOTS];
778        for (i, u) in used.iter().enumerate() {
779            if *u {
780                font_ids[i] = next;
781                // The CID font also needs its descendant font and font descriptor.
782                next += if i == CID_FONT { 3 } else { 1 };
783            }
784        }
785        let mut objects: Vec<(u32, Vec<u8>)> = Vec::new();
786        let mut kids = Vec::new();
787        let mut annotations = Vec::new();
788        let page_count = self.pages.len();
789        let mut font_dict = String::new();
790        for (i, id) in font_ids.iter().enumerate() {
791            if *id != 0 {
792                let _ = write!(font_dict, "/F{} {} 0 R ", i + 1, id);
793                if i == CID_FONT {
794                    let (cid_id, desc_id) = (id + 1, id + 2);
795                    objects.push((
796                        *id,
797                        format!(
798                            "<< /Type /Font /Subtype /Type0 /BaseFont /{CID_BASE_FONT} /Encoding /UniGB-UTF16-H /DescendantFonts [{cid_id} 0 R] >>"
799                        )
800                        .into_bytes(),
801                    ));
802                    objects.push((
803                        cid_id,
804                        format!(
805                            "<< /Type /Font /Subtype /CIDFontType0 /BaseFont /{CID_BASE_FONT} /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /FontDescriptor {desc_id} 0 R /DW {CID_WIDTH} >>"
806                        )
807                        .into_bytes(),
808                    ));
809                    objects.push((
810                        desc_id,
811                        format!(
812                            "<< /Type /FontDescriptor /FontName /{CID_BASE_FONT} /Flags 6 /FontBBox [-25 -254 1000 880] /ItalicAngle 0 /Ascent 880 /Descent -120 /CapHeight 880 /StemV 93 >>"
813                        )
814                        .into_bytes(),
815                    ));
816                    continue;
817                }
818                let name = Font::ALL.get(i).copied().unwrap_or_default().base_name();
819                let enc = if name.starts_with("Symbol") { "" } else { " /Encoding /WinAnsiEncoding" };
820                objects.push((*id, format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name}{enc} >>").into_bytes()));
821            }
822        }
823        for page in &mut self.pages {
824            while page.depth > 0 {
825                page.restore();
826            }
827            let mut xobjects = String::new();
828            for (k, img) in page.images.iter().enumerate() {
829                let img_id = next;
830                next += 1;
831                let smask = match &img.alpha {
832                    Some(a) => {
833                        let id = next;
834                        next += 1;
835                        let data = run_length(a);
836                        let mut o = format!(
837                            "<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /RunLengthDecode /Length {} >>\nstream\n",
838                            img.width,
839                            img.height,
840                            data.len()
841                        )
842                        .into_bytes();
843                        o.extend_from_slice(&data);
844                        o.extend_from_slice(b"\nendstream");
845                        objects.push((id, o));
846                        format!(" /SMask {id} 0 R")
847                    }
848                    None => String::new(),
849                };
850                let data = run_length(&img.rgb);
851                let mut o = format!(
852                    "<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceRGB /BitsPerComponent 8{} /Filter /RunLengthDecode /Length {} >>\nstream\n",
853                    img.width,
854                    img.height,
855                    smask,
856                    data.len()
857                )
858                .into_bytes();
859                o.extend_from_slice(&data);
860                o.extend_from_slice(b"\nendstream");
861                objects.push((img_id, o));
862                let _ = write!(xobjects, "/Im{} {} 0 R ", k + 1, img_id);
863            }
864            let content_id = next;
865            let page_id = next + 1;
866            next += 2;
867            let body = std::mem::take(&mut page.content);
868            let mut o = format!("<< /Length {} >>\nstream\n", body.len()).into_bytes();
869            o.extend_from_slice(body.as_bytes());
870            o.extend_from_slice(b"endstream");
871            objects.push((content_id, o));
872            let xo = if xobjects.is_empty() { String::new() } else { format!(" /XObject << {xobjects}>>") };
873            let mut link_refs = String::new();
874            for link in std::mem::take(&mut page.links) {
875                if matches!(&link.target, LinkTarget::Page { index, .. } if *index >= page_count) {
876                    continue;
877                }
878                let id = next;
879                next += 1;
880                let _ = write!(link_refs, "{id} 0 R ");
881                annotations.push((id, link));
882            }
883            let annots = if link_refs.is_empty() { String::new() } else { format!(" /Annots [{link_refs}]") };
884            objects.push((
885                page_id,
886                format!(
887                    "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << {}>>{} >> /Contents {} 0 R{} >>",
888                    n(page.width),
889                    n(page.height),
890                    font_dict,
891                    xo,
892                    content_id,
893                    annots
894                )
895                .into_bytes(),
896            ));
897            kids.push(page_id);
898        }
899        for (id, link) in annotations {
900            let [x0, y0, x1, y1] = link.rect;
901            let action = match link.target {
902                LinkTarget::Uri(uri) => format!("/A << /S /URI /URI {} >>", pdf_uri_string(&uri)),
903                LinkTarget::Page { index, x, y } => {
904                    let Some(&page_id) = kids.get(index) else { continue };
905                    let height = self.pages.get(index).map_or(0.0, |p| p.height);
906                    format!("/Dest [{page_id} 0 R /XYZ {} {} null]", n(x), n(height - y))
907                }
908            };
909            objects.push((
910                id,
911                format!("<< /Type /Annot /Subtype /Link /Rect [{} {} {} {}] /Border [0 0 0] {action} >>", n(x0), n(y0), n(x1), n(y1)).into_bytes(),
912            ));
913        }
914        objects.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()));
915        let kids_s: Vec<String> = kids.iter().map(|k| format!("{k} 0 R")).collect();
916        objects.push((2, format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids_s.join(" "), kids.len()).into_bytes()));
917        let creator = if self.info.creator.is_empty() { "GridCraft" } else { &self.info.creator };
918        let mut info = format!("<< /Producer {} /Creator {}", pdf_text_string("GridCraft PDF"), pdf_text_string(creator));
919        if !self.info.title.is_empty() {
920            let _ = write!(info, " /Title {}", pdf_text_string(&self.info.title));
921        }
922        if !self.info.author.is_empty() {
923            let _ = write!(info, " /Author {}", pdf_text_string(&self.info.author));
924        }
925        info.push_str(" >>");
926        objects.push((3, info.into_bytes()));
927        objects.sort_by_key(|(id, _)| *id);
928
929        let mut out: Vec<u8> = Vec::new();
930        out.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
931        let mut offsets = vec![0usize; next as usize];
932        for (id, body) in &objects {
933            if let Some(slot) = offsets.get_mut(*id as usize) {
934                *slot = out.len();
935            }
936            out.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
937            out.extend_from_slice(body);
938            out.extend_from_slice(b"\nendobj\n");
939        }
940        let xref = out.len();
941        let mut x = format!("xref\n0 {}\n0000000000 65535 f \n", next);
942        for off in offsets.iter().skip(1) {
943            let _ = writeln!(x, "{off:010} 00000 n ");
944        }
945        let _ = write!(x, "trailer\n<< /Size {} /Root 1 0 R /Info 3 0 R >>\nstartxref\n{}\n%%EOF\n", next, xref);
946        out.extend_from_slice(x.as_bytes());
947        out
948    }
949}
950
951#[cfg(test)]
952mod tests;