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 debug::validate(graph, report)?;
140 let (bytes, diagnostics) = build_pdf_impl(graph, metadata, Some(report))?;
141 write_pdf(&bytes, out)?;
142 Ok(diagnostics)
143}
144
145fn write_pdf(bytes: &[u8], out: &Path) -> Result<()> {
146 if let Some(parent) = out.parent()
147 && !parent.as_os_str().is_empty()
148 {
149 std::fs::create_dir_all(parent).map_err(|err| {
150 io_diagnostic(format!(
151 "could not create output directory `{}`: {err}",
152 mos_core::display_path(parent)
153 ))
154 })?;
155 }
156 std::fs::write(out, bytes).map_err(|err| {
157 io_diagnostic(format!(
158 "could not write PDF to `{}`: {err}",
159 mos_core::display_path(out)
160 ))
161 })?;
162 Ok(())
163}
164
165fn io_diagnostic(message: String) -> CoreError {
166 CoreError::Diagnostic(Box::new(Diagnostic::simple(&codes::MOS0014, None, message)))
167}
168
169pub(crate) fn build_pdf(
181 graph: &PageGraph,
182 metadata: &PdfMetadata,
183) -> Result<(Vec<u8>, Vec<Diagnostic>)> {
184 build_pdf_impl(graph, metadata, None)
185}
186
187fn build_pdf_impl(
188 graph: &PageGraph,
189 metadata: &PdfMetadata,
190 debug: Option<&mos_layout::debug::Report>,
191) -> Result<(Vec<u8>, Vec<Diagnostic>)> {
192 let mut diagnostics: Vec<Diagnostic> = Vec::new();
196 let encodings = plan_base14_encodings(graph, &mut diagnostics);
197
198 let embedded_runs: Vec<TextRun> = graph
205 .pages
206 .iter()
207 .flat_map(|p| p.runs.iter())
208 .filter(|r| matches!(r.font, Font::Embedded(_)))
209 .cloned()
210 .collect();
211 let embedded_plans: Vec<EmbeddedFontPlan> = embedded::plan_embedded(&embedded_runs)?;
212 let embedded_by_id: HashMap<EmbeddedFontId, &EmbeddedFontPlan> =
213 embedded_plans.iter().map(|p| (p.id, p)).collect();
214
215 let mut pdf = Pdf::new();
219 let mut next_id: i32 = 1;
220 let mut alloc = || {
221 let id = Ref::new(next_id);
222 next_id += 1;
223 id
224 };
225
226 let catalog_id = alloc();
227 let page_tree_id = alloc();
228 let info_id = alloc();
229
230 let base14_refs: Vec<(Font, Ref)> = Font::ALL_BASE14.iter().map(|f| (*f, alloc())).collect();
235
236 let mut encoding_refs: HashMap<Font, (Ref, Ref)> = HashMap::new();
243 for font in Font::ALL_BASE14 {
244 if let Some(enc) = encodings.get(&font)
245 && enc.has_differences()
246 {
247 let enc_ref = alloc();
248 let cmap_ref = alloc();
249 encoding_refs.insert(font, (enc_ref, cmap_ref));
250 }
251 }
252
253 let embedded_refs: HashMap<EmbeddedFontId, EmbeddedRefs> = embedded_plans
255 .iter()
256 .map(|plan| {
257 (
258 plan.id,
259 EmbeddedRefs {
260 font: alloc(),
261 cid_font: alloc(),
262 descriptor: alloc(),
263 font_file: alloc(),
264 to_unicode: alloc(),
265 },
266 )
267 })
268 .collect();
269
270 let image_refs: Vec<Ref> = graph.images.iter().map(|_| alloc()).collect();
276
277 let page_refs: Vec<(Ref, Ref)> = graph.pages.iter().map(|_| (alloc(), alloc())).collect();
278
279 let outline_refs: Option<(Ref, Vec<Ref>)> = (!graph.outline.is_empty()).then(|| {
285 let root = alloc();
286 let items: Vec<Ref> = graph.outline.iter().map(|_| alloc()).collect();
287 (root, items)
288 });
289 let debug_font_ref = debug.map(|_| alloc());
292 if let Some(font_ref) = debug_font_ref {
293 pdf.type1_font(font_ref)
294 .base_font(Name(b"Helvetica"))
295 .encoding_predefined(Name(b"WinAnsiEncoding"));
296 }
297
298 {
299 let mut catalog = pdf.catalog(catalog_id);
300 catalog.pages(page_tree_id);
301 if let Some((root, _)) = &outline_refs {
302 catalog.outlines(*root);
303 }
304 }
305
306 let page_count = i32::try_from(page_refs.len()).unwrap_or(i32::MAX);
307 pdf.pages(page_tree_id)
308 .kids(page_refs.iter().map(|(p, _)| *p))
309 .count(page_count);
310
311 emit_pages(
312 &mut pdf,
313 graph,
314 &PageEmitContext {
315 page_tree_id,
316 page_refs: &page_refs,
317 base14_refs: &base14_refs,
318 embedded_refs: &embedded_refs,
319 image_refs: &image_refs,
320 encodings: &encodings,
321 embedded_by_id: &embedded_by_id,
322 debug,
323 debug_font_ref,
324 },
325 )?;
326
327 emit_images(&mut pdf, graph, &image_refs);
334
335 emit_base14_fonts(&mut pdf, &base14_refs, &encoding_refs);
336
337 for plan in &embedded_plans {
340 let refs = embedded_refs[&plan.id];
341 embedded::emit_embedded(&mut pdf, plan, refs);
342 }
343
344 for font in Font::ALL_BASE14 {
348 let Some(enc) = encodings.get(&font) else {
349 continue;
350 };
351 let Some(&(enc_ref, cmap_ref)) = encoding_refs.get(&font) else {
352 continue;
353 };
354 emit_encoding_dict(&mut pdf, enc_ref, enc);
355 emit_to_unicode_cmap(&mut pdf, cmap_ref, enc);
356 }
357
358 {
359 let mut info = pdf.document_info(info_id);
360 if let Some(title) = metadata.title.as_deref() {
361 info.title(TextStr(title));
362 }
363 if let Some(author) = metadata.author.as_deref() {
364 info.author(TextStr(author));
365 }
366 info.producer(TextStr(PRODUCER));
370 info.creator(TextStr(PRODUCER));
371 info.finish();
372 }
373
374 if let Some((root, items)) = &outline_refs {
375 emit_outline(&mut pdf, graph, *root, items, &page_refs);
376 }
377
378 Ok((pdf.finish(), diagnostics))
379}
380
381fn emit_outline(
391 pdf: &mut Pdf,
392 graph: &PageGraph,
393 root: Ref,
394 items: &[Ref],
395 page_refs: &[(Ref, Ref)],
396) {
397 let entries = &graph.outline;
398 let n = entries.len();
399
400 let mut parent: Vec<Option<usize>> = vec![None; n];
407 let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
408 let mut roots: Vec<usize> = Vec::new();
409 let mut stack: Vec<usize> = Vec::new();
410 for (i, entry) in entries.iter().enumerate() {
411 while stack
412 .last()
413 .is_some_and(|&top| entries[top].level >= entry.level)
414 {
415 stack.pop();
416 }
417 match stack.last() {
418 Some(&p) => {
419 parent[i] = Some(p);
420 children[p].push(i);
421 }
422 None => roots.push(i),
423 }
424 stack.push(i);
425 }
426
427 let mut descendants: Vec<usize> = vec![0; n];
432 for i in (0..n).rev() {
433 if let Some(p) = parent[i] {
434 descendants[p] += descendants[i] + 1;
435 }
436 }
437
438 {
441 let mut outline = pdf.outline(root);
442 if let (Some(&first), Some(&last)) = (roots.first(), roots.last()) {
443 outline.first(items[first]);
444 outline.last(items[last]);
445 }
446 outline.count(i32::try_from(n).unwrap_or(i32::MAX));
447 }
448
449 for (i, entry) in entries.iter().enumerate() {
450 let mut item = pdf.outline_item(items[i]);
451 item.title(TextStr(&entry.title));
452 item.parent(match parent[i] {
453 Some(p) => items[p],
454 None => root,
455 });
456
457 let siblings: &[usize] = match parent[i] {
460 Some(p) => &children[p],
461 None => &roots,
462 };
463 if let Some(pos) = siblings.iter().position(|&s| s == i) {
464 if pos > 0 {
465 item.prev(items[siblings[pos - 1]]);
466 }
467 if pos + 1 < siblings.len() {
468 item.next(items[siblings[pos + 1]]);
469 }
470 }
471
472 if let (Some(&first), Some(&last)) = (children[i].first(), children[i].last()) {
474 item.first(items[first]);
475 item.last(items[last]);
476 item.count(i32::try_from(descendants[i]).unwrap_or(i32::MAX));
477 }
478
479 if let Some((page_ref, _)) = page_refs.get(entry.page_index)
484 && let Some(page) = graph.pages.get(entry.page_index)
485 {
486 let top = page.height_pt - entry.top_from_top_pt;
487 item.dest().page(*page_ref).xyz(0.0, top, None);
488 }
489 }
490}
491
492struct PageEmitContext<'a> {
493 page_tree_id: Ref,
494 page_refs: &'a [(Ref, Ref)],
495 base14_refs: &'a [(Font, Ref)],
496 embedded_refs: &'a HashMap<EmbeddedFontId, EmbeddedRefs>,
497 image_refs: &'a [Ref],
498 encodings: &'a HashMap<Font, DocEncoding>,
499 embedded_by_id: &'a HashMap<EmbeddedFontId, &'a EmbeddedFontPlan>,
500 debug: Option<&'a mos_layout::debug::Report>,
501 debug_font_ref: Option<Ref>,
502}
503
504fn plan_base14_encodings(
505 graph: &PageGraph,
506 diagnostics: &mut Vec<Diagnostic>,
507) -> HashMap<Font, DocEncoding> {
508 let mut planner = EncodingPlanner::new();
509 for page in &graph.pages {
510 planner.observe_runs(&page.runs);
511 }
512 planner.finalize(diagnostics)
513}
514
515fn emit_pages(pdf: &mut Pdf, graph: &PageGraph, ctx: &PageEmitContext<'_>) -> Result<()> {
516 for (index, (page, (page_id, content_id))) in
517 graph.pages.iter().zip(ctx.page_refs.iter()).enumerate()
518 {
519 let debug_page = ctx.debug.map(|report| &report.pages[index]);
520 let extra_height = if debug_page.is_some() {
521 debug::LEGEND_HEIGHT_PT
522 } else {
523 0.0
524 };
525 let width = if debug_page.is_some() {
526 debug::canvas_width(page.width_pt)
527 } else {
528 page.width_pt
529 };
530 let mut page_obj = pdf.page(*page_id);
531 page_obj.media_box(Rect::new(0.0, 0.0, width, page.height_pt + extra_height));
532 page_obj.parent(ctx.page_tree_id);
533 page_obj.contents(*content_id);
534 {
535 let mut resources = page_obj.resources();
536 {
537 let mut fonts = resources.fonts();
538 for (face, font_id) in ctx.base14_refs {
539 fonts.pair(Name(face.pdf_resource_name()), *font_id);
540 }
541 if let Some(font_ref) = ctx.debug_font_ref {
542 fonts.pair(debug::FONT_NAME, font_ref);
543 }
544 for id in EmbeddedFontId::ALL {
545 if let Some(refs) = ctx.embedded_refs.get(&id) {
546 fonts.pair(Name(id.pdf_resource_name()), refs.font);
547 }
548 }
549 }
550 if !graph.images.is_empty() {
551 let mut x_objects = resources.x_objects();
552 for (handle, image_id) in graph.images.iter().zip(ctx.image_refs.iter()) {
553 let name = images::resource_name(handle);
554 x_objects.pair(Name(name.as_bytes()), *image_id);
555 }
556 }
557 }
558 page_obj.finish();
559
560 let mut stream_bytes =
561 content::build_content_stream(page.height_pt, page, ctx.encodings, ctx.embedded_by_id)?;
562 if let Some(debug_page) = debug_page {
563 stream_bytes.splice(..0, b"q\n".iter().copied());
566 stream_bytes.extend_from_slice(b"\nQ\n");
567 stream_bytes.extend_from_slice(&debug::overlay(debug_page));
568 }
569 pdf.stream(*content_id, &stream_bytes);
570 }
571 Ok(())
572}
573
574fn emit_base14_fonts(
575 pdf: &mut Pdf,
576 base14_refs: &[(Font, Ref)],
577 encoding_refs: &HashMap<Font, (Ref, Ref)>,
578) {
579 for (face, font_id) in base14_refs {
580 let Some(base14) = face.base14() else {
581 continue;
582 };
583 let mut font_dict = pdf.type1_font(*font_id);
584 font_dict.base_font(Name(face.pdf_base_name().as_bytes()));
585 if matches!(base14, Base14Font::Symbol | Base14Font::ZapfDingbats) {
586 continue;
587 }
588 match encoding_refs.get(face) {
589 Some(&(enc_ref, cmap_ref)) => {
590 font_dict.pair(Name(b"Encoding"), enc_ref);
591 font_dict.to_unicode(cmap_ref);
592 }
593 None => {
594 font_dict.encoding_predefined(Name(b"WinAnsiEncoding"));
595 }
596 }
597 }
598}
599
600fn emit_images(pdf: &mut Pdf, graph: &PageGraph, image_refs: &[Ref]) {
601 for (handle, id) in graph.images.iter().zip(image_refs.iter()) {
602 let compressed = images::flate_compress(&handle.rgb8);
603 images::emit_image_xobject(pdf, *id, handle, &compressed);
604 }
605}
606
607fn emit_encoding_dict(pdf: &mut Pdf, id: Ref, enc: &DocEncoding) {
614 let mut enc_dict: Encoding<'_> = pdf.indirect(id).start();
615 enc_dict.base_encoding(Name(b"WinAnsiEncoding"));
616 {
617 let mut diffs = enc_dict.differences();
618 let mut i = 0;
619 while i < enc.differences.len() {
620 let (start, _) = enc.differences[i];
621 let mut j = i + 1;
623 while j < enc.differences.len() && enc.differences[j].0 == enc.differences[j - 1].0 + 1
624 {
625 j += 1;
626 }
627 let names = enc.differences[i..j]
628 .iter()
629 .map(|(_, n)| Name(n.as_bytes()));
630 diffs.consecutive(start, names);
631 i = j;
632 }
633 }
634 enc_dict.finish();
635}
636
637fn emit_to_unicode_cmap(pdf: &mut Pdf, id: Ref, enc: &DocEncoding) {
642 let system_info = SystemInfo {
651 registry: Str(b"Adobe"),
652 ordering: Str(b"UCS"),
653 supplement: 0,
654 };
655 let mut cmap: UnicodeCmap<u8> = UnicodeCmap::new(Name(b"Adobe-Identity-UCS"), system_info);
656 for &(byte, ch) in &enc.to_unicode_entries {
657 cmap.pair(byte, ch);
658 }
659 let cmap_bytes = cmap.finish();
660 let mut cmap_writer = pdf.cmap(id, &cmap_bytes);
661 cmap_writer.name(Name(b"Adobe-Identity-UCS"));
662 cmap_writer.system_info(system_info);
663}
664
665#[cfg(test)]
666mod tests {
667 use std::error::Error;
674
675 use lopdf::{Document as LopdfDocument, Object};
676 use mos_layout::{Base14Font, Font, OutlineEntry, Page, PageGraph, TextRun};
677
678 use super::*;
679
680 type TestResult = std::result::Result<(), Box<dyn Error>>;
684
685 macro_rules! ensure {
691 ($cond:expr, $($arg:tt)*) => {
692 if !$cond {
693 return Err(format!($($arg)*).into());
694 }
695 };
696 }
697
698 fn count_bytes(haystack: &[u8], needle: &[u8]) -> usize {
699 haystack
700 .windows(needle.len())
701 .filter(|w| *w == needle)
702 .count()
703 }
704
705 fn sample_graph() -> PageGraph {
706 PageGraph {
707 pages: vec![Page {
708 number: 1,
709 width_pt: 595.276_f32,
710 height_pt: 841.89_f32,
711 runs: vec![
712 TextRun {
713 x_pt: 68.0,
714 baseline_from_top_pt: 100.0,
715 size_pt: 20.0,
716 font: Font::Base14(Base14Font::HelveticaBold),
717 text: "Title".to_owned(),
718 actual_text: None,
719 glyphs: Vec::new(),
720 },
721 TextRun {
722 x_pt: 68.0,
723 baseline_from_top_pt: 130.0,
724 size_pt: 11.0,
725 font: Font::Base14(Base14Font::Helvetica),
726 text: "Body".to_owned(),
727 actual_text: None,
728 glyphs: Vec::new(),
729 },
730 ],
731 images: Vec::new(),
732 }],
733 images: Vec::new(),
734 outline: Vec::new(),
735 }
736 }
737
738 fn info_string<'info>(
739 info: &'info lopdf::Dictionary,
740 key: &[u8],
741 ) -> std::result::Result<&'info str, Box<dyn Error>> {
742 let Object::String(bytes, _) = info.get(key)? else {
743 return Err(format!(
744 "expected Info key /{} to be a string",
745 String::from_utf8_lossy(key)
746 )
747 .into());
748 };
749 Ok(std::str::from_utf8(bytes)?)
750 }
751
752 #[test]
753 fn build_pdf_starts_with_pdf_header_and_ends_with_eof() {
754 let (bytes, diags) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
755 assert!(bytes.starts_with(b"%PDF-"), "missing PDF header");
756 assert!(
757 bytes.windows(5).any(|w| w == b"%%EOF"),
758 "missing %%EOF marker"
759 );
760 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
761 }
762
763 #[test]
764 fn build_pdf_embeds_text_runs_as_visible_strings() {
765 let (bytes, _) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
766 assert!(
769 bytes.windows(b"(Title)".len()).any(|w| w == b"(Title)"),
770 "Title not found in stream"
771 );
772 assert!(
773 bytes.windows(b"(Body)".len()).any(|w| w == b"(Body)"),
774 "Body not found in stream"
775 );
776 }
777
778 #[test]
779 fn empty_graph_still_produces_valid_pdf() {
780 let (bytes, _) = build_pdf(&PageGraph::default(), &PdfMetadata::default()).unwrap();
781 assert!(bytes.starts_with(b"%PDF-"));
782 }
783
784 #[test]
785 fn metadata_and_provenance_appear_in_info_dictionary() -> TestResult {
786 let metadata = PdfMetadata {
787 title: Some("My Doc".to_owned()),
788 author: Some("A. Person".to_owned()),
789 language: None,
790 };
791 let (bytes, _) = build_pdf(&sample_graph(), &metadata).unwrap();
792 let doc = LopdfDocument::load_mem(&bytes)?;
793 let Object::Reference(info_id) = doc.trailer.get(b"Info")? else {
794 return Err("expected trailer /Info reference".into());
795 };
796 let info = doc.get_dictionary(*info_id)?;
797
798 ensure!(info_string(info, b"Title")? == "My Doc", "wrong /Title");
799 ensure!(
800 info_string(info, b"Author")? == "A. Person",
801 "wrong /Author"
802 );
803 ensure!(
804 info_string(info, b"Producer")? == PRODUCER,
805 "wrong /Producer"
806 );
807 ensure!(info_string(info, b"Creator")? == PRODUCER, "wrong /Creator");
808 Ok(())
809 }
810
811 #[test]
812 fn actual_text_is_emitted_for_replacement_runs() {
813 let graph = PageGraph {
814 pages: vec![Page {
815 number: 1,
816 width_pt: 595.276_f32,
817 height_pt: 841.89_f32,
818 runs: vec![TextRun {
819 x_pt: 68.0,
820 baseline_from_top_pt: 100.0,
821 size_pt: 12.0,
822 font: Font::Base14(Base14Font::Courier),
823 text: " println".to_owned(),
824 actual_text: Some("\tprintln".to_owned()),
825 glyphs: Vec::new(),
826 }],
827 images: Vec::new(),
828 }],
829 images: Vec::new(),
830 outline: Vec::new(),
831 };
832
833 let (bytes, diags) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
834
835 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
836 assert!(
837 bytes
838 .windows(b"/ActualText".len())
839 .any(|w| w == b"/ActualText"),
840 "missing /ActualText"
841 );
842 assert!(
843 bytes.windows(b"println".len()).any(|w| w == b"println"),
844 "actual text payload missing"
845 );
846 }
847
848 #[test]
849 fn actual_text_wraps_adjacent_fragments_once() {
850 let graph = PageGraph {
851 pages: vec![Page {
852 number: 1,
853 width_pt: 595.276_f32,
854 height_pt: 841.89_f32,
855 runs: vec![
856 TextRun {
857 x_pt: 68.0,
858 baseline_from_top_pt: 100.0,
859 size_pt: 12.0,
860 font: Font::Base14(Base14Font::Courier),
861 text: " ".to_owned(),
862 actual_text: Some("\tprintln".to_owned()),
863 glyphs: Vec::new(),
864 },
865 TextRun {
866 x_pt: 92.0,
867 baseline_from_top_pt: 100.0,
868 size_pt: 12.0,
869 font: Font::Base14(Base14Font::CourierBold),
870 text: "println".to_owned(),
871 actual_text: Some("\tprintln".to_owned()),
872 glyphs: Vec::new(),
873 },
874 ],
875 images: Vec::new(),
876 }],
877 images: Vec::new(),
878 outline: Vec::new(),
879 };
880
881 let (bytes, diags) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
882
883 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
884 assert_eq!(count_bytes(&bytes, b"/ActualText"), 1);
885 assert_eq!(count_bytes(&bytes, b"println"), 1);
886 }
887
888 fn extended_latin_graph() -> PageGraph {
891 PageGraph {
892 pages: vec![Page {
893 number: 1,
894 width_pt: 595.276_f32,
895 height_pt: 841.89_f32,
896 runs: vec![TextRun {
897 x_pt: 68.0,
898 baseline_from_top_pt: 100.0,
899 size_pt: 12.0,
900 font: Font::Base14(Base14Font::Helvetica),
901 text: "Łódź Příliš ě".to_owned(),
902 actual_text: None,
903 glyphs: Vec::new(),
904 }],
905 images: Vec::new(),
906 }],
907 images: Vec::new(),
908 outline: Vec::new(),
909 }
910 }
911
912 #[test]
913 fn extended_latin_emits_differences_and_to_unicode() {
914 let (bytes, diags) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
915 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
916 assert!(
918 bytes
919 .windows(b"/BaseEncoding /WinAnsiEncoding".len())
920 .any(|w| w == b"/BaseEncoding /WinAnsiEncoding"),
921 "missing /BaseEncoding"
922 );
923 for name in [b"/Lslash" as &[u8], b"/rcaron", b"/ecaron", b"/zacute"] {
928 assert!(
929 bytes.windows(name.len()).any(|w| w == name),
930 "missing {:?} in /Differences",
931 std::str::from_utf8(name).unwrap_or("?")
932 );
933 }
934 assert!(
936 bytes
937 .windows(b"/ToUnicode".len())
938 .any(|w| w == b"/ToUnicode"),
939 "missing /ToUnicode reference"
940 );
941 assert!(
942 bytes
943 .windows(b"beginbfchar".len())
944 .any(|w| w == b"beginbfchar"),
945 "missing beginbfchar in CMap"
946 );
947 }
948
949 #[test]
950 fn pure_ascii_graph_keeps_predefined_winansi_shortcut() {
951 let (bytes, _) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
956 assert!(
957 bytes
958 .windows(b"/Encoding /WinAnsiEncoding".len())
959 .any(|w| w == b"/Encoding /WinAnsiEncoding"),
960 "expected predefined WinAnsi shortcut on ASCII-only doc"
961 );
962 assert!(
963 !bytes
964 .windows(b"/BaseEncoding".len())
965 .any(|w| w == b"/BaseEncoding"),
966 "no custom /Encoding dict expected for ASCII-only doc"
967 );
968 }
969
970 #[test]
971 fn extended_latin_content_stream_uses_remapped_bytes() {
972 let (bytes, _) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
986 let content_stream = first_content_stream(&bytes).expect("content stream not found");
987 let needle = b"7F";
988 assert!(
989 content_stream.windows(needle.len()).any(|w| w == needle),
990 "content stream should reference remapped slot 0x7F"
991 );
992 let qmark_count = content_stream.split(|&b| b == b'?').count() - 1;
993 assert!(
994 qmark_count < 5,
995 "too many `?` in PDF ({qmark_count}); did Ł/ř/ě/ź get substituted?"
996 );
997 }
998
999 #[test]
1000 fn build_pdf_is_byte_for_byte_deterministic() {
1001 let (a, _) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
1006 let (b, _) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
1007 assert_eq!(
1008 a,
1009 b,
1010 "build_pdf is non-deterministic: byte lengths {} vs {}",
1011 a.len(),
1012 b.len()
1013 );
1014 }
1015
1016 fn first_content_stream(bytes: &[u8]) -> Option<&[u8]> {
1024 let open = b"\nstream\n";
1025 let close = b"\nendstream";
1026 let open_at = bytes.windows(open.len()).position(|w| w == open)?;
1027 let body = &bytes[open_at + open.len()..];
1028 let close_at = body.windows(close.len()).position(|w| w == close)?;
1029 Some(&body[..close_at])
1030 }
1031
1032 fn unique_temp_path(label: &str) -> std::path::PathBuf {
1033 std::env::temp_dir().join(format!(
1034 "mos-pdf-test-{label}-{}",
1035 std::time::SystemTime::now()
1036 .duration_since(std::time::UNIX_EPOCH)
1037 .map_or(0, |d| d.as_nanos())
1038 ))
1039 }
1040
1041 #[test]
1042 fn emit_writes_file() -> TestResult {
1043 let dir = unique_temp_path("write");
1044 let out = dir.join("out.pdf");
1045 let diags = emit(&sample_graph(), &PdfMetadata::default(), &out)
1046 .map_err(|e| format!("emit: {e:?}"))?;
1047 ensure!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
1048 let bytes = std::fs::read(&out)?;
1049 ensure!(bytes.starts_with(b"%PDF-"), "missing PDF header");
1050 std::fs::remove_dir_all(&dir).ok();
1051 Ok(())
1052 }
1053
1054 fn image_graph() -> PageGraph {
1058 use mos_layout::{ImageHandle, ImagePlacement};
1059 use std::sync::Arc;
1060 let mut rgb8 = Vec::with_capacity(4 * 2 * 3);
1062 for y in 0..2 {
1063 for x in 0..4 {
1064 if (x + y) % 2 == 0 {
1065 rgb8.extend_from_slice(&[255, 0, 0]);
1066 } else {
1067 rgb8.extend_from_slice(&[0, 0, 255]);
1068 }
1069 }
1070 }
1071 let handle = ImageHandle {
1072 id: 0,
1073 resolved_path: "/tmp/checker.png".to_owned(),
1074 pixel_width: 4,
1075 pixel_height: 2,
1076 rgb8: Arc::from(rgb8),
1077 };
1078 PageGraph {
1079 pages: vec![Page {
1080 number: 1,
1081 width_pt: 595.276_f32,
1082 height_pt: 841.89_f32,
1083 runs: Vec::new(),
1084 images: vec![ImagePlacement {
1085 handle: handle.clone(),
1086 x_pt: 68.0,
1087 top_from_top_pt: 100.0,
1088 width_pt: 40.0,
1089 height_pt: 20.0,
1090 }],
1091 }],
1092 images: vec![handle],
1093 outline: Vec::new(),
1094 }
1095 }
1096
1097 #[test]
1098 fn image_xobject_carries_width_height_and_devicergb() {
1099 let (bytes, diags) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1100 assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
1101 for needle in [
1105 b"/Subtype /Image" as &[u8],
1106 b"/Width 4",
1107 b"/Height 2",
1108 b"/ColorSpace /DeviceRGB",
1109 b"/BitsPerComponent 8",
1110 b"/Filter /FlateDecode",
1111 ] {
1112 assert!(
1113 bytes.windows(needle.len()).any(|w| w == needle),
1114 "missing {:?} in PDF",
1115 std::str::from_utf8(needle).unwrap_or("?")
1116 );
1117 }
1118 }
1119
1120 #[test]
1121 fn image_placement_emits_do_operator_referencing_xobject() {
1122 let (bytes, _) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1123 assert!(
1126 bytes.windows(b"/Im0 ".len()).any(|w| w == b"/Im0 "),
1127 "/Im0 resource name not found"
1128 );
1129 assert!(
1130 bytes.windows(b"/Im0 Do".len()).any(|w| w == b"/Im0 Do"),
1131 "/Im0 Do operator not found in content stream"
1132 );
1133 }
1134
1135 #[test]
1136 fn duplicate_image_emits_one_xobject() {
1137 use mos_layout::{ImageHandle, ImagePlacement};
1141 use std::sync::Arc;
1142 let handle = ImageHandle {
1143 id: 0,
1144 resolved_path: "/tmp/shared.png".to_owned(),
1145 pixel_width: 1,
1146 pixel_height: 1,
1147 rgb8: Arc::from(vec![10_u8, 20, 30]),
1148 };
1149 let graph = PageGraph {
1150 pages: vec![Page {
1151 number: 1,
1152 width_pt: 595.276_f32,
1153 height_pt: 841.89_f32,
1154 runs: Vec::new(),
1155 images: vec![
1156 ImagePlacement {
1157 handle: handle.clone(),
1158 x_pt: 10.0,
1159 top_from_top_pt: 50.0,
1160 width_pt: 5.0,
1161 height_pt: 5.0,
1162 },
1163 ImagePlacement {
1164 handle: handle.clone(),
1165 x_pt: 100.0,
1166 top_from_top_pt: 50.0,
1167 width_pt: 5.0,
1168 height_pt: 5.0,
1169 },
1170 ],
1171 }],
1172 images: vec![handle],
1173 outline: Vec::new(),
1174 };
1175 let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1176 let xobject_marker = b"/Subtype /Image";
1177 let count = bytes
1178 .windows(xobject_marker.len())
1179 .filter(|w| *w == xobject_marker)
1180 .count();
1181 assert_eq!(count, 1, "expected exactly one Image XObject, got {count}");
1182 let do_count = bytes
1184 .windows(b"/Im0 Do".len())
1185 .filter(|w| *w == b"/Im0 Do")
1186 .count();
1187 assert_eq!(
1188 do_count, 2,
1189 "expected two /Im0 Do operators, got {do_count}"
1190 );
1191 }
1192
1193 #[test]
1194 fn image_only_pdf_remains_byte_deterministic() {
1195 let (a, _) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1196 let (b, _) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1197 assert_eq!(a, b, "image emit must be byte-stable across runs");
1198 }
1199
1200 #[test]
1201 fn emit_fails_with_mos0014_when_target_is_a_directory() -> TestResult {
1202 let dir = unique_temp_path("conflict");
1206 std::fs::create_dir_all(&dir)?;
1207 let result = emit(&sample_graph(), &PdfMetadata::default(), &dir);
1210 std::fs::remove_dir_all(&dir).ok();
1211 let Err(err) = result else {
1212 return Err("expected emit to fail when target is a directory".into());
1213 };
1214 let CoreError::Diagnostic(d) = err else {
1215 return Err("expected Diagnostic, got Unimplemented".into());
1216 };
1217 ensure!(
1218 d.def().code() == codes::MOS0014.code(),
1219 "wrong code: {:?}",
1220 d.def().code()
1221 );
1222 ensure!(
1223 d.message().contains("could not write PDF"),
1224 "message={:?}",
1225 d.message()
1226 );
1227 Ok(())
1228 }
1229
1230 fn outline_graph(entries: Vec<OutlineEntry>) -> PageGraph {
1234 PageGraph {
1235 pages: vec![Page {
1236 number: 1,
1237 width_pt: 595.276_f32,
1238 height_pt: 841.89_f32,
1239 runs: Vec::new(),
1240 images: Vec::new(),
1241 }],
1242 images: Vec::new(),
1243 outline: entries,
1244 }
1245 }
1246
1247 fn entry(level: u8, title: &str, top_from_top_pt: f32) -> OutlineEntry {
1248 OutlineEntry {
1249 level,
1250 title: title.to_owned(),
1251 page_index: 0,
1252 top_from_top_pt,
1253 }
1254 }
1255
1256 fn ref_id(
1258 dict: &lopdf::Dictionary,
1259 key: &[u8],
1260 ) -> std::result::Result<(u32, u16), Box<dyn Error>> {
1261 match dict.get(key)? {
1262 Object::Reference(id) => Ok(*id),
1263 other => Err(format!(
1264 "expected /{} to be a reference, got {other:?}",
1265 String::from_utf8_lossy(key)
1266 )
1267 .into()),
1268 }
1269 }
1270
1271 fn load_outlines(
1273 doc: &LopdfDocument,
1274 ) -> std::result::Result<&lopdf::Dictionary, Box<dyn Error>> {
1275 let root_id = ref_id(&doc.trailer, b"Root")?;
1276 let catalog = doc.get_dictionary(root_id)?;
1277 let outlines_id = ref_id(catalog, b"Outlines")?;
1278 Ok(doc.get_dictionary(outlines_id)?)
1279 }
1280
1281 #[test]
1282 fn outline_emitted_when_headings_present() -> TestResult {
1283 let graph = outline_graph(vec![entry(1, "One", 100.0), entry(2, "Two", 200.0)]);
1284 let (bytes, diags) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1285 ensure!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
1286 ensure!(
1287 count_bytes(&bytes, b"/Type /Outlines") >= 1,
1288 "missing /Type /Outlines"
1289 );
1290
1291 let doc = LopdfDocument::load_mem(&bytes)?;
1292 let outlines = load_outlines(&doc)?;
1293 let Object::Integer(count) = outlines.get(b"Count")? else {
1295 return Err("missing /Count on root /Outlines".into());
1296 };
1297 ensure!(*count == 2, "wrong root /Count: {count}");
1298 ensure!(
1300 ref_id(outlines, b"First")? == ref_id(outlines, b"Last")?,
1301 "single top-level entry must be both /First and /Last"
1302 );
1303 Ok(())
1304 }
1305
1306 #[test]
1307 fn no_outline_dict_for_heading_free_doc() -> TestResult {
1308 let (bytes, _) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
1312 ensure!(
1313 count_bytes(&bytes, b"/Outlines") == 0,
1314 "heading-free doc must emit no /Outlines"
1315 );
1316 let doc = LopdfDocument::load_mem(&bytes)?;
1317 let root_id = ref_id(&doc.trailer, b"Root")?;
1318 let catalog = doc.get_dictionary(root_id)?;
1319 ensure!(
1320 catalog.get(b"Outlines").is_err(),
1321 "catalog must not carry an /Outlines key when there are no headings"
1322 );
1323 Ok(())
1324 }
1325
1326 #[test]
1327 fn outline_nesting_wires_parent_first_last() -> TestResult {
1328 let graph = outline_graph(vec![
1330 entry(1, "Chapter", 100.0),
1331 entry(2, "First", 200.0),
1332 entry(2, "Second", 300.0),
1333 ]);
1334 let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1335 let doc = LopdfDocument::load_mem(&bytes)?;
1336 let outlines = load_outlines(&doc)?;
1337
1338 let h1_id = ref_id(outlines, b"First")?;
1340 ensure!(h1_id == ref_id(outlines, b"Last")?, "H1 must be sole root");
1341 let h1 = doc.get_dictionary(h1_id)?;
1342
1343 let first_h2_id = ref_id(h1, b"First")?;
1344 let last_h2_id = ref_id(h1, b"Last")?;
1345 ensure!(first_h2_id != last_h2_id, "the two H2 items must differ");
1346
1347 let first_h2 = doc.get_dictionary(first_h2_id)?;
1348 let last_h2 = doc.get_dictionary(last_h2_id)?;
1349 ensure!(
1351 ref_id(first_h2, b"Parent")? == h1_id,
1352 "first H2 /Parent wrong"
1353 );
1354 ensure!(
1355 ref_id(last_h2, b"Parent")? == h1_id,
1356 "last H2 /Parent wrong"
1357 );
1358 ensure!(ref_id(first_h2, b"Next")? == last_h2_id, "H2 /Next wrong");
1360 ensure!(ref_id(last_h2, b"Prev")? == first_h2_id, "H2 /Prev wrong");
1361 Ok(())
1362 }
1363
1364 #[test]
1365 fn outline_skipped_level_attaches_to_nearest_ancestor() -> TestResult {
1366 let graph = outline_graph(vec![entry(1, "Top", 100.0), entry(3, "Deep", 200.0)]);
1368 let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1369 let doc = LopdfDocument::load_mem(&bytes)?;
1370 let outlines = load_outlines(&doc)?;
1371
1372 let h1_id = ref_id(outlines, b"First")?;
1373 let h1 = doc.get_dictionary(h1_id)?;
1374 let h3_id = ref_id(h1, b"First")?;
1375 let h3 = doc.get_dictionary(h3_id)?;
1376 ensure!(
1377 ref_id(h3, b"Parent")? == h1_id,
1378 "skipped-level H3 must attach to the nearest shallower ancestor (H1)"
1379 );
1380 Ok(())
1381 }
1382
1383 #[test]
1384 fn outline_dest_uses_flipped_top_coordinate() -> TestResult {
1385 let top_from_top = 120.0_f32;
1386 let graph = outline_graph(vec![entry(1, "Heading", top_from_top)]);
1387 let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1388 let doc = LopdfDocument::load_mem(&bytes)?;
1389 let outlines = load_outlines(&doc)?;
1390
1391 let item = doc.get_dictionary(ref_id(outlines, b"First")?)?;
1392 let Object::Array(dest) = item.get(b"Dest")? else {
1393 return Err("missing /Dest array on outline item".into());
1394 };
1395 let Some(Object::Real(top)) = dest.get(3) else {
1398 return Err(format!("dest top not a real: {dest:?}").into());
1399 };
1400 let expected = 841.89_f32 - top_from_top;
1401 let within_tolerance = (*top - expected).abs() < 0.01;
1402 ensure!(
1403 within_tolerance,
1404 "wrong dest top: {top} vs expected {expected}"
1405 );
1406 Ok(())
1407 }
1408
1409 #[test]
1410 fn outline_pdf_is_byte_for_byte_deterministic() {
1411 let graph = outline_graph(vec![
1414 entry(1, "Chapter", 100.0),
1415 entry(2, "First", 200.0),
1416 entry(2, "Second", 300.0),
1417 ]);
1418 let (a, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1419 let (b, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1420 assert_eq!(a, b, "outline emit must be byte-stable across runs");
1421 }
1422
1423 #[test]
1424 fn outline_after_populated_encoding_map_is_byte_deterministic() {
1425 let mut graph = extended_latin_graph();
1432 graph.outline = vec![entry(1, "Chapter", 100.0), entry(2, "Section", 200.0)];
1433 let (a, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1434 let (b, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1435 assert_eq!(a, b, "outline + custom encoding build must be byte-stable");
1436 }
1437}