#![cfg(feature = "write")] #![allow(clippy::unwrap_used, clippy::panic)] use backend::configure::{Archival, PdfVersion}; use backend::geom::{PathBuilder, Rect, Size}; use backend::page::PageSettings; use backend::paint::Fill; use craft_pdf::write::{self, Metadata, WriteError, Writer, backend}; fn document(compress: bool) -> Vec { let metadata = Metadata { creator: "Craft writer contract".into(), producer: "Craft writer contract".into(), title: Some("Synthetic export".into()), authors: vec!["Test author".into()], description: Some("Original synthetic artwork".into()), keywords: vec!["craft".into(), "pdf".into()], created: Some(backend::metadata::DateTime::new(2026).month(10).day(9)), modified: Some(backend::metadata::DateTime::new(2026).month(10).day(12)), }; let settings = backend::SerializeSettings { compress_content_streams: compress, configuration: write::configuration(PdfVersion::Pdf16, None).unwrap(), ..Default::default() }; let mut writer = Writer::new(settings, metadata); for (width, height) in [(80.0, 40.0), (30.0, 60.0)] { let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(width, height).unwrap())); let mut surface = page.surface(); let mut path = PathBuilder::new(); path.push_rect(Rect::from_xywh(2.0, 3.0, 15.0, 12.0).unwrap()); surface.set_fill(Some(Fill { paint: backend::color::rgb::Color::new(20, 80, 160).into(), ..Default::default() })); surface.draw_path(&path.finish().unwrap()); surface.finish(); page.finish(); } writer.finish().unwrap() } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn writes_pages_metadata_and_selected_version_without_a_renderer() { for compress in [false, true] { let bytes = document(compress); assert!(bytes.starts_with(b"%PDF-1.6")); let text = String::from_utf8_lossy(&bytes); for value in ["Synthetic export", "Test author", "Craft writer contract", "Original synthetic artwork"] { assert!(text.contains(value), "missing {value}"); } let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.clone())).unwrap(); let sizes: Vec<_> = pdf.pages().iter().map(|page| page.render_dimensions()).collect(); assert_eq!(sizes, vec![(80.0, 40.0), (30.0, 60.0)]); let day = |d: &Option| d.as_ref().map(|d| (d.year, d.month, d.day)); assert_eq!(day(&pdf.metadata().creation_date), Some((2026, 10, 9))); assert_eq!(day(&pdf.metadata().modification_date), Some((2026, 10, 12))); assert!(text.contains("2026-10-09") && text.contains("2026-10-12"), "XMP dates"); } } /// Without a modification date, the creation date is written as both. #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn the_creation_date_is_the_modification_date_when_none_is_given() { let created = Some(backend::metadata::DateTime::new(2026).month(10).day(9)); let mut writer = Writer::new(Default::default(), Metadata { created, ..Default::default() }); writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()).finish(); let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(writer.finish().unwrap())).unwrap(); let day = |d: &Option| d.as_ref().map(|d| (d.year, d.month, d.day)); assert_eq!(day(&pdf.metadata().modification_date), Some((2026, 10, 9))); } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn fixed_input_does_not_read_a_clock_or_change_pdf_bytes() { assert_eq!(document(false), document(false)); assert_eq!(document(true), document(true)); } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn document_language_is_preserved_in_catalog_and_xmp_when_supplied() { use hayro_syntax::object::{Dict, String as PdfString}; for language in [None, Some("es-CR"), Some("ja")] { let mut writer = Writer::new_with_language(Default::default(), Metadata::default(), language.map(str::to_owned)); writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()).finish(); let bytes = writer.finish().unwrap(); let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.clone())).unwrap(); let catalog = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); let actual = catalog.get::>(b"Lang"); assert_eq!(actual.as_ref().map(|value| value.as_bytes()), language.map(str::as_bytes)); let xmp = String::from_utf8_lossy(&bytes); if let Some(language) = language { assert!(xmp.contains("")); assert!(xmp.contains(&format!("{language}"))); } else { assert!(!xmp.contains("")); } } } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn incompatible_archival_version_is_a_configuration_error() { assert!(matches!(write::configuration(PdfVersion::Pdf17, Some(Archival::A1_B)), Err(WriteError::Configuration(_)))); } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn serialization_preserves_archival_validation_diagnostics() { let settings = backend::SerializeSettings { configuration: write::configuration(PdfVersion::Pdf17, Some(Archival::A2_B)).unwrap(), ..Default::default() }; // Deliberately missing the metadata required by the selected archival standard. let mut writer = Writer::new(settings, Metadata::default()); writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()).finish(); let Err(WriteError::Serialization(error)) = writer.finish() else { panic!("expected archival validation errors") }; assert!(matches!(*error, backend::error::KrillaError::Validation(_))); } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn invalid_fonts_and_variations_are_fallible() { assert!(matches!(write::font(b"not a font", 0, None), Err(WriteError::InvalidFont))); for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { assert!(matches!(write::font(&[], 0, Some(&[(*b"wght", value)])), Err(WriteError::FontVariations))); } } #[derive(Debug, PartialEq, Eq)] enum AppError { InvalidPage, Panic, Writer, } impl From for AppError { fn from(error: WriteError) -> Self { if matches!(error, WriteError::BackendPanic(_)) { Self::Panic } else { Self::Writer } } } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn export_guard_preserves_application_errors() { let result: Result<(), AppError> = write::guard(|| Err(AppError::InvalidPage)); assert_eq!(result, Err(AppError::InvalidPage)); } #[cfg(not(target_arch = "wasm32"))] #[test] fn a_native_drawing_panic_is_an_error_and_a_fresh_export_still_works() { let result: Result<(), AppError> = write::guard(|| { let mut writer = Writer::new(Default::default(), Metadata::default()); let _page = writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()); panic!("synthetic app drawing failure"); }); assert_eq!(result, Err(AppError::Panic)); assert!(!document(false).is_empty()); } #[cfg(feature = "write-pdf")] #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn embedded_pages_can_be_written_without_interpretation_or_rasterization() { let source = backend::pdf::Pdf::new(std::sync::Arc::new(document(false))).unwrap(); let source = backend::pdf::PdfDocument::new(std::sync::Arc::new(source)); let mut writer = Writer::new(Default::default(), Metadata::default()); let size = Size::from_wh(80.0, 40.0).unwrap(); let mut page = writer.document_mut().start_page_with(PageSettings::new(size)); let mut surface = page.surface(); surface.draw_pdf_page(&source, size, 0); surface.finish(); page.finish(); let bytes = writer.finish().unwrap(); let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.clone())).unwrap(); assert_eq!(pdf.pages().len(), 1); assert_eq!(pdf.pages()[0].render_dimensions(), (80.0, 40.0)); } #[cfg(not(target_arch = "wasm32"))] #[test] fn native_unbalanced_surface_cleanup_survives_in_a_subprocess() { const CHILD: &str = "CRAFT_PDF_SURFACE_PANIC_PROBE"; if std::env::var_os(CHILD).is_some() { let result: Result<(), AppError> = write::guard(|| { let mut writer = Writer::new(Default::default(), Metadata::default()); let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()); let mut surface = page.surface(); surface.push_transform(&backend::geom::Transform::from_translate(2.0, 3.0)); let mut clip = PathBuilder::new(); clip.push_rect(Rect::from_xywh(0.0, 0.0, 20.0, 10.0).unwrap()); surface.push_clip_path(&clip.finish().unwrap(), &backend::paint::FillRule::NonZero); surface.push_isolated(); surface.start_tagged(backend::tagging::ContentTag::Other); panic!("synthetic app drawing failure with open surface state"); }); assert_eq!(result, Err(AppError::Panic)); assert!(!document(false).is_empty()); return; } let output = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", "native_unbalanced_surface_cleanup_survives_in_a_subprocess", "--nocapture"]) .env(CHILD, "1") .output() .unwrap(); assert!(output.status.success(), "child failed: {:?}\n{}", output.status, String::from_utf8_lossy(&output.stderr)); } #[cfg(not(target_arch = "wasm32"))] #[test] fn native_panic_diagnostics_preserve_bounded_unicode_text() { let result: Result<(), WriteError> = write::guard(|| panic!("synthetic app export failure")); assert!(matches!(result, Err(WriteError::BackendPanic(message)) if message == "synthetic app export failure")); let result: Result<(), WriteError> = write::guard(|| std::panic::panic_any("界".repeat(2048))); assert!(matches!(result, Err(WriteError::BackendPanic(message)) if message == "界".repeat(1024))); let result: Result<(), WriteError> = write::guard(|| std::panic::panic_any(7_u32)); assert!(matches!(result, Err(WriteError::BackendPanic(message)) if message == "non-string panic payload")); } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn empty_metadata_stays_absent_and_populated_metadata_stays_present() { let export = |metadata| { let mut writer = Writer::new(Default::default(), metadata); writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()).finish(); writer.finish().unwrap() }; let empty = export(Metadata { title: Some(String::new()), description: Some(String::new()), ..Default::default() }); assert_eq!(empty, export(Metadata::default())); let empty = String::from_utf8_lossy(&empty); for key in [ "/Title", "/Author", "/Keywords", "/Creator", "/Producer", "/Subject", "/CreationDate", "", "", "", "", "", ] { assert!(!empty.contains(key), "unexpected empty metadata {key}"); } let full = document(false); let full = String::from_utf8_lossy(&full); for key in [ "/Title", "/Author", "/Keywords", "/Creator", "/Producer", "/Subject", "/CreationDate", "", "", "", "", "", ] { assert!(full.contains(key), "missing populated metadata {key}"); } for title in [None, Some(String::new())] { let settings = backend::SerializeSettings { configuration: write::configuration(PdfVersion::Pdf17, Some(Archival::A2_B)).unwrap(), ..Default::default() }; let mut writer = Writer::new(settings, Metadata { title, ..Default::default() }); writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()).finish(); assert!(matches!(writer.finish(), Err(WriteError::Serialization(_))), "empty title must not bypass archival validation"); } } #[cfg(not(target_arch = "wasm32"))] #[test] fn retaining_a_writer_after_page_unwind_cannot_serialize_missing_pages() { for mode in ["page", "surface", "explicit finish"] { let settings = backend::SerializeSettings { enable_tagging: false, ..Default::default() }; let mut writer = Writer::new(settings, Metadata::default()); writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()).finish(); let result: Result<(), WriteError> = if mode == "page" { write::guard(|| { let _page = writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()); panic!("synthetic failed middle page"); }) } else { // Keep the page outside the narrow guard: successful later page cleanup // must not clear poison from an unwound or explicitly failed surface. let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()); let result = write::guard(|| { let mut surface = page.surface(); surface.push_transform(&backend::geom::Transform::from_translate(2.0, 3.0)); if mode == "explicit finish" { surface.finish(); return Ok(()); } panic!("synthetic failed middle surface"); }); page.finish(); result }; assert!(matches!(result, Err(WriteError::BackendPanic(_)))); writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()).finish(); assert!( matches!(writer.finish(), Err(WriteError::Serialization(error)) if matches!(*error, backend::error::KrillaError::Poisoned)), "a retained failed writer must reject serialization after {mode}" ); } } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn empty_language_is_absent_and_keeps_required_language_validation() { let mut outputs = Vec::new(); for language in [None, Some(String::new())] { let mut writer = Writer::new_with_language(Default::default(), Metadata::default(), language.clone()); writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()).finish(); outputs.push(writer.finish().unwrap()); let settings = backend::SerializeSettings { configuration: write::configuration(PdfVersion::Pdf17, Some(Archival::A2_A)).unwrap(), ..Default::default() }; let mut writer = Writer::new_with_language(settings, Metadata::default(), language); writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()).finish(); let Err(WriteError::Serialization(error)) = writer.finish() else { panic!("expected archival errors") }; let backend::error::KrillaError::Validation(errors) = *error else { panic!("expected validation diagnostics") }; assert!( errors.iter().any(|(error, _)| matches!(error, backend::configure::ValidationError::NoDocumentLanguage)), "empty language must not bypass archival language validation" ); } assert_eq!(outputs[0], outputs[1], "empty language and absent language must serialize identically"); } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn only_web_and_mail_link_targets_become_uri_links() { use backend::annotation::{Annotation, LinkAnnotation}; use hayro_syntax::object::{Array, Dict, String as PdfString}; let urls = [ " https://example.com/deck ", "mailto:someone@example.com", "javascript:alert(1)", "data:text/html,", "file://attacker.example/share/x", "\\\\attacker.example\\share\\x", "//attacker.example/share/x", "ms-msdt:/id PCWDiagnostic", "search-ms:query=x&crumb=location:\\\\attacker.example\\share", ]; let settings = backend::SerializeSettings { configuration: write::configuration(PdfVersion::Pdf17, None).unwrap(), ..Default::default() }; let mut writer = Writer::new(settings, Metadata::default()); let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(200.0, 400.0).unwrap())); for (i, url) in (0u8..).zip(urls) { let rect = Rect::from_xywh(10.0, 10.0 + 30.0 * f32::from(i), 100.0, 20.0).unwrap(); if let Some(target) = write::uri_target(url) { page.add_annotation(Annotation::new_link(LinkAnnotation::new(rect, target), None)); } } page.finish(); let bytes = writer.finish().unwrap(); let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes)).unwrap(); let annotations = pdf.pages()[0].raw().get::>(b"Annots").unwrap(); let uris: Vec = annotations .iter::>() .filter_map(|a| a.get::>(b"A")?.get::>(b"URI")) .map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned()) .collect(); assert_eq!(uris, ["https://example.com/deck", "mailto:someone@example.com"]); }