Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/fleuron-wasm/npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ for selection and copy-and-paste.
`paintPage` draws one of them as SVG. Each run becomes one `<text>`
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
Expand Down
28 changes: 24 additions & 4 deletions crates/fleuron-wasm/npm/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type Names,
type NodeSource,
type Op,
type PdfOptions,
type Request,
type Response,
type Want,
Expand Down Expand Up @@ -117,9 +118,25 @@ export class Client {
return bytes === SUPERSEDED ? SUPERSEDED : decodeDisplayList(bytes);
}

/** The same, as PDF bytes. */
async exportPdf(ops: Op[] = []): Promise<Uint8Array | null> {
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<Uint8Array | null> {
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<number> {
return this.ask<number>({ ops: [], want: 'pages', ...options });
}

/**
Expand Down Expand Up @@ -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<Uint8Array | null> {
this.generation = this.generationFor(ops);
const response = await this.send({ ops, ...what, generation: this.generation });
Expand Down Expand Up @@ -322,6 +339,7 @@ export class Client {
box?: MarginBoxName;
x?: number;
y?: number;
multipleOf?: 2 | 4;
}): Promise<T> {
const response = await this.send(what);
if (!isRendered(response)) {
Expand All @@ -346,6 +364,7 @@ export class Client {
first?: number;
count?: number;
unzipped?: boolean;
multipleOf?: 2 | 4;
}): Promise<Response> {
this.id += 1;
const request: Request = {
Expand All @@ -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')
Expand Down
9 changes: 7 additions & 2 deletions crates/fleuron-wasm/npm/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand All @@ -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':
Expand All @@ -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);
}
Expand Down Expand Up @@ -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
Expand All @@ -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 &&
Expand Down
1 change: 1 addition & 0 deletions crates/fleuron-wasm/npm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export {
type NodeSource,
type PageBox,
type Op,
type PdfOptions,
type Rendered,
type Request,
type Response,
Expand Down
17 changes: 14 additions & 3 deletions crates/fleuron-wasm/npm/src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type Attributes,
type Metadata,
type Op,
type PdfOptions,
type Response,
type Sheet,
type Source,
Expand Down Expand Up @@ -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<Uint8Array | null> {
return this.client.exportPdf();
async exportPdf(options: PdfOptions = {}): Promise<Uint8Array | null> {
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<number> {
return this.client.pdfPages(options);
}

/**
Expand Down
31 changes: 29 additions & 2 deletions crates/fleuron-wasm/npm/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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. */
Expand Down
64 changes: 64 additions & 0 deletions crates/fleuron-wasm/npm/test/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 34 additions & 3 deletions crates/fleuron-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Vec<u8>, JsError> {
self.engine.export().map_err(js_error)
pub fn export_pdf(&mut self, multiple_of: Option<u32>) -> Result<Vec<u8>, 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<u32>) -> Result<u32, JsError> {
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
Expand Down Expand Up @@ -635,7 +653,7 @@ pub fn render(markdown: &str, css: &str) -> Result<Vec<u8>, JsError> {
/// The same, as PDF bytes.
#[wasm_bindgen(js_name = renderPdf)]
pub fn render_pdf(markdown: &str, css: &str) -> Result<Vec<u8>, JsError> {
once(markdown, css)?.export_pdf()
once(markdown, css)?.export_pdf(None)
}

fn once(markdown: &str, css: &str) -> Result<Session, JsError> {
Expand All @@ -647,6 +665,19 @@ fn once(markdown: &str, css: &str) -> Result<Session, JsError> {
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<u32>) -> Result<Option<PadTo>, 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 {
Expand Down
Loading
Loading