1use sha2::{Digest, Sha256};
60use thiserror::Error;
61
62pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub struct IccId(usize);
98
99#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub struct FontId(usize);
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct JpegInfo {
116 pub width: u32,
117 pub height: u32,
118 pub components: u8,
120}
121
122impl JpegInfo {
123 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 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
229pub 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 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 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 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 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 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 pub fn finish(&self) -> Vec<u8> {
358 self.try_finish()
359 .unwrap_or_else(|error| panic!("PDF writer: {error}"))
360 }
361
362 pub fn try_finish(&self) -> Result<Vec<u8>, WriteError> {
364 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); 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; 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 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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#[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 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
786fn 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 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 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 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 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 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}