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;
27mod debug;
28#[doc(hidden)]
29pub mod embedded;
30#[doc(hidden)]
31pub mod encoding;
32#[doc(hidden)]
33pub mod images;
34
35use std::collections::HashMap;
36use std::path::Path;
37
38use mos_core::{CoreError, Diagnostic, Result, codes};
39use mos_fonts::EmbeddedFontId;
40use mos_layout::{Base14Font, Font, PageGraph, TextRun};
41use pdf_writer::types::{SystemInfo, UnicodeCmap};
42use pdf_writer::writers::Encoding;
43use pdf_writer::{Finish, Name, Pdf, Rect, Ref, Str, TextStr};
44
45use crate::embedded::{EmbeddedFontPlan, EmbeddedRefs};
46use crate::encoding::{DocEncoding, EncodingPlanner};
47
48const PRODUCER: &str = concat!("Mosaic ", env!("CARGO_PKG_VERSION"));
61
62#[derive(Debug, Clone, Default)]
81pub struct PdfMetadata {
82 pub title: Option<String>,
83 pub author: Option<String>,
84 pub language: Option<String>,
85}
86
87pub fn emit(graph: &PageGraph, metadata: &PdfMetadata, out: &Path) -> Result<Vec<Diagnostic>> {
116 let (bytes, diagnostics) = build_pdf(graph, metadata)?;
117 write_pdf(&bytes, out)?;
118 Ok(diagnostics)
119}
120
121pub fn emit_debug(
134 graph: &PageGraph,
135 report: &mos_layout::debug::Report,
136 metadata: &PdfMetadata,
137 out: &Path,
138) -> Result<Vec<Diagnostic>> {
139 let (bytes, diagnostics) = build_debug_pdf(graph, report, metadata)?;
140 write_pdf(&bytes, out)?;
141 Ok(diagnostics)
142}
143
144fn write_pdf(bytes: &[u8], out: &Path) -> Result<()> {
145 if let Some(parent) = out.parent()
146 && !parent.as_os_str().is_empty()
147 {
148 std::fs::create_dir_all(parent).map_err(|err| {
149 io_diagnostic(format!(
150 "could not create output directory `{}`: {err}",
151 mos_core::display_path(parent)
152 ))
153 })?;
154 }
155 std::fs::write(out, bytes).map_err(|err| {
156 io_diagnostic(format!(
157 "could not write PDF to `{}`: {err}",
158 mos_core::display_path(out)
159 ))
160 })?;
161 Ok(())
162}
163
164fn io_diagnostic(message: String) -> CoreError {
165 CoreError::Diagnostic(Box::new(Diagnostic::simple(&codes::MOS0014, None, message)))
166}
167
168pub fn build_pdf(graph: &PageGraph, metadata: &PdfMetadata) -> Result<(Vec<u8>, Vec<Diagnostic>)> {
191 build_pdf_impl(graph, metadata, None)
192}
193
194pub fn build_debug_pdf(
220 graph: &PageGraph,
221 report: &mos_layout::debug::Report,
222 metadata: &PdfMetadata,
223) -> Result<(Vec<u8>, Vec<Diagnostic>)> {
224 debug::validate(graph, report)?;
225 build_pdf_impl(graph, metadata, Some(report))
226}
227
228fn build_pdf_impl(
229 graph: &PageGraph,
230 metadata: &PdfMetadata,
231 debug: Option<&mos_layout::debug::Report>,
232) -> Result<(Vec<u8>, Vec<Diagnostic>)> {
233 let mut diagnostics: Vec<Diagnostic> = Vec::new();
237 let encodings = plan_base14_encodings(graph, &mut diagnostics);
238
239 let embedded_runs: Vec<TextRun> = graph
246 .pages
247 .iter()
248 .flat_map(|p| p.runs.iter())
249 .filter(|r| matches!(r.font, Font::Embedded(_)))
250 .cloned()
251 .collect();
252 let embedded_plans: Vec<EmbeddedFontPlan> = embedded::plan_embedded(&embedded_runs)?;
253 let embedded_by_id: HashMap<EmbeddedFontId, &EmbeddedFontPlan> =
254 embedded_plans.iter().map(|p| (p.id, p)).collect();
255
256 let mut pdf = Pdf::new();
260 let mut next_id: i32 = 1;
261 let mut alloc = || {
262 let id = Ref::new(next_id);
263 next_id += 1;
264 id
265 };
266
267 let catalog_id = alloc();
268 let page_tree_id = alloc();
269 let info_id = alloc();
270
271 let base14_refs: Vec<(Font, Ref)> = Font::ALL_BASE14.iter().map(|f| (*f, alloc())).collect();
276
277 let mut encoding_refs: HashMap<Font, (Ref, Ref)> = HashMap::new();
284 for font in Font::ALL_BASE14 {
285 if let Some(enc) = encodings.get(&font)
286 && enc.has_differences()
287 {
288 let enc_ref = alloc();
289 let cmap_ref = alloc();
290 encoding_refs.insert(font, (enc_ref, cmap_ref));
291 }
292 }
293
294 let embedded_refs: HashMap<EmbeddedFontId, EmbeddedRefs> = embedded_plans
296 .iter()
297 .map(|plan| {
298 (
299 plan.id,
300 EmbeddedRefs {
301 font: alloc(),
302 cid_font: alloc(),
303 descriptor: alloc(),
304 font_file: alloc(),
305 to_unicode: alloc(),
306 },
307 )
308 })
309 .collect();
310
311 let image_refs: Vec<Ref> = graph.images.iter().map(|_| alloc()).collect();
317
318 let page_refs: Vec<(Ref, Ref)> = graph.pages.iter().map(|_| (alloc(), alloc())).collect();
319
320 let outline_refs: Option<(Ref, Vec<Ref>)> = (!graph.outline.is_empty()).then(|| {
326 let root = alloc();
327 let items: Vec<Ref> = graph.outline.iter().map(|_| alloc()).collect();
328 (root, items)
329 });
330 let debug_font_ref = debug.map(|_| alloc());
333 if let Some(font_ref) = debug_font_ref {
334 pdf.type1_font(font_ref)
335 .base_font(Name(b"Helvetica"))
336 .encoding_predefined(Name(b"WinAnsiEncoding"));
337 }
338
339 {
340 let mut catalog = pdf.catalog(catalog_id);
341 catalog.pages(page_tree_id);
342 if let Some((root, _)) = &outline_refs {
343 catalog.outlines(*root);
344 }
345 }
346
347 let page_count = i32::try_from(page_refs.len()).unwrap_or(i32::MAX);
348 pdf.pages(page_tree_id)
349 .kids(page_refs.iter().map(|(p, _)| *p))
350 .count(page_count);
351
352 emit_pages(
353 &mut pdf,
354 graph,
355 &PageEmitContext {
356 page_tree_id,
357 page_refs: &page_refs,
358 base14_refs: &base14_refs,
359 embedded_refs: &embedded_refs,
360 image_refs: &image_refs,
361 encodings: &encodings,
362 embedded_by_id: &embedded_by_id,
363 debug,
364 debug_font_ref,
365 },
366 )?;
367
368 emit_images(&mut pdf, graph, &image_refs);
375
376 emit_base14_fonts(&mut pdf, &base14_refs, &encoding_refs);
377
378 for plan in &embedded_plans {
381 let refs = embedded_refs[&plan.id];
382 embedded::emit_embedded(&mut pdf, plan, refs);
383 }
384
385 for font in Font::ALL_BASE14 {
389 let Some(enc) = encodings.get(&font) else {
390 continue;
391 };
392 let Some(&(enc_ref, cmap_ref)) = encoding_refs.get(&font) else {
393 continue;
394 };
395 emit_encoding_dict(&mut pdf, enc_ref, enc);
396 emit_to_unicode_cmap(&mut pdf, cmap_ref, enc);
397 }
398
399 {
400 let mut info = pdf.document_info(info_id);
401 if let Some(title) = metadata.title.as_deref() {
402 info.title(TextStr(title));
403 }
404 if let Some(author) = metadata.author.as_deref() {
405 info.author(TextStr(author));
406 }
407 info.producer(TextStr(PRODUCER));
411 info.creator(TextStr(PRODUCER));
412 info.finish();
413 }
414
415 if let Some((root, items)) = &outline_refs {
416 emit_outline(&mut pdf, graph, *root, items, &page_refs);
417 }
418
419 Ok((pdf.finish(), diagnostics))
420}
421
422fn emit_outline(
432 pdf: &mut Pdf,
433 graph: &PageGraph,
434 root: Ref,
435 items: &[Ref],
436 page_refs: &[(Ref, Ref)],
437) {
438 let entries = &graph.outline;
439 let n = entries.len();
440
441 let mut parent: Vec<Option<usize>> = vec![None; n];
448 let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
449 let mut roots: Vec<usize> = Vec::new();
450 let mut stack: Vec<usize> = Vec::new();
451 for (i, entry) in entries.iter().enumerate() {
452 while stack
453 .last()
454 .is_some_and(|&top| entries[top].level >= entry.level)
455 {
456 stack.pop();
457 }
458 match stack.last() {
459 Some(&p) => {
460 parent[i] = Some(p);
461 children[p].push(i);
462 }
463 None => roots.push(i),
464 }
465 stack.push(i);
466 }
467
468 let mut descendants: Vec<usize> = vec![0; n];
473 for i in (0..n).rev() {
474 if let Some(p) = parent[i] {
475 descendants[p] += descendants[i] + 1;
476 }
477 }
478
479 {
482 let mut outline = pdf.outline(root);
483 if let (Some(&first), Some(&last)) = (roots.first(), roots.last()) {
484 outline.first(items[first]);
485 outline.last(items[last]);
486 }
487 outline.count(i32::try_from(n).unwrap_or(i32::MAX));
488 }
489
490 for (i, entry) in entries.iter().enumerate() {
491 let mut item = pdf.outline_item(items[i]);
492 item.title(TextStr(&entry.title));
493 item.parent(match parent[i] {
494 Some(p) => items[p],
495 None => root,
496 });
497
498 let siblings: &[usize] = match parent[i] {
501 Some(p) => &children[p],
502 None => &roots,
503 };
504 if let Some(pos) = siblings.iter().position(|&s| s == i) {
505 if pos > 0 {
506 item.prev(items[siblings[pos - 1]]);
507 }
508 if pos + 1 < siblings.len() {
509 item.next(items[siblings[pos + 1]]);
510 }
511 }
512
513 if let (Some(&first), Some(&last)) = (children[i].first(), children[i].last()) {
515 item.first(items[first]);
516 item.last(items[last]);
517 item.count(i32::try_from(descendants[i]).unwrap_or(i32::MAX));
518 }
519
520 if let Some((page_ref, _)) = page_refs.get(entry.page_index)
525 && let Some(page) = graph.pages.get(entry.page_index)
526 {
527 let top = page.height_pt - entry.top_from_top_pt;
528 item.dest().page(*page_ref).xyz(0.0, top, None);
529 }
530 }
531}
532
533struct PageEmitContext<'a> {
534 page_tree_id: Ref,
535 page_refs: &'a [(Ref, Ref)],
536 base14_refs: &'a [(Font, Ref)],
537 embedded_refs: &'a HashMap<EmbeddedFontId, EmbeddedRefs>,
538 image_refs: &'a [Ref],
539 encodings: &'a HashMap<Font, DocEncoding>,
540 embedded_by_id: &'a HashMap<EmbeddedFontId, &'a EmbeddedFontPlan>,
541 debug: Option<&'a mos_layout::debug::Report>,
542 debug_font_ref: Option<Ref>,
543}
544
545fn plan_base14_encodings(
546 graph: &PageGraph,
547 diagnostics: &mut Vec<Diagnostic>,
548) -> HashMap<Font, DocEncoding> {
549 let mut planner = EncodingPlanner::new();
550 for page in &graph.pages {
551 planner.observe_runs(&page.runs);
552 }
553 planner.finalize(diagnostics)
554}
555
556fn emit_pages(pdf: &mut Pdf, graph: &PageGraph, ctx: &PageEmitContext<'_>) -> Result<()> {
557 for (index, (page, (page_id, content_id))) in
558 graph.pages.iter().zip(ctx.page_refs.iter()).enumerate()
559 {
560 let debug_page = ctx.debug.map(|report| &report.pages[index]);
561 let extra_height = if debug_page.is_some() {
562 debug::LEGEND_HEIGHT_PT
563 } else {
564 0.0
565 };
566 let width = if debug_page.is_some() {
567 debug::canvas_width(page.width_pt)
568 } else {
569 page.width_pt
570 };
571 let mut page_obj = pdf.page(*page_id);
572 page_obj.media_box(Rect::new(0.0, 0.0, width, page.height_pt + extra_height));
573 page_obj.parent(ctx.page_tree_id);
574 page_obj.contents(*content_id);
575 {
576 let mut resources = page_obj.resources();
577 {
578 let mut fonts = resources.fonts();
579 for (face, font_id) in ctx.base14_refs {
580 fonts.pair(Name(face.pdf_resource_name()), *font_id);
581 }
582 if let Some(font_ref) = ctx.debug_font_ref {
583 fonts.pair(debug::FONT_NAME, font_ref);
584 }
585 for id in EmbeddedFontId::ALL {
586 if let Some(refs) = ctx.embedded_refs.get(&id) {
587 fonts.pair(Name(id.pdf_resource_name()), refs.font);
588 }
589 }
590 }
591 if !graph.images.is_empty() {
592 let mut x_objects = resources.x_objects();
593 for (handle, image_id) in graph.images.iter().zip(ctx.image_refs.iter()) {
594 let name = images::resource_name(handle);
595 x_objects.pair(Name(name.as_bytes()), *image_id);
596 }
597 }
598 }
599 page_obj.finish();
600
601 let mut stream_bytes =
602 content::build_content_stream(page.height_pt, page, ctx.encodings, ctx.embedded_by_id)?;
603 if let Some(debug_page) = debug_page {
604 stream_bytes.splice(..0, b"q\n".iter().copied());
607 stream_bytes.extend_from_slice(b"\nQ\n");
608 stream_bytes.extend_from_slice(&debug::overlay(debug_page));
609 }
610 pdf.stream(*content_id, &stream_bytes);
611 }
612 Ok(())
613}
614
615fn emit_base14_fonts(
616 pdf: &mut Pdf,
617 base14_refs: &[(Font, Ref)],
618 encoding_refs: &HashMap<Font, (Ref, Ref)>,
619) {
620 for (face, font_id) in base14_refs {
621 let Some(base14) = face.base14() else {
622 continue;
623 };
624 let mut font_dict = pdf.type1_font(*font_id);
625 font_dict.base_font(Name(face.pdf_base_name().as_bytes()));
626 if matches!(base14, Base14Font::Symbol | Base14Font::ZapfDingbats) {
627 continue;
628 }
629 match encoding_refs.get(face) {
630 Some(&(enc_ref, cmap_ref)) => {
631 font_dict.pair(Name(b"Encoding"), enc_ref);
632 font_dict.to_unicode(cmap_ref);
633 }
634 None => {
635 font_dict.encoding_predefined(Name(b"WinAnsiEncoding"));
636 }
637 }
638 }
639}
640
641fn emit_images(pdf: &mut Pdf, graph: &PageGraph, image_refs: &[Ref]) {
642 for (handle, id) in graph.images.iter().zip(image_refs.iter()) {
643 let compressed = images::flate_compress(&handle.rgb8);
644 images::emit_image_xobject(pdf, *id, handle, &compressed);
645 }
646}
647
648fn emit_encoding_dict(pdf: &mut Pdf, id: Ref, enc: &DocEncoding) {
655 let mut enc_dict: Encoding<'_> = pdf.indirect(id).start();
656 enc_dict.base_encoding(Name(b"WinAnsiEncoding"));
657 {
658 let mut diffs = enc_dict.differences();
659 let mut i = 0;
660 while i < enc.differences.len() {
661 let (start, _) = enc.differences[i];
662 let mut j = i + 1;
664 while j < enc.differences.len() && enc.differences[j].0 == enc.differences[j - 1].0 + 1
665 {
666 j += 1;
667 }
668 let names = enc.differences[i..j]
669 .iter()
670 .map(|(_, n)| Name(n.as_bytes()));
671 diffs.consecutive(start, names);
672 i = j;
673 }
674 }
675 enc_dict.finish();
676}
677
678fn emit_to_unicode_cmap(pdf: &mut Pdf, id: Ref, enc: &DocEncoding) {
683 let system_info = SystemInfo {
692 registry: Str(b"Adobe"),
693 ordering: Str(b"UCS"),
694 supplement: 0,
695 };
696 let mut cmap: UnicodeCmap<u8> = UnicodeCmap::new(Name(b"Adobe-Identity-UCS"), system_info);
697 for &(byte, ch) in &enc.to_unicode_entries {
698 cmap.pair(byte, ch);
699 }
700 let cmap_bytes = cmap.finish();
701 let mut cmap_writer = pdf.cmap(id, &cmap_bytes);
702 cmap_writer.name(Name(b"Adobe-Identity-UCS"));
703 cmap_writer.system_info(system_info);
704}
705
706#[cfg(test)]
707mod tests {
708 use std::error::Error;
715
716 use lopdf::{Document as LopdfDocument, Object};
717 use mos_layout::{Base14Font, Font, OutlineEntry, Page, PageGraph, TextRun};
718
719 use super::*;
720
721 type TestResult = std::result::Result<(), Box<dyn Error>>;
725
726 macro_rules! ensure {
732 ($cond:expr, $($arg:tt)*) => {
733 if !$cond {
734 return Err(format!($($arg)*).into());
735 }
736 };
737 }
738
739 fn count_bytes(haystack: &[u8], needle: &[u8]) -> usize {
740 haystack
741 .windows(needle.len())
742 .filter(|w| *w == needle)
743 .count()
744 }
745
746 fn sample_graph() -> PageGraph {
747 PageGraph {
748 pages: vec![Page {
749 number: 1,
750 width_pt: 595.276_f32,
751 height_pt: 841.89_f32,
752 runs: vec![
753 TextRun {
754 x_pt: 68.0,
755 baseline_from_top_pt: 100.0,
756 size_pt: 20.0,
757 font: Font::Base14(Base14Font::HelveticaBold),
758 text: "Title".to_owned(),
759 actual_text: None,
760 glyphs: Vec::new(),
761 },
762 TextRun {
763 x_pt: 68.0,
764 baseline_from_top_pt: 130.0,
765 size_pt: 11.0,
766 font: Font::Base14(Base14Font::Helvetica),
767 text: "Body".to_owned(),
768 actual_text: None,
769 glyphs: Vec::new(),
770 },
771 ],
772 images: Vec::new(),
773 }],
774 images: Vec::new(),
775 outline: Vec::new(),
776 }
777 }
778
779 fn info_string<'info>(
780 info: &'info lopdf::Dictionary,
781 key: &[u8],
782 ) -> std::result::Result<&'info str, Box<dyn Error>> {
783 let Object::String(bytes, _) = info.get(key)? else {
784 return Err(format!(
785 "expected Info key /{} to be a string",
786 String::from_utf8_lossy(key)
787 )
788 .into());
789 };
790 Ok(std::str::from_utf8(bytes)?)
791 }
792
793 #[test]
794 fn build_pdf_starts_with_pdf_header_and_ends_with_eof() {
795 let (bytes, diags) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
796 assert!(bytes.starts_with(b"%PDF-"), "missing PDF header");
797 assert!(
798 bytes.windows(5).any(|w| w == b"%%EOF"),
799 "missing %%EOF marker"
800 );
801 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
802 }
803
804 #[test]
805 fn build_pdf_embeds_text_runs_as_visible_strings() {
806 let (bytes, _) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
807 assert!(
810 bytes.windows(b"(Title)".len()).any(|w| w == b"(Title)"),
811 "Title not found in stream"
812 );
813 assert!(
814 bytes.windows(b"(Body)".len()).any(|w| w == b"(Body)"),
815 "Body not found in stream"
816 );
817 }
818
819 #[test]
820 fn empty_graph_still_produces_valid_pdf() {
821 let (bytes, _) = build_pdf(&PageGraph::default(), &PdfMetadata::default()).unwrap();
822 assert!(bytes.starts_with(b"%PDF-"));
823 }
824
825 #[test]
826 fn metadata_and_provenance_appear_in_info_dictionary() -> TestResult {
827 let metadata = PdfMetadata {
828 title: Some("My Doc".to_owned()),
829 author: Some("A. Person".to_owned()),
830 language: None,
831 };
832 let (bytes, _) = build_pdf(&sample_graph(), &metadata).unwrap();
833 let doc = LopdfDocument::load_mem(&bytes)?;
834 let Object::Reference(info_id) = doc.trailer.get(b"Info")? else {
835 return Err("expected trailer /Info reference".into());
836 };
837 let info = doc.get_dictionary(*info_id)?;
838
839 ensure!(info_string(info, b"Title")? == "My Doc", "wrong /Title");
840 ensure!(
841 info_string(info, b"Author")? == "A. Person",
842 "wrong /Author"
843 );
844 ensure!(
845 info_string(info, b"Producer")? == PRODUCER,
846 "wrong /Producer"
847 );
848 ensure!(info_string(info, b"Creator")? == PRODUCER, "wrong /Creator");
849 Ok(())
850 }
851
852 #[test]
853 fn actual_text_is_emitted_for_replacement_runs() {
854 let graph = PageGraph {
855 pages: vec![Page {
856 number: 1,
857 width_pt: 595.276_f32,
858 height_pt: 841.89_f32,
859 runs: vec![TextRun {
860 x_pt: 68.0,
861 baseline_from_top_pt: 100.0,
862 size_pt: 12.0,
863 font: Font::Base14(Base14Font::Courier),
864 text: " println".to_owned(),
865 actual_text: Some("\tprintln".to_owned()),
866 glyphs: Vec::new(),
867 }],
868 images: Vec::new(),
869 }],
870 images: Vec::new(),
871 outline: Vec::new(),
872 };
873
874 let (bytes, diags) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
875
876 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
877 assert!(
878 bytes
879 .windows(b"/ActualText".len())
880 .any(|w| w == b"/ActualText"),
881 "missing /ActualText"
882 );
883 assert!(
884 bytes.windows(b"println".len()).any(|w| w == b"println"),
885 "actual text payload missing"
886 );
887 }
888
889 #[test]
890 fn actual_text_wraps_adjacent_fragments_once() {
891 let graph = PageGraph {
892 pages: vec![Page {
893 number: 1,
894 width_pt: 595.276_f32,
895 height_pt: 841.89_f32,
896 runs: vec![
897 TextRun {
898 x_pt: 68.0,
899 baseline_from_top_pt: 100.0,
900 size_pt: 12.0,
901 font: Font::Base14(Base14Font::Courier),
902 text: " ".to_owned(),
903 actual_text: Some("\tprintln".to_owned()),
904 glyphs: Vec::new(),
905 },
906 TextRun {
907 x_pt: 92.0,
908 baseline_from_top_pt: 100.0,
909 size_pt: 12.0,
910 font: Font::Base14(Base14Font::CourierBold),
911 text: "println".to_owned(),
912 actual_text: Some("\tprintln".to_owned()),
913 glyphs: Vec::new(),
914 },
915 ],
916 images: Vec::new(),
917 }],
918 images: Vec::new(),
919 outline: Vec::new(),
920 };
921
922 let (bytes, diags) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
923
924 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
925 assert_eq!(count_bytes(&bytes, b"/ActualText"), 1);
926 assert_eq!(count_bytes(&bytes, b"println"), 1);
927 }
928
929 fn extended_latin_graph() -> PageGraph {
932 PageGraph {
933 pages: vec![Page {
934 number: 1,
935 width_pt: 595.276_f32,
936 height_pt: 841.89_f32,
937 runs: vec![TextRun {
938 x_pt: 68.0,
939 baseline_from_top_pt: 100.0,
940 size_pt: 12.0,
941 font: Font::Base14(Base14Font::Helvetica),
942 text: "Łódź Příliš ě".to_owned(),
943 actual_text: None,
944 glyphs: Vec::new(),
945 }],
946 images: Vec::new(),
947 }],
948 images: Vec::new(),
949 outline: Vec::new(),
950 }
951 }
952
953 #[test]
954 fn extended_latin_emits_differences_and_to_unicode() {
955 let (bytes, diags) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
956 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
957 assert!(
959 bytes
960 .windows(b"/BaseEncoding /WinAnsiEncoding".len())
961 .any(|w| w == b"/BaseEncoding /WinAnsiEncoding"),
962 "missing /BaseEncoding"
963 );
964 for name in [b"/Lslash" as &[u8], b"/rcaron", b"/ecaron", b"/zacute"] {
969 assert!(
970 bytes.windows(name.len()).any(|w| w == name),
971 "missing {:?} in /Differences",
972 std::str::from_utf8(name).unwrap_or("?")
973 );
974 }
975 assert!(
977 bytes
978 .windows(b"/ToUnicode".len())
979 .any(|w| w == b"/ToUnicode"),
980 "missing /ToUnicode reference"
981 );
982 assert!(
983 bytes
984 .windows(b"beginbfchar".len())
985 .any(|w| w == b"beginbfchar"),
986 "missing beginbfchar in CMap"
987 );
988 }
989
990 #[test]
991 fn pure_ascii_graph_keeps_predefined_winansi_shortcut() {
992 let (bytes, _) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
997 assert!(
998 bytes
999 .windows(b"/Encoding /WinAnsiEncoding".len())
1000 .any(|w| w == b"/Encoding /WinAnsiEncoding"),
1001 "expected predefined WinAnsi shortcut on ASCII-only doc"
1002 );
1003 assert!(
1004 !bytes
1005 .windows(b"/BaseEncoding".len())
1006 .any(|w| w == b"/BaseEncoding"),
1007 "no custom /Encoding dict expected for ASCII-only doc"
1008 );
1009 }
1010
1011 #[test]
1012 fn extended_latin_content_stream_uses_remapped_bytes() {
1013 let (bytes, _) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
1027 let content_stream = first_content_stream(&bytes).expect("content stream not found");
1028 let needle = b"7F";
1029 assert!(
1030 content_stream.windows(needle.len()).any(|w| w == needle),
1031 "content stream should reference remapped slot 0x7F"
1032 );
1033 let qmark_count = content_stream.split(|&b| b == b'?').count() - 1;
1034 assert!(
1035 qmark_count < 5,
1036 "too many `?` in PDF ({qmark_count}); did Ł/ř/ě/ź get substituted?"
1037 );
1038 }
1039
1040 #[test]
1041 fn build_pdf_is_byte_for_byte_deterministic() {
1042 let (a, _) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
1047 let (b, _) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
1048 assert_eq!(
1049 a,
1050 b,
1051 "build_pdf is non-deterministic: byte lengths {} vs {}",
1052 a.len(),
1053 b.len()
1054 );
1055 }
1056
1057 fn first_content_stream(bytes: &[u8]) -> Option<&[u8]> {
1065 let open = b"\nstream\n";
1066 let close = b"\nendstream";
1067 let open_at = bytes.windows(open.len()).position(|w| w == open)?;
1068 let body = &bytes[open_at + open.len()..];
1069 let close_at = body.windows(close.len()).position(|w| w == close)?;
1070 Some(&body[..close_at])
1071 }
1072
1073 fn unique_temp_path(label: &str) -> std::path::PathBuf {
1074 std::env::temp_dir().join(format!(
1075 "mos-pdf-test-{label}-{}",
1076 std::time::SystemTime::now()
1077 .duration_since(std::time::UNIX_EPOCH)
1078 .map_or(0, |d| d.as_nanos())
1079 ))
1080 }
1081
1082 #[test]
1083 fn debug_bytes_match_file_output_and_validate_report() -> TestResult {
1084 let layout = mos_layout::LayoutEngine::new()
1085 .layout_with_debug(&mos_core::Document::new("debug.mos".into()));
1086 let mut report = layout.debug.ok_or("missing debug report")?;
1087 let metadata = PdfMetadata::default();
1088 let (bytes, diagnostics) = build_debug_pdf(&layout.graph, &report, &metadata)?;
1089 ensure!(
1090 diagnostics.is_empty(),
1091 "unexpected diagnostics: {diagnostics:?}"
1092 );
1093 let parsed = LopdfDocument::load_mem(&bytes)?;
1094 ensure!(
1095 parsed.get_pages().len() == layout.graph.pages.len(),
1096 "page count changed"
1097 );
1098 let dir = unique_temp_path("debug-parity");
1099 let out = dir.join("out.pdf");
1100 let file_diagnostics = emit_debug(&layout.graph, &report, &metadata, &out)?;
1101 let file_bytes = std::fs::read(&out)?;
1102 std::fs::remove_dir_all(&dir)?;
1103 ensure!(bytes == file_bytes, "debug file and memory bytes differ");
1104 ensure!(file_diagnostics.is_empty(), "unexpected file diagnostics");
1105 report.pages.clear();
1106 let Err(CoreError::Diagnostic(diagnostic)) =
1107 build_debug_pdf(&layout.graph, &report, &metadata)
1108 else {
1109 return Err("mismatched debug report accepted".into());
1110 };
1111 ensure!(
1112 diagnostic.def().code() == codes::MOS0051.code(),
1113 "wrong mismatch diagnostic"
1114 );
1115 Ok(())
1116 }
1117
1118 #[test]
1119 fn emit_writes_file() -> TestResult {
1120 let dir = unique_temp_path("write");
1121 let out = dir.join("out.pdf");
1122 let diags = emit(&sample_graph(), &PdfMetadata::default(), &out)
1123 .map_err(|e| format!("emit: {e:?}"))?;
1124 ensure!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
1125 let bytes = std::fs::read(&out)?;
1126 ensure!(bytes.starts_with(b"%PDF-"), "missing PDF header");
1127 let (memory_bytes, memory_diags) = build_pdf(&sample_graph(), &PdfMetadata::default())?;
1128 ensure!(memory_bytes == bytes, "file and memory bytes differ");
1129 ensure!(memory_diags.is_empty(), "unexpected memory diagnostics");
1130 std::fs::remove_dir_all(&dir)?;
1131 Ok(())
1132 }
1133
1134 fn image_graph() -> PageGraph {
1138 use mos_layout::{ImageHandle, ImagePlacement};
1139 use std::sync::Arc;
1140 let mut rgb8 = Vec::with_capacity(4 * 2 * 3);
1142 for y in 0..2 {
1143 for x in 0..4 {
1144 if (x + y) % 2 == 0 {
1145 rgb8.extend_from_slice(&[255, 0, 0]);
1146 } else {
1147 rgb8.extend_from_slice(&[0, 0, 255]);
1148 }
1149 }
1150 }
1151 let handle = ImageHandle {
1152 id: 0,
1153 resolved_path: "/tmp/checker.png".to_owned(),
1154 pixel_width: 4,
1155 pixel_height: 2,
1156 rgb8: Arc::from(rgb8),
1157 };
1158 PageGraph {
1159 pages: vec![Page {
1160 number: 1,
1161 width_pt: 595.276_f32,
1162 height_pt: 841.89_f32,
1163 runs: Vec::new(),
1164 images: vec![ImagePlacement {
1165 handle: handle.clone(),
1166 x_pt: 68.0,
1167 top_from_top_pt: 100.0,
1168 width_pt: 40.0,
1169 height_pt: 20.0,
1170 }],
1171 }],
1172 images: vec![handle],
1173 outline: Vec::new(),
1174 }
1175 }
1176
1177 #[test]
1178 fn image_xobject_carries_width_height_and_devicergb() {
1179 let (bytes, diags) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1180 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
1181 for needle in [
1185 b"/Subtype /Image" as &[u8],
1186 b"/Width 4",
1187 b"/Height 2",
1188 b"/ColorSpace /DeviceRGB",
1189 b"/BitsPerComponent 8",
1190 b"/Filter /FlateDecode",
1191 ] {
1192 assert!(
1193 bytes.windows(needle.len()).any(|w| w == needle),
1194 "missing {:?} in PDF",
1195 std::str::from_utf8(needle).unwrap_or("?")
1196 );
1197 }
1198 }
1199
1200 #[test]
1201 fn image_placement_emits_do_operator_referencing_xobject() {
1202 let (bytes, _) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1203 assert!(
1206 bytes.windows(b"/Im0 ".len()).any(|w| w == b"/Im0 "),
1207 "/Im0 resource name not found"
1208 );
1209 assert!(
1210 bytes.windows(b"/Im0 Do".len()).any(|w| w == b"/Im0 Do"),
1211 "/Im0 Do operator not found in content stream"
1212 );
1213 }
1214
1215 #[test]
1216 fn duplicate_image_emits_one_xobject() {
1217 use mos_layout::{ImageHandle, ImagePlacement};
1221 use std::sync::Arc;
1222 let handle = ImageHandle {
1223 id: 0,
1224 resolved_path: "/tmp/shared.png".to_owned(),
1225 pixel_width: 1,
1226 pixel_height: 1,
1227 rgb8: Arc::from(vec![10_u8, 20, 30]),
1228 };
1229 let graph = PageGraph {
1230 pages: vec![Page {
1231 number: 1,
1232 width_pt: 595.276_f32,
1233 height_pt: 841.89_f32,
1234 runs: Vec::new(),
1235 images: vec![
1236 ImagePlacement {
1237 handle: handle.clone(),
1238 x_pt: 10.0,
1239 top_from_top_pt: 50.0,
1240 width_pt: 5.0,
1241 height_pt: 5.0,
1242 },
1243 ImagePlacement {
1244 handle: handle.clone(),
1245 x_pt: 100.0,
1246 top_from_top_pt: 50.0,
1247 width_pt: 5.0,
1248 height_pt: 5.0,
1249 },
1250 ],
1251 }],
1252 images: vec![handle],
1253 outline: Vec::new(),
1254 };
1255 let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1256 let xobject_marker = b"/Subtype /Image";
1257 let count = bytes
1258 .windows(xobject_marker.len())
1259 .filter(|w| *w == xobject_marker)
1260 .count();
1261 assert_eq!(count, 1, "expected exactly one Image XObject, got {count}");
1262 let do_count = bytes
1264 .windows(b"/Im0 Do".len())
1265 .filter(|w| *w == b"/Im0 Do")
1266 .count();
1267 assert_eq!(
1268 do_count, 2,
1269 "expected two /Im0 Do operators, got {do_count}"
1270 );
1271 }
1272
1273 #[test]
1274 fn image_only_pdf_remains_byte_deterministic() {
1275 let (a, _) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1276 let (b, _) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1277 assert_eq!(a, b, "image emit must be byte-stable across runs");
1278 }
1279
1280 #[test]
1281 fn emit_fails_with_mos0014_when_target_is_a_directory() -> TestResult {
1282 let dir = unique_temp_path("conflict");
1286 std::fs::create_dir_all(&dir)?;
1287 let result = emit(&sample_graph(), &PdfMetadata::default(), &dir);
1290 std::fs::remove_dir_all(&dir).ok();
1291 let Err(err) = result else {
1292 return Err("expected emit to fail when target is a directory".into());
1293 };
1294 let CoreError::Diagnostic(d) = err else {
1295 return Err("expected Diagnostic, got Unimplemented".into());
1296 };
1297 ensure!(
1298 d.def().code() == codes::MOS0014.code(),
1299 "wrong code: {:?}",
1300 d.def().code()
1301 );
1302 ensure!(
1303 d.message().contains("could not write PDF"),
1304 "message={:?}",
1305 d.message()
1306 );
1307 Ok(())
1308 }
1309
1310 fn outline_graph(entries: Vec<OutlineEntry>) -> PageGraph {
1314 PageGraph {
1315 pages: vec![Page {
1316 number: 1,
1317 width_pt: 595.276_f32,
1318 height_pt: 841.89_f32,
1319 runs: Vec::new(),
1320 images: Vec::new(),
1321 }],
1322 images: Vec::new(),
1323 outline: entries,
1324 }
1325 }
1326
1327 fn entry(level: u8, title: &str, top_from_top_pt: f32) -> OutlineEntry {
1328 OutlineEntry {
1329 level,
1330 title: title.to_owned(),
1331 page_index: 0,
1332 top_from_top_pt,
1333 }
1334 }
1335
1336 fn ref_id(
1338 dict: &lopdf::Dictionary,
1339 key: &[u8],
1340 ) -> std::result::Result<(u32, u16), Box<dyn Error>> {
1341 match dict.get(key)? {
1342 Object::Reference(id) => Ok(*id),
1343 other => Err(format!(
1344 "expected /{} to be a reference, got {other:?}",
1345 String::from_utf8_lossy(key)
1346 )
1347 .into()),
1348 }
1349 }
1350
1351 fn load_outlines(
1353 doc: &LopdfDocument,
1354 ) -> std::result::Result<&lopdf::Dictionary, Box<dyn Error>> {
1355 let root_id = ref_id(&doc.trailer, b"Root")?;
1356 let catalog = doc.get_dictionary(root_id)?;
1357 let outlines_id = ref_id(catalog, b"Outlines")?;
1358 Ok(doc.get_dictionary(outlines_id)?)
1359 }
1360
1361 #[test]
1362 fn outline_emitted_when_headings_present() -> TestResult {
1363 let graph = outline_graph(vec![entry(1, "One", 100.0), entry(2, "Two", 200.0)]);
1364 let (bytes, diags) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1365 ensure!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
1366 ensure!(
1367 count_bytes(&bytes, b"/Type /Outlines") >= 1,
1368 "missing /Type /Outlines"
1369 );
1370
1371 let doc = LopdfDocument::load_mem(&bytes)?;
1372 let outlines = load_outlines(&doc)?;
1373 let Object::Integer(count) = outlines.get(b"Count")? else {
1375 return Err("missing /Count on root /Outlines".into());
1376 };
1377 ensure!(*count == 2, "wrong root /Count: {count}");
1378 ensure!(
1380 ref_id(outlines, b"First")? == ref_id(outlines, b"Last")?,
1381 "single top-level entry must be both /First and /Last"
1382 );
1383 Ok(())
1384 }
1385
1386 #[test]
1387 fn no_outline_dict_for_heading_free_doc() -> TestResult {
1388 let (bytes, _) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
1392 ensure!(
1393 count_bytes(&bytes, b"/Outlines") == 0,
1394 "heading-free doc must emit no /Outlines"
1395 );
1396 let doc = LopdfDocument::load_mem(&bytes)?;
1397 let root_id = ref_id(&doc.trailer, b"Root")?;
1398 let catalog = doc.get_dictionary(root_id)?;
1399 ensure!(
1400 catalog.get(b"Outlines").is_err(),
1401 "catalog must not carry an /Outlines key when there are no headings"
1402 );
1403 Ok(())
1404 }
1405
1406 #[test]
1407 fn outline_nesting_wires_parent_first_last() -> TestResult {
1408 let graph = outline_graph(vec![
1410 entry(1, "Chapter", 100.0),
1411 entry(2, "First", 200.0),
1412 entry(2, "Second", 300.0),
1413 ]);
1414 let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1415 let doc = LopdfDocument::load_mem(&bytes)?;
1416 let outlines = load_outlines(&doc)?;
1417
1418 let h1_id = ref_id(outlines, b"First")?;
1420 ensure!(h1_id == ref_id(outlines, b"Last")?, "H1 must be sole root");
1421 let h1 = doc.get_dictionary(h1_id)?;
1422
1423 let first_h2_id = ref_id(h1, b"First")?;
1424 let last_h2_id = ref_id(h1, b"Last")?;
1425 ensure!(first_h2_id != last_h2_id, "the two H2 items must differ");
1426
1427 let first_h2 = doc.get_dictionary(first_h2_id)?;
1428 let last_h2 = doc.get_dictionary(last_h2_id)?;
1429 ensure!(
1431 ref_id(first_h2, b"Parent")? == h1_id,
1432 "first H2 /Parent wrong"
1433 );
1434 ensure!(
1435 ref_id(last_h2, b"Parent")? == h1_id,
1436 "last H2 /Parent wrong"
1437 );
1438 ensure!(ref_id(first_h2, b"Next")? == last_h2_id, "H2 /Next wrong");
1440 ensure!(ref_id(last_h2, b"Prev")? == first_h2_id, "H2 /Prev wrong");
1441 Ok(())
1442 }
1443
1444 #[test]
1445 fn outline_skipped_level_attaches_to_nearest_ancestor() -> TestResult {
1446 let graph = outline_graph(vec![entry(1, "Top", 100.0), entry(3, "Deep", 200.0)]);
1448 let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1449 let doc = LopdfDocument::load_mem(&bytes)?;
1450 let outlines = load_outlines(&doc)?;
1451
1452 let h1_id = ref_id(outlines, b"First")?;
1453 let h1 = doc.get_dictionary(h1_id)?;
1454 let h3_id = ref_id(h1, b"First")?;
1455 let h3 = doc.get_dictionary(h3_id)?;
1456 ensure!(
1457 ref_id(h3, b"Parent")? == h1_id,
1458 "skipped-level H3 must attach to the nearest shallower ancestor (H1)"
1459 );
1460 Ok(())
1461 }
1462
1463 #[test]
1464 fn outline_dest_uses_flipped_top_coordinate() -> TestResult {
1465 let top_from_top = 120.0_f32;
1466 let graph = outline_graph(vec![entry(1, "Heading", top_from_top)]);
1467 let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1468 let doc = LopdfDocument::load_mem(&bytes)?;
1469 let outlines = load_outlines(&doc)?;
1470
1471 let item = doc.get_dictionary(ref_id(outlines, b"First")?)?;
1472 let Object::Array(dest) = item.get(b"Dest")? else {
1473 return Err("missing /Dest array on outline item".into());
1474 };
1475 let Some(Object::Real(top)) = dest.get(3) else {
1478 return Err(format!("dest top not a real: {dest:?}").into());
1479 };
1480 let expected = 841.89_f32 - top_from_top;
1481 let within_tolerance = (*top - expected).abs() < 0.01;
1482 ensure!(
1483 within_tolerance,
1484 "wrong dest top: {top} vs expected {expected}"
1485 );
1486 Ok(())
1487 }
1488
1489 #[test]
1490 fn outline_pdf_is_byte_for_byte_deterministic() {
1491 let graph = outline_graph(vec![
1494 entry(1, "Chapter", 100.0),
1495 entry(2, "First", 200.0),
1496 entry(2, "Second", 300.0),
1497 ]);
1498 let (a, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1499 let (b, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1500 assert_eq!(a, b, "outline emit must be byte-stable across runs");
1501 }
1502
1503 #[test]
1504 fn outline_after_populated_encoding_map_is_byte_deterministic() {
1505 let mut graph = extended_latin_graph();
1512 graph.outline = vec![entry(1, "Chapter", 100.0), entry(2, "Section", 200.0)];
1513 let (a, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1514 let (b, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1515 assert_eq!(a, b, "outline + custom encoding build must be byte-stable");
1516 }
1517}