diff --git a/crates/fleuron-wasm/npm/README.md b/crates/fleuron-wasm/npm/README.md index 41f2e10d..bc67770d 100644 --- a/crates/fleuron-wasm/npm/README.md +++ b/crates/fleuron-wasm/npm/README.md @@ -199,7 +199,8 @@ for selection and copy-and-paste. `paintPage` draws one of them as SVG. Each run becomes one `` with an x for every character in it, so the browser places the glyphs where the engine put them instead of working out positions of -its own. `exportPdf` writes the same pages as PDF. `exportEpub` writes +its own. `exportPdf` writes the same pages as PDF, and `multipleOf` +pads it with blank pages to a multiple of 2 or 4. `exportEpub` writes the book as a reflowable EPUB, with the warnings that writing it raised, and lays out nothing to do it. `exportEpubFiles` returns the files of the same EPUB one by one, not zipped, with the spine, for a diff --git a/crates/fleuron-wasm/npm/src/client.ts b/crates/fleuron-wasm/npm/src/client.ts index b7b15151..615400d7 100644 --- a/crates/fleuron-wasm/npm/src/client.ts +++ b/crates/fleuron-wasm/npm/src/client.ts @@ -12,6 +12,7 @@ import { type Names, type NodeSource, type Op, + type PdfOptions, type Request, type Response, type Want, @@ -117,9 +118,25 @@ export class Client { return bytes === SUPERSEDED ? SUPERSEDED : decodeDisplayList(bytes); } - /** The same, as PDF bytes. */ - async exportPdf(ops: Op[] = []): Promise { - return this.render(ops, 'pdf'); + /** + * The same, as PDF bytes. `options` can pad the book to an even + * page count or to a multiple of four: see {@link PdfOptions}. + */ + async exportPdf(ops: Op[] = [], options: PdfOptions = {}): Promise { + return this.renderAs(ops, { want: 'pdf', ...options }); + } + + /** + * How many pages {@link Client.exportPdf} writes with the same + * `options`: the pages of the book, and the blank pages that pad + * it. No PDF is written to answer. + * + * A question rather than a render: nothing overtakes it. The answer + * is about the book as the worker holds it, so an edit still in + * flight is an edit this has not seen. + */ + async pdfPages(options: PdfOptions = {}): Promise { + return this.ask({ ops: [], want: 'pages', ...options }); } /** @@ -293,7 +310,7 @@ export class Client { /** {@link Client.render}, with any of the request's own options. */ private async renderAs( ops: Op[], - what: { want: Want; first?: number; count?: number; unzipped?: boolean }, + what: { want: Want; first?: number; count?: number; unzipped?: boolean; multipleOf?: 2 | 4 }, ): Promise { this.generation = this.generationFor(ops); const response = await this.send({ ops, ...what, generation: this.generation }); @@ -322,6 +339,7 @@ export class Client { box?: MarginBoxName; x?: number; y?: number; + multipleOf?: 2 | 4; }): Promise { const response = await this.send(what); if (!isRendered(response)) { @@ -346,6 +364,7 @@ export class Client { first?: number; count?: number; unzipped?: boolean; + multipleOf?: 2 | 4; }): Promise { this.id += 1; const request: Request = { @@ -365,6 +384,7 @@ export class Client { ...(what.first === undefined ? {} : { first: what.first }), ...(what.count === undefined ? {} : { count: what.count }), ...(what.unzipped === undefined ? {} : { unzipped: what.unzipped }), + ...(what.multipleOf === undefined ? {} : { multipleOf: what.multipleOf }), }; const transfer = request.ops .filter((op) => op.op === 'font' || op.op === 'image') diff --git a/crates/fleuron-wasm/npm/src/engine.ts b/crates/fleuron-wasm/npm/src/engine.ts index 1c9f93ea..7e8ea567 100644 --- a/crates/fleuron-wasm/npm/src/engine.ts +++ b/crates/fleuron-wasm/npm/src/engine.ts @@ -114,6 +114,7 @@ export class Engine { kind: request.want, bytes, stages: [stages[0] ?? 0, stages[1] ?? 0, stages[2] ?? 0, stages[3] ?? 0], + ...(request.want === 'pdf' ? { pages: this.session.pdfPages(request.multipleOf) } : {}), }, [bytes.buffer as ArrayBuffer], ); @@ -125,7 +126,7 @@ export class Engine { private produce(request: Request): Uint8Array { switch (request.want) { case 'pdf': - return this.session.exportPdf(); + return this.session.exportPdf(request.multipleOf); case 'epub': return request.unzipped === true ? this.session.exportEpubFiles() : this.session.exportEpub(); case 'font': @@ -146,6 +147,8 @@ export class Engine { return answer(this.session.hit(request.page ?? 0, request.x ?? 0, request.y ?? 0) ?? null); case 'names': return answer(this.session.names(request.source)); + case 'pages': + return answer(this.session.pdfPages(request.multipleOf)); default: return this.session.preview(request.first, request.count); } @@ -229,7 +232,8 @@ function answer(json: string | number | null): Uint8Array { * face's bytes, asking where a node was read from or what was read at * a byte, asking which folios some nodes are set on, asking what * styled an element or which element is at a point, asking which - * classes and ids a source writes, or asking which + * classes and ids a source writes, asking how many pages a PDF + * holds, or asking which * pages of the book as it stands (no `ops` of its own) fall in a * range. None of them overtakes a render nor is overtaken by one, or * by a sibling question, since none says what the last edit @@ -249,6 +253,7 @@ function isQuestion(request: Request): boolean { request.want === 'inspect' || request.want === 'hit' || request.want === 'names' || + request.want === 'pages' || (request.want === 'preview' && request.ops.length === 0 && request.first !== undefined && diff --git a/crates/fleuron-wasm/npm/src/index.ts b/crates/fleuron-wasm/npm/src/index.ts index 8f35b956..09be7978 100644 --- a/crates/fleuron-wasm/npm/src/index.ts +++ b/crates/fleuron-wasm/npm/src/index.ts @@ -34,6 +34,7 @@ export { type NodeSource, type PageBox, type Op, + type PdfOptions, type Rendered, type Request, type Response, diff --git a/crates/fleuron-wasm/npm/src/preview.ts b/crates/fleuron-wasm/npm/src/preview.ts index 153a9952..0603c1b4 100644 --- a/crates/fleuron-wasm/npm/src/preview.ts +++ b/crates/fleuron-wasm/npm/src/preview.ts @@ -23,6 +23,7 @@ import { type Attributes, type Metadata, type Op, + type PdfOptions, type Response, type Sheet, type Source, @@ -476,10 +477,20 @@ export class Preview { /** * The book as PDF bytes, from the stages the preview settled, so - * the export cannot contradict what is on screen. + * the export cannot contradict what is on screen. `options` can pad + * the book to an even page count or to a multiple of four, with + * blank pages that are in the PDF and not on screen. */ - async exportPdf(): Promise { - return this.client.exportPdf(); + async exportPdf(options: PdfOptions = {}): Promise { + return this.client.exportPdf([], options); + } + + /** + * How many pages {@link Preview.exportPdf} writes with the same + * `options`, answered without writing them. + */ + async pdfPages(options: PdfOptions = {}): Promise { + return this.client.pdfPages(options); } /** diff --git a/crates/fleuron-wasm/npm/src/protocol.ts b/crates/fleuron-wasm/npm/src/protocol.ts index 5068165d..2ecc34c3 100644 --- a/crates/fleuron-wasm/npm/src/protocol.ts +++ b/crates/fleuron-wasm/npm/src/protocol.ts @@ -237,12 +237,27 @@ export type MarginBoxName = | 'left-middle' | 'left-top'; +/** How `want: 'pdf'` writes the book. */ +export interface PdfOptions { + /** + * Pads the book to a page count a print vendor asks for: 2 for an + * even count, 4 for a multiple of four. The PDF gets blank pages + * after the last page of the book until the count is reached. A + * blank page is the size of that last page, and has no folio, no + * running head and no other margin box. The blank pages are in the + * PDF only: the book is not laid out again and the preview keeps + * its pages. Left out, the PDF holds the pages of the preview. + */ + multipleOf?: 2 | 4; +} + /** * What a request wants back, if anything: a display structure, a PDF, * an EPUB, the file a face was registered from, the node one byte of a source * was read into, the source one node was read from, the folios some * nodes are set on, what styled one element or margin box, the - * element at a point, or the classes and ids a source writes. + * element at a point, the classes and ids a source writes, or how + * many pages a PDF holds. * * The first three are renders and the rest are questions, which is * what decides whether a later request may overtake it. @@ -257,7 +272,8 @@ export type Want = | 'folios' | 'inspect' | 'hit' - | 'names'; + | 'names' + | 'pages'; /** An edit, a render, a question, or an edit and one of those. */ export interface Request { @@ -309,6 +325,12 @@ export interface Request { * rather than the zip: see {@link EpubFiles}. */ unzipped?: boolean; + /** + * With `want: 'pdf'`, the page count to pad the PDF to, and with + * `want: 'pages'`, the padding to count: see + * {@link PdfOptions.multipleOf}. + */ + multipleOf?: 2 | 4; } /** @@ -328,6 +350,11 @@ export interface Rendered { * a clock would only show a fast machine. */ stages: [number, number, number, number]; + /** + * With `kind: 'pdf'`, how many pages the PDF holds: the pages of + * the book and the blank pages that pad it. + */ + pages?: number; } /** Inputs applied, with nothing asked for back. */ diff --git a/crates/fleuron-wasm/npm/test/harness.ts b/crates/fleuron-wasm/npm/test/harness.ts index 87a8cdb8..a094e912 100644 --- a/crates/fleuron-wasm/npm/test/harness.ts +++ b/crates/fleuron-wasm/npm/test/harness.ts @@ -91,6 +91,12 @@ function info(pdf: Uint8Array): string | null { return run.status === 0 ? run.stdout : null; } +/** How many pages the page tree of a PDF counts. */ +function pagesOf(pdf: Uint8Array): number { + const count = /\/Type \/Pages\s+\/Count (\d+)/.exec(Buffer.from(pdf).toString('latin1')); + return count === null ? -1 : Number(count[1]); +} + /** * What the CLI makes of some markdown: the run this one is checked * against. @@ -921,6 +927,64 @@ if (extracted === null || wanted === null) { check('the PDF reads back as the text the CLI wrote', extracted === wanted); } +// The export pads the book to the page count a print vendor asks +// for. The blank pages are the writer's own: no stage runs to add +// them, and the preview answers with the bytes it answered with +// before. The reply to the export says how many pages the PDF holds, +// and the same count is a question a host asks before it exports. +const unpadded = await client.render([], 'preview'); +const beforePadding = client.stages; +const laidOut = preview.pages.length; +check('a PDF with no padding holds the pages of the preview', pagesOf(pdf) === laidOut, `${pagesOf(pdf)} of ${laidOut}`); +check('the count asked for with no padding is the pages of the preview', (await client.pdfPages()) === laidOut); +for (const multipleOf of [2, 4] as const) { + const wanted = Math.ceil(laidOut / multipleOf) * multipleOf; + const asked = await client.pdfPages({ multipleOf }); + const padReplies: Response[] = []; + const tapPad = (response: Response): void => { + padReplies.push(response); + }; + worker.on('message', tapPad); + const padded = await client.exportPdf([], { multipleOf }); + worker.off('message', tapPad); + if (padded === null) { + throw new Error('nothing overtook the padded export, and it still came back superseded'); + } + const reply = padReplies.find(isRendered); + check( + `the PDF padded to a multiple of ${multipleOf} holds the least such count`, + pagesOf(padded) === wanted, + `${pagesOf(padded)} pages for a book of ${laidOut}`, + ); + check( + `the reply to that export says how many pages the PDF holds`, + reply?.pages === wanted && asked === wanted, + `reply ${reply?.pages}, asked ${asked}, written ${wanted}`, + ); + const readBack = text(padded); + if (readBack !== null && extracted !== null) { + check( + `the pages that pad to a multiple of ${multipleOf} have no text on them`, + readBack.replace(/\f/g, '') === extracted.replace(/\f/g, ''), + ); + } +} +check( + 'a book whose count is short of a multiple of four is padded', + laidOut % 4 !== 0, + `the fixture book is ${laidOut} pages, so the padding to four added nothing`, +); +check( + 'padding the PDF runs no stage', + JSON.stringify(client.stages) === JSON.stringify(beforePadding), + `${JSON.stringify(beforePadding)} before, ${JSON.stringify(client.stages)} after`, +); +const afterPadding = await client.render([], 'preview'); +check( + 'the preview after a padded export is the preview before it', + unpadded !== null && afterPadding !== null && Buffer.from(unpadded).equals(Buffer.from(afterPadding)), +); + // The EPUB comes from the tree and the sheets the session holds, so // no stage runs, and it is the file the CLI writes for the same book. const settled = client.stages; diff --git a/crates/fleuron-wasm/src/lib.rs b/crates/fleuron-wasm/src/lib.rs index 329a12af..fca35053 100644 --- a/crates/fleuron-wasm/src/lib.rs +++ b/crates/fleuron-wasm/src/lib.rs @@ -27,6 +27,7 @@ use std::collections::BTreeMap; use fleuron::Warning; use fleuron::content::{Attributes, Book, HeadingLevel, Metadata, NodeId, Section}; use fleuron::fonts::{FontSource, bundled_registry}; +use fleuron::pdf::PadTo; use fleuron::session::Session as Engine; use fleuron::style::{MarginBox, Source, Stylesheets}; use fleuron::wire; @@ -364,9 +365,26 @@ impl Session { /// The same run as PDF bytes. Both painters read the stages the /// preview settled, so an export cannot contradict what is on /// screen. + /// + /// `multiple_of` pads the book: 2 for an even page count, 4 for a + /// multiple of four. The writer adds blank pages after the last + /// page until the book has that count. A blank page has the size + /// of that last page and has nothing on it. The blank pages are + /// in the PDF only. No stage runs, and the preview keeps its + /// pages. Without `multiple_of`, the PDF holds the pages of the + /// preview. #[wasm_bindgen(js_name = exportPdf)] - pub fn export_pdf(&mut self) -> Result, JsError> { - self.engine.export().map_err(js_error) + pub fn export_pdf(&mut self, multiple_of: Option) -> Result, JsError> { + let pad = pad_to(multiple_of).map_err(js_error)?; + self.engine.export_padded(pad).map_err(js_error) + } + + /// How many pages [`Session::export_pdf`] writes with the same + /// `multiple_of`, answered without writing them. + #[wasm_bindgen(js_name = pdfPages)] + pub fn pdf_pages(&mut self, multiple_of: Option) -> Result { + let pad = pad_to(multiple_of).map_err(js_error)?; + Ok(self.engine.exported_pages(pad) as u32) } /// The book as a reflowable EPUB, and what writing it warned @@ -635,7 +653,7 @@ pub fn render(markdown: &str, css: &str) -> Result, JsError> { /// The same, as PDF bytes. #[wasm_bindgen(js_name = renderPdf)] pub fn render_pdf(markdown: &str, css: &str) -> Result, JsError> { - once(markdown, css)?.export_pdf() + once(markdown, css)?.export_pdf(None) } fn once(markdown: &str, css: &str) -> Result { @@ -647,6 +665,19 @@ fn once(markdown: &str, css: &str) -> Result { Ok(session) } +/// The count a host asked a PDF to be padded to, which it names by +/// the number the count is a multiple of. +fn pad_to(multiple_of: Option) -> Result, String> { + match multiple_of { + None => Ok(None), + Some(2) => Ok(Some(PadTo::Even)), + Some(4) => Ok(Some(PadTo::MultipleOfFour)), + Some(other) => Err(format!( + "a PDF is padded to a multiple of 2 or 4 pages, not of {other}" + )), + } +} + /// Anything that went wrong, in the host's own error type. The /// engine's errors all say one line about themselves. fn js_error(error: impl std::fmt::Display) -> JsError { diff --git a/crates/fleuron-wasm/tests/pdf.rs b/crates/fleuron-wasm/tests/pdf.rs new file mode 100644 index 00000000..a8b45cb9 --- /dev/null +++ b/crates/fleuron-wasm/tests/pdf.rs @@ -0,0 +1,51 @@ +//! The export pads a book to the page count a print vendor asks for. + +use fleuron::wire; +use fleuron_wasm::Session; + +fn session() -> Session { + let mut session = Session::new().unwrap(); + session.set_markdown("book.md", "# One\n\nAlpha.\n"); + session +} + +/// How many pages the file's page tree counts. +fn counted(pdf: &[u8]) -> u32 { + let text = String::from_utf8_lossy(pdf); + let (_, rest) = text + .split_once("/Type /Pages\n /Count ") + .expect("a page tree"); + let (count, _) = rest.split_once('\n').expect("a count"); + count.parse().expect("a number") +} + +/// Acceptance: `exportPdf` takes the count to pad to, an even one or +/// a multiple of four, and the session says how many pages the file +/// holds. +#[test] +fn the_export_pads_to_the_count_it_is_asked_for() { + let mut session = session(); + let laid_out = wire::decode(&session.preview(None, None).unwrap()) + .expect("the reply reads back") + .pages + .len() as u32; + assert_eq!(laid_out, 1, "the book is one page"); + + for (multiple_of, pages) in [(None, 1), (Some(2), 2), (Some(4), 4)] { + assert_eq!(session.pdf_pages(multiple_of).unwrap(), pages); + assert_eq!(counted(&session.export_pdf(multiple_of).unwrap()), pages); + } +} + +/// Acceptance: the padding runs no stage, and the preview answers +/// with the bytes it answered with before. +#[test] +fn padding_leaves_the_preview_as_it_was() { + let mut session = session(); + let before = session.preview(None, None).unwrap(); + let settled = session.stages(); + + session.export_pdf(Some(4)).unwrap(); + assert_eq!(session.stages(), settled, "padding re-ran a stage"); + assert_eq!(session.preview(None, None).unwrap(), before); +} diff --git a/crates/fleuron/src/pdf.rs b/crates/fleuron/src/pdf.rs index 1eb6fce2..afeaf482 100644 --- a/crates/fleuron/src/pdf.rs +++ b/crates/fleuron/src/pdf.rs @@ -60,6 +60,39 @@ pub enum PdfError { Serialize(String), } +/// The page count a print vendor asks a book to reach. +/// +/// The writer adds blank pages after the last page of a book that +/// falls short. A blank page has the size of that last page, with +/// the same bleed and the same room for marks. It has no page +/// number, no running head and no other margin box. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PadTo { + /// An even count. + Even, + /// A multiple of four. + MultipleOfFour, +} + +impl PadTo { + /// How many pages a book of `pages` holds once it is padded. A + /// book that already has the count, or has no page at all, is + /// left as it is. + pub fn count(self, pages: usize) -> usize { + pages.next_multiple_of(match self { + PadTo::Even => 2, + PadTo::MultipleOfFour => 4, + }) + } +} + +/// How many pages [`write_padded`] writes for this book: the pages +/// laid out, and the blank pages `pad` adds after them. +pub fn page_count(output: &LayoutOutput, pad: Option) -> usize { + let pages = output.pages.len(); + pad.map_or(pages, |pad| pad.count(pages)) +} + /// Writes one laid-out book as PDF bytes. /// /// Fonts resolve through the registry that shaped the run and images @@ -74,12 +107,28 @@ pub fn write( registry: &FontRegistry, assets: &Assets, metadata: &Metadata, +) -> Result, PdfError> { + write_padded(output, registry, assets, metadata, None) +} + +/// The same, with blank pages after the last page until the book +/// has the count `pad` names. [`page_count`] returns that count. +/// +/// The blank pages are in the PDF only. The writer reads the display +/// structure and does not change it, so nothing is laid out again. +pub fn write_padded( + output: &LayoutOutput, + registry: &FontRegistry, + assets: &Assets, + metadata: &Metadata, + pad: Option, ) -> Result, PdfError> { write_with( output, registry, assets, metadata, + pad, SerializeSettings::default(), ) } @@ -89,6 +138,7 @@ fn write_with( registry: &FontRegistry, assets: &Assets, metadata: &Metadata, + pad: Option, settings: SerializeSettings, ) -> Result, PdfError> { let fonts = embed_fonts(registry)?; @@ -120,6 +170,11 @@ fn write_with( } pdf_page.finish(); } + if let Some(last) = output.pages.last() { + for _ in output.pages.len()..page_count(output, pad) { + document.start_page_with(settings_of(last)).finish(); + } + } if !output.navigation.outline.is_empty() { let mut outline = Outline::new(); for entry in &output.navigation.outline { @@ -920,11 +975,21 @@ mod tests { } fn bytes_of(output: &LayoutOutput, assets: &Assets, metadata: &Metadata) -> Vec { + bytes_padded(output, assets, metadata, None) + } + + fn bytes_padded( + output: &LayoutOutput, + assets: &Assets, + metadata: &Metadata, + pad: Option, + ) -> Vec { write_with( output, registry(), assets, metadata, + pad, SerializeSettings { compress_content_streams: false, ..Default::default() @@ -933,6 +998,44 @@ mod tests { .expect("the bundled face embeds") } + /// The same as [`readable`], padded to a count. + fn padded(output: &LayoutOutput, pad: Option) -> String { + latin1(&bytes_padded( + output, + &Assets::none(), + &Metadata::default(), + pad, + )) + } + + /// How many pages the page tree counts. + fn counted(pdf: &str) -> usize { + let (_, rest) = pdf + .split_once("/Type /Pages\n /Count ") + .expect("a page tree"); + let (count, _) = rest.split_once('\n').expect("a count"); + count.parse().expect("a number") + } + + /// Each page of the file, in the order the file writes them: its + /// object, and its content stream. + fn written_pages(pdf: &str) -> Vec<(&str, &str)> { + pdf.split(" /Type /Page\n") + .skip(1) + .map(|rest| { + let (page, _) = rest.split_once("endobj").expect("the page object ends"); + let (_, contents) = page.split_once("/Contents ").expect("a content stream"); + let (id, _) = contents.split_once(' ').expect("an object number"); + let (_, object) = pdf + .split_once(&format!("\n{id} 0 obj\n")) + .expect("the stream is in the file"); + let (_, stream) = object.split_once("stream\n").expect("a stream"); + let (body, _) = stream.split_once("endstream").expect("the stream ends"); + (page, body) + }) + .collect() + } + fn latin1(bytes: &[u8]) -> String { bytes.iter().map(|b| *b as char).collect() } @@ -2143,6 +2246,106 @@ mod tests { assert!(pdf.trim_end().ends_with("%%EOF"), "no trailer"); } + /// A book of this many pages, each with a line of text on it. + fn pages_of(count: usize) -> LayoutOutput { + let book = book("Some prose."); + let mut output = laid_out(&book); + output.pages = (0..count).flat_map(|_| laid_out(&book).pages).collect(); + output + } + + /// Acceptance: the writer takes a count to pad to, an even one or + /// a multiple of four, and the book has the least such count that + /// holds its pages. + #[test] + fn a_book_is_padded_to_an_even_count_or_a_multiple_of_four() { + for pages in 1..=9 { + let output = pages_of(pages); + assert_eq!(counted(&padded(&output, None)), pages); + + let even = counted(&padded(&output, Some(PadTo::Even))); + assert_eq!(even % 2, 0, "{pages} pages padded to {even}"); + assert!((pages..pages + 2).contains(&even), "{pages} to {even}"); + + let fours = counted(&padded(&output, Some(PadTo::MultipleOfFour))); + assert_eq!(fours % 4, 0, "{pages} pages padded to {fours}"); + assert!((pages..pages + 4).contains(&fours), "{pages} to {fours}"); + } + } + + /// A book with no page has no last page to size a leaf by, and + /// the file is the one the writer makes of it unpadded. + #[test] + fn a_book_with_no_page_is_not_padded() { + let output = pages_of(0); + assert_eq!(page_count(&output, Some(PadTo::MultipleOfFour)), 0); + assert_eq!( + padded(&output, Some(PadTo::MultipleOfFour)), + padded(&output, None) + ); + } + + /// Acceptance: a blank leaf has the size of the book's last page, + /// sheet and trim and bleed, and carries no folio, no running + /// head and no other margin box. + #[test] + fn a_blank_leaf_is_the_size_of_the_last_page_and_carries_nothing() { + let book = book("Some prose."); + let furniture = "bleed: 3mm; marks: crop; + @top-center { content: 'Gulliver' } + @bottom-center { content: counter(page) }"; + let mut output = styled( + &book, + &format!("@page {{ size: 300pt 400pt; {furniture} }}"), + ); + output.pages.extend( + styled( + &book, + &format!("@page {{ size: 432pt 648pt; {furniture} }}"), + ) + .pages, + ); + assert_eq!(output.pages.len(), 2); + + let pdf = padded(&output, Some(PadTo::MultipleOfFour)); + let pages = written_pages(&pdf); + assert_eq!(pages.len(), 4); + let (last, painted) = pages[1]; + assert!( + painted.contains("Tj") || painted.contains("TJ"), + "the last page of the book shows no folio, so this proves nothing:\n{painted}" + ); + for (leaf, stream) in &pages[2..] { + for name in ["MediaBox", "TrimBox", "BleedBox"] { + assert_eq!(page_box(leaf, name), page_box(last, name), "{name}"); + } + assert_eq!(stream.trim(), "", "a blank leaf paints something"); + assert!(!leaf.contains("/Annots"), "a blank leaf carries a link"); + assert!(!leaf.contains("/Font"), "a blank leaf names a face"); + } + assert_ne!( + page_box(pages[0].0, "MediaBox"), + page_box(last, "MediaBox"), + "the two pages of the book are one size, so this proves nothing" + ); + } + + /// Acceptance: the count the writer answers with is the count the + /// file holds. + #[test] + fn the_count_answered_is_the_count_written() { + for pages in 1..=9 { + let output = pages_of(pages); + for pad in [None, Some(PadTo::Even), Some(PadTo::MultipleOfFour)] { + assert_eq!( + page_count(&output, pad), + counted(&padded(&output, pad)), + "{pages} pages, {pad:?}" + ); + } + } + } + /// Two runs over one book produce byte-identical PDFs: nothing in /// the writer reads a clock or a hash of an address. #[test] diff --git a/crates/fleuron/src/session/output.rs b/crates/fleuron/src/session/output.rs index dc060fcf..ec0e8d4e 100644 --- a/crates/fleuron/src/session/output.rs +++ b/crates/fleuron/src/session/output.rs @@ -9,7 +9,7 @@ use crate::fonts::FontRegistry; use crate::images::Assets; use crate::layout::font_table; use crate::pages::{DrawItem, Folios, Navigation}; -use crate::pdf::{self, PdfError}; +use crate::pdf::{self, PadTo, PdfError}; use super::Session; @@ -23,16 +23,33 @@ impl Session<'_> { /// The same, as PDF bytes. The stages above the painter are the /// ones the preview used, so an export cannot contradict it. pub fn export(&mut self) -> Result, PdfError> { + self.export_padded(None) + } + + /// The same, with blank pages after the last page until the + /// book has the count `pad` names. The blank pages are in the + /// PDF only. No stage runs to add them, and + /// [`Session::preview`] returns the same pages as before. + pub fn export_padded(&mut self, pad: Option) -> Result, PdfError> { self.update(); let output = self.output.as_ref().expect("an update leaves an output"); - pdf::write( + pdf::write_padded( output, self.registry.get(), self.assets.get(), &self.book.metadata, + pad, ) } + /// How many pages [`Session::export_padded`] writes with the + /// same `pad`, answered without writing them. + pub fn exported_pages(&mut self, pad: Option) -> usize { + self.update(); + let output = self.output.as_ref().expect("an update leaves an output"); + pdf::page_count(output, pad) + } + /// Where each of these nodes' content is set, answered in the /// order they were asked about: the folios it runs between, and /// the pages of the book those folios are. @@ -264,4 +281,50 @@ mod tests { assert_eq!(session.stages(), before, "an export re-ran a stage"); assert!(bytes.starts_with(b"%PDF"), "that is not a PDF"); } + + /// Acceptance: the session's export takes the count to pad to, + /// and says how many pages the file holds. + #[test] + fn the_export_takes_a_count_and_says_how_many_pages_it_writes() { + let mut session = three_chapters(); + let laid_out = session.preview().pages.len(); + assert_eq!(session.exported_pages(None), laid_out); + for (pad, multiple) in [(PadTo::Even, 2), (PadTo::MultipleOfFour, 4)] { + let pages = session.exported_pages(Some(pad)); + assert_eq!(pages, laid_out.next_multiple_of(multiple)); + let bytes = session + .export_padded(Some(pad)) + .expect("the fixture book writes PDF"); + let pdf = String::from_utf8_lossy(&bytes); + assert!( + pdf.contains(&format!("/Type /Pages\n /Count {pages}")), + "the file does not hold the {pages} pages the session answered with" + ); + } + } + + /// Acceptance: the padding lays nothing out again, and the pages + /// the preview answers with are the ones it answered with before. + #[test] + fn padding_runs_no_stage_and_leaves_the_preview_as_it_was() { + let mut session = Session::new(registry()); + session.set_content(book(vec![section("one.md", prose("alpha", 1))])); + let encoded = |output: &LayoutOutput| crate::wire::encode(output).expect("it encodes"); + let before = encoded(session.preview()); + let laid_out = session.preview().pages.len(); + assert_eq!(laid_out % 4, 1, "the book needs no padding"); + let settled = session.stages(); + + let pages = session.exported_pages(Some(PadTo::MultipleOfFour)); + session + .export_padded(Some(PadTo::MultipleOfFour)) + .expect("the fixture book writes PDF"); + assert_eq!(pages, laid_out + 3); + assert_eq!(session.stages(), settled, "padding re-ran a stage"); + assert!( + encoded(session.preview()) == before, + "padding changed the preview" + ); + assert_eq!(session.stages(), settled, "the preview after it re-ran one"); + } } diff --git a/docs/library/sessions.md b/docs/library/sessions.md index e154e519..796cdc2d 100644 --- a/docs/library/sessions.md +++ b/docs/library/sessions.md @@ -24,6 +24,17 @@ let bytes = session.export()?; // the same run, as PDF `preview` and `export` are two painters over one set of stages, so an export cannot contradict the preview it came from. +`export_padded` adds blank pages at the end of the PDF to reach an even page count or a multiple of four. No stage runs, and `preview` does not change. `exported_pages` returns the page count of that PDF. + +The following example reads the page count of a book padded to a multiple of four, then writes the PDF: + +```rust +use fleuron::pdf::PadTo; + +let pages = session.exported_pages(Some(PadTo::MultipleOfFour)); +let bytes = session.export_padded(Some(PadTo::MultipleOfFour))?; +``` + A writer that reads the book rather than the pages, such as [the EPUB writer](epub.md), reads the inputs of the session instead. `book`, `sheets`, `images`, and `font_files` return what the host set and sent. Reading them runs no stage. ## What an edit costs diff --git a/docs/wasm/preview.mdx b/docs/wasm/preview.mdx index 71b300fb..4a042d63 100644 --- a/docs/wasm/preview.mdx +++ b/docs/wasm/preview.mdx @@ -102,7 +102,8 @@ Everything in the first table changes an input and repaints. Everything in the s | `onLink` | the function that a click on a link calls, and assigning to it replaces the function | | `follow(link)` | follows a link the same way a click does | | `svg(page?)` | the markup for a fetched page, painted but not mounted; empty for one that is not | -| `exportPdf()` | the same run as PDF bytes | +| `exportPdf({ multipleOf }?)` | the same run as PDF bytes, [padded](quickstart.md#exporting) to a multiple of 2 or 4 pages | +| `pdfPages({ multipleOf }?)` | how many pages that PDF has | | `exportEpub()` | the book as a [reflowable EPUB](../library/epub.md#from-a-worker), with its warnings | | `exportEpubFiles()` | the same EPUB as [its files](../library/epub.md#the-files-one-by-one), not zipped, with the spine | | `destroy()` | closes the worker and empties the element | diff --git a/docs/wasm/quickstart.md b/docs/wasm/quickstart.md index 2ad3c5e2..2fe2e93a 100644 --- a/docs/wasm/quickstart.md +++ b/docs/wasm/quickstart.md @@ -146,6 +146,15 @@ const pdf = await client.exportPdf(); The export draws from the same laid-out pages the preview drew, rather than laying the book out a second time, so it cannot come out different from what is on screen. +A print vendor can ask for an even page count, or for a multiple of four. With `multipleOf` set to 2 or 4, the engine adds blank pages at the end of the PDF to reach that count. The preview does not change. `client.pdfPages` returns the page count of that PDF. + +The following example reads the page count of a book padded to a multiple of four, then exports the book: + +```js +const pages = await client.pdfPages({ multipleOf: 4 }); +const padded = await client.exportPdf([], { multipleOf: 4 }); +``` + ```js const epub = await client.exportEpub(); ``` diff --git a/docs/wasm/wire.md b/docs/wasm/wire.md index f59d0d16..7fbf9a88 100644 --- a/docs/wasm/wire.md +++ b/docs/wasm/wire.md @@ -72,6 +72,8 @@ Two more are about what styled the page. `want: 'inspect'`, with `node`, answers `want: 'hit'`, with `page`, `x`, and `y`, answers with the innermost element at that point on the page, or `null` outside every box. A page counts from 0, and a point is in points from the top-left corner of the page. Both questions answer in JSON, from the same cascade that styled the book, so a host matches no selectors of its own. A node id names a node only until the next edit. `Client.inspect`, `Client.inspectMarginBox`, and `Client.hit` are the host's side, and [sessions](../library/sessions.md) describes the answer in full. +`want: 'pdf'` takes `multipleOf`, which [pads the PDF](quickstart.md#exporting) to a multiple of 2 or 4 pages. The reply has `pages`, the page count of the PDF. `want: 'pages'` is a question that takes the same `multipleOf` and answers with that count in JSON. `Client.exportPdf` and `Client.pdfPages` are the host's side. + `want: 'epub'` is a render, as `want: 'pdf'` is. It answers with the book that the session holds as a reflowable EPUB, and runs no layout stage. The reply is postcard behind the same version as a display structure: the warnings, then the file. `decodeEpub` reads it, and `Client.exportEpub` is the host's side. [EPUB](../library/epub.md#from-a-worker) describes what the EPUB holds. With `unzipped: true`, the reply is the files of the EPUB instead of the zip. It holds the warnings, the spine, then each file with its path, its media type, and its bytes. Each entry of the spine has the path of a document and the node id of its section, which is the id that `want: 'source'` takes. `decodeEpubFiles` reads it, and `Client.exportEpubFiles` is the host's side. See [The files one by one](../library/epub.md#the-files-one-by-one) and [A place in the book](../library/epub.md#a-place-in-the-book). The host raises the generation whenever the input goes stale, at a keystroke in a stylesheet or a new manuscript. A response whose generation is behind the current one is dropped without painting.