1#![doc(
21 html_logo_url = "https://mosaiclang.dev/assets/A4.svg",
22 html_favicon_url = "https://mosaiclang.dev/assets/A4.svg"
23)]
24
25#[doc(hidden)]
26pub mod content;
27#[doc(hidden)]
28pub mod embedded;
29#[doc(hidden)]
30pub mod encoding;
31#[doc(hidden)]
32pub mod images;
33
34use std::collections::HashMap;
35use std::path::Path;
36
37use mos_core::{CoreError, Diagnostic, Result, codes};
38use mos_fonts::EmbeddedFontId;
39use mos_layout::{Base14Font, Font, PageGraph, TextRun};
40use pdf_writer::types::{SystemInfo, UnicodeCmap};
41use pdf_writer::writers::Encoding;
42use pdf_writer::{Finish, Name, Pdf, Rect, Ref, Str, TextStr};
43
44use crate::embedded::{EmbeddedFontPlan, EmbeddedRefs};
45use crate::encoding::{DocEncoding, EncodingPlanner};
46
47const PRODUCER: &str = concat!("Mosaic ", env!("CARGO_PKG_VERSION"));
60
61#[derive(Debug, Clone, Default)]
80pub struct PdfMetadata {
81 pub title: Option<String>,
82 pub author: Option<String>,
83 pub language: Option<String>,
84}
85
86pub fn emit(graph: &PageGraph, metadata: &PdfMetadata, out: &Path) -> Result<Vec<Diagnostic>> {
115 let (bytes, diagnostics) = build_pdf(graph, metadata)?;
116 if let Some(parent) = out.parent()
117 && !parent.as_os_str().is_empty()
118 {
119 std::fs::create_dir_all(parent).map_err(|err| {
120 io_diagnostic(format!(
121 "could not create output directory `{}`: {err}",
122 mos_core::display_path(parent)
123 ))
124 })?;
125 }
126 std::fs::write(out, bytes).map_err(|err| {
127 io_diagnostic(format!(
128 "could not write PDF to `{}`: {err}",
129 mos_core::display_path(out)
130 ))
131 })?;
132 Ok(diagnostics)
133}
134
135fn io_diagnostic(message: String) -> CoreError {
136 CoreError::Diagnostic(Box::new(Diagnostic::simple(&codes::MOS0014, None, message)))
137}
138
139pub(crate) fn build_pdf(
151 graph: &PageGraph,
152 metadata: &PdfMetadata,
153) -> Result<(Vec<u8>, Vec<Diagnostic>)> {
154 let mut diagnostics: Vec<Diagnostic> = Vec::new();
158 let encodings = plan_base14_encodings(graph, &mut diagnostics);
159
160 let embedded_runs: Vec<TextRun> = graph
167 .pages
168 .iter()
169 .flat_map(|p| p.runs.iter())
170 .filter(|r| matches!(r.font, Font::Embedded(_)))
171 .cloned()
172 .collect();
173 let embedded_plans: Vec<EmbeddedFontPlan> = embedded::plan_embedded(&embedded_runs)?;
174 let embedded_by_id: HashMap<EmbeddedFontId, &EmbeddedFontPlan> =
175 embedded_plans.iter().map(|p| (p.id, p)).collect();
176
177 let mut pdf = Pdf::new();
181 let mut next_id: i32 = 1;
182 let mut alloc = || {
183 let id = Ref::new(next_id);
184 next_id += 1;
185 id
186 };
187
188 let catalog_id = alloc();
189 let page_tree_id = alloc();
190 let info_id = alloc();
191
192 let base14_refs: Vec<(Font, Ref)> = Font::ALL_BASE14.iter().map(|f| (*f, alloc())).collect();
197
198 let mut encoding_refs: HashMap<Font, (Ref, Ref)> = HashMap::new();
205 for font in Font::ALL_BASE14 {
206 if let Some(enc) = encodings.get(&font)
207 && enc.has_differences()
208 {
209 let enc_ref = alloc();
210 let cmap_ref = alloc();
211 encoding_refs.insert(font, (enc_ref, cmap_ref));
212 }
213 }
214
215 let embedded_refs: HashMap<EmbeddedFontId, EmbeddedRefs> = embedded_plans
217 .iter()
218 .map(|plan| {
219 (
220 plan.id,
221 EmbeddedRefs {
222 font: alloc(),
223 cid_font: alloc(),
224 descriptor: alloc(),
225 font_file: alloc(),
226 to_unicode: alloc(),
227 },
228 )
229 })
230 .collect();
231
232 let image_refs: Vec<Ref> = graph.images.iter().map(|_| alloc()).collect();
238
239 let page_refs: Vec<(Ref, Ref)> = graph.pages.iter().map(|_| (alloc(), alloc())).collect();
240
241 let outline_refs: Option<(Ref, Vec<Ref>)> = (!graph.outline.is_empty()).then(|| {
247 let root = alloc();
248 let items: Vec<Ref> = graph.outline.iter().map(|_| alloc()).collect();
249 (root, items)
250 });
251
252 {
253 let mut catalog = pdf.catalog(catalog_id);
254 catalog.pages(page_tree_id);
255 if let Some((root, _)) = &outline_refs {
256 catalog.outlines(*root);
257 }
258 }
259
260 let page_count = i32::try_from(page_refs.len()).unwrap_or(i32::MAX);
261 pdf.pages(page_tree_id)
262 .kids(page_refs.iter().map(|(p, _)| *p))
263 .count(page_count);
264
265 emit_pages(
266 &mut pdf,
267 graph,
268 &PageEmitContext {
269 page_tree_id,
270 page_refs: &page_refs,
271 base14_refs: &base14_refs,
272 embedded_refs: &embedded_refs,
273 image_refs: &image_refs,
274 encodings: &encodings,
275 embedded_by_id: &embedded_by_id,
276 },
277 )?;
278
279 emit_images(&mut pdf, graph, &image_refs);
286
287 emit_base14_fonts(&mut pdf, &base14_refs, &encoding_refs);
288
289 for plan in &embedded_plans {
292 let refs = embedded_refs[&plan.id];
293 embedded::emit_embedded(&mut pdf, plan, refs);
294 }
295
296 for font in Font::ALL_BASE14 {
300 let Some(enc) = encodings.get(&font) else {
301 continue;
302 };
303 let Some(&(enc_ref, cmap_ref)) = encoding_refs.get(&font) else {
304 continue;
305 };
306 emit_encoding_dict(&mut pdf, enc_ref, enc);
307 emit_to_unicode_cmap(&mut pdf, cmap_ref, enc);
308 }
309
310 {
311 let mut info = pdf.document_info(info_id);
312 if let Some(title) = metadata.title.as_deref() {
313 info.title(TextStr(title));
314 }
315 if let Some(author) = metadata.author.as_deref() {
316 info.author(TextStr(author));
317 }
318 info.producer(TextStr(PRODUCER));
322 info.creator(TextStr(PRODUCER));
323 info.finish();
324 }
325
326 if let Some((root, items)) = &outline_refs {
327 emit_outline(&mut pdf, graph, *root, items, &page_refs);
328 }
329
330 Ok((pdf.finish(), diagnostics))
331}
332
333fn emit_outline(
343 pdf: &mut Pdf,
344 graph: &PageGraph,
345 root: Ref,
346 items: &[Ref],
347 page_refs: &[(Ref, Ref)],
348) {
349 let entries = &graph.outline;
350 let n = entries.len();
351
352 let mut parent: Vec<Option<usize>> = vec![None; n];
359 let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
360 let mut roots: Vec<usize> = Vec::new();
361 let mut stack: Vec<usize> = Vec::new();
362 for (i, entry) in entries.iter().enumerate() {
363 while stack
364 .last()
365 .is_some_and(|&top| entries[top].level >= entry.level)
366 {
367 stack.pop();
368 }
369 match stack.last() {
370 Some(&p) => {
371 parent[i] = Some(p);
372 children[p].push(i);
373 }
374 None => roots.push(i),
375 }
376 stack.push(i);
377 }
378
379 let mut descendants: Vec<usize> = vec![0; n];
384 for i in (0..n).rev() {
385 if let Some(p) = parent[i] {
386 descendants[p] += descendants[i] + 1;
387 }
388 }
389
390 {
393 let mut outline = pdf.outline(root);
394 if let (Some(&first), Some(&last)) = (roots.first(), roots.last()) {
395 outline.first(items[first]);
396 outline.last(items[last]);
397 }
398 outline.count(i32::try_from(n).unwrap_or(i32::MAX));
399 }
400
401 for (i, entry) in entries.iter().enumerate() {
402 let mut item = pdf.outline_item(items[i]);
403 item.title(TextStr(&entry.title));
404 item.parent(match parent[i] {
405 Some(p) => items[p],
406 None => root,
407 });
408
409 let siblings: &[usize] = match parent[i] {
412 Some(p) => &children[p],
413 None => &roots,
414 };
415 if let Some(pos) = siblings.iter().position(|&s| s == i) {
416 if pos > 0 {
417 item.prev(items[siblings[pos - 1]]);
418 }
419 if pos + 1 < siblings.len() {
420 item.next(items[siblings[pos + 1]]);
421 }
422 }
423
424 if let (Some(&first), Some(&last)) = (children[i].first(), children[i].last()) {
426 item.first(items[first]);
427 item.last(items[last]);
428 item.count(i32::try_from(descendants[i]).unwrap_or(i32::MAX));
429 }
430
431 if let Some((page_ref, _)) = page_refs.get(entry.page_index)
436 && let Some(page) = graph.pages.get(entry.page_index)
437 {
438 let top = page.height_pt - entry.top_from_top_pt;
439 item.dest().page(*page_ref).xyz(0.0, top, None);
440 }
441 }
442}
443
444struct PageEmitContext<'a> {
445 page_tree_id: Ref,
446 page_refs: &'a [(Ref, Ref)],
447 base14_refs: &'a [(Font, Ref)],
448 embedded_refs: &'a HashMap<EmbeddedFontId, EmbeddedRefs>,
449 image_refs: &'a [Ref],
450 encodings: &'a HashMap<Font, DocEncoding>,
451 embedded_by_id: &'a HashMap<EmbeddedFontId, &'a EmbeddedFontPlan>,
452}
453
454fn plan_base14_encodings(
455 graph: &PageGraph,
456 diagnostics: &mut Vec<Diagnostic>,
457) -> HashMap<Font, DocEncoding> {
458 let mut planner = EncodingPlanner::new();
459 for page in &graph.pages {
460 planner.observe_runs(&page.runs);
461 }
462 planner.finalize(diagnostics)
463}
464
465fn emit_pages(pdf: &mut Pdf, graph: &PageGraph, ctx: &PageEmitContext<'_>) -> Result<()> {
466 for (page, (page_id, content_id)) in graph.pages.iter().zip(ctx.page_refs.iter()) {
467 let mut page_obj = pdf.page(*page_id);
468 page_obj.media_box(Rect::new(0.0, 0.0, page.width_pt, page.height_pt));
469 page_obj.parent(ctx.page_tree_id);
470 page_obj.contents(*content_id);
471 {
472 let mut resources = page_obj.resources();
473 {
474 let mut fonts = resources.fonts();
475 for (face, font_id) in ctx.base14_refs {
476 fonts.pair(Name(face.pdf_resource_name()), *font_id);
477 }
478 for id in EmbeddedFontId::ALL {
479 if let Some(refs) = ctx.embedded_refs.get(&id) {
480 fonts.pair(Name(id.pdf_resource_name()), refs.font);
481 }
482 }
483 }
484 if !graph.images.is_empty() {
485 let mut x_objects = resources.x_objects();
486 for (handle, image_id) in graph.images.iter().zip(ctx.image_refs.iter()) {
487 let name = images::resource_name(handle);
488 x_objects.pair(Name(name.as_bytes()), *image_id);
489 }
490 }
491 }
492 page_obj.finish();
493
494 let stream_bytes =
495 content::build_content_stream(page.height_pt, page, ctx.encodings, ctx.embedded_by_id)?;
496 pdf.stream(*content_id, &stream_bytes);
497 }
498 Ok(())
499}
500
501fn emit_base14_fonts(
502 pdf: &mut Pdf,
503 base14_refs: &[(Font, Ref)],
504 encoding_refs: &HashMap<Font, (Ref, Ref)>,
505) {
506 for (face, font_id) in base14_refs {
507 let Some(base14) = face.base14() else {
508 continue;
509 };
510 let mut font_dict = pdf.type1_font(*font_id);
511 font_dict.base_font(Name(face.pdf_base_name().as_bytes()));
512 if matches!(base14, Base14Font::Symbol | Base14Font::ZapfDingbats) {
513 continue;
514 }
515 match encoding_refs.get(face) {
516 Some(&(enc_ref, cmap_ref)) => {
517 font_dict.pair(Name(b"Encoding"), enc_ref);
518 font_dict.to_unicode(cmap_ref);
519 }
520 None => {
521 font_dict.encoding_predefined(Name(b"WinAnsiEncoding"));
522 }
523 }
524 }
525}
526
527fn emit_images(pdf: &mut Pdf, graph: &PageGraph, image_refs: &[Ref]) {
528 for (handle, id) in graph.images.iter().zip(image_refs.iter()) {
529 let compressed = images::flate_compress(&handle.rgb8);
530 images::emit_image_xobject(pdf, *id, handle, &compressed);
531 }
532}
533
534fn emit_encoding_dict(pdf: &mut Pdf, id: Ref, enc: &DocEncoding) {
541 let mut enc_dict: Encoding<'_> = pdf.indirect(id).start();
542 enc_dict.base_encoding(Name(b"WinAnsiEncoding"));
543 {
544 let mut diffs = enc_dict.differences();
545 let mut i = 0;
546 while i < enc.differences.len() {
547 let (start, _) = enc.differences[i];
548 let mut j = i + 1;
550 while j < enc.differences.len() && enc.differences[j].0 == enc.differences[j - 1].0 + 1
551 {
552 j += 1;
553 }
554 let names = enc.differences[i..j]
555 .iter()
556 .map(|(_, n)| Name(n.as_bytes()));
557 diffs.consecutive(start, names);
558 i = j;
559 }
560 }
561 enc_dict.finish();
562}
563
564fn emit_to_unicode_cmap(pdf: &mut Pdf, id: Ref, enc: &DocEncoding) {
569 let system_info = SystemInfo {
578 registry: Str(b"Adobe"),
579 ordering: Str(b"UCS"),
580 supplement: 0,
581 };
582 let mut cmap: UnicodeCmap<u8> = UnicodeCmap::new(Name(b"Adobe-Identity-UCS"), system_info);
583 for &(byte, ch) in &enc.to_unicode_entries {
584 cmap.pair(byte, ch);
585 }
586 let cmap_bytes = cmap.finish();
587 let mut cmap_writer = pdf.cmap(id, &cmap_bytes);
588 cmap_writer.name(Name(b"Adobe-Identity-UCS"));
589 cmap_writer.system_info(system_info);
590}
591
592#[cfg(test)]
593mod tests {
594 use std::error::Error;
601
602 use lopdf::{Document as LopdfDocument, Object};
603 use mos_layout::{Base14Font, Font, OutlineEntry, Page, PageGraph, TextRun};
604
605 use super::*;
606
607 type TestResult = std::result::Result<(), Box<dyn Error>>;
611
612 macro_rules! ensure {
618 ($cond:expr, $($arg:tt)*) => {
619 if !$cond {
620 return Err(format!($($arg)*).into());
621 }
622 };
623 }
624
625 fn count_bytes(haystack: &[u8], needle: &[u8]) -> usize {
626 haystack
627 .windows(needle.len())
628 .filter(|w| *w == needle)
629 .count()
630 }
631
632 fn sample_graph() -> PageGraph {
633 PageGraph {
634 pages: vec![Page {
635 number: 1,
636 width_pt: 595.276_f32,
637 height_pt: 841.89_f32,
638 runs: vec![
639 TextRun {
640 x_pt: 68.0,
641 baseline_from_top_pt: 100.0,
642 size_pt: 20.0,
643 font: Font::Base14(Base14Font::HelveticaBold),
644 text: "Title".to_owned(),
645 actual_text: None,
646 glyphs: Vec::new(),
647 },
648 TextRun {
649 x_pt: 68.0,
650 baseline_from_top_pt: 130.0,
651 size_pt: 11.0,
652 font: Font::Base14(Base14Font::Helvetica),
653 text: "Body".to_owned(),
654 actual_text: None,
655 glyphs: Vec::new(),
656 },
657 ],
658 images: Vec::new(),
659 }],
660 images: Vec::new(),
661 outline: Vec::new(),
662 }
663 }
664
665 fn info_string<'info>(
666 info: &'info lopdf::Dictionary,
667 key: &[u8],
668 ) -> std::result::Result<&'info str, Box<dyn Error>> {
669 let Object::String(bytes, _) = info.get(key)? else {
670 return Err(format!(
671 "expected Info key /{} to be a string",
672 String::from_utf8_lossy(key)
673 )
674 .into());
675 };
676 Ok(std::str::from_utf8(bytes)?)
677 }
678
679 #[test]
680 fn build_pdf_starts_with_pdf_header_and_ends_with_eof() {
681 let (bytes, diags) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
682 assert!(bytes.starts_with(b"%PDF-"), "missing PDF header");
683 assert!(
684 bytes.windows(5).any(|w| w == b"%%EOF"),
685 "missing %%EOF marker"
686 );
687 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
688 }
689
690 #[test]
691 fn build_pdf_embeds_text_runs_as_visible_strings() {
692 let (bytes, _) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
693 assert!(
696 bytes.windows(b"(Title)".len()).any(|w| w == b"(Title)"),
697 "Title not found in stream"
698 );
699 assert!(
700 bytes.windows(b"(Body)".len()).any(|w| w == b"(Body)"),
701 "Body not found in stream"
702 );
703 }
704
705 #[test]
706 fn empty_graph_still_produces_valid_pdf() {
707 let (bytes, _) = build_pdf(&PageGraph::default(), &PdfMetadata::default()).unwrap();
708 assert!(bytes.starts_with(b"%PDF-"));
709 }
710
711 #[test]
712 fn metadata_and_provenance_appear_in_info_dictionary() -> TestResult {
713 let metadata = PdfMetadata {
714 title: Some("My Doc".to_owned()),
715 author: Some("A. Person".to_owned()),
716 language: None,
717 };
718 let (bytes, _) = build_pdf(&sample_graph(), &metadata).unwrap();
719 let doc = LopdfDocument::load_mem(&bytes)?;
720 let Object::Reference(info_id) = doc.trailer.get(b"Info")? else {
721 return Err("expected trailer /Info reference".into());
722 };
723 let info = doc.get_dictionary(*info_id)?;
724
725 ensure!(info_string(info, b"Title")? == "My Doc", "wrong /Title");
726 ensure!(
727 info_string(info, b"Author")? == "A. Person",
728 "wrong /Author"
729 );
730 ensure!(
731 info_string(info, b"Producer")? == PRODUCER,
732 "wrong /Producer"
733 );
734 ensure!(info_string(info, b"Creator")? == PRODUCER, "wrong /Creator");
735 Ok(())
736 }
737
738 #[test]
739 fn actual_text_is_emitted_for_replacement_runs() {
740 let graph = PageGraph {
741 pages: vec![Page {
742 number: 1,
743 width_pt: 595.276_f32,
744 height_pt: 841.89_f32,
745 runs: vec![TextRun {
746 x_pt: 68.0,
747 baseline_from_top_pt: 100.0,
748 size_pt: 12.0,
749 font: Font::Base14(Base14Font::Courier),
750 text: " println".to_owned(),
751 actual_text: Some("\tprintln".to_owned()),
752 glyphs: Vec::new(),
753 }],
754 images: Vec::new(),
755 }],
756 images: Vec::new(),
757 outline: Vec::new(),
758 };
759
760 let (bytes, diags) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
761
762 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
763 assert!(
764 bytes
765 .windows(b"/ActualText".len())
766 .any(|w| w == b"/ActualText"),
767 "missing /ActualText"
768 );
769 assert!(
770 bytes.windows(b"println".len()).any(|w| w == b"println"),
771 "actual text payload missing"
772 );
773 }
774
775 #[test]
776 fn actual_text_wraps_adjacent_fragments_once() {
777 let graph = PageGraph {
778 pages: vec![Page {
779 number: 1,
780 width_pt: 595.276_f32,
781 height_pt: 841.89_f32,
782 runs: vec![
783 TextRun {
784 x_pt: 68.0,
785 baseline_from_top_pt: 100.0,
786 size_pt: 12.0,
787 font: Font::Base14(Base14Font::Courier),
788 text: " ".to_owned(),
789 actual_text: Some("\tprintln".to_owned()),
790 glyphs: Vec::new(),
791 },
792 TextRun {
793 x_pt: 92.0,
794 baseline_from_top_pt: 100.0,
795 size_pt: 12.0,
796 font: Font::Base14(Base14Font::CourierBold),
797 text: "println".to_owned(),
798 actual_text: Some("\tprintln".to_owned()),
799 glyphs: Vec::new(),
800 },
801 ],
802 images: Vec::new(),
803 }],
804 images: Vec::new(),
805 outline: Vec::new(),
806 };
807
808 let (bytes, diags) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
809
810 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
811 assert_eq!(count_bytes(&bytes, b"/ActualText"), 1);
812 assert_eq!(count_bytes(&bytes, b"println"), 1);
813 }
814
815 fn extended_latin_graph() -> PageGraph {
818 PageGraph {
819 pages: vec![Page {
820 number: 1,
821 width_pt: 595.276_f32,
822 height_pt: 841.89_f32,
823 runs: vec![TextRun {
824 x_pt: 68.0,
825 baseline_from_top_pt: 100.0,
826 size_pt: 12.0,
827 font: Font::Base14(Base14Font::Helvetica),
828 text: "Łódź Příliš ě".to_owned(),
829 actual_text: None,
830 glyphs: Vec::new(),
831 }],
832 images: Vec::new(),
833 }],
834 images: Vec::new(),
835 outline: Vec::new(),
836 }
837 }
838
839 #[test]
840 fn extended_latin_emits_differences_and_to_unicode() {
841 let (bytes, diags) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
842 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
843 assert!(
845 bytes
846 .windows(b"/BaseEncoding /WinAnsiEncoding".len())
847 .any(|w| w == b"/BaseEncoding /WinAnsiEncoding"),
848 "missing /BaseEncoding"
849 );
850 for name in [b"/Lslash" as &[u8], b"/rcaron", b"/ecaron", b"/zacute"] {
855 assert!(
856 bytes.windows(name.len()).any(|w| w == name),
857 "missing {:?} in /Differences",
858 std::str::from_utf8(name).unwrap_or("?")
859 );
860 }
861 assert!(
863 bytes
864 .windows(b"/ToUnicode".len())
865 .any(|w| w == b"/ToUnicode"),
866 "missing /ToUnicode reference"
867 );
868 assert!(
869 bytes
870 .windows(b"beginbfchar".len())
871 .any(|w| w == b"beginbfchar"),
872 "missing beginbfchar in CMap"
873 );
874 }
875
876 #[test]
877 fn pure_ascii_graph_keeps_predefined_winansi_shortcut() {
878 let (bytes, _) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
883 assert!(
884 bytes
885 .windows(b"/Encoding /WinAnsiEncoding".len())
886 .any(|w| w == b"/Encoding /WinAnsiEncoding"),
887 "expected predefined WinAnsi shortcut on ASCII-only doc"
888 );
889 assert!(
890 !bytes
891 .windows(b"/BaseEncoding".len())
892 .any(|w| w == b"/BaseEncoding"),
893 "no custom /Encoding dict expected for ASCII-only doc"
894 );
895 }
896
897 #[test]
898 fn extended_latin_content_stream_uses_remapped_bytes() {
899 let (bytes, _) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
913 let content_stream = first_content_stream(&bytes).expect("content stream not found");
914 let needle = b"7F";
915 assert!(
916 content_stream.windows(needle.len()).any(|w| w == needle),
917 "content stream should reference remapped slot 0x7F"
918 );
919 let qmark_count = content_stream.split(|&b| b == b'?').count() - 1;
920 assert!(
921 qmark_count < 5,
922 "too many `?` in PDF ({qmark_count}); did Ł/ř/ě/ź get substituted?"
923 );
924 }
925
926 #[test]
927 fn build_pdf_is_byte_for_byte_deterministic() {
928 let (a, _) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
933 let (b, _) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
934 assert_eq!(
935 a,
936 b,
937 "build_pdf is non-deterministic: byte lengths {} vs {}",
938 a.len(),
939 b.len()
940 );
941 }
942
943 fn first_content_stream(bytes: &[u8]) -> Option<&[u8]> {
951 let open = b"\nstream\n";
952 let close = b"\nendstream";
953 let open_at = bytes.windows(open.len()).position(|w| w == open)?;
954 let body = &bytes[open_at + open.len()..];
955 let close_at = body.windows(close.len()).position(|w| w == close)?;
956 Some(&body[..close_at])
957 }
958
959 fn unique_temp_path(label: &str) -> std::path::PathBuf {
960 std::env::temp_dir().join(format!(
961 "mos-pdf-test-{label}-{}",
962 std::time::SystemTime::now()
963 .duration_since(std::time::UNIX_EPOCH)
964 .map_or(0, |d| d.as_nanos())
965 ))
966 }
967
968 #[test]
969 fn emit_writes_file() -> TestResult {
970 let dir = unique_temp_path("write");
971 let out = dir.join("out.pdf");
972 let diags = emit(&sample_graph(), &PdfMetadata::default(), &out)
973 .map_err(|e| format!("emit: {e:?}"))?;
974 ensure!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
975 let bytes = std::fs::read(&out)?;
976 ensure!(bytes.starts_with(b"%PDF-"), "missing PDF header");
977 std::fs::remove_dir_all(&dir).ok();
978 Ok(())
979 }
980
981 fn image_graph() -> PageGraph {
985 use mos_layout::{ImageHandle, ImagePlacement};
986 use std::sync::Arc;
987 let mut rgb8 = Vec::with_capacity(4 * 2 * 3);
989 for y in 0..2 {
990 for x in 0..4 {
991 if (x + y) % 2 == 0 {
992 rgb8.extend_from_slice(&[255, 0, 0]);
993 } else {
994 rgb8.extend_from_slice(&[0, 0, 255]);
995 }
996 }
997 }
998 let handle = ImageHandle {
999 id: 0,
1000 resolved_path: "/tmp/checker.png".to_owned(),
1001 pixel_width: 4,
1002 pixel_height: 2,
1003 rgb8: Arc::from(rgb8),
1004 };
1005 PageGraph {
1006 pages: vec![Page {
1007 number: 1,
1008 width_pt: 595.276_f32,
1009 height_pt: 841.89_f32,
1010 runs: Vec::new(),
1011 images: vec![ImagePlacement {
1012 handle: handle.clone(),
1013 x_pt: 68.0,
1014 top_from_top_pt: 100.0,
1015 width_pt: 40.0,
1016 height_pt: 20.0,
1017 }],
1018 }],
1019 images: vec![handle],
1020 outline: Vec::new(),
1021 }
1022 }
1023
1024 #[test]
1025 fn image_xobject_carries_width_height_and_devicergb() {
1026 let (bytes, diags) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1027 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
1028 for needle in [
1032 b"/Subtype /Image" as &[u8],
1033 b"/Width 4",
1034 b"/Height 2",
1035 b"/ColorSpace /DeviceRGB",
1036 b"/BitsPerComponent 8",
1037 b"/Filter /FlateDecode",
1038 ] {
1039 assert!(
1040 bytes.windows(needle.len()).any(|w| w == needle),
1041 "missing {:?} in PDF",
1042 std::str::from_utf8(needle).unwrap_or("?")
1043 );
1044 }
1045 }
1046
1047 #[test]
1048 fn image_placement_emits_do_operator_referencing_xobject() {
1049 let (bytes, _) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1050 assert!(
1053 bytes.windows(b"/Im0 ".len()).any(|w| w == b"/Im0 "),
1054 "/Im0 resource name not found"
1055 );
1056 assert!(
1057 bytes.windows(b"/Im0 Do".len()).any(|w| w == b"/Im0 Do"),
1058 "/Im0 Do operator not found in content stream"
1059 );
1060 }
1061
1062 #[test]
1063 fn duplicate_image_emits_one_xobject() {
1064 use mos_layout::{ImageHandle, ImagePlacement};
1068 use std::sync::Arc;
1069 let handle = ImageHandle {
1070 id: 0,
1071 resolved_path: "/tmp/shared.png".to_owned(),
1072 pixel_width: 1,
1073 pixel_height: 1,
1074 rgb8: Arc::from(vec![10_u8, 20, 30]),
1075 };
1076 let graph = PageGraph {
1077 pages: vec![Page {
1078 number: 1,
1079 width_pt: 595.276_f32,
1080 height_pt: 841.89_f32,
1081 runs: Vec::new(),
1082 images: vec![
1083 ImagePlacement {
1084 handle: handle.clone(),
1085 x_pt: 10.0,
1086 top_from_top_pt: 50.0,
1087 width_pt: 5.0,
1088 height_pt: 5.0,
1089 },
1090 ImagePlacement {
1091 handle: handle.clone(),
1092 x_pt: 100.0,
1093 top_from_top_pt: 50.0,
1094 width_pt: 5.0,
1095 height_pt: 5.0,
1096 },
1097 ],
1098 }],
1099 images: vec![handle],
1100 outline: Vec::new(),
1101 };
1102 let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1103 let xobject_marker = b"/Subtype /Image";
1104 let count = bytes
1105 .windows(xobject_marker.len())
1106 .filter(|w| *w == xobject_marker)
1107 .count();
1108 assert_eq!(count, 1, "expected exactly one Image XObject, got {count}");
1109 let do_count = bytes
1111 .windows(b"/Im0 Do".len())
1112 .filter(|w| *w == b"/Im0 Do")
1113 .count();
1114 assert_eq!(
1115 do_count, 2,
1116 "expected two /Im0 Do operators, got {do_count}"
1117 );
1118 }
1119
1120 #[test]
1121 fn image_only_pdf_remains_byte_deterministic() {
1122 let (a, _) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1123 let (b, _) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1124 assert_eq!(a, b, "image emit must be byte-stable across runs");
1125 }
1126
1127 #[test]
1128 fn emit_fails_with_mos0014_when_target_is_a_directory() -> TestResult {
1129 let dir = unique_temp_path("conflict");
1133 std::fs::create_dir_all(&dir)?;
1134 let result = emit(&sample_graph(), &PdfMetadata::default(), &dir);
1137 std::fs::remove_dir_all(&dir).ok();
1138 let Err(err) = result else {
1139 return Err("expected emit to fail when target is a directory".into());
1140 };
1141 let CoreError::Diagnostic(d) = err else {
1142 return Err("expected Diagnostic, got Unimplemented".into());
1143 };
1144 ensure!(
1145 d.def().code() == codes::MOS0014.code(),
1146 "wrong code: {:?}",
1147 d.def().code()
1148 );
1149 ensure!(
1150 d.message().contains("could not write PDF"),
1151 "message={:?}",
1152 d.message()
1153 );
1154 Ok(())
1155 }
1156
1157 fn outline_graph(entries: Vec<OutlineEntry>) -> PageGraph {
1161 PageGraph {
1162 pages: vec![Page {
1163 number: 1,
1164 width_pt: 595.276_f32,
1165 height_pt: 841.89_f32,
1166 runs: Vec::new(),
1167 images: Vec::new(),
1168 }],
1169 images: Vec::new(),
1170 outline: entries,
1171 }
1172 }
1173
1174 fn entry(level: u8, title: &str, top_from_top_pt: f32) -> OutlineEntry {
1175 OutlineEntry {
1176 level,
1177 title: title.to_owned(),
1178 page_index: 0,
1179 top_from_top_pt,
1180 }
1181 }
1182
1183 fn ref_id(
1185 dict: &lopdf::Dictionary,
1186 key: &[u8],
1187 ) -> std::result::Result<(u32, u16), Box<dyn Error>> {
1188 match dict.get(key)? {
1189 Object::Reference(id) => Ok(*id),
1190 other => Err(format!(
1191 "expected /{} to be a reference, got {other:?}",
1192 String::from_utf8_lossy(key)
1193 )
1194 .into()),
1195 }
1196 }
1197
1198 fn load_outlines(
1200 doc: &LopdfDocument,
1201 ) -> std::result::Result<&lopdf::Dictionary, Box<dyn Error>> {
1202 let root_id = ref_id(&doc.trailer, b"Root")?;
1203 let catalog = doc.get_dictionary(root_id)?;
1204 let outlines_id = ref_id(catalog, b"Outlines")?;
1205 Ok(doc.get_dictionary(outlines_id)?)
1206 }
1207
1208 #[test]
1209 fn outline_emitted_when_headings_present() -> TestResult {
1210 let graph = outline_graph(vec![entry(1, "One", 100.0), entry(2, "Two", 200.0)]);
1211 let (bytes, diags) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1212 ensure!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
1213 ensure!(
1214 count_bytes(&bytes, b"/Type /Outlines") >= 1,
1215 "missing /Type /Outlines"
1216 );
1217
1218 let doc = LopdfDocument::load_mem(&bytes)?;
1219 let outlines = load_outlines(&doc)?;
1220 let Object::Integer(count) = outlines.get(b"Count")? else {
1222 return Err("missing /Count on root /Outlines".into());
1223 };
1224 ensure!(*count == 2, "wrong root /Count: {count}");
1225 ensure!(
1227 ref_id(outlines, b"First")? == ref_id(outlines, b"Last")?,
1228 "single top-level entry must be both /First and /Last"
1229 );
1230 Ok(())
1231 }
1232
1233 #[test]
1234 fn no_outline_dict_for_heading_free_doc() -> TestResult {
1235 let (bytes, _) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
1239 ensure!(
1240 count_bytes(&bytes, b"/Outlines") == 0,
1241 "heading-free doc must emit no /Outlines"
1242 );
1243 let doc = LopdfDocument::load_mem(&bytes)?;
1244 let root_id = ref_id(&doc.trailer, b"Root")?;
1245 let catalog = doc.get_dictionary(root_id)?;
1246 ensure!(
1247 catalog.get(b"Outlines").is_err(),
1248 "catalog must not carry an /Outlines key when there are no headings"
1249 );
1250 Ok(())
1251 }
1252
1253 #[test]
1254 fn outline_nesting_wires_parent_first_last() -> TestResult {
1255 let graph = outline_graph(vec![
1257 entry(1, "Chapter", 100.0),
1258 entry(2, "First", 200.0),
1259 entry(2, "Second", 300.0),
1260 ]);
1261 let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1262 let doc = LopdfDocument::load_mem(&bytes)?;
1263 let outlines = load_outlines(&doc)?;
1264
1265 let h1_id = ref_id(outlines, b"First")?;
1267 ensure!(h1_id == ref_id(outlines, b"Last")?, "H1 must be sole root");
1268 let h1 = doc.get_dictionary(h1_id)?;
1269
1270 let first_h2_id = ref_id(h1, b"First")?;
1271 let last_h2_id = ref_id(h1, b"Last")?;
1272 ensure!(first_h2_id != last_h2_id, "the two H2 items must differ");
1273
1274 let first_h2 = doc.get_dictionary(first_h2_id)?;
1275 let last_h2 = doc.get_dictionary(last_h2_id)?;
1276 ensure!(
1278 ref_id(first_h2, b"Parent")? == h1_id,
1279 "first H2 /Parent wrong"
1280 );
1281 ensure!(
1282 ref_id(last_h2, b"Parent")? == h1_id,
1283 "last H2 /Parent wrong"
1284 );
1285 ensure!(ref_id(first_h2, b"Next")? == last_h2_id, "H2 /Next wrong");
1287 ensure!(ref_id(last_h2, b"Prev")? == first_h2_id, "H2 /Prev wrong");
1288 Ok(())
1289 }
1290
1291 #[test]
1292 fn outline_skipped_level_attaches_to_nearest_ancestor() -> TestResult {
1293 let graph = outline_graph(vec![entry(1, "Top", 100.0), entry(3, "Deep", 200.0)]);
1295 let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1296 let doc = LopdfDocument::load_mem(&bytes)?;
1297 let outlines = load_outlines(&doc)?;
1298
1299 let h1_id = ref_id(outlines, b"First")?;
1300 let h1 = doc.get_dictionary(h1_id)?;
1301 let h3_id = ref_id(h1, b"First")?;
1302 let h3 = doc.get_dictionary(h3_id)?;
1303 ensure!(
1304 ref_id(h3, b"Parent")? == h1_id,
1305 "skipped-level H3 must attach to the nearest shallower ancestor (H1)"
1306 );
1307 Ok(())
1308 }
1309
1310 #[test]
1311 fn outline_dest_uses_flipped_top_coordinate() -> TestResult {
1312 let top_from_top = 120.0_f32;
1313 let graph = outline_graph(vec![entry(1, "Heading", top_from_top)]);
1314 let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1315 let doc = LopdfDocument::load_mem(&bytes)?;
1316 let outlines = load_outlines(&doc)?;
1317
1318 let item = doc.get_dictionary(ref_id(outlines, b"First")?)?;
1319 let Object::Array(dest) = item.get(b"Dest")? else {
1320 return Err("missing /Dest array on outline item".into());
1321 };
1322 let Some(Object::Real(top)) = dest.get(3) else {
1325 return Err(format!("dest top not a real: {dest:?}").into());
1326 };
1327 let expected = 841.89_f32 - top_from_top;
1328 let within_tolerance = (*top - expected).abs() < 0.01;
1329 ensure!(
1330 within_tolerance,
1331 "wrong dest top: {top} vs expected {expected}"
1332 );
1333 Ok(())
1334 }
1335
1336 #[test]
1337 fn outline_pdf_is_byte_for_byte_deterministic() {
1338 let graph = outline_graph(vec![
1341 entry(1, "Chapter", 100.0),
1342 entry(2, "First", 200.0),
1343 entry(2, "Second", 300.0),
1344 ]);
1345 let (a, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1346 let (b, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1347 assert_eq!(a, b, "outline emit must be byte-stable across runs");
1348 }
1349
1350 #[test]
1351 fn outline_after_populated_encoding_map_is_byte_deterministic() {
1352 let mut graph = extended_latin_graph();
1359 graph.outline = vec![entry(1, "Chapter", 100.0), entry(2, "Section", 200.0)];
1360 let (a, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1361 let (b, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1362 assert_eq!(a, b, "outline + custom encoding build must be byte-stable");
1363 }
1364}