diff --git a/Cargo.toml b/Cargo.toml index 104bd06..a0b76b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "textris-pdf" -version = "0.10.4" +version = "0.11.0" license = "MIT" edition = "2024" description = "Simple but opinionated document generation" diff --git a/README.md b/README.md index bc33857..9daaabd 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,40 @@ Header and footer sections additionally accept `SectionContent::page_counter(|page, total| …)` for a `Page N of M` counter, filled in once the total page count is known. +To append several documents into one PDF, each with its own chrome and +numbering, see [Several documents in one PDF](#several-documents-in-one-pdf). + +## Several documents in one PDF + +A [`Bundle`](src/build/mod.rs) appends whole documents into a single PDF file. +Each document keeps everything it dictates itself: its theme (so page sizes +may differ), its running header and footer, its page counter, which restarts +at 1 with that document's own page count as the total, and its section +numbering, which also counts from 1 again. Only what a file has one of, the +title, language and creation date in the PDF metadata, is set on the bundle; +left unset, it falls back to the first document's. + +```rust +use textris_pdf::build::{Bundle, Textris}; + +let mut report = Textris::new(); +report.h1("Report").footer_right("Report page"); // fill as usual +let mut appendix = Textris::new(); +appendix.h1("Appendix").footer_right("Appendix page"); + +let mut bundle = Bundle::new(); +bundle.title("Report with appendix").language("en"); +bundle.push(report).push(appendix); // Textris by value or reference, or a Document +bundle.render_to_file("out.pdf", &fonts)?; +``` + +In the tagged output each document becomes a `Part` of the structure tree, and +the bookmark outline nests each document's headings on their own. Code that +walks the pipeline by hand (as the web editor does, for its source map) can use +[`render::render_many`](src/render.rs), which takes already laid-out documents +plus a [`PdfMetadata`](src/render.rs). `tests/bundle.rs` exercises the whole +feature and writes `tests/bundle-example.pdf` for inspection. + ## Markdown, docx Besides the PDF pipeline there are structural exports, and a Markdown *input* diff --git a/src/build/mod.rs b/src/build/mod.rs index 05146cd..4c94fdd 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -25,6 +25,26 @@ //! # let _ = pdf; //! ``` //! +//! ## Several documents in one PDF +//! +//! A [`Bundle`] appends whole documents, each with its own chrome, theme, page +//! numbering and section numbering, into a single PDF file: +//! +//! ```no_run +//! use textris_pdf::build::{Bundle, Textris}; +//! use textris_pdf::fonts::Fonts; +//! +//! let fonts = Fonts::from_variable_files("regular.ttf", "italic.ttf", "mono.ttf").unwrap(); +//! let (report, appendix) = (Textris::new(), Textris::new()); +//! // ... fill both as usual; each gets its own header, footer and page counter. +//! +//! let mut bundle = Bundle::new(); +//! bundle.title("Annual report, with appendix"); +//! bundle.push(report).push(appendix); +//! let pdf = bundle.render(&fonts); +//! # let _ = pdf; +//! ``` +//! //! ## Rich text //! //! Anywhere a method takes text it accepts [`IntoText`]: a plain `&str`/`String` @@ -54,7 +74,7 @@ use std::{io, path::Path}; use crate::{ fonts::Fonts, model::{Block, Cell, Chrome, Document, Inline, ListMarker, SectionContent, Table, TaskItem}, - render::RenderError, + render::{Part, PdfMetadata, RenderError}, theme::{BoxStyle, TableStyle, Theme}, }; @@ -727,6 +747,156 @@ impl Textris { } } +impl From for Document { + /// The assembled document with its section numbering resolved: the same + /// as [`Textris::build`]. + fn from(builder: Textris) -> Self { + builder.build() + } +} + +impl From<&Textris> for Document { + /// A resolved copy of the document built so far, leaving the builder + /// untouched; see [`Textris::build`]. + fn from(builder: &Textris) -> Self { + builder.clone().build() + } +} + +/// Several documents appended into one PDF, in order. +/// +/// Each document keeps everything it dictates itself: its [`Theme`] (so page +/// sizes may differ), its running header and footer, its page counter (which +/// restarts at 1 and whose total is that document's own page count) and its +/// section numbering, exactly as if it had been rendered on its own. Only what +/// a PDF file has one of - the title, language and creation date written to +/// the metadata - is set on the bundle; a field left unset there falls back to +/// the first document's, and then to the renderer's usual defaults (the first +/// heading, `"en"`, the system clock). +/// +/// Push documents as finished [`Textris`] builders (by value or by reference) +/// or as bare [`Document`]s; section numbering is resolved per document, so +/// numbered headings count from 1 in each. Then [`render`](Self::render): +/// +/// ```no_run +/// # use textris_pdf::build::{Bundle, Textris}; +/// # use textris_pdf::fonts::Fonts; +/// # let fonts = Fonts::from_variable_files("regular.ttf", "italic.ttf", "mono.ttf").unwrap(); +/// let mut report = Textris::new(); +/// report.h1("Report").footer_right("Report page"); +/// let mut appendix = Textris::new(); +/// appendix.h1("Appendix").footer_right("Appendix page"); +/// +/// let mut bundle = Bundle::new(); +/// bundle.title("Report with appendix").language("en"); +/// bundle.push(report).push(appendix); +/// bundle.render_to_file("out.pdf", &fonts).unwrap(); +/// ``` +/// +/// In the PDF's accessibility structure each document becomes a `Part` under +/// the document root, and the bookmark outline nests each document's headings +/// on their own. For the lower-level entry point that takes already laid-out +/// documents, see [`render_many`](crate::render::render_many). +#[derive(Debug, Default, Clone)] +pub struct Bundle { + documents: Vec, + metadata: PdfMetadata, +} + +impl Bundle { + /// An empty bundle. Rendering it without any documents is an error + /// ([`RenderError::NoDocuments`]). + pub fn new() -> Self { + Self::default() + } + + /// Set the title of the combined PDF (see [`Textris::title`]). Falls back + /// to the first document's title when unset. + pub fn title(&mut self, title: impl Into) -> &mut Self { + self.metadata.title = Some(title.into()); + self + } + + /// Set the language of the combined PDF (see [`Textris::language`]). Falls + /// back to the first document's language when unset. + pub fn language(&mut self, language: impl Into) -> &mut Self { + self.metadata.language = Some(language.into()); + self + } + + /// Set the creation date of the combined PDF, as Unix seconds (see + /// [`Textris::created_at`]). Falls back to the first document's when unset. + pub fn created_at(&mut self, unix_seconds: i64) -> &mut Self { + self.metadata.created = Some(unix_seconds); + self + } + + /// Append a document. Accepts a [`Textris`] builder by value or by + /// reference (its sections are resolved as by [`Textris::build`]) or a + /// bare [`Document`]. + pub fn push(&mut self, document: impl Into) -> &mut Self { + self.documents.push(document.into()); + self + } + + /// The documents appended so far, in order. + pub fn documents(&self) -> &[Document] { + &self.documents + } + + /// The metadata set on the bundle itself, before any fallback. + pub fn metadata(&self) -> &PdfMetadata { + &self.metadata + } + + /// Lay out every document and render them, appended in order, into one + /// tagged, accessible PDF/A-2A + PDF/UA-1 file. + pub fn render(&self, fonts: &Fonts) -> Result, RenderError> { + let documents: Vec = self + .documents + .iter() + .map(|document| { + // Idempotent, so a document that arrived resolved is unchanged. + let mut document = document.clone(); + document.resolve_sections(); + document + }) + .collect(); + let layouts: Vec<_> = documents + .iter() + .map(|document| crate::layout::layout(document, fonts)) + .collect(); + let parts: Vec> = layouts + .iter() + .zip(&documents) + .map(|(layout, document)| Part { layout, document }) + .collect(); + let fallback = documents.first().map(PdfMetadata::from).unwrap_or_default(); + let metadata = self.metadata.clone().or(&fallback); + crate::render::render_many(&parts, &metadata, fonts) + } + + /// Render the bundle and write the PDF to `path`. + pub fn render_to_file(&self, path: impl AsRef, fonts: &Fonts) -> io::Result<()> { + let pdf = self.render(fonts).map_err(io::Error::other)?; + std::fs::write(path, pdf) + } +} + +impl> Extend for Bundle { + fn extend>(&mut self, documents: I) { + self.documents.extend(documents.into_iter().map(Into::into)); + } +} + +impl> FromIterator for Bundle { + fn from_iter>(documents: I) -> Self { + let mut bundle = Self::new(); + bundle.extend(documents); + bundle + } +} + /// A table under construction, handed to the closure of [`Textris::table_with`]. /// /// Defaults to [`TableStyle::data`]; call [`style`](Self::style) to use another. @@ -904,6 +1074,36 @@ mod tests { assert!(!items[1].checked); } + #[test] + fn bundle_resolves_section_numbering_per_document() { + let mut first = Textris::new(); + first.h3_numbered("Intro").h3_numbered("Body"); + let mut second = Textris::new(); + second.h3_numbered("Intro"); + + let bundle: Bundle = [first, second].into_iter().collect(); + let heading = |doc: usize, block: usize| match &bundle.documents()[doc].blocks[block] { + Block::Heading { content, .. } => plain_text(content), + _ => panic!("expected a heading"), + }; + assert_eq!(heading(0, 0), "1. Intro"); + assert_eq!(heading(0, 1), "2. Body"); + assert_eq!(heading(1, 0), "1. Intro", "numbering restarts per document"); + } + + #[test] + fn bundle_accepts_builders_by_value_by_reference_and_bare_documents() { + let by_value = Textris::new(); + let by_reference = Textris::new(); + let mut bundle = Bundle::new(); + bundle + .push(by_value) + .push(&by_reference) + .push(Document::default()); + assert_eq!(bundle.documents().len(), 3); + assert_eq!(bundle.metadata(), &PdfMetadata::default()); + } + #[test] fn label_table_has_blank_headers() { let mut doc = Textris::new(); diff --git a/src/lib.rs b/src/lib.rs index f4cbe23..bcfad4a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,9 @@ //! [`theme`] holds the visual design tokens shared across stages, plus the //! per-element styles ([`theme::TableStyle`], [`theme::BoxStyle`]). //! +//! Several documents can be appended into one PDF with [`build::Bundle`]; each +//! keeps its own theme, header and footer, page counter and section numbering. +//! //! ## Example //! //! ```no_run diff --git a/src/render.rs b/src/render.rs index d7d3463..ec2f67b 100644 --- a/src/render.rs +++ b/src/render.rs @@ -5,6 +5,10 @@ //! draws the running header and footer (which depend on the total page count //! and therefore cannot be produced during layout). //! +//! [`render`] paints one document; [`render_many`] appends several laid-out +//! documents into a single PDF, each keeping its own theme, chrome and page +//! numbering (see [`crate::build::Bundle`] for the builder-level API). +//! //! ## Accessibility //! //! The output conforms to **PDF/A-2A** (the accessible archival profile of PDF @@ -61,6 +65,8 @@ pub enum RenderError { /// krilla failed to serialize the document, most commonly a validation /// failure against the PDF/A-2A or PDF/UA-1 profile. Serialize(KrillaError), + /// [`render_many`] was given no documents; a PDF needs at least one page. + NoDocuments, } impl fmt::Display for RenderError { @@ -70,12 +76,65 @@ impl fmt::Display for RenderError { write!(f, "invalid page size: {width} x {height} pt") } Self::Serialize(error) => write!(f, "failed to serialize PDF: {error}"), + Self::NoDocuments => write!(f, "no documents to render"), } } } impl std::error::Error for RenderError {} +/// The document-level metadata written to the PDF: the title and language +/// (both required by PDF/UA) and the creation date (required by PDF/A). +/// +/// Every field is optional and falls back the same way as the fields of +/// [`Document`] it mirrors: the title to the first heading in the output, the +/// language to `"en"`, the date to the system clock. [`render`] takes them +/// from the document itself (see [`From<&Document>`](#impl-From<%26Document>-for-PdfMetadata)); +/// [`render_many`] takes them explicitly, because a PDF that appends several +/// documents has exactly one title. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PdfMetadata { + /// See [`Document::title`]. + pub title: Option, + /// See [`Document::language`]. + pub language: Option, + /// See [`Document::created`]. + pub created: Option, +} + +impl PdfMetadata { + /// Fill every unset field from `fallback`. + pub fn or(self, fallback: &Self) -> Self { + Self { + title: self.title.or_else(|| fallback.title.clone()), + language: self.language.or_else(|| fallback.language.clone()), + created: self.created.or(fallback.created), + } + } +} + +impl From<&Document> for PdfMetadata { + fn from(document: &Document) -> Self { + Self { + title: document.title.clone(), + language: document.language.clone(), + created: document.created, + } + } +} + +/// One laid-out document to paint, for [`render_many`]. +/// +/// `layout` must be the result of [`layout`](crate::layout::layout) for this +/// same `document` (and the same fonts the renderer is given): the structure +/// tree indexes into node ids allocated during layout, and a mismatched pair +/// panics. +#[derive(Debug, Clone, Copy)] +pub struct Part<'a> { + pub layout: &'a Layout, + pub document: &'a Document, +} + /// Render a laid-out document plus its chrome into tagged PDF/A-2A + PDF/UA-1 /// bytes. /// @@ -83,6 +142,40 @@ impl std::error::Error for RenderError {} /// same `document` (and the same `fonts`): the structure tree indexes into /// node ids allocated during layout, and a mismatched pair panics. pub fn render(layout: &Layout, document: &Document, fonts: &Fonts) -> Result, RenderError> { + render_many( + &[Part { layout, document }], + &PdfMetadata::from(document), + fonts, + ) +} + +/// Render several laid-out documents, appended in order, into one tagged +/// PDF/A-2A + PDF/UA-1 file. +/// +/// Every document keeps what it dictates itself: its pages are painted with +/// its own [`Theme`] (so page sizes may differ), its running header and footer +/// appear on its pages only, and a page counter in its chrome counts *its* +/// pages - restarting at 1 and with a total equal to that document's page +/// count, exactly as if it had been rendered on its own. Section numbering is +/// resolved per document before layout, so it restarts as well. +/// +/// What the file has only one of is provided by `metadata`: the title, +/// language and creation date (each falling back as described on +/// [`PdfMetadata`]). In the structure tree each document becomes a `Part` +/// under the implicit document root, and the bookmark outline nests each +/// document's headings on their own, so a document that opens with a +/// subheading is not filed under the previous document's title. Rendering a +/// single part is exactly [`render`]: no `Part` wrapper is added. +/// +/// Returns [`RenderError::NoDocuments`] for an empty `parts`. +pub fn render_many( + parts: &[Part<'_>], + metadata: &PdfMetadata, + fonts: &Fonts, +) -> Result, RenderError> { + if parts.is_empty() { + return Err(RenderError::NoDocuments); + } let configuration = ConfigurationBuilder::new() .with_archival_validator(Archival::A2_A) .with_accessibility_validator(Accessibility::UA1) @@ -92,79 +185,106 @@ pub fn render(layout: &Layout, document: &Document, fonts: &Fonts) -> Result> = vec![Vec::new(); layout.nodes]; - - for (index, page) in layout.pages.iter().enumerate() { - let settings = PageSettings::from_wh(theme.page.width, theme.page.height).ok_or( - RenderError::InvalidPageSize { - width: theme.page.width, - height: theme.page.height, - }, - )?; - let mut krilla_page = pdf.start_page_with(settings); - let mut surface = krilla_page.surface(); - - let page_number = index + 1; - draw_chrome( - &mut surface, - fonts, - theme, - &document.header, - header_baseline, - page_number, - total, - ArtifactType::Header, - ); - for element in &page.elements { - draw_element(&mut surface, fonts, element, &mut idents); - } - draw_chrome( - &mut surface, - fonts, - theme, - &document.footer, - footer_baseline, - page_number, - total, - ArtifactType::Footer, - ); + // part and then by node id (each part's layout has its own id space). + // Filled as content is drawn, then woven into the tag tree below. + let mut idents: Vec>> = parts + .iter() + .map(|part| vec![Vec::new(); part.layout.nodes]) + .collect(); + // The 0-based index of each part's first page in the combined file, for + // the outline destinations. + let mut first_pages = Vec::with_capacity(parts.len()); + let mut pages_so_far = 0; + + for (part, idents) in parts.iter().zip(&mut idents) { + let Part { layout, document } = *part; + first_pages.push(pages_so_far); + pages_so_far += layout.pages.len(); + + let total = layout.pages.len(); + let theme = &document.theme; + let header_baseline = theme.page.content_top() - theme.page.header_offset; + let footer_baseline = theme.page.content_bottom() + theme.page.footer_offset; + + for (index, page) in layout.pages.iter().enumerate() { + let settings = PageSettings::from_wh(theme.page.width, theme.page.height).ok_or( + RenderError::InvalidPageSize { + width: theme.page.width, + height: theme.page.height, + }, + )?; + let mut krilla_page = pdf.start_page_with(settings); + let mut surface = krilla_page.surface(); + + let page_number = index + 1; + draw_chrome( + &mut surface, + fonts, + theme, + &document.header, + header_baseline, + page_number, + total, + ArtifactType::Header, + ); + for element in &page.elements { + draw_element(&mut surface, fonts, element, idents); + } + draw_chrome( + &mut surface, + fonts, + theme, + &document.footer, + footer_baseline, + page_number, + total, + ArtifactType::Footer, + ); - surface.finish(); - krilla_page.finish(); + surface.finish(); + krilla_page.finish(); + } } // Logical structure tree, in reading order. krilla wraps these top-level - // nodes in an implicit Document root. + // nodes in an implicit Document root. A single document's nodes sit + // directly under it; appended documents each get a Part of their own. let mut tree = TagTree::new(); - for node in &layout.structure { - tree.push(build_node(node, &idents)); + for (part, idents) in parts.iter().zip(&idents) { + let nodes = part + .layout + .structure + .iter() + .map(|node| build_node(node, idents)); + if parts.len() == 1 { + tree.children.extend(nodes); + } else { + tree.push(Node::Group(TagGroup::with_children( + Tag::Part, + nodes.collect(), + ))); + } } pdf.set_tag_tree(tree); // Metadata: a title and language are required by PDF/UA, a creation date by // PDF/A. Fall back to the first heading for the title and to English for the - // language when the document leaves them unset. - let title = document + // language when left unset. + let title = metadata .title .clone() .filter(|s| !s.trim().is_empty()) .or_else(|| { - layout - .outline + parts .iter() + .flat_map(|part| &part.layout.outline) .map(|e| e.title.clone()) .find(|s| !s.trim().is_empty()) }) .unwrap_or_else(|| "Untitled document".to_string()); - let language = document + let language = metadata .language .clone() .filter(|s| !s.trim().is_empty()) @@ -173,11 +293,17 @@ pub fn render(layout: &Layout, document: &Document, fonts: &Fonts) -> Result = parts + .iter() + .zip(first_pages) + .map(|(part, first_page)| (first_page, part.layout.outline.as_slice())) + .collect(); + pdf.set_outline(build_outline(&outlines, &title)); pdf.finish().map_err(RenderError::Serialize) } @@ -449,15 +575,19 @@ fn to_tag_kind(tag: &StructTag) -> TagKind { } } -/// Build the bookmark outline from the flat list of headings, nesting each -/// entry under the most recent shallower one. Levels need not be contiguous. -/// A destination points at the heading's top on its page. -fn build_outline(entries: &[OutlineEntry], fallback_title: &str) -> Outline { +/// Build the bookmark outline from the headings of each document, given as +/// `(first page, entries)`: the 0-based index of the document's first page in +/// the combined file, and its headings in document order (page indices +/// relative to the document). Within a document each entry nests under the +/// most recent shallower one; levels need not be contiguous. Nesting restarts +/// at every document, so its headings never file under the previous +/// document's. A destination points at the heading's top on its page. +fn build_outline(documents: &[(usize, &[OutlineEntry])], fallback_title: &str) -> Outline { let mut outline = Outline::new(); - // PDF/UA requires an outline; if the document has no headings, point a - // single entry at the start of the document so one always exists. - if entries.is_empty() { + // PDF/UA requires an outline; if no document has a heading, point a single + // entry at the start of the file so one always exists. + if documents.iter().all(|(_, entries)| entries.is_empty()) { outline.push_child(OutlineNode::new( fallback_title.to_string(), XyzDestination::new(0, Point::from_xy(0.0, 0.0)), @@ -465,23 +595,25 @@ fn build_outline(entries: &[OutlineEntry], fallback_title: &str) -> Outline { return outline; } - // A stack of open ancestors (by level). A new entry closes every open node - // at its level or deeper, attaching each to its parent, then becomes the - // new deepest open node. - let mut stack: Vec<(u8, OutlineNode)> = Vec::new(); - for entry in entries { - let node = OutlineNode::new( - entry.title.clone(), - XyzDestination::new(entry.page_index, Point::from_xy(0.0, entry.y)), - ); - while stack.last().is_some_and(|(level, _)| *level >= entry.level) { - let (_, done) = stack.pop().expect("checked non-empty"); + for &(first_page, entries) in documents { + // A stack of open ancestors (by level). A new entry closes every open + // node at its level or deeper, attaching each to its parent, then + // becomes the new deepest open node. + let mut stack: Vec<(u8, OutlineNode)> = Vec::new(); + for entry in entries { + let node = OutlineNode::new( + entry.title.clone(), + XyzDestination::new(first_page + entry.page_index, Point::from_xy(0.0, entry.y)), + ); + while stack.last().is_some_and(|(level, _)| *level >= entry.level) { + let (_, done) = stack.pop().expect("checked non-empty"); + attach_outline(&mut stack, &mut outline, done); + } + stack.push((entry.level, node)); + } + while let Some((_, done)) = stack.pop() { attach_outline(&mut stack, &mut outline, done); } - stack.push((entry.level, node)); - } - while let Some((_, done)) = stack.pop() { - attach_outline(&mut stack, &mut outline, done); } outline } diff --git a/tests/bundle.rs b/tests/bundle.rs new file mode 100644 index 0000000..27a6d4f --- /dev/null +++ b/tests/bundle.rs @@ -0,0 +1,260 @@ +//! End-to-end checks for appending several documents into one PDF with +//! [`Bundle`]: each document keeps its own chrome, page counter, theme and +//! section numbering, and the combined file still validates as PDF/A-2A + +//! PDF/UA-1 (krilla fails serialization otherwise, so a successful render is +//! the conformance check). +//! +//! The first test also writes `tests/bundle-example.pdf` for inspection. + +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; + +use textris_pdf::{ + build::{Bundle, Textris, text}, + fonts::Fonts, + model::SectionContent, + render::RenderError, +}; + +fn load_fonts() -> Fonts { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fonts"); + Fonts::from_variable_files( + dir.join("Newsreader/Newsreader-Variable.ttf"), + dir.join("Newsreader/Newsreader-Italic-Variable.ttf"), + dir.join("Fira_Code/FiraCode-Variable.ttf"), + ) + .expect("test fonts should load") +} + +/// Whether `needle` occurs in `haystack` (bytes). +fn contains(haystack: &[u8], needle: &str) -> bool { + haystack + .windows(needle.len()) + .any(|w| w == needle.as_bytes()) +} + +/// How many times `needle` occurs in `haystack` (bytes, non-overlapping). +fn count(haystack: &[u8], needle: &str) -> usize { + let mut count = 0; + let mut rest = haystack; + while let Some(at) = rest + .windows(needle.len()) + .position(|w| w == needle.as_bytes()) + { + count += 1; + rest = &rest[at + needle.len()..]; + } + count +} + +/// A page counter that also records every `(page, total)` it was asked to +/// render, so a test can see how each document's pages were numbered. +fn recording_counter(log: &Arc>>) -> SectionContent { + let log = Arc::clone(log); + SectionContent::page_counter(move |page, total| { + log.lock().expect("no poisoned lock").push((page, total)); + text(format!("Page {page} of {total}")) + }) +} + +/// A document of `pages` full pages: the `label` as a level-1 heading and a +/// running header, then page-break-separated filler. +fn document(label: &str, pages: usize) -> Textris { + let mut doc = Textris::new(); + doc.title(format!("{label} title")); + doc.header_left(format!("{label} header")); + doc.h1(label); + doc.h3_numbered("Intro"); + doc.paragraph("Body."); + for page in 1..pages { + doc.page_break(); + doc.h3_numbered(format!("Section on page {}", page + 1)); + doc.paragraph("More body."); + } + doc +} + +#[test] +fn appended_documents_render_into_one_valid_pdf_written_to_disk() { + let fonts = load_fonts(); + let mut bundle = Bundle::new(); + bundle.title("Two field guides in one file").language("en"); + bundle + .push(document("Guide A", 2)) + .push(document("Guide B", 3)); + + let pdf = bundle + .render(&fonts) + .expect("a bundle should render as valid tagged PDF/A-2A + PDF/UA-1"); + + // Written out first, so it can be inspected even when a check below fails. + let out = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/bundle-example.pdf"); + std::fs::write(&out, &pdf).expect("should write PDF to disk"); + assert!(out.exists()); + + assert!(pdf.starts_with(b"%PDF-"), "output is not a PDF"); + // All pages of both documents, in one file. Page objects are written as + // uncompressed dictionaries (`/Type/Pages` is the tree node, not a page). + assert_eq!( + count(&pdf, "/Type/Page/"), + 5, + "expected the pages of both documents" + ); + // Accessibility scaffolding is present, and the bundle's title and + // language reached the metadata. + assert!(contains(&pdf, "StructTreeRoot"), "no structure tree"); + assert!(contains(&pdf, "pdfuaid"), "no PDF/UA identifier"); + assert!(contains(&pdf, "Outlines"), "no outline"); + assert!( + contains(&pdf, "Two field guides in one file"), + "bundle title missing" + ); + // Each appended document is a Part of the structure tree. + assert_eq!( + count(&pdf, "/S/Part"), + 2, + "each document should be tagged as a Part" + ); +} + +#[test] +fn page_counters_restart_for_every_document() { + let fonts = load_fonts(); + let first_log = Arc::new(Mutex::new(Vec::new())); + let second_log = Arc::new(Mutex::new(Vec::new())); + + let mut first = document("First", 2); + first.footer_right(recording_counter(&first_log)); + let mut second = document("Second", 3); + second.footer_right(recording_counter(&second_log)); + + let mut bundle = Bundle::new(); + bundle.push(first).push(second); + bundle.render(&fonts).expect("should render"); + + // Each document's counter sees only its own pages: numbered from 1, with + // its own page count as the total - not 1..=5 of 5. + assert_eq!( + first_log.lock().unwrap().as_slice(), + [(1, 2), (2, 2)], + "the first document counts its own two pages" + ); + assert_eq!( + second_log.lock().unwrap().as_slice(), + [(1, 3), (2, 3), (3, 3)], + "the second document restarts at page 1 of 3" + ); +} + +#[test] +fn section_numbering_restarts_for_every_document() { + let fonts = load_fonts(); + let mut bundle = Bundle::new(); + bundle + .push(document("First", 2)) + .push(document("Second", 1)); + let pdf = bundle.render(&fonts).expect("should render"); + + // Headings reach the PDF as outline (bookmark) and structure titles, so + // the numbering is visible in the bytes: "1. Intro" once per document, + // and the first document's second section is "2.", not "3.". + assert_eq!( + count(&pdf, "1. Intro"), + 2 * count(&pdf, "2. Section on page 2"), + "both documents should start their numbering at 1" + ); + assert!( + !contains(&pdf, "3. "), + "the second document's sections must not continue the first's count" + ); +} + +#[test] +fn documents_may_use_different_themes_and_page_sizes() { + let fonts = load_fonts(); + let portrait = document("Portrait", 1); + let mut landscape = document("Landscape", 1); + { + let page = &mut landscape.theme_mut().page; + std::mem::swap(&mut page.width, &mut page.height); + } + landscape.theme_mut().spacing.line_height = 1.6; + + let mut bundle = Bundle::new(); + bundle.push(portrait).push(landscape); + let pdf = bundle + .render(&fonts) + .expect("mixed page sizes should render"); + // Both media boxes are present: A4 portrait and A4 landscape. + assert!( + contains(&pdf, "595.276 841.89") && contains(&pdf, "841.89 595.276"), + "expected both a portrait and a landscape page" + ); +} + +#[test] +fn metadata_falls_back_to_the_first_document() { + let fonts = load_fonts(); + let mut first = document("First", 1); + first.title("First document's own title").language("nl"); + let second = document("Second", 1); + + // Nothing set on the bundle: the first document's title and language win. + let mut bundle = Bundle::new(); + bundle.push(&first).push(&second); + let pdf = bundle.render(&fonts).expect("should render"); + assert!(contains(&pdf, "First document's own title")); + assert!( + contains(&pdf, "/Lang(nl)"), + "language should be the first document's" + ); + + // The bundle's own title takes precedence. + bundle.title("The bundle's title"); + let pdf = bundle.render(&fonts).expect("should render"); + assert!(contains(&pdf, "The bundle's title")); +} + +#[test] +fn an_empty_bundle_is_an_error_not_a_panic() { + let fonts = load_fonts(); + let error = Bundle::new() + .render(&fonts) + .expect_err("nothing to render must fail"); + assert_eq!(error, RenderError::NoDocuments); +} + +#[test] +fn a_single_document_bundle_matches_rendering_the_document_alone() { + let fonts = load_fonts(); + let mut doc = document("Alone", 2); + doc.created_at(1_700_000_000); + + let alone = doc.render(&fonts).expect("should render"); + let mut bundle = Bundle::new(); + bundle.push(&doc); + let bundled = bundle.render(&fonts).expect("should render"); + assert_eq!( + alone, bundled, + "a bundle of one document is the document rendered on its own" + ); + assert!( + !contains(&bundled, "/S/Part"), + "a lone document is not wrapped in a Part" + ); +} + +#[test] +fn pinning_the_creation_date_makes_a_bundle_reproducible() { + let fonts = load_fonts(); + let render = || { + let mut bundle = Bundle::new(); + bundle.created_at(1_700_000_000); + bundle.push(document("A", 1)).push(document("B", 1)); + bundle.render(&fonts).expect("should render") + }; + assert_eq!(render(), render()); + assert!(contains(&render(), "D:20231114221320")); +}