Skip to main content

rightkit_pdf/
write.rs

1//! Document writer: a NEW PDF, one full-page JPEG per page, optional embedded ICC profile.
2//!
3//! The editing half of this crate changes existing files; this module creates one from
4//! nothing, for print, slideshow and book exports that place a JPEG on every page.
5//!
6//! ```no_run
7//! use rightkit_pdf::write::Document;
8//! # let srgb_profile_bytes = std::fs::read("sRGB.icc")?;
9//! # let jpeg_bytes = std::fs::read("page.jpg")?;
10//! # let path = std::path::Path::new("output.pdf");
11//! let mut doc = Document::new("1.4")?;
12//! let srgb = doc.add_icc_profile(srgb_profile_bytes, 3)?;
13//! doc.add_jpeg_page(595.0, 842.0, jpeg_bytes, Some(srgb))?;
14//! let pdf: Vec<u8> = doc.finish();
15//! # #[cfg(feature = "edit")]
16//! doc.write_to(path)?; // feature `edit`: rightkit-fs atomic replace
17//! # Ok::<(), Box<dyn std::error::Error>>(())
18//! ```
19//!
20//! ## What is written
21//!
22//! * The header is `%PDF-<version>` (1.0 to 1.7) plus the binary-marker comment, then a classic
23//!   cross-reference table (no object or xref streams), so a PDF 1.4 reader accepts it.
24//! * Per page: `/Page` with `/MediaBox [0 0 w h]`, a content stream
25//!   `q w 0 0 h 0 0 cm /Im0 Do Q`, and an image XObject with `/Filter /DCTDecode`,
26//!   `/BitsPerComponent 8`. The JPEG bytes are copied unchanged (no re-encoding).
27//! * Colour space: `/DeviceRGB` (3 components) or `/DeviceGray` (1), or, when an ICC profile is
28//!   chosen, `[/ICCBased n 0 R]` with the profile as a stream (`/N`, `/Alternate`). A profile is
29//!   written once however many pages use it.
30//! * Deterministic: object numbers follow insertion order, no dates, no `/Info`, and the trailer
31//!   `/ID` is the SHA-256 of the body. The same calls give the same bytes.
32//!
33//! ## Limits
34//!
35//! * JPEG: baseline, extended-sequential or progressive Huffman (SOF0, SOF1, SOF2), 8 bits per
36//!   component, 1 or 3 components. 4-component (CMYK/YCCK, whose Adobe inversion needs a
37//!   `/Decode` array), 12-bit, lossless and arithmetic-coded files are refused, not guessed.
38//!   The ICC profile's component count must equal the JPEG's.
39//! * The ICC profile is checked for the `acsp` signature at byte 36 and a header of at least 128
40//!   bytes, not for validity. Streams are stored uncompressed (this module has no deflate).
41//! * One image per page, full page, no rotation or crop: the image is stretched to the media
42//!   box, so pass the JPEG's own aspect ratio.
43//! * Text (feature `fonts`, on by default): [`Document::add_font`] takes a TrueType font
44//!   (glyf/loca outlines, `.ttf` or a TrueType collection face; CFF/OpenType-CFF is refused by the
45//!   subsetter) and [`Document::draw_text`] draws a string with it. At finish each used font is
46//!   subset to exactly the glyphs drawn (`font::subset_for_pdf`) and embedded as
47//!   Type0 / CIDFontType2 / `FontFile2`, `/Encoding /Identity-H`, `/CIDToGIDMap /Identity`, a `/W`
48//!   widths array and a `ToUnicode` CMap, so CJK or any covered script is real, selectable
49//!   text. The string is shown as 2-byte glyph codes in a hex string; there is no shaping,
50//!   kerning, bidi or line breaking (one `draw_text` call is one run at one point), and a
51//!   character the font has no glyph for is refused ([`WriteError::MissingGlyph`]), never
52//!   substituted. A font whose OS/2 `fsType` forbids embedding or subsetting is refused.
53//!   Text is painted after the page image. Subset tags and `/BaseFont` are derived from the font
54//!   and the glyph set, so output stays deterministic.
55//! * The PDF is built in memory; `lopdf` is used by the tests to read it back.
56//! * Not embedded: font hinting programs beyond what the subsetter keeps, `/CIDSet`, glyph
57//!   variations (a variable font is written at its default instance, as the subsetter does).
58
59use sha2::{Digest, Sha256};
60use thiserror::Error;
61
62/// Longest page side accepted, in points (the PDF 1.4 implementation limit, 200 inches).
63pub const MAX_PAGE_SIDE_PT: f64 = 14_400.0;
64
65#[derive(Debug, Error, PartialEq, Eq)]
66pub enum WriteError {
67    #[error("unsupported PDF version {0:?}: use 1.0 to 1.7")]
68    InvalidVersion(String),
69    #[error("page size {width} x {height} pt is not a finite size between 0 and 14400 pt")]
70    InvalidPageSize { width: String, height: String },
71    #[error("not a JPEG: {0}")]
72    NotJpeg(&'static str),
73    #[error("unsupported JPEG: {0}")]
74    UnsupportedJpeg(String),
75    #[error("ICC profile is not usable: {0}")]
76    InvalidIccProfile(&'static str),
77    #[error("ICC profile has {profile} components but the JPEG has {jpeg}")]
78    IccComponentMismatch { profile: u8, jpeg: u8 },
79    #[error("ICCBased colour needs PDF 1.3 or later, this document is {0}")]
80    IccNeedsNewerVersion(String),
81    #[error("no such page or ICC profile")]
82    UnknownHandle,
83    #[error("page {0} already has an image")]
84    PageHasImage(usize),
85    #[error("font cannot be used: {0}")]
86    FontUnusable(String),
87    #[error("the font's licence flags (OS/2 fsType) forbid embedding or subsetting")]
88    FontEmbeddingDenied,
89    #[error("the font has no glyph for {0:?}")]
90    MissingGlyph(char),
91    #[error("text size or position is not finite or is out of range")]
92    InvalidTextParams,
93}
94
95/// Handle to an ICC profile added with [`Document::add_icc_profile`].
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub struct IccId(usize);
98
99/// Handle to a page added with [`Document::add_page`] (its zero-based index).
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct PageId(usize);
102
103impl PageId {
104    pub fn index(self) -> usize {
105        self.0
106    }
107}
108
109/// Handle to a font added with [`Document::add_font`].
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub struct FontId(usize);
112
113/// What a JPEG header says.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct JpegInfo {
116    pub width: u32,
117    pub height: u32,
118    /// 1 (gray) or 3 (RGB or YCbCr).
119    pub components: u8,
120}
121
122impl JpegInfo {
123    /// Read the frame header (SOF) of `data`, checking it is a JPEG this writer can place.
124    pub fn parse(data: &[u8]) -> Result<JpegInfo, WriteError> {
125        if data.len() < 4 || data[0] != 0xFF || data[1] != 0xD8 {
126            return Err(WriteError::NotJpeg("missing SOI marker"));
127        }
128        let mut i = 2usize;
129        loop {
130            // Skip to the next marker (fill bytes are 0xFF).
131            while i < data.len() && data[i] != 0xFF {
132                i += 1;
133            }
134            while i < data.len() && data[i] == 0xFF {
135                i += 1;
136            }
137            let Some(&marker) = data.get(i) else {
138                return Err(WriteError::NotJpeg("no frame header before the end"));
139            };
140            i += 1;
141            match marker {
142                0x00 | 0x01 | 0xD0..=0xD8 => continue,
143                0xD9 => return Err(WriteError::NotJpeg("no frame header before EOI")),
144                0xDA => return Err(WriteError::NotJpeg("scan before frame header")),
145                _ => {}
146            }
147            let len = match data.get(i..i + 2) {
148                Some(b) => u16::from_be_bytes([b[0], b[1]]) as usize,
149                None => return Err(WriteError::NotJpeg("truncated segment")),
150            };
151            if len < 2 {
152                return Err(WriteError::NotJpeg("bad segment length"));
153            }
154            let is_sof = (0xC0..=0xCF).contains(&marker) && ![0xC4, 0xC8, 0xCC].contains(&marker);
155            if !is_sof {
156                i += len;
157                continue;
158            }
159            if !matches!(marker, 0xC0..=0xC2) {
160                return Err(WriteError::UnsupportedJpeg(format!(
161                    "SOF{} (only baseline, extended-sequential and progressive Huffman)",
162                    marker - 0xC0
163                )));
164            }
165            let seg = data
166                .get(i + 2..i + len)
167                .filter(|s| s.len() >= 6)
168                .ok_or(WriteError::NotJpeg("truncated frame header"))?;
169            let precision = seg[0];
170            let height = u32::from(u16::from_be_bytes([seg[1], seg[2]]));
171            let width = u32::from(u16::from_be_bytes([seg[3], seg[4]]));
172            let components = seg[5];
173            if precision != 8 {
174                return Err(WriteError::UnsupportedJpeg(format!(
175                    "{precision} bits per component (8 only)"
176                )));
177            }
178            if width == 0 || height == 0 {
179                return Err(WriteError::UnsupportedJpeg("zero width or height".into()));
180            }
181            if components != 1 && components != 3 {
182                return Err(WriteError::UnsupportedJpeg(format!(
183                    "{components} components (1 or 3 only)"
184                )));
185            }
186            return Ok(JpegInfo {
187                width,
188                height,
189                components,
190            });
191        }
192    }
193}
194
195struct Icc {
196    data: Vec<u8>,
197    components: u8,
198}
199
200struct Placed {
201    info: JpegInfo,
202    data: Vec<u8>,
203    icc: Option<usize>,
204}
205
206#[cfg_attr(not(feature = "fonts"), allow(dead_code))]
207struct FontEntry {
208    bytes: Vec<u8>,
209    face_index: u32,
210}
211
212#[cfg_attr(not(feature = "fonts"), allow(dead_code))]
213struct TextRun {
214    font: usize,
215    size: f64,
216    x: f64,
217    y: f64,
218    rgb: Option<[f64; 3]>,
219    text: String,
220}
221
222struct Page {
223    width: f64,
224    height: f64,
225    image: Option<Placed>,
226    runs: Vec<TextRun>,
227}
228
229/// A PDF being built. See the module documentation.
230pub struct Document {
231    minor: u8,
232    version: String,
233    icc: Vec<Icc>,
234    fonts: Vec<FontEntry>,
235    pages: Vec<Page>,
236}
237
238impl Document {
239    /// Start a document. `version` is `"1.0"` to `"1.7"` (`"1.4"` for the widest readers).
240    pub fn new(version: &str) -> Result<Document, WriteError> {
241        let minor = match version.as_bytes() {
242            [b'1', b'.', d @ b'0'..=b'7'] => d - b'0',
243            _ => return Err(WriteError::InvalidVersion(version.to_string())),
244        };
245        Ok(Document {
246            minor,
247            version: version.to_string(),
248            icc: Vec::new(),
249            fonts: Vec::new(),
250            pages: Vec::new(),
251        })
252    }
253
254    pub fn page_count(&self) -> usize {
255        self.pages.len()
256    }
257
258    /// Register an ICC profile (`components` 1 or 3) to colour-tag JPEGs. Needs PDF 1.3+.
259    pub fn add_icc_profile(
260        &mut self,
261        profile: Vec<u8>,
262        components: u8,
263    ) -> Result<IccId, WriteError> {
264        if self.minor < 3 {
265            return Err(WriteError::IccNeedsNewerVersion(self.version.clone()));
266        }
267        if components != 1 && components != 3 {
268            return Err(WriteError::InvalidIccProfile("1 or 3 components only"));
269        }
270        if profile.len() < 128 || &profile[36..40] != b"acsp" {
271            return Err(WriteError::InvalidIccProfile(
272                "no 128-byte header with the acsp signature",
273            ));
274        }
275        self.icc.push(Icc {
276            data: profile,
277            components,
278        });
279        Ok(IccId(self.icc.len() - 1))
280    }
281
282    /// Append an empty page of `width_pt` x `height_pt` (1/72 inch).
283    pub fn add_page(&mut self, width_pt: f64, height_pt: f64) -> Result<PageId, WriteError> {
284        let ok = |v: f64| v.is_finite() && v > 0.0 && v <= MAX_PAGE_SIDE_PT;
285        if !ok(width_pt) || !ok(height_pt) {
286            return Err(WriteError::InvalidPageSize {
287                width: width_pt.to_string(),
288                height: height_pt.to_string(),
289            });
290        }
291        self.pages.push(Page {
292            width: width_pt,
293            height: height_pt,
294            image: None,
295            runs: Vec::new(),
296        });
297        Ok(PageId(self.pages.len() - 1))
298    }
299
300    /// Draw `jpeg` over the whole of `page` (`q w 0 0 h 0 0 cm /Im0 Do Q`). `icc` tags it with a
301    /// profile whose component count equals the JPEG's; `None` uses DeviceRGB or DeviceGray.
302    pub fn place_jpeg(
303        &mut self,
304        page: PageId,
305        jpeg: Vec<u8>,
306        icc: Option<IccId>,
307    ) -> Result<JpegInfo, WriteError> {
308        let info = JpegInfo::parse(&jpeg)?;
309        if let Some(IccId(k)) = icc {
310            let profile = self.icc.get(k).ok_or(WriteError::UnknownHandle)?;
311            if profile.components != info.components {
312                return Err(WriteError::IccComponentMismatch {
313                    profile: profile.components,
314                    jpeg: info.components,
315                });
316            }
317        }
318        let slot = self
319            .pages
320            .get_mut(page.0)
321            .ok_or(WriteError::UnknownHandle)?;
322        if slot.image.is_some() {
323            return Err(WriteError::PageHasImage(page.0));
324        }
325        slot.image = Some(Placed {
326            info,
327            data: jpeg,
328            icc: icc.map(|i| i.0),
329        });
330        Ok(info)
331    }
332
333    /// [`Self::add_page`] then [`Self::place_jpeg`]. On an error no page is added.
334    pub fn add_jpeg_page(
335        &mut self,
336        width_pt: f64,
337        height_pt: f64,
338        jpeg: Vec<u8>,
339        icc: Option<IccId>,
340    ) -> Result<PageId, WriteError> {
341        let before = self.pages.len();
342        let page = self.add_page(width_pt, height_pt)?;
343        if let Err(error) = self.place_jpeg(page, jpeg, icc) {
344            self.pages.truncate(before);
345            return Err(error);
346        }
347        Ok(page)
348    }
349
350    /// The finished file. A document with no pages has an empty page tree.
351    ///
352    /// # Panics
353    ///
354    /// Only if a font that [`Self::add_font`] accepted cannot be subset (a damaged table the
355    /// subsetter rejects); use [`Self::try_finish`] to get that as an error. A document without
356    /// text cannot panic.
357    pub fn finish(&self) -> Vec<u8> {
358        self.try_finish()
359            .unwrap_or_else(|error| panic!("PDF writer: {error}"))
360    }
361
362    /// [`Self::finish`] returning font subsetting failures as errors.
363    pub fn try_finish(&self) -> Result<Vec<u8>, WriteError> {
364        // Object numbers: 1 catalog, 2 page tree, ICC streams, five objects per used font, then
365        // per page its page object, content stream and (when it has an image) image XObject.
366        let icc_base = 3usize;
367        let mut next = icc_base + self.icc.len();
368        let built = self.build_fonts()?;
369        let mut font_ids: Vec<Option<usize>> = vec![None; self.fonts.len()];
370        for (k, b) in built.iter().enumerate() {
371            if b.is_some() {
372                font_ids[k] = Some(next); // five consecutive objects starting here
373                next += 5;
374            }
375        }
376        let mut ids = Vec::with_capacity(self.pages.len());
377        for page in &self.pages {
378            let page_id = next;
379            let content_id = next + 1;
380            let image_id = page.image.as_ref().map(|_| next + 2);
381            next += if image_id.is_some() { 3 } else { 2 };
382            ids.push((page_id, content_id, image_id));
383        }
384        let total = next; // objects 1..next-1, plus the free entry 0
385
386        let mut objects: Vec<(usize, Vec<u8>)> = Vec::with_capacity(total);
387        objects.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()));
388        let kids = ids
389            .iter()
390            .map(|(p, _, _)| format!("{p} 0 R"))
391            .collect::<Vec<_>>()
392            .join(" ");
393        objects.push((
394            2,
395            format!(
396                "<< /Type /Pages /Kids [{kids}] /Count {} >>",
397                self.pages.len()
398            )
399            .into_bytes(),
400        ));
401        for (k, profile) in self.icc.iter().enumerate() {
402            let alt = if profile.components == 1 {
403                "/DeviceGray"
404            } else {
405                "/DeviceRGB"
406            };
407            objects.push((
408                icc_base + k,
409                stream_object(
410                    &format!("/N {} /Alternate {alt}", profile.components),
411                    &profile.data,
412                ),
413            ));
414        }
415        for (k, b) in built.iter().enumerate() {
416            if let (Some(b), Some(first)) = (b, font_ids[k]) {
417                // first: FontFile2, +1 descriptor, +2 CIDFontType2, +3 ToUnicode, +4 Type0.
418                objects.push((
419                    first,
420                    stream_object(&format!("/Length1 {}", b.program.len()), &b.program),
421                ));
422                objects.push((
423                    first + 1,
424                    format!(
425                        "<< /Type /FontDescriptor /FontName /{} /Flags 4 /FontBBox [{} {} {} {}] /ItalicAngle 0 /Ascent {} /Descent {} /CapHeight {} /StemV 80 /FontFile2 {first} 0 R >>",
426                        b.base_font, b.bbox[0], b.bbox[1], b.bbox[2], b.bbox[3], b.ascent, b.descent, b.cap_height
427                    )
428                    .into_bytes(),
429                ));
430                objects.push((
431                    first + 2,
432                    format!(
433                        "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {} 0 R /DW 1000 /W [{}] /CIDToGIDMap /Identity >>",
434                        b.base_font,
435                        first + 1,
436                        b.w_array
437                    )
438                    .into_bytes(),
439                ));
440                objects.push((first + 3, stream_object("", b.to_unicode.as_bytes())));
441                objects.push((
442                    first + 4,
443                    format!(
444                        "<< /Type /Font /Subtype /Type0 /BaseFont /{} /Encoding /Identity-H /DescendantFonts [{} 0 R] /ToUnicode {} 0 R >>",
445                        b.base_font,
446                        first + 2,
447                        first + 3
448                    )
449                    .into_bytes(),
450                ));
451            }
452        }
453        for (page_index, (page, &(page_id, content_id, image_id))) in
454            self.pages.iter().zip(&ids).enumerate()
455        {
456            let (w, h) = (num(page.width), num(page.height));
457            let mut used_fonts: Vec<usize> = page.runs.iter().map(|r| r.font).collect();
458            used_fonts.sort_unstable();
459            used_fonts.dedup();
460            used_fonts.retain(|f| font_ids[*f].is_some());
461            let mut resources = String::from("<< ");
462            if let (Some(placed), Some(image_id)) = (&page.image, image_id) {
463                resources.push_str(&format!("/XObject << /Im0 {image_id} 0 R >> "));
464                let _ = placed;
465            }
466            if !used_fonts.is_empty() {
467                let entries = used_fonts
468                    .iter()
469                    .map(|f| format!("/F{f} {} 0 R", font_ids[*f].unwrap_or(0) + 4))
470                    .collect::<Vec<_>>()
471                    .join(" ");
472                resources.push_str(&format!("/Font << {entries} >> "));
473            }
474            let mut procset = String::from("/PDF");
475            if let Some(placed) = &page.image {
476                procset.push_str(if placed.info.components == 1 {
477                    " /ImageB"
478                } else {
479                    " /ImageC"
480                });
481            }
482            if !used_fonts.is_empty() {
483                procset.push_str(" /Text");
484            }
485            resources.push_str(&format!("/ProcSet [{procset}] >>"));
486            objects.push((
487                page_id,
488                format!(
489                    "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Resources {resources} /Contents {content_id} 0 R >>"
490                )
491                .into_bytes(),
492            ));
493            let mut content = String::new();
494            if image_id.is_some() {
495                content.push_str(&format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q\n"));
496            }
497            for (run_index, run) in page.runs.iter().enumerate() {
498                let Some(codes) = built[run.font]
499                    .as_ref()
500                    .and_then(|b| b.codes.get(&(page_index, run_index)))
501                else {
502                    continue;
503                };
504                content.push_str("q ");
505                if let Some([r, g, b]) = run.rgb {
506                    content.push_str(&format!("{} {} {} rg ", num(r), num(g), num(b)));
507                }
508                content.push_str(&format!(
509                    "BT /F{} {} Tf {} {} Td <{}> Tj ET Q\n",
510                    run.font,
511                    num(run.size),
512                    num(run.x),
513                    num(run.y),
514                    codes
515                ));
516            }
517            objects.push((content_id, stream_object("", content.as_bytes())));
518            if let (Some(placed), Some(image_id)) = (&page.image, image_id) {
519                let colour = match placed.icc {
520                    Some(k) => format!("[/ICCBased {} 0 R]", icc_base + k),
521                    None if placed.info.components == 1 => "/DeviceGray".to_string(),
522                    None => "/DeviceRGB".to_string(),
523                };
524                objects.push((
525                    image_id,
526                    stream_object(
527                        &format!(
528                            "/Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {colour} /BitsPerComponent 8 /Filter /DCTDecode",
529                            placed.info.width, placed.info.height
530                        ),
531                        &placed.data,
532                    ),
533                ));
534            }
535        }
536
537        let mut out = Vec::new();
538        out.extend_from_slice(format!("%PDF-{}\n", self.version).as_bytes());
539        out.extend_from_slice(&[b'%', 0xE2, 0xE3, 0xCF, 0xD3, b'\n']);
540        let mut offsets = vec![0usize; total];
541        for (id, body) in &objects {
542            offsets[*id] = out.len();
543            out.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
544            out.extend_from_slice(body);
545            out.extend_from_slice(b"\nendobj\n");
546        }
547        let id_hex = hex(&Sha256::digest(&out)[..16]);
548        let xref_at = out.len();
549        out.extend_from_slice(format!("xref\n0 {total}\n").as_bytes());
550        out.extend_from_slice(b"0000000000 65535 f \n");
551        for offset in &offsets[1..] {
552            out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
553        }
554        out.extend_from_slice(
555            format!(
556                "trailer\n<< /Size {total} /Root 1 0 R /ID [<{id_hex}> <{id_hex}>] >>\nstartxref\n{xref_at}\n%%EOF\n"
557            )
558            .as_bytes(),
559        );
560        Ok(out)
561    }
562
563    /// Register a TrueType font for [`Self::draw_text`]. `face_index` is 0 for a single-face
564    /// file. Refused when the font does not parse or its `fsType` forbids embedding or
565    /// subsetting. Needs feature `fonts`.
566    #[cfg(feature = "fonts")]
567    pub fn add_font(&mut self, bytes: Vec<u8>, face_index: u32) -> Result<FontId, WriteError> {
568        let face = ttf_parser::Face::parse(&bytes, face_index)
569            .map_err(|e| WriteError::FontUnusable(format!("not a parseable font: {e}")))?;
570        if face.units_per_em() == 0 {
571            return Err(WriteError::FontUnusable("unitsPerEm is 0".into()));
572        }
573        let (embeddable, subsettable) = crate::font::face_embedding_permissions(&face);
574        if !embeddable || !subsettable {
575            return Err(WriteError::FontEmbeddingDenied);
576        }
577        self.fonts.push(FontEntry { bytes, face_index });
578        Ok(FontId(self.fonts.len() - 1))
579    }
580
581    /// Width of `text` in points at `size_pt`: the sum of the font's advances (no kerning),
582    /// for right-aligning or centring before [`Self::draw_text`]. Refuses a missing glyph.
583    #[cfg(feature = "fonts")]
584    pub fn measure_text(&self, font: FontId, size_pt: f64, text: &str) -> Result<f64, WriteError> {
585        let entry = self.fonts.get(font.0).ok_or(WriteError::UnknownHandle)?;
586        let face = ttf_parser::Face::parse(&entry.bytes, entry.face_index)
587            .map_err(|e| WriteError::FontUnusable(e.to_string()))?;
588        let upm = f64::from(face.units_per_em());
589        let mut units = 0.0;
590        for c in text.chars() {
591            let gid = face.glyph_index(c).ok_or(WriteError::MissingGlyph(c))?;
592            units += f64::from(face.glyph_hor_advance(gid).unwrap_or(0));
593        }
594        Ok(units * size_pt / upm)
595    }
596
597    /// Draw `text` on `page` with its baseline starting at (`x_pt`, `y_pt`) from the page's
598    /// bottom-left, `size_pt` high. `rgb` is 0.0 to 1.0 per channel (`None` leaves the default
599    /// black). One call is one run: no wrapping, shaping or kerning. Every character needs a
600    /// glyph in the font ([`WriteError::MissingGlyph`] otherwise, nothing is drawn).
601    #[cfg(feature = "fonts")]
602    #[allow(clippy::too_many_arguments)]
603    pub fn draw_text(
604        &mut self,
605        page: PageId,
606        font: FontId,
607        size_pt: f64,
608        x_pt: f64,
609        y_pt: f64,
610        rgb: Option<[f64; 3]>,
611        text: &str,
612    ) -> Result<(), WriteError> {
613        let in_range = |v: f64| v.is_finite() && v.abs() <= 1.0e6;
614        if !(size_pt.is_finite() && size_pt > 0.0 && size_pt <= 10_000.0)
615            || !in_range(x_pt)
616            || !in_range(y_pt)
617            || rgb.is_some_and(|c| c.iter().any(|v| !(0.0..=1.0).contains(v)))
618        {
619            return Err(WriteError::InvalidTextParams);
620        }
621        let entry = self.fonts.get(font.0).ok_or(WriteError::UnknownHandle)?;
622        let face = ttf_parser::Face::parse(&entry.bytes, entry.face_index)
623            .map_err(|e| WriteError::FontUnusable(e.to_string()))?;
624        for c in text.chars() {
625            if face.glyph_index(c).is_none() {
626                return Err(WriteError::MissingGlyph(c));
627            }
628        }
629        let slot = self
630            .pages
631            .get_mut(page.0)
632            .ok_or(WriteError::UnknownHandle)?;
633        slot.runs.push(TextRun {
634            font: font.0,
635            size: size_pt,
636            x: x_pt,
637            y: y_pt,
638            rgb,
639            text: text.to_string(),
640        });
641        Ok(())
642    }
643
644    /// Subset every used font once, over all its text in page and call order.
645    #[cfg(feature = "fonts")]
646    fn build_fonts(&self) -> Result<Vec<Option<BuiltFont>>, WriteError> {
647        use std::collections::BTreeMap;
648        let mut out: Vec<Option<BuiltFont>> = Vec::with_capacity(self.fonts.len());
649        for (font_index, entry) in self.fonts.iter().enumerate() {
650            let mut all_text = String::new();
651            let mut run_keys: Vec<((usize, usize), &str)> = Vec::new();
652            for (pi, page) in self.pages.iter().enumerate() {
653                for (ri, run) in page.runs.iter().enumerate() {
654                    if run.font == font_index && !run.text.is_empty() {
655                        all_text.push_str(&run.text);
656                        run_keys.push(((pi, ri), run.text.as_str()));
657                    }
658                }
659            }
660            if all_text.is_empty() {
661                out.push(None);
662                continue;
663            }
664            let subset = crate::font::subset_for_pdf(&entry.bytes, entry.face_index, &all_text)
665                .map_err(|e| match e {
666                    crate::model::PdfEditError::FontEmbeddingDenied => {
667                        WriteError::FontEmbeddingDenied
668                    }
669                    other => WriteError::FontUnusable(format!("subsetting failed: {other:?}")),
670                })?;
671            let face = ttf_parser::Face::parse(&entry.bytes, entry.face_index)
672                .map_err(|e| WriteError::FontUnusable(e.to_string()))?;
673
674            // CID == new glyph id, numbered in first-appearance order starting at 1, the same
675            // rule `subset_for_pdf` documents (and `compose::embed_type0_font` recomputes).
676            let mut seen: Vec<u16> = Vec::new();
677            let mut codes: BTreeMap<(usize, usize), String> = BTreeMap::new();
678            for (key, text) in &run_keys {
679                let mut hex_codes = String::with_capacity(text.chars().count() * 4);
680                for c in text.chars() {
681                    let old = face.glyph_index(c).ok_or(WriteError::MissingGlyph(c))?.0;
682                    let cid = match seen.iter().position(|g| *g == old) {
683                        Some(pos) => pos + 1,
684                        None => {
685                            seen.push(old);
686                            seen.len()
687                        }
688                    };
689                    hex_codes.push_str(&format!("{cid:04X}"));
690                }
691                codes.insert(*key, hex_codes);
692            }
693
694            let mut widths = subset.widths.clone();
695            widths.sort_by_key(|w| w.cid);
696            let w_array = widths
697                .iter()
698                .map(|w| format!("{} [{}]", w.cid, w.width_1000.round() as i64))
699                .collect::<Vec<_>>()
700                .join(" ");
701
702            let upm = f64::from(face.units_per_em());
703            let scale = |v: i16| (f64::from(v) * 1000.0 / upm).round() as i64;
704            let bb = face.global_bounding_box();
705            let ascent = scale(face.ascender());
706            let postscript = face
707                .names()
708                .into_iter()
709                .find(|n| n.name_id == ttf_parser::name_id::POST_SCRIPT_NAME)
710                .and_then(|n| n.to_string())
711                .unwrap_or_else(|| "Font".to_string());
712            let clean: String = postscript
713                .chars()
714                .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
715                .take(40)
716                .collect();
717            let clean = if clean.is_empty() {
718                "Font".to_string()
719            } else {
720                clean
721            };
722            // Subset tag: six capitals from the hash of the subset program (ISO 32000-1 9.6.4).
723            let digest = Sha256::digest(&subset.font_program);
724            let tag: String = digest[..6]
725                .iter()
726                .map(|b| char::from(b'A' + b % 26))
727                .collect();
728
729            out.push(Some(BuiltFont {
730                base_font: format!("{tag}+{clean}"),
731                program: subset.font_program,
732                to_unicode: crate::font::build_to_unicode_cmap(&subset.to_unicode),
733                w_array,
734                bbox: [
735                    scale(bb.x_min),
736                    scale(bb.y_min),
737                    scale(bb.x_max),
738                    scale(bb.y_max),
739                ],
740                ascent,
741                descent: scale(face.descender()),
742                cap_height: face.capital_height().map(scale).unwrap_or(ascent),
743                codes,
744            }));
745        }
746        Ok(out)
747    }
748
749    /// Without feature `fonts` no font can be added, so there is nothing to build.
750    #[cfg(not(feature = "fonts"))]
751    fn build_fonts(&self) -> Result<Vec<Option<BuiltFont>>, WriteError> {
752        Ok(self.fonts.iter().map(|_| None).collect())
753    }
754
755    /// Write [`Self::finish`] to `path` through [`crate::commit::write_atomic`] (temp sibling,
756    /// flush, rename, directory sync): the one documented way to put a new file on disk. Path
757    /// authorization is the caller's job.
758    #[cfg(feature = "edit")]
759    pub fn write_to(&self, path: &std::path::Path) -> std::io::Result<()> {
760        crate::commit::write_atomic(path, &self.finish())
761    }
762}
763
764/// One font ready to write: the subset program and the values of its five objects.
765#[cfg_attr(not(feature = "fonts"), allow(dead_code))]
766struct BuiltFont {
767    base_font: String,
768    program: Vec<u8>,
769    to_unicode: String,
770    w_array: String,
771    bbox: [i64; 4],
772    ascent: i64,
773    descent: i64,
774    cap_height: i64,
775    /// `(page, run)` to the run's hex glyph codes.
776    codes: std::collections::BTreeMap<(usize, usize), String>,
777}
778
779fn stream_object(dict_entries: &str, data: &[u8]) -> Vec<u8> {
780    let mut out = format!("<< {dict_entries} /Length {} >>\nstream\n", data.len()).into_bytes();
781    out.extend_from_slice(data);
782    out.extend_from_slice(b"\nendstream");
783    out
784}
785
786/// A point value with at most three decimals, trailing zeros trimmed (`595`, `841.89`).
787fn num(v: f64) -> String {
788    let s = format!("{v:.3}");
789    let s = s.trim_end_matches('0').trim_end_matches('.');
790    if s.is_empty() || s == "-0" {
791        "0".to_string()
792    } else {
793        s.to_string()
794    }
795}
796
797fn hex(bytes: &[u8]) -> String {
798    bytes.iter().map(|b| format!("{b:02x}")).collect()
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    /// A minimal baseline JPEG header: SOI, SOF0 (8-bit, `h` x `w`, `n` components), EOI.
806    fn jpeg(w: u16, h: u16, n: u8) -> Vec<u8> {
807        let mut d = vec![0xFF, 0xD8, 0xFF, 0xC0];
808        let len = 8 + 3 * u16::from(n);
809        d.extend_from_slice(&len.to_be_bytes());
810        d.push(8);
811        d.extend_from_slice(&h.to_be_bytes());
812        d.extend_from_slice(&w.to_be_bytes());
813        d.push(n);
814        for c in 0..n {
815            d.extend_from_slice(&[c + 1, 0x11, 0]);
816        }
817        d.extend_from_slice(&[0xFF, 0xD9]);
818        d
819    }
820
821    fn profile() -> Vec<u8> {
822        let mut p = vec![0u8; 200];
823        p[36..40].copy_from_slice(b"acsp");
824        p
825    }
826
827    #[test]
828    fn jpeg_header_is_read() {
829        assert_eq!(
830            JpegInfo::parse(&jpeg(640, 480, 3)).unwrap(),
831            JpegInfo {
832                width: 640,
833                height: 480,
834                components: 3
835            }
836        );
837        assert_eq!(JpegInfo::parse(&jpeg(2, 3, 1)).unwrap().components, 1);
838    }
839
840    #[test]
841    fn jpeg_refusals() {
842        assert_eq!(
843            JpegInfo::parse(b"GIF89a....").unwrap_err(),
844            WriteError::NotJpeg("missing SOI marker")
845        );
846        assert!(matches!(
847            JpegInfo::parse(&jpeg(8, 8, 4)),
848            Err(WriteError::UnsupportedJpeg(_))
849        ));
850        let mut twelve = jpeg(8, 8, 3);
851        twelve[6] = 12;
852        assert!(matches!(
853            JpegInfo::parse(&twelve),
854            Err(WriteError::UnsupportedJpeg(_))
855        ));
856        let mut arithmetic = jpeg(8, 8, 3);
857        arithmetic[3] = 0xC9;
858        assert!(matches!(
859            JpegInfo::parse(&arithmetic),
860            Err(WriteError::UnsupportedJpeg(_))
861        ));
862        assert!(matches!(
863            JpegInfo::parse(&[0xFF, 0xD8, 0xFF, 0xD9]),
864            Err(WriteError::NotJpeg(_))
865        ));
866    }
867
868    #[test]
869    fn version_and_page_size_are_checked() {
870        assert!(Document::new("1.4").is_ok());
871        assert!(Document::new("2.0").is_err());
872        assert!(Document::new("1.8").is_err());
873        let mut doc = Document::new("1.4").unwrap();
874        assert!(doc.add_page(0.0, 10.0).is_err());
875        assert!(doc.add_page(f64::NAN, 10.0).is_err());
876        assert!(doc.add_page(10.0, 20_000.0).is_err());
877        assert_eq!(doc.page_count(), 0);
878    }
879
880    #[test]
881    fn icc_rules() {
882        let mut old = Document::new("1.2").unwrap();
883        assert!(matches!(
884            old.add_icc_profile(profile(), 3),
885            Err(WriteError::IccNeedsNewerVersion(_))
886        ));
887        let mut doc = Document::new("1.4").unwrap();
888        assert!(doc.add_icc_profile(vec![0; 10], 3).is_err());
889        assert!(doc.add_icc_profile(profile(), 4).is_err());
890        let rgb = doc.add_icc_profile(profile(), 3).unwrap();
891        let error = doc
892            .add_jpeg_page(100.0, 100.0, jpeg(4, 4, 1), Some(rgb))
893            .unwrap_err();
894        assert_eq!(
895            error,
896            WriteError::IccComponentMismatch {
897                profile: 3,
898                jpeg: 1
899            }
900        );
901        assert_eq!(doc.page_count(), 0, "a refused page is not added");
902    }
903
904    #[test]
905    fn one_image_per_page() {
906        let mut doc = Document::new("1.4").unwrap();
907        let page = doc.add_page(100.0, 100.0).unwrap();
908        doc.place_jpeg(page, jpeg(4, 4, 3), None).unwrap();
909        assert_eq!(
910            doc.place_jpeg(page, jpeg(4, 4, 3), None).unwrap_err(),
911            WriteError::PageHasImage(0)
912        );
913    }
914
915    #[test]
916    fn number_formatting() {
917        assert_eq!(num(595.0), "595");
918        assert_eq!(num(841.89), "841.89");
919        assert_eq!(num(0.1 + 0.2), "0.3");
920        assert_eq!(num(612.0004), "612");
921    }
922
923    #[test]
924    fn output_has_header_pages_icc_and_content_stream() {
925        let mut doc = Document::new("1.4").unwrap();
926        let srgb = doc.add_icc_profile(profile(), 3).unwrap();
927        doc.add_jpeg_page(595.0, 842.0, jpeg(10, 20, 3), Some(srgb))
928            .unwrap();
929        doc.add_jpeg_page(300.5, 200.0, jpeg(30, 20, 3), Some(srgb))
930            .unwrap();
931        doc.add_jpeg_page(100.0, 100.0, jpeg(5, 5, 1), None)
932            .unwrap();
933        let bytes = doc.finish();
934        assert!(bytes.starts_with(b"%PDF-1.4\n"));
935        assert!(bytes.ends_with(b"%%EOF\n"));
936        let text = String::from_utf8_lossy(&bytes);
937        assert!(text.contains("q 595 0 0 842 0 0 cm /Im0 Do Q"));
938        assert!(text.contains("q 300.5 0 0 200 0 0 cm /Im0 Do Q"));
939        assert!(text.contains("/Filter /DCTDecode"));
940        assert!(text.contains("[/ICCBased 3 0 R]"));
941        assert!(text.contains("/ColorSpace /DeviceGray"));
942        assert_eq!(text.matches("/N 3 /Alternate /DeviceRGB").count(), 1);
943
944        let loaded = lopdf::Document::load_mem(&bytes).expect("lopdf reads the writer's output");
945        assert_eq!(loaded.get_pages().len(), 3);
946        let first = *loaded.get_pages().get(&1).unwrap();
947        let page = loaded.get_dictionary(first).unwrap();
948        let media = page.get(b"MediaBox").unwrap().as_array().unwrap();
949        assert_eq!(media[2].as_i64().unwrap(), 595);
950        let content = loaded.get_page_content(first);
951        // The writer's stream is exactly `q ... Q\n`; lopdf's `get_page_content` may append a
952        // separator newline after each stream, so compare without trailing whitespace.
953        assert_eq!(content.trim_ascii_end(), b"q 595 0 0 842 0 0 cm /Im0 Do Q");
954        let contents = page.get(b"Contents").unwrap().as_reference().unwrap();
955        let raw = loaded.get_object(contents).unwrap().as_stream().unwrap();
956        assert_eq!(raw.content, b"q 595 0 0 842 0 0 cm /Im0 Do Q\n");
957        assert!(text.contains("/XObject << /Im0 "));
958    }
959
960    #[test]
961    fn output_is_deterministic_and_empty_document_loads() {
962        let build = || {
963            let mut doc = Document::new("1.4").unwrap();
964            let srgb = doc.add_icc_profile(profile(), 3).unwrap();
965            doc.add_jpeg_page(595.0, 842.0, jpeg(10, 20, 3), Some(srgb))
966                .unwrap();
967            doc.finish()
968        };
969        assert_eq!(build(), build());
970        let empty = Document::new("1.4").unwrap().finish();
971        let loaded = lopdf::Document::load_mem(&empty).expect("empty page tree loads");
972        assert_eq!(loaded.get_pages().len(), 0);
973    }
974
975    #[cfg(feature = "edit")]
976    #[test]
977    fn write_to_goes_through_the_atomic_writer() {
978        let dir = std::env::temp_dir().join(format!("rightkit-pdf-write-{}", std::process::id()));
979        std::fs::create_dir_all(&dir).unwrap();
980        let path = dir.join("out.pdf");
981        let mut doc = Document::new("1.4").unwrap();
982        doc.add_jpeg_page(100.0, 100.0, jpeg(4, 4, 3), None)
983            .unwrap();
984        doc.write_to(&path).unwrap();
985        assert_eq!(std::fs::read(&path).unwrap(), doc.finish());
986        let _ = std::fs::remove_dir_all(&dir);
987    }
988
989    #[cfg(feature = "fonts")]
990    mod text {
991        use super::*;
992        use crate::test_fixtures::build_test_ttf;
993
994        const INSTALLABLE: u16 = 0;
995        const RESTRICTED: u16 = 0x0002;
996
997        fn font() -> Vec<u8> {
998            build_test_ttf(INSTALLABLE, &[('A', 1), ('B', 2), ('C', 3)], 600)
999        }
1000
1001        #[test]
1002        fn font_permissions_and_parse_are_checked() {
1003            let mut doc = Document::new("1.4").unwrap();
1004            assert!(matches!(
1005                doc.add_font(vec![1, 2, 3], 0),
1006                Err(WriteError::FontUnusable(_))
1007            ));
1008            assert_eq!(
1009                doc.add_font(build_test_ttf(RESTRICTED, &[('A', 1)], 600), 0)
1010                    .unwrap_err(),
1011                WriteError::FontEmbeddingDenied
1012            );
1013            assert!(doc.add_font(font(), 0).is_ok());
1014        }
1015
1016        #[test]
1017        fn missing_glyph_and_bad_params_are_refused() {
1018            let mut doc = Document::new("1.4").unwrap();
1019            let f = doc.add_font(font(), 0).unwrap();
1020            let page = doc.add_page(200.0, 200.0).unwrap();
1021            assert_eq!(
1022                doc.draw_text(page, f, 12.0, 10.0, 10.0, None, "AZ")
1023                    .unwrap_err(),
1024                WriteError::MissingGlyph('Z')
1025            );
1026            assert_eq!(
1027                doc.draw_text(page, f, 0.0, 10.0, 10.0, None, "A")
1028                    .unwrap_err(),
1029                WriteError::InvalidTextParams
1030            );
1031            assert_eq!(
1032                doc.draw_text(page, f, 12.0, f64::NAN, 10.0, None, "A")
1033                    .unwrap_err(),
1034                WriteError::InvalidTextParams
1035            );
1036            assert_eq!(
1037                doc.draw_text(page, f, 12.0, 1.0, 1.0, Some([2.0, 0.0, 0.0]), "A")
1038                    .unwrap_err(),
1039                WriteError::InvalidTextParams
1040            );
1041            // Nothing was drawn, so no font object is written.
1042            let text = String::from_utf8_lossy(&doc.finish()).into_owned();
1043            assert!(!text.contains("CIDFontType2"));
1044        }
1045
1046        #[test]
1047        fn measure_sums_advances() {
1048            let mut doc = Document::new("1.4").unwrap();
1049            let f = doc.add_font(font(), 0).unwrap();
1050            // 600 units per glyph at 1000 upem: 2 glyphs at 10 pt = 12 pt.
1051            assert!((doc.measure_text(f, 10.0, "AB").unwrap() - 12.0).abs() < 1e-9);
1052            assert_eq!(
1053                doc.measure_text(f, 10.0, "Q").unwrap_err(),
1054                WriteError::MissingGlyph('Q')
1055            );
1056        }
1057
1058        #[test]
1059        fn text_is_embedded_as_identity_h_cid_font() {
1060            let mut doc = Document::new("1.4").unwrap();
1061            let f = doc.add_font(font(), 0).unwrap();
1062            let p1 = doc.add_page(200.0, 200.0).unwrap();
1063            let p2 = doc.add_page(200.0, 200.0).unwrap();
1064            doc.draw_text(p1, f, 12.0, 10.0, 20.5, Some([1.0, 0.0, 0.0]), "ABA")
1065                .unwrap();
1066            doc.draw_text(p2, f, 8.0, 5.0, 5.0, None, "C").unwrap();
1067            let bytes = doc.finish();
1068            let text = String::from_utf8_lossy(&bytes).into_owned();
1069            // First-appearance CIDs over "ABA" + "C": A=1, B=2, C=3.
1070            assert!(text.contains("q 1 0 0 rg BT /F0 12 Tf 10 20.5 Td <000100020001> Tj ET Q"));
1071            assert!(text.contains("q BT /F0 8 Tf 5 5 Td <0003> Tj ET Q"));
1072            assert!(text.contains("/Subtype /Type0"));
1073            assert!(text.contains("/Encoding /Identity-H"));
1074            assert!(text.contains("/Subtype /CIDFontType2"));
1075            assert!(text.contains("/CIDToGIDMap /Identity"));
1076            assert!(text.contains("/FontFile2 "));
1077            assert!(text.contains("/ToUnicode "));
1078            assert!(text.contains("/W [1 [600] 2 [600] 3 [600]]"));
1079            assert!(text.contains("beginbfchar"));
1080            assert!(text.contains("/Text]"));
1081            assert_eq!(
1082                text.matches("/Subtype /Type0").count(),
1083                1,
1084                "one font, shared"
1085            );
1086
1087            let loaded = lopdf::Document::load_mem(&bytes).expect("lopdf reads text output");
1088            assert_eq!(loaded.get_pages().len(), 2);
1089            let first = *loaded.get_pages().get(&1).unwrap();
1090            let content = loaded.get_page_content(first);
1091            assert!(String::from_utf8_lossy(&content).contains("<000100020001> Tj"));
1092        }
1093
1094        #[test]
1095        fn text_and_image_together_and_deterministic() {
1096            let build = || {
1097                let mut doc = Document::new("1.4").unwrap();
1098                let f = doc.add_font(font(), 0).unwrap();
1099                let page = doc
1100                    .add_jpeg_page(100.0, 100.0, jpeg(4, 4, 3), None)
1101                    .unwrap();
1102                doc.draw_text(page, f, 10.0, 1.0, 2.0, None, "AB").unwrap();
1103                doc.finish()
1104            };
1105            let bytes = build();
1106            assert_eq!(bytes, build());
1107            let text = String::from_utf8_lossy(&bytes).into_owned();
1108            let image = text.find("/Im0 Do").unwrap();
1109            let run = text.find("Tj ET").unwrap();
1110            assert!(image < run, "text is painted after the image");
1111            assert!(text.contains("/ProcSet [/PDF /ImageC /Text]"));
1112        }
1113    }
1114}