Capitolo 12 · Parte II · Il mestiere
Configurazione: uso programmatico
Chiamare Postext dal codice: buildDocument, i Web Worker, il visualizzatore HTML, i file PDF, il libro 3D, l’EPUB e i pacchetti .postext
In breve
Questa pagina è per chi scrive codice. Mostra come costruire un libro con una sola chiamata di funzione e come leggere gli avvisi che segnala. Spiega come eseguire il lavoro in background, così la pagina resta reattiva. Mostra come produrre una vista web, un file PDF, un libro 3D e un e-book EPUB. L’ultima sezione spiega il file che contiene un libro intero con i suoi font e le sue immagini.
#Uso programmatico
Percorso consigliato: usa il Web Worker. In un browser, la stragrande maggioranza delle integrazioni dovrebbe guidare la pipeline di impaginazione tramite
createLayoutWorker()dapostext/worker, non chiamandobuildDocumentdirettamente sul thread principale. Il worker mantiene reattiva l’interfaccia durante le generazioni, mette in cache le misure del testo tra una rigenerazione incrementale e l’altra e predispone l’annullamento in cui vince l’ultima richiesta, così un nuovo tasto premuto interrompe qualsiasi generazione superata ancora in corso. Vai direttamente a Eseguire l’impaginazione in un Web Worker per la ricetta canonica. Tutto il resto di questa sezione (buildDocumentdiretto, resolver, funzioni di pulizia, cache) resta utile, perché il worker espone esattamente gli stessi input e output, ma per il codice dell’interfaccia il wrapper del worker è il punto di partenza giusto. Ripiega sulla chiamata dibuildDocumentnel thread principale solo per esportazioni una tantum, rendering lato server (Node) o test.
#Costruire un documento
La funzione buildDocument esegue l’intera pipeline di impaginazione e restituisce un albero virtuale del documento (VDT) con coordinate precise per ogni elemento. È il punto d’ingresso di livello più basso; il codice dell’interfaccia dovrebbe preferire il wrapper Web Worker, che chiama buildDocument dentro un thread worker dedicato con gli stessi argomenti.
import { buildDocument } from 'postext';
const content = {
markdown: '# Chapter One\n\nThe story begins here...',
};
const config = {
page: { sizePreset: '17x24' },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt overrides the 8 pt default
};
// Build the layout — produces a VDT with one entry per page in `vdt.pages`
const vdt = buildDocument(content, config);
console.log(`Document has ${vdt.pages.length} pages`);#Avvisi nel documento
buildDocument non si ferma davanti a un rimando sbagliato o a uno stile sconosciuto: imposta un ripiego e registra ciò che ha fatto in doc.contentWarnings. I riquadri che l’impaginazione ha dovuto forzare sono in doc.warnings, che mantiene la forma che aveva in postext 1.4: ogni voce lì è un calloutOverflow con i suoi pageIndex, columnIndex e overflowPx. Ogni campo è assente quando non c’è nulla da segnalare. Ogni voce ha un kind. I tipi di contenuto portano l’intervallo di sorgente del costrutto (sourceStart / sourceEnd, offset nel Markdown che hai passato, frontmatter compreso) e, quando il costrutto è finito su una pagina, il suo pageIndex.
| Tipo | Segnalato quando | Che cosa fa l’output |
|---|---|---|
calloutOverflow | Un riquadro :::callout non entra in nessuna colonna e nessun taglio può dividerlo. Lo è anche un riquadro span: 'side' più alto di una colonna laterale vuota (da postext 1.25). | Viene collocato comunque, sbordando dalla sua colonna di overflowPx (su pageIndex / columnIndex). È l’unico tipo elencato in doc.warnings; i tipi seguenti sono in doc.contentWarnings. |
invalidFrontmatter | Il frontmatter non è YAML valido (una virgoletta non chiusa, del testo dopo un valore tra virgolette). message è il motivo indicato dal parser, con riga e colonna. | Il documento viene composto senza i suoi metadati; il corpo dopo il --- di chiusura viene impaginato come al solito. |
unknownResourceId | Un’incorporazione ::resource (usage: 'embed'), un :ref in linea ('ref') o l’immagine di una cella di tabella ('cellImage') indica un id che nessuna risorsa ha. | L’incorporazione viene omessa, il rimando stampa ? (o la sua etichetta text=) senza numero né link, la cella resta solo testo. inResource indica la risorsa la cui didascalia, nota o cella contiene il rimando. |
unknownDirective | Una riga :::name il cui nome non è né una direttiva né un contenitore. | La riga viene composta come testo. |
malformedEmbed | Una riga ::name che non è un’incorporazione ben formata e isolata: ::resource con un id senza virgolette o tra virgolette semplici o con un altro attributo, oppure una riga attaccata sotto un paragrafo senza una riga vuota. | La riga viene composta come testo. |
fullwidthMarkup | Una riga contiene marcatura digitata con un metodo di input cinese o giapponese: una recinzione :::, un titolo #, un richiamo di nota [^…], attributi {…} dopo una recinzione o un titolo, oppure un grassetto **…**. typed è la marcatura così come è scritta, ascii la forma da digitare. Uno per riga. | La riga viene composta come testo; non viene convertito nulla. |
attributeKeyInvalid | La chiave di un attributo contiene lettere non ASCII (作者=曹雪芹); punta alla chiave. | L’attributo viene ignorato. |
unknownParagraphStyle | :::paragraphsstyle non indica alcuno stile di paragrafo. | I paragrafi vengono composti come testo corrente. |
unknownCalloutType | :::callouttype non indica nessuno dei calloutStyles; viene segnalato solo quando ne è configurato almeno uno. | Il riquadro prende il primo stile di riquadro. |
columnsFlowUnknown | :::columnsflow non è né snake né parallel; value è il valore indicato. | Il gruppo prende il valore predefinito: parallel con breaks, snake senza. |
unknownChipStyle | :chip[…]style non indica alcuno stile di chip. | Il chip prende il primo stile di chip. |
undefinedFootnote | Un richiamo di nota [^id] che nessun paragrafo [^id]: definisce (id è quello della nota). | Il numero viene stampato; la nota è vuota. |
unusedFootnote | Una definizione di nota [^id]: che nessun richiamo cita. | La nota non viene composta. |
indexMarkInvalid | Un segno d’indice senza termine: :index, oppure attributi senza term su un segno senza testo tra parentesi quadre. | Il segno non indicizza nulla. |
indexSeeUnknown | Una destinazione see o seealso (target) che non è una voce del suo indice (index, '' per quello principale). Punta alla riga :::index. | Il rimando viene stampato comunque. |
indexRangeUnclosed | Un segno range="start" senza il corrispondente range="end", o viceversa (missing dice quale estremo manca; term indica la voce). Punta alla riga :::index. | L’intervallo stampa la sua unica pagina. |
unknownHeadingStyle | Il style="…" di un titolo non indica alcuno stile di titolo (level è quello del titolo). | Il titolo e la sua sezione mantengono le impostazioni proprie del livello. |
unknownTableStyle | Il table.styleId di una risorsa tabella non indica alcuna voce di tableStyles. | La tabella viene composta con tableStyle. |
raggedTableGrid | La griglia di una tabella non è rettangolare una volta contate le unioni (vedi Costruire i modelli di tabella). | Le celle slittano su un’unione o lasciano un buco. reason ('spanOverlap' / 'missingCells'), row e col individuano il primo problema; count dice quanti sono. |
lineNumberOverlap | Con lineNumbers.position: 'side', un numero di riga si sovrappone a un riquadro laterale, a una didascalia laterale o a una figura nella colonna laterale. Punta alla riga numerata; number è il numero come viene stampato. | Il numero viene disegnato comunque, e nessuno dei due si sposta. |
dropCap | Un paragrafo aperto da un capolettera che non può accoglierlo come configurato. reason: 'shortParagraph' (meno righe di quelle su cui scende l’iniziale; handling è ciò che ha fatto shortParagraph, lines le righe occupate da un’iniziale ridotta), 'split' (si interrompe prima dell’ultima riga dell’iniziale, da solo in una colonna troppo corta), 'joiningScript' (la sua prima lettera si lega alla successiva), 'verticalText' o 'noLetter' (si apre con un rimando, una formula o un richiamo di nota). text è la sua prima riga. | Lo spazio viene mantenuto, l’iniziale si riduce o viene omessa, come dice l’avviso. |
codeOverflow | Un listato di codice ha righe più larghe del suo riquadro. mode è ciò che ha fatto codeStyle.overflow ('wrap', 'shrink', 'clip'), lines quante righe di sorgente erano troppo larghe, scale il corpo con cui è stato composto un listato ridotto (una frazione di fontSize), lang il linguaggio della recinzione. Punta al listato. | Le righe vanno a capo, vengono rimpicciolite o tagliate, come dice mode. |
floatShrunk | Un’immagine flottante è stata composta più piccola della sua dimensione per entrare nello spazio disponibile (placement.shrink). resourceId la indica, scale è la frazione della sua larghezza che mantiene, e overflowPx, quando presente, quanto sborda ancora oltre il piede della gabbia del testo alla scala minima (placement.minScale), su una pagina nuova dove non aveva altro posto. Punta al paragrafo che la cita per primo. | L’immagine viene stampata a quella scala; oltre la gabbia del testo solo quando lo dice overflowPx. |
textWrap | Una risorsa o un riquadro impostato per far scorrere il testo attorno (placement.wrap, il wrap di un riquadro) che non lo fa come richiesto. reason: 'tooNarrow' (il testo accanto sarebbe più stretto di layout.wrap.minTextWidth), 'fewLines' (è più corto di layout.wrap.minLinesBeside righe), 'moved' (uno nel testo troppo alto per lo spazio rimasto nella sua colonna è passato alla successiva, insieme alla sua ancora) o 'verticalText'. resourceId indica una risorsa, box lo stile di un riquadro. Punta all’incorporazione o al riquadro. | L’elemento occupa la sua fascia per intero, o si colloca nella colonna successiva, come dice il motivo. |
columnsTooNarrow | Le sottocolonne di un gruppo :::columns sono più strette di sei em del loro testo: columns sottocolonne, widthPx ciascuna. Punta alla recinzione del gruppo. | Il gruppo viene composto come richiesto, con poche parole per riga. |
afterText | Un riquadro span: 'side', o una figura o tabella della colonna laterale (resourceId), composto su una pagina che non contiene testo: il testo del suo capitolo, o del documento, è finito mentre aspettava ancora spazio nella colonna laterale. Uno per riquadro o elemento flottante; punta al riquadro (al blocco che cita l’elemento flottante), con la sua pagina. Da postext 1.25. | Si colloca nella colonna laterale di una pagina aperta dopo il testo, con i riquadri nell’ordine delle loro recinzioni. |
unplaced | Un riquadro o una risorsa flottante (resourceId) ancora in attesa di uno spazio quando l’impaginazione è finita: le pagine aperte per lui non potevano accoglierlo (per esempio un riquadro laterale dove quelle pagine non hanno una colonna laterale). Punta al riquadro o al blocco che cita la risorsa; nessuna pagina. Da postext 1.25. | Non si trova su nessuna pagina. Fino a postext 1.24 spariva senza avviso. |
shortListSplit | Un elenco di items voci, breve secondo bodyText.shortListMaxItems, tagliato tra due colonne o pagine. Con bodyText.shortListSplit: 'warn' viene segnalato ogni elenco del genere; con 'avoid', solo quello che nessuna colonna poteva contenere intero. Uno per elenco; punta all’elenco, con la pagina della sua prima voce. Da postext 1.28. | L’elenco viene tagliato dove finisce la colonna. |
openerFloat | Un elemento flottante composto su una pagina d’apertura con layout.openerFloats: 'warn': una figura, una tabella o una striscia (resourceId), o un riquadro flottante. Uno per elemento flottante; punta al blocco che lo cita (al riquadro), con la sua pagina. Da postext 1.28. | L’elemento flottante resta sulla pagina d’apertura; 'avoid' lo sposta nel primo spazio successivo. |
fontFallback | Un carattere con cui è stato composto il testo (family, weight, style) che l’insieme dei font non poteva fornire al momento della generazione: reason: 'missing', nessun carattere della famiglia era caricato né installato, oppure quello che risponde per quel peso e quell’inclinazione non era ancora caricato; 'synthesized', la famiglia non ha un carattere di quel peso o di quell’inclinazione e il browser lo prende da un altro, così com’è o disegnato più nero o inclinato (un 600 ricavato dal 700, un 700 corsivo chiesto a una famiglia che ha solo un 400 tondo). Viene controllato dove c’è un insieme di font (document.fonts, il self.fonts di un worker, o BuildDocumentOptions.fontSet), dietro debug.warnings.missingFont. Nessuna pagina e nessun intervallo di sorgente. | Il testo viene misurato e disegnato con il carattere di ripiego, o con un altro carattere della famiglia, così com’è o reso più nero o inclinato; i suoi a capo cambiano quando arriva il carattere. Vedi Caricare i font prima dell’impaginazione. |
Gli avvisi su una risorsa (il suo stile di tabella, la sua griglia, un rimando dentro la sua didascalia, nota o celle) puntano alla prima incorporazione o al primo rimando della risorsa nel testo, e ciascuno viene elencato una volta per risorsa. Si controllano solo le risorse che il documento usa: un capitolo di un libro segnala le tabelle che cita, non tutte le tabelle del libro.
import { buildDocument, formatWarning } from 'postext';
const doc = buildDocument({ markdown: 'See :ref{id="fig-map"}.\n\n:::sidebar\nNotes.' }, config);
for (const w of [...(doc.warnings ?? []), ...(doc.contentWarnings ?? []), ...(doc.configWarnings ?? [])]) console.warn(formatWarning(w));
// Unknown resource id "fig-map" in :ref — it prints "?" (or its text= label), with no number or link (page 1, offset 4)
// Unknown directive ":::sidebar" — the line is set as text (page 1, offset 25)
// Narrow on `kind` to read the fields of a kind.
const missing = (doc.contentWarnings ?? []).flatMap((w) => (w.kind === 'unknownResourceId' ? [w.resourceId] : []));formatWarning(w) restituisce una descrizione di una riga in inglese. Un host che localizza i propri messaggi fa invece uno switch su kind, e tiene un ramo predefinito, perché le versioni minori possono aggiungere tipi. collectContentWarnings(markdown, config, resources) restituisce gli avvisi di contenuto senza impaginare nulla (l’elenco che aggiunge la generazione, senza pageIndex), per un editor che controlla il testo mentre viene digitato. collectHeadingDesignCuts(doc) controlla un’impaginazione finita alla ricerca di progetti grafici di titoli il cui testo sborda oltre il piede della loro pagina o colonna (kind: 'headingDesignCut'; vedi Altezza riservata), che l’impaginazione stessa non segnala, e formatWarning descrive anche i suoi risultati. Il pannello Verifiche della Sandbox li elenca tutti.
I renderer segnalano ciò che non riescono a disegnare come richiesto tramite un’opzione onWarning: renderPageToCanvas, renderPage e renderToCanvas (RenderPageOptions), renderToHtml e renderToHtmlIndexed (RenderHtmlOptions), e renderToPdf (RenderToPdfOptions, anche tramite il worker PDF). Il tipo principale di avviso di rendering è missingImage: un’immagine (una figura, l’immagine di una cella di tabella, l’icona di un riquadro, un’immagine di design) senza nulla da disegnare viene resa come segnaposto neutro e segnalata, una volta per fileId e chiamata di rendering, con il suo pageIndex, il resourceId quando il renderer lo conosce (figure e immagini delle celle) e, nel PDF, il documentIndex di un rendering di più documenti. Nulla da disegnare significa nessun registerResourceImage per il fileId sul canvas, nessun URL da resourceImageUrl in HTML, e nessun byte da resourceBytes (o byte che non si decodificano) nel PDF. Una risorsa bitmap o SVG che non indica alcun fileId non ha nulla da chiedere: viene disegnata come segnaposto senza segnalazione. Altri due tipi vengono dagli host che incorporano i font nelle immagini SVG (registerSvgImage, registerBundleImages, bundleImageUrl, renderToHtml con inlineSvgFonts, postext-epub; vedi Font nel testo SVG), con il fileId e il resourceId dell’immagine: svgFontUnavailable (family, weight, style), una famiglia nominata dal suo testo senza un carattere da incorporare, per cui l’immagine compone quel testo con un carattere di ripiego; e svgFontsTooLarge (bytes, maxBytes), caratteri oltre il limite di dimensione, nessuno incorporato. Gli avvisi di rendering non vengono memorizzati nel VDT: ciò che un host può fornire cambia dopo l’impaginazione.
import { buildDocument, renderPage, type RenderWarning, type Resource } from 'postext';
const map: Resource = {
id: 'fig-map', typeId: 'figure', kind: 'bitmap', caption: 'The route.', createdAt: 0, updatedAt: 0,
bitmap: { fileId: 'map-file', format: 'png', width: 1200, height: 800 },
};
const doc = buildDocument({ markdown: 'See :ref{id="fig-map"}.', resources: [map] }, config);
const warnings: RenderWarning[] = [];
const canvas = renderPage(doc.pages[0], doc, { onWarning: (w) => warnings.push(w) });
// Until 'map-file' is registered with registerResourceImage:
// [{ kind: 'missingImage', fileId: 'map-file', resourceId: 'fig-map', pageIndex: 0 }]#Rendere una pagina come bitmap
Ogni pagina può essere rasterizzata in modo indipendente. Usa renderPage(page, doc) per ottenere un HTMLCanvasElement per un dato numero di pagina: il canvas è una bitmap dimensionata esattamente sulle misure della pagina in pixel (ai DPI configurati), quindi puoi mostrarla, esportarla o passarla a qualsiasi pipeline di immagini:
import { buildDocument, renderPage } from 'postext';
const vdt = buildDocument(content, config);
// Render page 3 (zero-indexed) to a bitmap canvas
const pageNumber = 2;
const page = vdt.pages[pageNumber];
if (!page) throw new Error(`Page ${pageNumber} does not exist`);
const canvas = renderPage(page, vdt);
// canvas.width / canvas.height are the page bitmap size in pixels
// Show it in the DOM
document.body.appendChild(canvas);
// …or export it as a PNG data URL
const pngDataUrl = canvas.toDataURL('image/png');
// …or get a Blob for download / upload
canvas.toBlob((blob) => {
if (blob) saveAs(blob, `page-${pageNumber + 1}.png`);
}, 'image/png');
// …or grab raw RGBA pixels
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);Se preferisci disegnare in un canvas che hai già (per esempio uno inserito nel DOM con una disposizione precisa), usa renderPageToCanvas(page, doc, canvas): ridimensiona e disegna nel canvas che passi, invece di crearne uno nuovo.
Per rendere tutte le pagine, itera su vdt.pages:
const bitmaps = vdt.pages.map((page) => renderPage(page, vdt));Esempio dal vivo: una pagina come immagine
Tutto quanto sopra, in esecuzione nel browser. Il pen importa l’ultima versione di postext da una CDN, aspetta i web font, impagina un breve documento a due colonne, disegna la sua prima pagina in un canvas e offre quella bitmap come PNG. Premi Esegui su CodePen per caricare l’editor e modificare il Markdown o la configurazione; la pagina si ridisegna a ogni modifica.
import { buildDocument, renderPage } from 'https://esm.sh/postext';
const markdown = `# The Lantern
The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
## Two columns
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.`;
const config = {
// 150 dpi: crisp enough for a preview, light enough to paint instantly.
page: { sizePreset: '17x24', dpi: 150 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
// The whole layout: one entry per page in doc.pages, with exact coordinates.
const doc = buildDocument({ markdown }, config);
// Rasterise the first page. The canvas is sized to the page at the configured dpi.
const canvas = renderPage(doc.pages[0], doc);
document.getElementById('page').replaceChildren(canvas);
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · page 1 is ${canvas.width} × ${canvas.height} px`;
// The same bitmap as a PNG file.
canvas.toBlob((blob) => {
const link = document.getElementById('download');
link.href = URL.createObjectURL(blob);
link.hidden = false;
}, 'image/png');index.html
<p id="status">Laying out…</p>
<a id="download" download="page-1.png" hidden>Download page 1 as PNG</a>
<div id="page"></div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#page canvas {
display: block;
max-width: 100%;
height: auto;
margin-top: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Carica un editor interattivo da codepen.io. L’esempio importa da una CDN l’ultima versione di postext.
#React
postext/react esporta createLayout(content, config?): un componente che impagina il documento una volta, al montaggio, e mostra ogni pagina come un <canvas> dentro un <div>.
import { createLayout } from 'postext/react';
const Article = createLayout(
{ markdown: '# Hello\n\nThe first paragraph of the article.' },
{ page: { sizePreset: '17x24' } },
);
export function ArticlePage() {
return <Article className="pages" style={{ maxWidth: 480 }} />;
}- Thread principale, una volta sola. Le pagine vengono disegnate alla risoluzione del documento e scalate alla larghezza del contenitore.
contenteconfigsono fissati quando chiamicreateLayout; crea un altro componente per mostrare qualcos’altro. Per un’anteprima dal vivo, genera nel Web Worker e disegna conrenderPageToCanvas, come nell’esempio React di quella sezione. - Prima i font e le immagini. Carica i web font del documento prima che il componente venga montato, e registra le sue immagini con
registerResourceImage. Quando il Markdown contiene un$, il componente avvia da solo il motore matematico. - React resta fuori dal punto d’ingresso principale.
postextnon importa mai React; lo fa solopostext/react.createLayoutè ancora esportato dapostextperché il codice esistente continui a funzionare, ma è deprecato: caricapostext/reactquando lo chiami, e il componente resta sospeso finché quel modulo non è arrivato (React lo ridisegna da solo). Importalo dapostext/react. - Il componente deprecato si sospende. Finché
postext/reactnon è arrivato,createLayoutdapostextha bisogno di una root concorrente (createRoot) o di un confine<Suspense>sopra di sé. In una root legacyReactDOM.render, o inrenderToString, senza confine, React segnala invece un errore.reactresta una peer dependency obbligatoria, così i bundler possono risolvere quell’import pigro.
#Risolvere i valori predefiniti
Le funzioni di risoluzione completano con i valori predefiniti gli oggetti di configurazione parziali. Sono utili quando ti serve una configurazione completa da ispezionare o confrontare:
import { resolvePageConfig, resolveBodyTextConfig } from 'postext';
const fullPage = resolvePageConfig({ sizePreset: '21x28' });
// => { sizePreset: '21x28', width: { value: 21, unit: 'cm' }, height: { value: 28, unit: 'cm' },
// margins: { top: { value: 2, unit: 'cm' }, ... }, dpi: 300, cutLines: { enabled: false, ... }, ... }
const fullBody = resolveBodyTextConfig({ fontFamily: 'Inter' });
// => { fontFamily: 'Inter', fontSize: { value: 8, unit: 'pt' }, lineHeight: { value: 1.5, unit: 'em' }, ... }Funzioni di risoluzione disponibili, una per sezione di primo livello: resolvePageConfig, resolveLayoutConfig, resolveBodyTextConfig, resolveHeadingsConfig, resolveHeadingStylesConfig, resolveTocConfig, resolvePartsConfig, resolveUnorderedListsConfig, resolveOrderedListsConfig, resolveMathConfig, resolveTableStyleConfig, resolveCaptionStyleConfig, resolveDiagramStyleConfig, resolveParagraphStylesConfig, resolveCalloutStylesConfig, resolveHeaderFooterConfig, resolveDebugConfig, resolveHtmlViewerConfig, resolvePdfGenerationConfig, più resolveDesignSlot per un singolo slot di design. Le tavolozze di colori si applicano a parte tramite applyPaletteToConfig(config), applyPaletteToResolvedConfig(resolved, palette) e resolveColorValue(value, palette, fallback): vedi Tavolozza dei colori.
Le funzioni di risoluzione i cui valori predefiniti derivano a cascata da un’altra sezione ricevono quella sezione, già risolta, come argomento aggiuntivo. resolveUnorderedListsConfig e resolveOrderedListsConfig ricevono il testo corrente risolto, perché i valori predefiniti degli elenchi per fontFamily e color derivano da esso; resolveCalloutStylesConfig riceve il testo corrente, i titoli e gli elenchi puntati risolti (vedi l’esempio in Stili dei riquadri), e resolveHeadingStylesConfig la pagina, il testo corrente ed entrambe le sezioni degli elenchi risolti. Controlla le dichiarazioni di tipo del pacchetto per la firma esatta di ciascuna:
import { resolveBodyTextConfig, resolveUnorderedListsConfig } from 'postext';
const body = resolveBodyTextConfig({ fontFamily: 'Inter' });
const lists = resolveUnorderedListsConfig({ bulletChar: '—' }, body);
// => lists.fontFamily === 'Inter' (inherited)Vengono esportati anche gli insiemi statici di valori predefiniti, cioè i valori usati quando non entra in gioco alcuna cascata: DEFAULT_PAGE_CONFIG, DEFAULT_CUT_LINES, DEFAULT_PAGE_NUMBERING, PAGE_SIZE_PRESETS, DEFAULT_LAYOUT_CONFIG, DEFAULT_COLUMN_RULE, DEFAULT_COLUMN_BALANCING, DEFAULT_BODY_TEXT_CONFIG, DEFAULT_HYPHENATION_CONFIG, DEFAULT_HEADINGS_CONFIG, DEFAULT_UNORDERED_LISTS_STATIC, DEFAULT_ORDERED_LISTS_STATIC, DEFAULT_PARAGRAPH_STYLES, DEFAULT_CALLOUT_STYLES, DEFAULT_CALLOUT_STYLE_STATIC, DEFAULT_PARTS_CONFIG, DEFAULT_HEADING_STYLES, DEFAULT_TOC_CONFIG, DEFAULT_MATH_CONFIG, DEFAULT_DIAGRAM_STYLE_CONFIG, DEFAULT_DEBUG_CONFIG, DEFAULT_HTML_VIEWER_CONFIG, DEFAULT_PDF_GENERATION_CONFIG, DEFAULT_COLOR_PALETTE, DEFAULT_MAIN_COLOR, DEFAULT_MAIN_COLOR_ID, DEFAULT_MAIN_COLOR_NAME, DEFAULT_MAIN_COLOR_HEX, più i valori predefiniti degli elementi di intestazione e piè di pagina (DEFAULT_HEADER_FOOTER_SLOT, DEFAULT_HEADER_SLOT, DEFAULT_FOOTER_SLOT, DEFAULT_TEXT_ELEMENT, DEFAULT_RULE_ELEMENT, DEFAULT_BOX_ELEMENT) e defaultResourceTypes(locale), che tiene conto della lingua (vedi Tipi di risorsa).
#Eliminare i valori predefiniti
Quando salvi una configurazione (ad esempio in localStorage o in un file), usa stripConfigDefaults per eliminare i valori che coincidono con quelli predefiniti. Così le configurazioni salvate restano minime: si salvano solo le sostituzioni volute.
import { stripConfigDefaults } from 'postext';
const minimal = stripConfigDefaults(fullConfig);
// Only properties that differ from defaults remainSono disponibili anche le singole funzioni di pulizia, una per funzione di risoluzione: stripPageDefaults, stripLayoutDefaults, stripBodyTextDefaults, stripHeadingsDefaults, stripHeadingStylesDefaults, stripTocDefaults, stripPartsDefaults, stripUnorderedListsDefaults, stripOrderedListsDefaults, stripMathDefaults, stripTableStyleDefaults, stripCaptionStyleDefaults, stripDiagramStyleDefaults, stripParagraphStylesDefaults, stripCalloutStylesDefaults, stripHeaderFooterDefaults, stripDesignSlotDefaults, stripDebugDefaults, stripHtmlViewerDefaults, stripPdfGenerationDefaults.
Alcuni valori predefiniti dipendono dal resto della configurazione: il bilanciamento delle colonne è disattivato su una griglia di caratteri e nel testo verticale, bodyText.avoidWidows è disattivato su una griglia di caratteri (e anche bodyText.avoidOrphans nella regione japan), e le note a piè di pagina, le didascalie e l’indice analitico seguono la lingua del documento. stripConfigDefaults confronta ogni valore con il predefinito della configurazione che riceve, così headings.balancing.enabled: true resta dove il bilanciamento è disattivato per impostazione predefinita, e lì false viene eliminato. Allo stesso modo bodyText.avoidWidows: true resta su una griglia di caratteri. Una funzione di pulizia chiamata da sola riceve quel contesto come argomenti: stripHeadingsDefaults(headings, balancingOnByDefault(config)), stripIndexDefaults(index, locale), stripCaptionStyleDefaults(captionStyle, locale), stripFootnotesDefaults(footnotes, locale, writingMode), stripBodyTextDefaults(bodyText, locale, avoidWidowsByDefault(config), avoidOrphansByDefault(config)).
Un valore che dice «niente» viene mantenuto ovunque il niente non sia il predefinito. Uno stile di titolo prende l’intestazione e il piè di pagina del documento quando non ne imposta, così uno stile che li svuota (footer: { elements: [] } su una copertina) mantiene lo slot vuoto, e i suoi margins, layout e bodyStyle vengono mantenuti anche quando sono vuoti. Un livello di titolo riportato al suo valore predefinito sotto valori comuni a tutti i titoli che sono diversi mantiene il valore. calloutStyles: [] e chipStyles: [] restano elenchi vuoti: se venissero omessi, tornerebbe lo stile integrato. In ogni caso resolveAllConfig(stripConfigDefaults(config)) si risolve come resolveAllConfig(config).
#Parsing
Il motore espone il proprio tokenizzatore Markdown e il lettore del frontmatter. Usali per ispezionare un documento prima di generarlo, o per alimentare altri strumenti con la stessa struttura a blocchi che vede Postext:
import { parseMarkdown, extractFrontmatter } from 'postext';
const source = '---\ntitle: Chapter One\n---\n\n# Opening\n\nThe story begins here.';
const { metadata, content } = extractFrontmatter(source);
// metadata.title === 'Chapter One'
const blocks = parseMarkdown(content);
// => [ { type: 'heading', level: 1, text: 'Opening', … },
// { type: 'paragraph', text: 'The story begins here.', … } ]Vedi la pagina Formato del documento per l’elenco completo dei costrutti Markdown che Postext riconosce.
#Caricare i font prima dell’impaginazione
L’impaginazione misura il testo con i caratteri che l’insieme dei font ha nel momento in cui viene eseguita. prepareFonts carica prima della prima generazione ogni carattere richiesto da una configurazione e dal suo testo: testo corrente, titoli, elenchi, titoli e corpi dei riquadri, tabelle, testi di design, testatine, indice, codice e lettering dei fumetti, in ogni peso e inclinazione che la configurazione imposta (i quattro della famiglia del testo corrente, perché ** e * vi compongono grassetto e corsivo). Ogni carattere viene caricato per i caratteri che il documento compone, così una famiglia servita a fette unicode-range (Latin Extended, greco, arabo, le fette CJK di Google Fonts e Fontsource) porta i file di cui il testo ha bisogno.
import { prepareFonts, buildDocument, buildDocumentWithFonts } from 'postext';
// The host's files: the PDF font provider's contract, so one function serves both.
async function resolve(family: string, weight: number, style: 'normal' | 'italic') {
const id = family.toLowerCase().replace(/\s+/g, '-');
return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}-${style}.woff2`;
}
const report = await prepareFonts(content, config, { resolve });
// report.loaded, report.missing, report.synthesized: { family, weight, style }[]
const doc = buildDocument(content, config);
// Or in one call: prepare, build, load any face the pages used that was not there, build again.
const same = await buildDocumentWithFonts(content, config, { resolve });- I caratteri che la pagina dichiara (una regola
@font-face, unFontFacegià aggiunto) vengono caricati tramite l’insieme dei font (document.fonts.load, oself.fontsin un worker). I caratteri che non dichiara vengono chiesti aresolve(family, weight, style, { text, codePoints }), che risponde con un file (byte o un URL), più file (le fette di un carattere),{ source, unicodeRange, weight, style }per una fetta o un intervallo variabile, oppurenull. Il motore li aggiunge comeFontFaceuna volta caricati tutti, nell’ordine della configurazione e di ogni risposta (latin, poi latin-ext, poi greek quando il resolver risponde così), così l’insieme dei font è lo stesso in qualunque ordine arrivino i file. Li registra anche nel registro dei font che leggono le immagini SVG e i worker di impaginazione. Un resolver può dichiarare il carattere da sé (aggiungendo un foglio di stile) e risponderenull. - Il resoconto elenca i caratteri a cui risponde un carattere caricato (o una famiglia installata), quelli ancora
missing, e quelli che il browser ricaverebbe (synthesize) da un altro peso o inclinazione.timeoutMs(10.000 per impostazione predefinita) limita l’attesa; un carattere ancora in caricamento a quel punto conta come mancante. Dove non c’è un insieme di font (Node),prepareFontsnon fa nulla e segnala tutti i caratteri come caricati. buildDocumentWithFonts(content, config, options)prepara, genera conbuildDocumentAsync, legge i caratteri con cui le pagine compongono effettivamente il testo, carica quelli che l’insieme dei font non poteva fornire (un peso che solo le pagine rivelano viene chiesto aresolveanche quando un altro peso della famiglia risponderebbe per lui) e genera di nuovo (al massimo due generazioni in più).withLoadedFonts(build, options)fa lo stesso attorno a qualsiasi funzione di generazione, per un libro generato capitolo per capitolo o per un pacchetto (buildBundle) che restituisce più documenti.options.onFontsriceve il resoconto finale.- Dopo la generazione, ogni carattere con cui è stato composto il testo e che l’insieme dei font non poteva fornire viene elencato in
doc.contentWarningscomefontFallback(vedi Avvisi nel documento).
I caratteri che arrivano dopo vengono rilevati da soli. Il motore tiene traccia, per ogni insieme di font, di quali caratteri di ogni famiglia erano caricati l’ultima volta che ha guardato; una generazione guarda di nuovo all’inizio (solo quando l’insieme è cresciuto o si è ridotto, sta caricando o ha finito di caricare un carattere) e scarta ciò che era stato misurato nelle famiglie i cui caratteri sono cambiati. watchFonts(fontSet) fa lo stesso mentre i caratteri si caricano, al massimo una volta per frame di animazione per quante fette porti un’ondata, e onFontsChanged(listener) dice all’host quali famiglie sono cambiate, così può reimpaginare le pagine:
import { watchFonts, onFontsChanged } from 'postext';
const stop = watchFonts(); // document.fonts by default
const off = onFontsChanged((families) => relayout());prepareFonts avvia il controllo sull’insieme in cui carica (watch: false lo lascia spento).
#Cache delle misure
La misurazione del testo è il passaggio costoso dell’impaginazione. Due tipi di cache la mantengono economica:
- Una cache dei blocchi che gestisci tu.
createMeasurementCache()restituisce unaMeasurementCacheche ricorda ogni paragrafo misurato, con una chiave basata su testo, font, larghezza, opzioni di divisione in righe e dizionario di sillabazione attivo. Passala come terzo argomento dibuildDocument(o dibuildDocumentAsync) per riusare le misure tra i passaggi di collocazione di una generazione e tra una generazione e l’altra: un editor che impagina il documento a ogni tasto premuto misura allora solo i paragrafi cambiati. Senza cache, ogni passaggio misura di nuovo ogni blocco. Un paragrafo letto dalla cache è identico a uno misurato da capo, quindi una generazione con cache compone ogni riga come una generazione senza; in postext 1.4.1 un paragrafo in cache perdeva il contrassegno di ultima riga troppo corta, e la correzione delle righe corte e il bilanciamento delle colonne potevano allora dividerlo in modo diverso. La cache porta con sé la generazione di misure in cui è stata riempita: quando i caratteri di una famiglia arrivano o se ne vanno, la consultazione successiva scarta i blocchi composti in quella famiglia, così una cache conservata attraverso i caricamenti dei font non fornisce mai righe misurate con il carattere di ripiego. - Cache globali delle larghezze. Le larghezze delle parole sono in cache per stringa di font nello stato del modulo che tutte le generazioni della pagina condividono, e pretext tiene una cache propria. Il motore scarta le larghezze di una famiglia quando i suoi caratteri cambiano (all’inizio di una generazione, da
watchFonts, daprepareFontse daloadBundleFonts); la cache di pretext non ha un indice per famiglia e viene svuotata per intero.
import { buildDocument, createMeasurementCache, evictFontFamilies, clearMeasurementCache } from 'postext';
const cache = createMeasurementCache();
let doc = buildDocument(content, config, cache);
// A face of "EB Garamond" was added to document.fonts: the next build sees it,
// with the same cache, and measures that family again.
doc = buildDocument(content, config, cache);
// A host that changes faces the engine cannot see (a font set of its own) says so:
evictFontFamilies(['EB Garamond']); // that family's widths and cached blocks
clearMeasurementCache(); // every familyPer le applicazioni che misurano il testo pezzo per pezzo, cachedMeasureBlock(text, font, maxWidthPx, lineHeightPx, options, cache) e cachedMeasureRichBlock(spans, normalFont, boldFont, italicFont, boldItalicFont, maxWidthPx, lineHeightPx, options, cache) prendono gli argomenti di measureBlock e measureRichBlock più la cache, in ultima posizione.
#Il motore di divisione in righe (WebAssembly)
I paragrafi vengono divisi in righe con Knuth–Plass. Da postext 1.33 il motore lo esegue in un modulo di divisione in righe scritto in Rust e compilato in WebAssembly (30 KB, dentro il pacchetto). Sceglie le stesse divisioni del modulo TypeScript, con la stessa aritmetica nello stesso ordine, e un libro intero si impagina circa il 15 % più in fretta. Non c’è nulla da configurare:
- In un worker, in Node e in Bun, il modulo viene compilato al primo paragrafo.
- Nel thread principale di una pagina, dove un browser compila un modulo di queste dimensioni solo in modo asincrono, viene compilato in background e fino ad allora risponde il modulo TypeScript.
await initLineBreaker()prima della prima impaginazione lo mette al lavoro dal primo paragrafo; si risolve confalsedove WebAssembly non è disponibile. - Dove WebAssembly manca, o una Content Security Policy lo rifiuta (
script-srcsenza'wasm-unsafe-eval'), risponde a ogni paragrafo il modulo TypeScript.
Entrambi scelgono le stesse divisioni, quindi quale dei due abbia risposto non cambia mai un’impaginazione. useWasmLineBreaker(false) mantiene il modulo TypeScript, per confrontare i due.
import { initLineBreaker, buildDocument } from 'postext';
await initLineBreaker(); // optional: the first layout already uses it
const doc = buildDocument({ markdown }, config);#Stato globale condiviso in una pagina
Parte dello stato di Postext vive in variabili a livello di modulo. Tutto ciò che importa postext nello stesso realm JavaScript lo condivide: una pagina e i suoi script condividono una copia, mentre ogni iframe e ogni worker ha la propria. Un solo documento per pagina non se ne accorge mai. Più documenti in una pagina (due anteprime dal vivo, una galleria di esempi) sì:
- Immagini delle risorse.
registerResourceImage(fileId, image)riempie un unico registro con chiavefileId, cherenderPageerenderPageToCanvasleggono. Due documenti che registrano entrambifigure.svgcondividono quella voce: vince l’ultima registrazione, per tutti e due. Dai agli id dei file un prefisso per documento, e chiamaunregisterResourceImage(fileId)oclearResourceImages()quando un documento viene rimosso. I raster che il backend canvas mette in cache hanno la stessa chiave e vengono scartati con l’immagine. - Misure del testo. Le larghezze misurate sono in cache per stringa di font e testo per l’intero realm. Quando i caratteri di una famiglia arrivano o se ne vanno, il motore scarta le larghezze di quella famiglia per tutti i documenti (vedi Caricare i font prima dell’impaginazione);
clearMeasurementCache()le scarta tutte. - Configurazioni risolte. Ogni oggetto di configurazione viene risolto una volta e il risultato viene messo in cache associato a quell’oggetto. Ogni generazione confronta prima l’oggetto con il testo che aveva quando è stato risolto (un
JSON.stringify, circa 0,1 ms per una configurazione di libro di 55 KB e 1,5 ms per una di 240 KB), così una configurazione modificata sul posto, a qualsiasi profondità (config.bodyText.fontSize = …, un colore della tavolozza), viene risolta di nuovo.invalidateConfig(config)scarta la risoluzione a mano.stableStringifyehashStringdanno una chiave di contenuto che non dipende dall’ordine delle chiavi, per gli host che mettono in cache le impaginazioni per configurazione. - Lingua di sillabazione. Ogni generazione imposta la lingua di sillabazione dell’intero processo sul
bodyText.hyphenation.localedel proprio documento. Le funzioni esportatehyphenateText(text)elayoutDesignSlotusano la lingua dell’ultima generazione a meno che tu non ne passi una: chiamahyphenateText(text, 'es'). - Motore matematico. C’è un solo motore MathJax e una sola cache di formule rese per il realm;
initMathEngine()lo avvia per tutti. - Motore di divisione in righe. C’è un solo modulo WebAssembly di divisione in righe per il realm, compilato una volta;
useWasmLineBreaker(false)lo disattiva per tutti.
L’isolamento più semplice è un realm per documento: un iframe per esempio dal vivo (un embed di CodePen lo è), oppure un worker di impaginazione per documento per le misure e la sillabazione (le immagini vengono comunque registrate sulla pagina).
#Eseguire l’impaginazione in un Web Worker
È il modo consigliato di usare Postext nel browser. Se stai costruendo qualcosa di interattivo (un’anteprima dal vivo, un editor, un visualizzatore che reagisce al ridimensionamento o un ambiente di prova come la Sandbox), guida la pipeline tramite createLayoutWorker() da postext/worker. Non chiamare buildDocument direttamente sul thread principale nel codice dell’interfaccia.
Chiamare buildDocument sul thread principale esegue l’intera pipeline (parsing, misurazione, collocazione e ogni nuova esecuzione del passaggio di collocazione di cui l’impaginazione ha bisogno) sul thread che l’ha invocata. Per un’esportazione una tantum va bene. Per un’interfaccia interattiva è il thread sbagliato: un’impaginazione di 150 ms blocca gli eventi di input, i tasti premuti si accodano e lo scorrimento scatta. Il worker sposta ognuno di quei millisecondi su un thread in background.
Postext fornisce un punto d’ingresso Web Worker dedicato, postext/worker, che toglie la pipeline dal thread principale. È il percorso che ci aspettiamo usi la maggior parte delle integrazioni: le viste Canvas, HTML e PDF della Sandbox condividono tutte lo stesso handle createLayoutWorker() tramite un unico hook useLayoutWorker (packages/postext-sandbox/src/worker/useLayoutWorker.ts) e lo guidano con l’annullamento in cui vince l’ultima richiesta: un nuovo tasto premuto interrompe la generazione in corso prima ancora che finisca.
A colpo d’occhio, l’integrazione canonica è:
- Crea un worker una volta per vista con
createLayoutWorker(). - Registra i font una volta per famiglia inviando
ArrayBuffertrasferibili tramiteregisterFonts(payloads). - Genera con
build(content, config, { signal }), passando unAbortSignalnuovo a ogni chiamata così le generazioni superate possono essere annullate. - Sostituisci qualsiasi generazione precedente annullandone il segnale prima di avviare la successiva: è lo schema in cui vince l’ultima richiesta.
- Elimina il worker quando il componente che lo possiede viene smontato.
Lo stesso VDTDocument restituito da build(...) alimenta tutti i renderer a valle: renderPage/renderPageToCanvas per il canvas, renderToHtmlIndexed per l’HTML e renderToPdf (da postext-pdf) per il PDF. Generi una volta nel worker e rasterizzi sul thread principale tutte le volte che serve all’interfaccia.
#Che cosa ti dà il worker
- Il thread principale resta libero. Parsing, misurazione e ogni passaggio di collocazione vengono eseguiti dentro il worker. Il thread principale viene toccato solo quando il
VDTDocumentfinito viene rispedito indietro. - Annullamento in cui vince l’ultima richiesta.
build(content, config, { signal })porta unAbortSignaldentro il worker. Annullare prima del completamento solleva unAbortErrorlato principale; dentro il worker la pipeline lancia unBuildCancelledErroral successivo punto di controllo dell’annullamento, che c’è a ogni blocco, e si ferma subito. - Cache delle misure per worker. Il worker mantiene una sola
MeasurementCacheper tutta la sua vita. Le generazioni successive che condividono font, testo e larghezza riusano le misure delle righe in cache: digitare un solo carattere in un documento lungo rimisura solo i blocchi il cui input è davvero cambiato. - Metriche identiche al thread principale. I font vengono spediti nel worker come
ArrayBuffertrasferibili e registrati tramitenew FontFace(...)nelFontFaceSetdel worker stesso. Il worker misura con le stesse metriche dei font su canvas che userebbe il thread principale, quindi gli a capo e le altezze delle colonne sono identici byte per byte. - La cache dei raster matematici sopravvive alle generazioni nel worker. Il renderer matematico affianca alla cache con chiave d’identità una cache dei raster con chiave di contenuto: altrimenti la clonazione strutturata di un
MathRenderattraverso il confine del worker mancherebbe la cache d’identità a ogni rigenerazione. - Le stesse righe della pagina. Un canvas creato in un worker non ha una pagina da cui prendere una lingua, e un font risponde con glifi diversi a seconda della lingua. Il motore imposta la lingua del documento (
locale) su ogni contesto con cui misura, nel worker come sulla pagina, cosìbuild()restituisce le righe chebuildDocumentdà su una pagina, in qualsiasi lingua (bodyText.shapingLanguage: 'document', il valore predefinito da postext 1.30). Con'host', che una configurazione salvata prima della 1.30 conserva, un worker misura senza lingua e una pagina con la propria, come fino a postext 1.28.
#API pubblica
Il client del worker si trova nel sottopercorso postext/worker ed è composto da pochi nomi:
createLayoutWorker(opts?): LayoutWorkerHandle: avvia un worker dedicato (o avvolge quello che passi tramiteopts.worker, o avvia il punto d’ingresso del worker inopts.url) e restituisce un handle tipizzato. Vedi Caricare il worker da una CDN.LayoutWorkerHandle.registerFonts(faces: FontPayload[]): Promise<void>: spedisce i byte dei font nel worker. I buffer vengono trasferiti, quindi tieni una copia nuova sul thread principale se devi rinviarli più tardi.LayoutWorkerHandle.build(content, config?, { signal? }): Promise<VDTDocument>: esegue la pipeline. Annullare il segnale interrompe la generazione in corso.LayoutWorkerHandle.dispose(): void: termina il worker e rifiuta tutte le generazioni in sospeso conAbortError.FontPayload:{ family, weight, style, unicodeRange?, buffer: ArrayBuffer }.weightè una stringa di peso CSS ('700','bold');registerFontslo accetta anche come numero (700). Ilbufferviene trasferito al worker quando chiamiregisterFonts.BuildCancelledError(riesportato dapostext): ciò chebuildDocumentlancia internamente quandooptions.shouldCancelrestituiscetrue. Di solito non lo vedi sul thread principale: il protocollo del worker lo converte in unAbortErrorprima che raggiunga il tuo codice.
Il pacchetto pubblica anche un percorso postext/worker/entry che punta allo script compilato del worker. createLayoutWorker() risolve questo URL automaticamente; devi farvi riferimento esplicito solo quando il tuo bundler richiede una chiamata costruita a mano new Worker(new URL(...), { type: 'module' }), o quando servi tu stesso il punto d’ingresso (opts.url).
#Integrazione minima
import { createLayoutWorker } from 'postext/worker';
import type { FontPayload, LayoutWorkerHandle } from 'postext/worker';
import type { PostextConfig, VDTDocument } from 'postext';
// 1. Create the worker once and keep the handle for the lifetime of your viewport.
const layout: LayoutWorkerHandle = createLayoutWorker();
// 2. Register fonts once per family (transferable ArrayBuffers).
// getConfigFontFamilies(config) is a helper that lists the families your config will render.
const payloads: FontPayload[] = await collectFontPayloadsForFamilies([
'EB Garamond',
'Open Sans',
]);
await layout.registerFonts(payloads);
// 3. Drive builds with last-wins cancellation: abort the previous signal
// before starting a new build. A stale build is thrown away inside the worker.
let pending: AbortController | null = null;
async function rebuild(
markdown: string,
config: PostextConfig,
): Promise<VDTDocument | null> {
pending?.abort();
pending = new AbortController();
try {
return await layout.build({ markdown }, config, { signal: pending.signal });
} catch (err) {
if ((err as { name?: string } | null)?.name === 'AbortError') return null;
throw err;
}
}
// 4. Dispose when the component that owns the worker unmounts.
// Pending builds reject with AbortError.
layout.dispose();Avvolta in un componente React, la forma è questa:
import { useEffect, useRef } from 'react';
import { createLayoutWorker } from 'postext/worker';
import type { LayoutWorkerHandle } from 'postext/worker';
import { renderPageToCanvas } from 'postext';
import type { PostextConfig } from 'postext';
export function CanvasPreview({
markdown,
config,
}: {
markdown: string;
config: PostextConfig;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const workerRef = useRef<LayoutWorkerHandle | null>(null);
const pendingRef = useRef<AbortController | null>(null);
// Mount: spin up the worker and ship the fonts once.
useEffect(() => {
const handle = createLayoutWorker();
workerRef.current = handle;
(async () => {
const payloads = await collectFontPayloadsForFamilies(
getConfigFontFamilies(config),
);
await handle.registerFonts(payloads);
})();
return () => {
pendingRef.current?.abort();
handle.dispose();
};
}, []); // fonts registered once; re-register only when the family set changes
// Every keystroke or config change: supersede the in-flight build and kick a new one.
useEffect(() => {
const handle = workerRef.current;
if (!handle) return;
pendingRef.current?.abort();
const ac = new AbortController();
pendingRef.current = ac;
(async () => {
try {
const vdt = await handle.build({ markdown }, config, { signal: ac.signal });
const canvas = canvasRef.current;
if (!canvas || !vdt.pages[0]) return;
renderPageToCanvas(vdt.pages[0], vdt, canvas); // rasterise on the main thread
} catch (err) {
if ((err as { name?: string } | null)?.name !== 'AbortError') throw err;
}
})();
}, [markdown, config]);
return <canvas ref={canvasRef} />;
}Lo schema è sempre lo stesso: crea una volta, registra i font una volta, genera con AbortSignal tutte le volte che serve, elimina allo smontaggio.
#Caricare il worker da una CDN
Uno script worker deve provenire dall’origine della pagina stessa, quindi una copia di postext/worker servita da una CDN non può avviare il file layout.worker.js che ha accanto. createLayoutWorker() se ne occupa:
- esm.sh, senza opzioni. Quando
postext/workerè stato caricato a sua volta da esm.sh (il suo URL di modulo ha la formahttps://esm.sh/postext@1.5.0/es2022/worker.mjs), il client avvia il corrispondentehttps://esm.sh/postext@1.5.0/worker/entrytramite un modulo blob di una riga, della stessa origine, che lo importa. Lo stesso vale per gli import che aggiungono?deps=,?external=o?alias=, e per la formahttps://esm.sh/*postext@1.5.0/worker. Il worker riceve sempre la build semplice di quella versione, perché un worker non ha una import map con cui risolvere le dipendenze esterne. - Qualsiasi altro server, con
url. Le altre CDN, come jsDelivr (/+esm) o unpkg, non vengono rilevate.createLayoutWorker({ url })avvia il modulo d’ingresso del worker inurl: direttamente se l’URL è della stessa origine, tramite lo stesso involucro blob se è di un’altra origine. Quel server deve consentire le richieste cross-origin (CORS). - I bundler non cambiano nulla. Con Vite, webpack o Next.js, continua a chiamare
createLayoutWorker()senza opzioni: emettono il worker come un chunk della tua app.
import { createLayoutWorker } from 'https://esm.sh/postext/worker';
const layout = createLayoutWorker();
const face = async (weight, style) => ({
family: 'EB Garamond',
weight,
style,
buffer: await (await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/eb-garamond@5/files/eb-garamond-latin-${weight}-${style}.woff2`)).arrayBuffer(),
});
await layout.registerFonts(await Promise.all([face(400, 'normal'), face(700, 'normal'), face(400, 'italic')]));
const doc = await layout.build({ markdown }, { bodyText: { fontFamily: 'EB Garamond' } });Un worker non vede i font della pagina. Ha un proprio insieme di font, che contiene solo i caratteri inviati con registerFonts e i font installati nel sistema. Quando una generazione compone testo in una famiglia che il worker non trova, quel testo viene misurato con un font di ripiego, quindi i suoi a capo non corrisponderanno a quelli della pagina. Il client stampa allora un avviso nella console per famiglia ("EB Garamond" is not available inside the layout worker…) ed elenca le famiglie in BuildStats.missingFonts, che riceve la callback onStats di build. Il documento stesso riporta gli stessi caratteri come avvisi di contenuto fontFallback.
handle.prepareFonts(content, config, options) esegue prepareFonts sulla pagina e poi invia al worker i file di ogni carattere trovato che il registro dei font contiene (i file del resolver, i caratteri di un pacchetto, le regole @font-face leggibili della pagina), solo le fette che contengono i caratteri del documento. Quando i caratteri raggiungono il worker, questo scarta solo le misure di quelle famiglie e la sua cache di documenti finiti.
I caratteri possono raggiungere il worker anche dopo una generazione: un peso di cui le pagine si rivelano avere bisogno, un’altra fetta unicode-range di un taglio, un file caricato di nuovo con lo stesso nome di famiglia. La generazione successiva misura con essi. Il canvas di un worker non lo farebbe da solo: in Chrome risolve un font la prima volta che viene impostata una scorciatoia font e non guarda più l’insieme dei font per quella scorciatoia, così il testo manterrebbe le larghezze del carattere che ha risposto per primo. Il worker quindi nomina una famiglia ancora una volta nelle scorciatoie che imposta quando un carattere di quella famiglia è arrivato in ritardo (Geist, Geist): un altro elenco di famiglie per il canvas, lo stesso font e le stesse larghezze. I documenti che restituisce mantengono le stringhe di font come le scrive il motore, e un worker che ha i suoi caratteri prima della prima generazione imposta ogni scorciatoia così come è scritta.
#Raccolta dei payload dei font (Fontsource / Google Fonts)
registerFonts accetta i byte grezzi dei font. Il thread principale è il posto giusto dove scaricarli, perché una cache centrale permette a più istanze di worker di condividere gli stessi byte.
Il collectFontPayloadsForFamilies della Sandbox (packages/postext-sandbox/src/controls/fontLoader.ts) è un’implementazione di riferimento. Questa funzione:
- Interroga
https://api.fontsource.org/v1/fonts/{family-id}per sapere quali pesi e stili ha la famiglia. - Legge i sottoinsiemi della famiglia e il loro
unicode-rangedal suo pacchetto su jsDelivr (@fontsource/{family-id}@5/400.css), una volta per famiglia. I file vengono poi chiesti alla versione esatta da cui è stato servito quel foglio di stile (l’x-jsd-versiondi jsDelivr), così una famiglia non arriva mai da due versioni del suo pacchetto. - Scarica tutti i file del tondo, del grassetto e dei rispettivi corsivi di ogni famiglia (
files/{family-id}-{subset}-{weight}-{style}.woff2);collectFontPayloadsForConfig(config, markdown)prende invece i pesi e gli stili richiesti da una configurazione. Sono file statici, quelli che il PDF incorpora, così il worker misura con ciò che il PDF disegna. Un file variabile dà avanzamenti diversi tra due dei suoi master, e una riga a una frazione di pixel dalla sua giustezza si divide altrove. - Restituisce un
FontPayload[]in cuibufferè unArrayBuffernuovo a ogni chiamata; è importante, perchéregisterFontstrasferisce il buffer e lascia staccata la copia del mittente.
I file di un taglio vanno nell’ordine in cui li dichiara un pen, prima latin e per ultimi i file propri della scrittura (arabic, le fette numerate di una famiglia cinese o giapponese), perché il file aggiunto per ultimo risponde per un carattere contenuto in due file. Una famiglia per cui Fontsource non ha file viene letta invece dal foglio di stile CSS2 di Google, che restituisce WOFF2 solo a stringhe User-Agent simili a quelle di un browser.
Abbinala a getConfigFontFamilies(config) per ottenere l’elenco delle famiglie che una data PostextConfig comporrà davvero (testo corrente, titoli, punti elenco, numeri degli elenchi numerati).
#Annullamento cooperativo dentro il motore
Se guidi tu stesso buildDocument (per esempio dentro un worker personalizzato), la pipeline espone un hook shouldCancel che puoi usare direttamente:
import { buildDocument, BuildCancelledError } from 'postext';
let superseded = false;
try {
const vdt = buildDocument(content, config, cache, {
shouldCancel: () => superseded,
});
} catch (err) {
if (err instanceof BuildCancelledError) return; // a newer build took over
throw err;
}shouldCancel viene chiamato una volta per ogni blocco di primo livello durante la collocazione. L’hook è volutamente cooperativo: non può fermare la chiamata di impaginazione di pretext a metà riga, ma mantiene la granularità dell’annullamento abbastanza fine (millisecondi) perché chi digita velocemente non aspetti mai una generazione superata.
#Guidare l’esportazione PDF dal worker
Il backend PDF prende un VDTDocument pronto e lo trasforma in byte PDF. Non riesegue l’impaginazione. Questo significa che il flusso PDF canonico nel browser si abbina senza attriti al worker: genera il VDT nel worker (fuori dal thread principale, annullabile, con riuso della cache), poi chiama renderToPdf sul thread principale sullo stesso VDT.
import type { LayoutWorkerHandle } from 'postext/worker';
import { renderToPdf } from 'postext-pdf';
import type { PostextConfig } from 'postext';
import { createPdfFontProvider } from './pdfFontProvider';
const fontProvider = createPdfFontProvider();
export async function exportPdf(
layout: LayoutWorkerHandle,
markdown: string,
config: PostextConfig,
): Promise<Uint8Array> {
// 1. Build the VDT in the worker — UI stays responsive during the layout passes.
const vdt = await layout.build({ markdown }, config);
// 2. Rasterise to PDF on the main thread. renderToPdf is fast once the VDT exists
// because it is walking precomputed coordinates, not remeasuring text.
return renderToPdf(vdt, {
fontProvider,
// pdfGeneration config on `vdt.config` is honoured automatically.
});
}Se mantieni già un handle del worker per l’anteprima dal vivo, riusalo per l’esportazione invece di avviare un secondo worker: la cache delle misure dentro il worker rende praticamente gratuita un’esportazione PDF che segue un’anteprima a schermo.
Per un libro lungo, anche scrivere il PDF richiede secondi; postext-pdf/worker esegue quel passaggio in un worker tutto suo (vedi Rendere il PDF in un worker).
#Quando usare il worker e quando no
Usa il worker per:
- Anteprime dal vivo, editor e ambienti di prova. Tutto ciò in cui il documento viene rigenerato in risposta a un input dell’utente.
- Visualizzatori HTML che reagiscono al ridimensionamento e rieseguono l’impaginazione a ogni notifica di
ResizeObserver. - Esportazione PDF nel browser avviata da un’interfaccia che ha già un’anteprima dal vivo: riusa l’handle del worker esistente così l’esportazione sfrutta la cache delle misure.
- Più schede di output che hanno tutte bisogno dello stesso VDT (le viste Canvas / HTML / PDF della Sandbox condividono un handle del worker per ogni montaggio della vista).
Evita il worker per:
- La generazione lato server: Node non ha un
FontFaceSetdel browser, e il thread lo controlli comunque tu. - Esportazioni una tantum isolate (una CLI, uno script di esportazione headless, una Cloud Function) dove non esiste un’interfaccia interattiva da bloccare. Chiamare direttamente
buildDocumentè più semplice ed evita il costo del trasferimento iniziale dei font.
#Integrare il visualizzatore HTML
Il visualizzatore HTML è il renderer di Postext pensato prima di tutto per lo schermo. Invece di rasterizzare le pagine in una bitmap emette nodi DOM posizionati in modo assoluto, la cui geometria è determinata dalla stessa pipeline che produce l’output di stampa. Per questo è la scelta giusta quando vuoi in un browser una tipografia leggibile, selezionabile e sensibile al ridimensionamento (un’app di lettura, un’anteprima dentro un prodotto o una superficie di documentazione incorporata) senza dover includere un visualizzatore PDF.
I pezzi principali dell’API pubblica:
buildDocument(content, config, cache?): esegue l’intera pipeline di impaginazione e restituisce unVDTDocument.renderToHtmlIndexed(doc, options): trasforma il VDT in un’unica stringa HTML più una scomposizione per pagina e per blocco. La scomposizione permette di aggiornare il DOM a basso costo quando tra due rendering cambiano solo pochi blocchi.resolveHtmlViewerConfig(partial): completa i valori predefiniti del visualizzatore HTML (maxCharsPerLine,columnGap,optimalLineBreaking).buildFontString+measureGlyphWidth+dimensionToPx: primitive di misurazione usate per ricavare una larghezza di colonna reale in pixel da un numero di caratteri desiderato.createMeasurementCache/clearMeasurementCache: cache intercambiabili che ti permettono di riutilizzare le misure tra un’impaginazione e l’altra.prepareFonts/buildDocumentWithFonts/watchFonts/onFontsChanged: caricano i caratteri del documento prima dell’impaginazione e impaginano di nuovo quando i caratteri arrivano (vedi Caricare i font prima dell’impaginazione).
#Esempio dal vivo: una stringa HTML
L’intero percorso in JavaScript semplice, prima dell’integrazione con React più sotto: costruisci il documento, passa il VDTDocument a renderToHtml e inserisci la stringa in un contenitore. mode: 'single' impila le pagine in verticale; background dà loro un colore, perché per impostazione predefinita le pagine sono trasparenti. Il pen stampa anche il markup generato, così puoi vedere le righe posizionate in modo assoluto che il renderer emette: il browser le dipinge ma non le ridistribuisce mai.
import { buildDocument, renderToHtml } from 'https://esm.sh/postext';
const markdown = `# The Lantern
The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
## Two columns
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.`;
const config = {
// 96 dpi: page pixels are CSS pixels, so the HTML shows at its real size.
page: { sizePreset: '17x24', dpi: 96 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
// One HTML string for the whole document. Every line is an absolutely
// positioned element, so the browser never reflows the text.
const html = renderToHtml(doc, { mode: 'single', background: '#ffffff' });
document.getElementById('viewer').innerHTML = html;
document.getElementById('source').textContent = html;
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · ${(html.length / 1024).toFixed(1)} KB of HTML`;index.html
<p id="status">Laying out…</p>
<div id="viewer"></div>
<details>
<summary>Generated HTML</summary>
<pre id="source"></pre>
</details>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
/* The page is wider than this pane: let it scroll instead of clipping it.
The renderer centres pages with an inline style, hence the !important. */
#viewer {
overflow: auto;
}
#viewer .pt-doc {
align-items: flex-start !important;
}
/* Each page is a .pt-page block; the renderer positions every line inside it. */
#viewer .pt-page {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
details {
margin-top: 16px;
}
#source {
max-height: 240px;
overflow: auto;
padding: 8px;
background: #fff;
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
}Carica un editor interattivo da codepen.io. L’esempio importa da una CDN l’ultima versione di postext.
#Integrazione minima
Il frammento qui sotto è l’integrazione utile più breve: costruisce il documento alla dimensione attuale della finestra, lo rende in un contenitore e lo rieseguisce a ogni ridimensionamento.
import { useEffect, useRef } from 'react';
import {
buildDocument,
renderToHtmlIndexed,
resolveHtmlViewerConfig,
buildFontString,
measureGlyphWidth,
dimensionToPx,
createMeasurementCache,
watchFonts,
onFontsChanged,
} from 'postext';
import type { PostextConfig, MeasurementCache } from 'postext';
// Screen-friendly DPI: at 144 DPI, an 8pt body size resolves to 16 px.
const HTML_DPI = 144;
const PADDING_PX = 24;
// Prose sample used to measure the target column width. Proportional fonts
// make "N × average width" unreliable, so we measure a representative string.
const SAMPLE =
'The quick brown fox jumps over the lazy dog. Sphinx of black quartz, judge my vow.';
function sampleForChars(n: number): string {
let s = SAMPLE;
while (s.length < n) s += ' ' + SAMPLE;
return s.slice(0, n);
}
export function PostextHtmlViewer({
markdown,
config,
mode = 'multi',
}: {
markdown: string;
config: PostextConfig;
mode?: 'single' | 'multi';
}) {
const hostRef = useRef<HTMLDivElement | null>(null);
const cacheRef = useRef<MeasurementCache>(createMeasurementCache());
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const relayout = () => {
const rect = host.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const viewer = resolveHtmlViewerConfig(config.htmlViewer);
const fontFamily = config.bodyText?.fontFamily ?? 'EB Garamond';
const fontWeight = config.bodyText?.fontWeight ?? 400;
const fontSize = config.bodyText?.fontSize ?? { value: 8, unit: 'pt' as const };
const fontSizePx = dimensionToPx(fontSize, HTML_DPI);
// Measure the *actual* column width for N characters of body prose.
const targetColumnPx = measureGlyphWidth(
sampleForChars(viewer.maxCharsPerLine),
buildFontString(fontFamily, fontSizePx, String(fontWeight), 'normal'),
);
const inner = Math.max(rect.width - PADDING_PX * 2, 100);
let columnWidthPx: number;
if (mode === 'single') {
columnWidthPx = Math.min(targetColumnPx, inner);
} else {
// Fit as many columns as we can at the target width.
const count = Math.max(
1,
Math.floor((inner + viewer.columnGap) / (targetColumnPx + viewer.columnGap)),
);
columnWidthPx = (inner - viewer.columnGap * (count - 1)) / count;
}
columnWidthPx = Math.max(Math.floor(columnWidthPx), 80);
// Single mode uses one very tall page; multi mode uses the viewport
// height so each VDT "page" becomes one column.
const pageHeightPx =
mode === 'single' ? Math.max(rect.height * 20, 200_000) : Math.max(rect.height - PADDING_PX * 2, 400);
const override: PostextConfig = {
...config,
page: {
...config.page,
dpi: HTML_DPI,
width: { value: columnWidthPx, unit: 'px' },
height: { value: pageHeightPx, unit: 'px' },
margins: {
top: { value: 0, unit: 'px' },
bottom: { value: 0, unit: 'px' },
left: { value: 0, unit: 'px' },
right: { value: 0, unit: 'px' },
},
},
layout: { ...config.layout, layoutType: 'single' },
bodyText: {
...config.bodyText,
optimalLineBreaking: viewer.optimalLineBreaking,
},
};
const doc = buildDocument({ markdown }, override, cacheRef.current);
const { html } = renderToHtmlIndexed(doc, {
mode,
columnGap: viewer.columnGap,
padding: PADDING_PX,
background: 'transparent',
});
host.innerHTML = html;
};
relayout();
const ro = new ResizeObserver(() => relayout());
ro.observe(host);
// Re-measure when web fonts land so glyph widths aren't taken from fallbacks.
const stopWatching = watchFonts();
const off = onFontsChanged(() => relayout());
return () => {
ro.disconnect();
off();
stopWatching();
};
}, [markdown, config, mode]);
return <div ref={hostRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
}Qualche nota su che cosa fa questo esempio:
- Misurare la colonna, non stimarla. Poiché
maxCharsPerLineè un obiettivo espresso in caratteri, la larghezza reale in pixel dipende dal font del testo corrente.measureGlyphWidthfornisce una misura reale con il font scelto, il che mantiene coerente la giustezza quando cambi font. - Riscrivere la pagina. Il visualizzatore HTML tratta ogni «pagina» del VDT come una colonna sullo schermo. L’esempio sostituisce
page.widthcon la larghezza di colonna misurata, azzera i margini (il padding vive fuori dalla pagina, nel div.pt-docche la avvolge) e usaHTML_DPI = 144perché un testo corrente di8ptdiventi16px. - Attenzione al caricamento dei font.
watchFontsascoltadocument.fontse, una volta per frame, scarta ciò che era stato misurato nelle famiglie i cui caratteri sono arrivati;onFontsChangedimpagina poi di nuovo la colonna. Senza la nuova impaginazione, il primo rendering usa le metriche di un font di ripiego e salta quando arriva il font vero. - Riutilizzare la cache delle misure. Creare la cache una volta per componente significa che i ridimensionamenti e i cambi di scala del font riutilizzano le misure del rendering precedente invece di rimisurare ogni paragrafo.
#Andare oltre
L’esempio qui sopra è volutamente essenziale. Le integrazioni in produzione di solito aggiungono:
- Isolamento con Shadow DOM: rendi dentro
host.attachShadow({ mode: 'open' })così che niente della pagina esterna possa far trapelare CSS nel visualizzatore. - Aggiornamento incrementale:
renderToHtmlIndexedrestituiscepages[i].blocks, ognuno con unidstabile e l’HTML esterno del blocco. Quando tra due rendering differiscono solo pochi blocchi puoi sostituire sul posto quei contenitori di blocco invece di ricostruireinnerHTML. - Sovrapposizioni: un SVG posizionato in modo assoluto sopra ogni
.pt-pageper cursori, selezioni o griglie della linea di base. - Link: le parole di un link Markdown sono avvolte in
<a href="…" rel="noopener noreferrer">, che prende il colore del testo e non ha sottolineatura; vedi Formato del documento › Link. In un visualizzatore simile a un editor, intercetta i clic suglia[href]che non cominciano con#e aprili in una nuova scheda (le ancore di:refpuntano dentro il documento). - Immagini a un solo inchiostro: con
diagramStyle.singleInkattivo, gli<img>SVG ricevono un filtro CSS, a meno che tu non passisingleInk: falseper URL già ricolorati; vedi Un solo inchiostro su canvas e in HTML.
Il componente HtmlPreview della Sandbox (packages/postext-sandbox/src/viewport/HtmlPreview/index.tsx) implementa tutto questo sopra la stessa API mostrata qui e può servire da riferimento. Inoltre fa passare ogni build da un worker di impaginazione condiviso (vedi Eseguire l’impaginazione in un Web Worker), così le modifiche dal vivo e i ridimensionamenti non bloccano mai il thread principale: sostituisci la chiamata diretta buildDocument(...) del frammento qui sopra con layoutWorker.build(...) quando sei pronto a spostare l’impaginazione fuori dal thread principale.
#In che cosa l’output HTML differisce da canvas e PDF
renderToHtml colloca ogni riga, figura ed elemento di design esattamente dove li collocano il canvas e il PDF, ma attorno a essi dipinge meno:
| Funzione | Canvas (renderPage) | HTML (renderToHtml) | PDF (renderToPdf) |
|---|---|---|---|
| Sfondo della pagina | Bianco, con page.backgroundColor sul formato rifilato e sull’abbondanza. | Trasparente, a meno che tu non passi background o imposti page.backgroundColor (che allora riempie l’intero riquadro della pagina, area esterna compresa). | Bianco, con page.backgroundColor sul formato rifilato e sull’abbondanza. |
Griglia della linea di base (page.baselineGrid) | Disegnata | Non disegnata | Disegnata |
Filetto tra le colonne (layout.columnRule) | Disegnato | Non disegnato | Disegnato |
Segni di taglio (page.cutLines) | Disegnati | Non disegnati; il riquadro della pagina include comunque l’area esterna attorno al formato rifilato. | Disegnati |
| Negativo della pagina | Opzione pageNegative | Non disponibile | Opzione pageNegative |
| Testo | Pixel | Testo selezionabile in elementi posizionati in modo assoluto, composto nelle famiglie di caratteri CSS: la pagina deve caricare gli stessi font. | Font incorporati dal tuo fontProvider; selezionabile, ricercabile e taggato. |
Testo verticale (layout.writingMode: 'vertical-rl') | Caratteri dipinti una cella alla volta, raddrizzati; forme verticali tramite un font gemello (loadVerticalAlternates). | Il flusso in un unico riquadro ruotato di un quarto di giro; ogni riga raddrizzata e composta con writing-mode: vertical-rl, così il browser prende le forme verticali e mette in piedi i caratteri; i numeri brevi in text-combine-upright: all; una lineetta, un’ellissi, un punto mediano o una tilde ondulata in un riquadro della sua cella (il browser la farebbe avanzare della sua larghezza orizzontale), una lineetta allungata per riempirla da flow.dashAdvances. | Caratteri in piedi tramite un gemello Identity-V di ogni font; vedi Testo verticale nel PDF. |
| Immagini | registerResourceImage | L’opzione resourceImageUrl(fileId); senza di essa, un riquadro grigio segnaposto. | L’opzione resourceBytes(fileId). |
| Formule | Tracciati vettoriali | <svg> in linea | Tracciati vettoriali |
| Link | Nessuno | I rimandi :ref puntano alla loro risorsa; le righe dell’indice no. | I rimandi :ref e le righe dell’indice, più la struttura (segnalibri). |
La pagina trasparente conta su un sito scuro: un’anteprima senza background mostra testo nero sullo sfondo scuro del sito. Passa renderToHtml(doc, { background: '#ffffff' }), oppure dai al documento un page.backgroundColor.
Gli stili di testo della pagina ospite restano fuori. Ogni riga è composta alle larghezze misurate dal motore, quindi un letter-spacing, word-spacing, text-transform o font-variant che l’output ereditasse dalla pagina circostante allargherebbe le sequenze di glifi e farebbe sovrapporre le righe. Per questo la radice .pt-doc reimposta le proprietà di testo ereditate (spaziatura tra lettere e tra parole, maiuscole e minuscole, rientro, spazi bianchi, stile, variante, peso ed estensione del font, funzioni OpenType e crenatura, interlinea, allineamento, ombra ed enfasi del testo, sillabazione, direzione, modo di scrittura, contorno e riempimento del testo, e l’ingrandimento automatico del testo su mobile) prima delle proprie dichiarazioni di impaginazione, così l’output appare uguale dentro una shadow root o sotto un elemento con stili propri. L’elenco è esportato come HTML_TEXT_RESET, una stringa di dichiarazioni CSS: un ospite che monta l’innerHtml delle pagine (da renderToHtmlIndexed) in contenitori propri la imposta sulla loro radice. Fino a postext 1.4 la radice non reimpostava nulla; la soluzione era un contenitore con all: initial.
#Generare PDF
L’output PDF vive in un pacchetto separato, postext-pdf, così che le integrazioni solo web non paghino il costo di pdf-lib e @pdf-lib/fontkit. Il backend PDF non rimisura il testo: consuma esattamente lo stesso VDTDocument che passeresti a renderToCanvas o renderToHtml e traduce le sue coordinate in pixel in punti PDF. I tre output sono quindi garantiti concordi su a capo, altezze delle colonne e collocazione delle risorse.
Nel browser, costruisci il VDT tramite il Web Worker.
renderToPdfin sé è veloce una volta che il VDT esiste: la parte costosa è la pipeline di impaginazione che lo ha prodotto. Eseguire quella pipeline nel worker mantiene reattiva l’interfaccia e permette a un’esportazione PDF di riutilizzare la stessa cache delle misure che l’anteprima dal vivo ha già scaldato. Vedi Guidare l’esportazione PDF dal worker per il flusso consigliato. Gli esempi sul thread principale qui sotto sono il riferimento per che cosa significano gli argomenti: per il codice dell’interfaccia, costruisci prima il VDT nel worker e chiama direttamente solorenderToPdf.
#Installazione
npm install postext postext-pdfpostext è una peer dependency di postext-pdf. Ogni release di postext-pdf richiede il postext con cui è stata pubblicata, o uno successivo della stessa major (il suo intervallo peer è ^ quella versione, ^1.5.0 per la 1.5.0), perché importa funzioni di supporto che postext ha aggiunto in quella release. Aggiorna i due pacchetti insieme e, su una CDN, fissali alla stessa versione.
#API pubblica
Il pacchetto espone un unico punto di ingresso e una manciata di tipi:
renderToPdf(doc, options): Promise<Uint8Array>: prende unVDTDocument(o i capitoli di un libro come array di documenti) e restituisce i byte grezzi del PDF.PdfFontProvider: la firma della callback(family, weight, style, request?) => Promise<Uint8Array | Uint8Array[]>cherenderToPdfusa per chiedere i byte di un font quando deve incorporare una nuova combinazione di famiglia, peso e stile.request.codePointscontiene i caratteri che le pagine compongono in quel font; la risposta è un file, o più file che insieme formano il font (vedi Font cinesi, giapponesi e coreani).declaredFontFile(bytes, { unicodeRange?, order? }): PdfFontFile: un file di un font che dice come la pagina lo ha dichiarato: l’unicode-rangedella sua regola@font-facee la sua posizione nell’ordine di dichiarazione. UnPdfFontFileè unUint8Arraycon queste due proprietà facoltative (vedi Font cinesi, giapponesi e coreani). Da postext-pdf 1.30.RenderToPdfOptions:{ fontProvider, resourceBytes?, outlines?, accessible?, colorSpace?, pageNegative?, characterGrid?, onProgress?, onWarning?, rasterizeSvg?, harfbuzzWasm?, print?, outputProfile?, profileBaseUrl?, date? }.outlines,accessibleecolorSpacericadono sulpdfGenerationdel documento quando sono omessi (vedi Generazione PDF (configurazione)).resourceBytesè descritto in Byte delle risorse e master di stampa;onWarningin Quali font vengono chiesti al provider e in Avvisi nel documento.characterGrid: truestampa la griglia checjk.grid.showdisegna sullo schermo e che altrimenti il PDF tralascia (vedi Griglia dei caratteri).harfbuzzWasmindica da dove caricareharfbuzz.wasmdi HarfBuzz (un URL, relativo alla pagina, o i byte del file) per un documento con testo da destra a sinistra o con lettere legate; se omesso, la copia accanto al modulo di postext-pdf, poi la stessa release di harfbuzzjs da jsDelivr ed esm.sh.printprende le impostazioni di produzione per la stampa (standard PDF/X, profilo di output, nero, preflight), con ripiego sulprintdel documento; con uno standard PDF/X, o concolorSpace: 'cmyk', ogni colore viene separato tramite il profilo ICC di output, i cui byte sono dati daoutputProfile(altrimenti viene scaricato daprofileBaseUrl, per impostazione predefinita la copia sulla CDN di npm della cartellaicc/di postext).date(unDate, un istante in millisecondi o una stringa di data, come ilmtimedicreateBundle) è la data di creazione e di modifica del file e, in un file PDF/X, le date del suo pacchetto XMP e l’origine del suo identificatore di documento: lo stesso documento reso due volte con la stessadatedà gli stessi byte. Se è omessa, il file porta l’ora della chiamata. Una data non valida solleva un’eccezione.PdfWarning: un problema non bloccante segnalato tramiteonWarning; si distingue perkind:'fontFallback'(PdfFontFallbackWarning), un font sostituito da un’altra variante della sua famiglia;'missingGlyph'(PdfMissingGlyphWarning), caratteri per cui nessun file di un font ha un glifo;'variableFontDefaultInstance'(PdfVariableFontWarning), un font variabile chiesto a un peso diverso dalla sua istanza predefinita;'cffEmbeddedWhole'(PdfCffEmbeddedWholeWarning), un font CFF di oltre 2 MB incorporato per intero;'complexShapingUnavailable'(PdfComplexShapingWarning), testo da destra a sinistra o con lettere legate disegnato senza HarfBuzz, che non è stato possibile caricare (reasonelenca dove è stato cercato), per cui i segni dell’arabo sono fuori posto;'outputProfileUnavailable'(PdfPrintWarning), un rendering CMYK il cui profilo di output non si è potuto caricare, convertito con la formula semplificata;'pageNegativeIgnored'(PdfPrintWarning), il negativo della pagina escluso da un file PDF/X-1a; oppure'missingImage', un’immagine senza byte disegnata come segnaposto (segnalato solo a unonWarningche passi tu; gli avvisi sui font, in sua assenza, vanno aconsole.warn).decompressWoff2(bytes): Uint8Array: funzione di supporto che trasforma un file WOFF2 in byte TTF, il formato chepdf-libpuò incorporare direttamente.createPdfWorker(options?), dapostext-pdf/worker: lo stesso rendering in un Web Worker; vedi Rendere il PDF in un worker.
#Esempio minimo
import { buildDocument } from 'postext';
import { renderToPdf } from 'postext-pdf';
const vdt = buildDocument(
{ markdown: '# Chapter One\n\nThe story begins here…' },
{
page: { sizePreset: '17x24' },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt overrides the 8 pt default
},
);
const pdfBytes = await renderToPdf(vdt, {
fontProvider: async (family, weight, style) => {
// Return TTF bytes for this family/weight/style.
// See the "Font provider" section below for a real implementation.
const res = await fetch(`/fonts/${family}-${weight}${style === 'italic' ? 'i' : ''}.ttf`);
return new Uint8Array(await res.arrayBuffer());
},
});
// `pdfBytes` is a Uint8Array — save, download, or stream it.
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
window.open(url);#Perché un provider di font?
pdf-lib incorpora nel PDF file di font veri: i font installati nel browser non sono disponibili al momento del rendering, e un font che hai caricato solo per la misurazione a schermo non basta, da solo, a produrre un PDF autonomo. renderToPdf scorre le pagine alla ricerca di ogni font che dipingono (uno per combinazione family|weight|style; vedi Quali font vengono chiesti al provider) e chiama il tuo provider una volta per ogni combinazione distinta. Il provider restituisce un Uint8Array di byte TTF o OTF, o un elenco di essi per un font servito in più file (vedi Font cinesi, giapponesi e coreani); pdf-lib crea un sottoinsieme dei contorni TrueType e incorpora per intero i file CFF (.otf). I font a cui il provider risponde con lo stesso file, come una variante regular che fa le veci del grassetto che manca a una famiglia, condividono un unico font incorporato. Un font con cui alla fine nessuna pagina disegna, come il font del testo di una figura SVG quando la figura è disegnata come immagine, viene lasciato fuori dal file.
Usa font statici per ogni peso, non un unico font variabile. Google Fonts serve spesso un solo WOFF2 variabile per famiglia, che copre l’intero asse dei pesi. pdf-lib può incorporare da un file variabile solo l’istanza predefinita, quindi un paragrafo in grassetto verrebbe reso con il peso regular. Fontsource pubblica file WOFF2 statici per ogni peso che risolvono il problema in modo pulito: è lo schema che usa la Sandbox. Un file variabile chiesto a un peso diverso dalla sua istanza predefinita viene segnalato con un avviso variableFontDefaultInstance.
Ogni parola del testo finisce dove l’ha messa l’impaginazione. Nei paragrafi, nelle voci di elenco, nelle citazioni, nei riquadri e nel resto del testo che scorre, ogni parola comincia nella posizione misurata dal VDT, quindi una differenza tra le larghezze del browser e quelle del font incorporato non si accumula mai lungo una riga. Una riga senza formattazione in linea viene dipinta come un unico oggetto di testo che sposta la penna tra le parole; le righe giustificate, quelle centrate e quelle con formattazione vengono dipinte parola per parola. Un carattere per cui il font non ha glifo, che il browser ha misurato in un altro font e che il PDF dipinge come il riquadro del glifo mancante del font, non sposta nessuna delle parole che lo seguono, e viene segnalato una volta per font con un avviso missingGlyph. Uno spazio che manca al font, come uno spazio unificatore stretto o uno spazio cifra, prende la larghezza che gli ha dato il browser, e i caratteri invisibili come il word joiner e lo spazio a larghezza zero non vengono disegnati. Un trattino unificatore (U+2011) che manca al font viene disegnato con il trattino del font (U+2010), o con il suo trattino-meno quando non ha nemmeno il trattino, come lo mostra il browser; Open Sans e Outfit, tra gli altri, non hanno né l’uno né l’altro. Nessuno dei due casi conta come glifo mancante. Ci sono due eccezioni. Una riga con lettere da destra a sinistra viene ancora dipinta come un’unica sequenza (vedi Lingue e scritture). Il testo collocato da un design (testatine e piè di pagina, aperture, titoli dei riquadri e altri elementi di design) viene composto con le larghezze proprie del font incorporato, quindi un glifo che lì manca al font sposta ancora il resto della sua riga.
#Quali font vengono chiesti al provider
renderToPdf percorre le pagine nel modo in cui le dipinge e chiede al provider solo i font che disegnano:
- il font regular di un blocco che compone almeno una riga, e il grassetto, corsivo o grassetto corsivo di ogni sequenza composta effettivamente in quel modo;
- le sequenze dei chip, i segni degli elenchi e il testo degli slot del design (testatine, folio, fasce di apertura e di parte);
- il testo di didascalie, note e celle di tabella di ogni risorsa;
- i font nominati dal
<text>di una figura SVG, quando la figura è incorporata. Una famiglia che il provider non riesce a fornire affatto passa alla famiglia successiva dell’elencofont-familydell’SVG.
Così a una famiglia dei titoli che nessuno inclina non viene mai chiesto il corsivo, e una figura senza nota non ha mai bisogno dei font delle note.
Quando il provider rifiuta un font, il rendering continua. Al suo posto viene incorporato un altro font della stessa famiglia, e viene segnalato un PdfWarning. Il sostituto è il primo font che si carica, provando i nove pesi standard (da 100 a 900) nell’ordine usato dalla selezione dei font del CSS, che è anche il font che il browser mostra nell’anteprima:
- prima lo stesso stile. Per un peso da 400 a 500, vengono prima i pesi fino a 500, poi quelli più leggeri dal più vicino in giù, poi quelli più pesanti da 600 in su. Per un peso sotto 400, vengono prima i pesi più leggeri, dal più vicino in giù, poi quelli più pesanti. Per un peso sopra 500, vengono prima i pesi più pesanti, poi quelli più leggeri;
- poi l’altro stile, corsivo per il tondo e tondo per il corsivo, al peso richiesto e agli altri pesi nello stesso ordine.
Una famiglia senza corsivi compone quindi in tondo le sue sequenze in corsivo, una famiglia che fornisce solo 400 e 700 prende un 600 come 700, e una famiglia che fornisce una sola variante compone tutto in quella. Al provider viene chiesto un font alla volta, in quell’ordine, e mai due volte lo stesso font, quindi un font che nessuno usa non viene mai incorporato. A una famiglia che il provider non riesce a servire affatto vengono chiesti tutti e 18 quei font prima che il rendering fallisca.
const bytes = await renderToPdf(doc, {
fontProvider,
onWarning: (w) => {
// { kind: 'fontFallback', family: 'Oswald', weight: 700, style: 'italic',
// fallback: { weight: 700, style: 'normal' }, reason: '…', message: '…' }
console.info(w.message);
},
});Senza onWarning, il messaggio va a console.warn. Il testo mantiene le sue posizioni, che vengono dal VDT e sono state misurate con i font che aveva il browser, quindi un sostituto con larghezze diverse può sembrare stretto o largo. Fornisci il font vero per correggerlo. Un rendering fallisce ancora (postext-pdf: failed to load font(s): …) solo quando il provider non riesce a fornire nessun font di una famiglia a un peso standard, né in tondo né in corsivo.
#Provider di font per il browser (Fontsource + WOFF2)
La Sandbox include createPdfFontProvider() (packages/postext-sandbox/src/viewport/pdfFontProvider.ts), che puoi copiare in qualsiasi app per browser. L’essenziale:
import type { PdfFontProvider } from 'postext-pdf';
import { decompressWoff2 } from 'postext-pdf';
const bytesCache = new Map<string, Promise<Uint8Array>>();
function fontsourceId(family: string): string {
return family.toLowerCase().replace(/\s+/g, '-');
}
function fontsourceWoff2Url(
family: string,
weight: number,
style: 'normal' | 'italic',
): string {
const id = fontsourceId(family);
return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}-${style}.woff2`;
}
export function createPdfFontProvider(): PdfFontProvider {
return async (family, weight, style) => {
const key = `${family}|${weight}|${style}`;
const cached = bytesCache.get(key);
if (cached) return cached;
const promise = (async (): Promise<Uint8Array> => {
const url = fontsourceWoff2Url(family, weight, style);
const res = await fetch(url, { mode: 'cors' });
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${url}`);
// pdf-lib needs TTF bytes, so decompress the WOFF2 wrapper client-side.
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
})();
bytesCache.set(key, promise);
return promise;
};
}Una versione per la produzione dovrebbe anche:
- Interrogare i pesi disponibili (tramite
https://api.fontsource.org/v1/fonts/{id}) e agganciare il peso richiesto al più vicino che la famiglia fornisce davvero, così una richiesta diweight: 600per una famiglia che ha solo{400, 700}va comunque a buon fine. - Ripiegare dal corsivo al tondo quando una famiglia non ha una variante corsiva per il peso richiesto, invece di far fallire tutto il rendering.
- Riutilizzare la cache tra un rendering e l’altro (tieni
bytesCachea livello di modulo, non per chiamata) così rigenerare il PDF dopo una modifica della configurazione non costa praticamente nulla.
#Font cinesi, giapponesi e coreani
Un font CJK non arriva come un unico file piccolo. Fontsource fornisce Noto Serif SC come un centinaio di file per peso, ognuno con una parte dei caratteri e dichiarato nel foglio di stile della famiglia con il suo unicode-range (@fontsource/noto-serif-sc/400.css); il browser scarica i file toccati dal testo di una pagina. Il file latin che scarica il provider qui sopra non contiene alcun carattere Han, e i sottoinsiemi con nome sono incompleti: chinese-simplified di Noto Serif SC non ha 釵, e chinese-traditional di Noto Serif TC non ha nessuno dei segni a larghezza piena (),!?:;.
Per questo un provider può rispondere per un font con più file. renderToPdf gli passa i caratteri che le pagine compongono in quel font (request.codePoints), raccolti da ogni capitolo prima di disegnare qualsiasi cosa, e il provider restituisce i file che li contengono, nell’ordine in cui il browser li consulta. Ogni file viene incorporato come sottoinsieme a sé e ogni carattere viene disegnato dal primo file che ha un glifo per esso: un capitolo che tocca 60 porzioni incorpora 60 piccoli sottoinsiemi. A un font chiesto di nuovo, per esempio per il testo di una figura SVG, vengono chiesti solo i caratteri che mancano ai suoi file. Un provider che restituisce un solo Uint8Array funziona come prima. Il provider della Sandbox legge il foglio di stile di Fontsource del peso e dello stile e scarica i file i cui intervalli contengono il testo; il nucleo:
import type { PdfFontProvider } from 'postext-pdf';
import { decompressWoff2 } from 'postext-pdf';
type Slice = { url: string; ranges: Array<[number, number]> };
async function fontsourceSlices(family: string, weight: number, style: 'normal' | 'italic'): Promise<Slice[]> {
const id = family.toLowerCase().replace(/\s+/g, '-');
const cssUrl = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`;
const css = await (await fetch(cssUrl)).text();
return [...css.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => ({
url: new URL(/url\(\.?\/?([^)]+\.woff2)\)/.exec(rule)![1], cssUrl).href,
ranges: /unicode-range:\s*([^;]+);/.exec(rule)![1].split(',').map((part) => {
const [lo, hi = lo] = part.trim().slice(2).split('-');
return [parseInt(lo, 16), parseInt(hi, 16)] as [number, number];
}),
}));
}
export const sliceFontProvider: PdfFontProvider = async (family, weight, style, request) => {
// Where ranges overlap, the browser tries the last rule first.
const slices = (await fontsourceSlices(family, weight, style)).reverse();
const picked = new Set<Slice>();
for (const cp of request?.codePoints ?? []) {
const slice = slices.find((s) => s.ranges.some(([lo, hi]) => cp >= lo && cp <= hi));
if (slice) picked.add(slice);
}
if (picked.size === 0) picked.add(slices[0]!);
return Promise.all(slices.filter((s) => picked.has(s)).map(async (s) =>
decompressWoff2(new Uint8Array(await (await fetch(s.url)).arrayBuffer()))));
};Una famiglia latina passa per lo stesso codice: un testo inglese riceve solo il suo file latin, uno ceco riceve latin e latin-ext.
Un browser non si ferma al primo file con un glifo. Compone un carattere nel file dichiarato per ultimo tra quelli il cui unicode-range lo contiene, e i file di una famiglia si sovrappongono: Fontsource ripete le lettere ASCII di Noto Serif JP nel suo file latin e nelle sue porzioni numerate. Due caratteri composti in due file diversi non hanno crenatura tra loro, quindi è il file a decidere la larghezza: Chrome 154 compone Shift_JIS largo 4,25 em con le porzioni dichiarate dopo latin, mentre il solo file latin, che applica la crenatura a t_ e _J, lo compone largo 4,32 em. Da postext-pdf 1.30 un provider può dire come la pagina ha dichiarato ogni file: declaredFontFile(bytes, { unicodeRange, order }) restituisce gli stessi byte con l’unicode-range della regola del file, come lo scrive il CSS, e la posizione di quella regola tra le regole del font, un numero qualsiasi che cresce con l’ordine di dichiarazione (il più grande è dichiarato per ultimo).
import { declaredFontFile } from 'postext-pdf';
declaredFontFile(bytes, { unicodeRange: 'U+0000-00FF,U+0131,U+0152-0153', order: 0 });Quando i file di un font portano i loro intervalli, in un documento modellato nella propria lingua (bodyText.shapingLanguage: 'document', il valore predefinito), un carattere viene disegnato dal file dichiarato per ultimo il cui intervallo lo contiene e che ha un glifo per esso, come un browser sceglie tra le regole @font-face, e una lettera e i suoi segni combinanti restano in un solo file. Un carattere che nessun intervallo contiene viene disegnato dal primo file con un glifo. I file semplici, e ogni file in un documento con 'host', vengono letti nell’ordine dato, come prima, quindi un provider che descrive i suoi file elenca comunque per primo quello dichiarato per ultimo. A un font descritto in questo modo vengono chiesti di nuovo tutti i caratteri che non gli erano stati chiesti prima, perché un file dichiarato più tardi potrebbe contenerli. Il provider della Sandbox descrive i suoi file.
I caratteri per cui nessun file del font ha un glifo vengono disegnati con il glifo .notdef del font (un riquadro vuoto nella maggior parte dei font), e renderToPdf li segnala una volta per font dopo aver disegnato le pagine:
// { kind: 'missingGlyph', family: 'Noto Serif TC', weight: 400, style: 'normal',
// characters: [',', '!', '?'], message: '…' }La Sandbox li elenca, insieme ai due avvisi qui sotto, nel suo pannello Verifiche dopo ogni PDF che genera. Quando il libro, le sue impostazioni o le sue risorse cambiano, vengono segnati come provenienti da un PDF precedente finché il successivo non li sostituisce; aprire un altro libro li cancella.
- Il grassetto richiede un file statico per peso. Fontsource serve ogni peso di Noto Serif SC e TC come file statici separati, quindi nella Sandbox il grassetto funziona. I file di Google Fonts (
NotoSerifSC[wght].ttf, 25 MB) sono font variabili: pdf-lib ne incorpora l’istanza predefinita, quindi un font 700 viene stampato a 400, erenderToPdflo segnala comevariableFontDefaultInstance. Per un pacchetto, ricava con fontTools un’istanza statica per peso (fonttools varLib.instancer NotoSerifSC[wght].ttf wght=700) e riducila ai caratteri del libro conpyftsubset. - Usa le versioni TrueType. Source Han Serif e i file
.otfdi Noto Serif CJK hanno contorni CFF, che postext-pdf incorpora per intero, da 8 a 25 MB per peso; un font CFF di oltre 2 MB viene segnalato comecffEmbeddedWhole. Delle versioni TrueType (Google Fonts, Fontsource) si incorpora solo il sottoinsieme dei glifi usati. Per il giapponese, Noto Serif JP e Noto Sans JP (Google Fonts, o le porzioni numerate di Fontsource), Shippori Mincho, Zen Old Mincho e BIZ UDMincho sono in TrueType; Source Han Serif JP e i file.otfJPdi Noto Serif CJK sono CFF. - Forme giapponesi di un font pan-CJK. Uno stesso code point di un Han, della punteggiatura o delle virgolette può essere disegnato in un modo in Giappone e in un altro in Cina, e un font pan-CJK (Source Han, Noto CJK) li contiene entrambi. Il PDF modella un documento giapponese (
locale: 'ja'), e un isolamento in giapponese (:ltr[…]{lang=ja}) in qualsiasi documento, con il sistema linguistico OpenTypeJAN, così la funzionelocldel font stampa le forme giapponesi che il canvas e l’HTML stampano tramitelang. Un isolamento in un’altra lingua dentro un libro giapponese viene modellato con le forme di quella lingua (quelle predefinite del font per il cinese con'host') e taggato comeSpancon il suo/Lang. ConbodyText.shapingLanguage: 'document', il valore predefinito da postext 1.30, ogni documento e ogni isolamento è modellato nella propria lingua (ZHSper il cinese semplificato), come l’ha misurato l’impaginazione; con'host', i documenti cinesi e gli altri sono modellati con le forme predefinite del font, come prima. Un font fatto per il giapponese come Noto Serif JP ha forme predefinite giapponesi; compone comunque le “ ” del testo giapponese nelle loro formeJAN.
Un carattere che manca in una famiglia non viene preso da un’altra: Noto Serif TC non prende in prestito da Noto Serif SC. Risolvi la copertura quando costruisci i file dei font; la vetrina 红楼梦 copia dal font SC i glifi che mancano ai suoi sottoinsiemi TC.
#Provider di font lato server (Node, file locali)
In Node puoi saltare del tutto il passaggio WOFF2 e leggere i file TTF/OTF dal disco:
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { PdfFontProvider } from 'postext-pdf';
const FONT_DIR = '/path/to/fonts';
function filename(family: string, weight: number, style: 'normal' | 'italic'): string {
const slug = family.replace(/\s+/g, '');
const styleSuffix = style === 'italic' ? 'Italic' : '';
const weightName =
weight >= 700 ? 'Bold'
: weight >= 600 ? 'SemiBold'
: weight >= 500 ? 'Medium'
: weight >= 300 ? 'Light'
: 'Regular';
return `${slug}-${weightName}${styleSuffix}.ttf`;
}
export const localFontProvider: PdfFontProvider = async (family, weight, style) => {
const buf = await readFile(join(FONT_DIR, filename(family, weight, style)));
return new Uint8Array(buf);
};#Byte delle risorse e master di stampa
resourceBytes(fileId) restituisce i byte grezzi di un’immagine, e il backend ne riconosce il formato:
- PNG, JPEG, GIF e WebP vengono incorporati come immagini;
- il markup SVG viene disegnato come tracciati vettoriali, con il suo
textcomposto come testo vero nei font incorporati del documento, oppure rasterizzato a 600 dpi nel browser quando usa funzioni fuori dal sottoinsieme vettoriale. Uno<style>che contiene solo regole@font-face(font incorporati da un autore) viene saltato e mantiene vettoriale la figura (da postext-pdf 1.25); qualsiasi altro foglio di stile la trasforma in un raster, realizzato con i font nominati dal suo testo incorporati dafontProvider(a meno chediagramStyle.inlineFontso losvg.inlineFontsdella risorsa non siafalse); - di un PDF viene incorporata così com’è la prima pagina, come form XObject.
Ogni immagine viene memorizzata nel file una sola volta, per quante volte venga disegnata. Un SVG disegnato come tracciati vettoriali diventa un form XObject che ogni pagina dipinge, quindi una cornice o un logo nel design della pagina di un documento di trenta pagine viene scritto una volta, non trenta; ogni pagina in più aggiunge qualche centinaio di byte. Fino a postext-pdf 1.4 ogni pagina portava la propria copia dei tracciati.
Una figura SVG può indicare un master di stampa in svg.pdfFileId: un PDF di una sola pagina della stessa figura, di solito l’originale da cui l’SVG è stato esportato. renderToPdf chiede prima a resourceBytes l’id del master. Incorpora quella pagina al posto dell’SVG, con i suoi font, sfumature e spazi colore intatti, ovunque l’SVG venga disegnato: come figura, come immagine di una cella di tabella (TableCell.image), come immagine di design o come icona di un riquadro (anche il suo marker). Il VDT porta l’id del master in ognuno di questi usi (svg.pdfFileId sulla risorsa della figura, pdfFileId su un’immagine di cella e su un blocco di immagine di design), così in un libro ogni capitolo riceve il master. I backend canvas e HTML continuano a disegnare l’SVG. Si usano invece i byte dell’SVG stesso in tre casi: il master manca, non è un PDF, oppure è attivo il solo inchiostro (diagramStyle.singleInk ricolora solo il markup SVG).
const resources: Resource[] = [{
id: 'map', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'map.svg', width: 800, height: 600, pdfFileId: 'map.pdf' },
}];
const files = new Map([['map.svg', svgBytes], ['map.pdf', masterPdfBytes]]);
const pdf = await renderToPdf(buildDocument({ markdown, resources }, config), {
fontProvider,
resourceBytes: (fileId) => files.get(fileId),
});Un ospite può anche restituire i byte del master per l’id dell’SVG stesso, come fa bundleResourceBytes; funzionano entrambi i modi.
#Testo verticale nel PDF
Una pagina verticale (layout.writingMode: 'vertical-rl') viene disegnata tramite un riferimento ruotato di un quarto di giro, come la dipinge il canvas, e il suo testo è composto lungo la colonna:
- I caratteri in piedi sono mostrati tramite un secondo font Type0 dello stesso file incorporato: lo stesso CIDFont, le stesse larghezze e la stessa mappa ToUnicode, con
Encoding /Identity-V(modo verticale). Una sequenza di caratteri è un unico oggetto di testo i cui glifi avanzano da soli di un em lungo la colonna (DW2 [880 −1000]), così i lettori selezionano ed estraggono una colonna come un’unica riga. I glifi sono modellati con le funzioni OpenTypevertefwid, che danno le loro forme verticali a parentesi, virgolette, segni di pausa della Cina continentale, ellissi e lineette; un carattere che sta già in piedi così com’è mantiene il suo glifo orizzontale. Niente del font viene incorporato due volte. - Le parole latine e i numeri lunghi corrono di lato con il font orizzontale; un numero in una sola cella sta in piedi, compresso trasversalmente fino all’em quando è più largo; un segno per cui il font non ha forma verticale viene ruotato o spostato, come sul canvas.
- La spaziatura tra lettere tra i caratteri è scritta come numeri
TJ, che in modo verticale spostano la penna lungo la colonna. - Ogni riga verticale è marcata con un
/ActualTextdel suo testo, così la copia e l’estrazione del testo la leggono come è scritta.pdftotexte pdf.js leggono le colonne dall’alto in basso, da destra a sinistra; pdf.js comincia una nuova riga a un numero composto in una sola cella. - Link, segnalibri e destinazioni sono riportati sul foglio: un link su una riga verticale è un rettangolo alto e stretto, e un segnalibro apre la pagina in cima alla colonna del suo titolo.
- Un PDF taggato dichiara il modo di scrittura sul suo elemento
Document(l’attributo di layoutWritingMode /TbRl, che ogni elemento eredita); la validazione PDF/UA-1 (veraPDF) passa su un capitolo verticale. - Visualizzatori: Acrobat, Anteprima, Chrome (PDFium), pdf.js e Poppler rendono i font verticali. Un libro rilegato a destra (
page.binding) chiede anche ai visualizzatori di disporre le sue doppie pagine da destra a sinistra (/Direction /R2L,/PageLayout /TwoPageRight); Acrobat e Foxit lo seguono, Chrome no.
Un capitolo di 43 pagine composto in Noto Serif TC (i caratteri del libro, TrueType) pesa circa 820 KB, di cui la maggior parte sono i due sottoinsiemi di font.
#Link nel PDF
Le parole di un link Markdown (vedi Formato del documento › Link) diventano annotazioni di link URI, una per ogni sequenza di parole collegate su una riga. Ognuna copre il riquadro della riga e non ha bordo. In un rendering accessibile, ogni sequenza è un elemento Link il cui /Contents è il suo testo. Vengono collegate solo le destinazioni assolute http:, https:, mailto:, tel: e ftp:, perché un URL relativo non ha una base dentro un PDF. I caratteri fuori dall’ASCII stampabile vengono codificati con il segno di percentuale. I rimandi :ref e le righe dell’indice mantengono i loro link dentro il documento.
#Esempio completo per il browser: costruire, rendere, scaricare
Mettendo tutto insieme: costruisci il VDT, rendilo in PDF e avvia un download dal browser:
import { buildDocument, createMeasurementCache } from 'postext';
import { renderToPdf } from 'postext-pdf';
import { createPdfFontProvider } from './pdfFontProvider';
const fontProvider = createPdfFontProvider();
export async function downloadPdf(markdown: string, config: PostextConfig) {
const cache = createMeasurementCache();
const vdt = buildDocument({ markdown }, config, cache);
const bytes = await renderToPdf(vdt, { fontProvider });
const blob = new Blob([bytes.slice().buffer], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'document.pdf';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}Importante: chiama ensureConfigFontsLoaded(config) (o un equivalente) prima di buildDocument quando la tua configurazione fa riferimento a web font. L’impaginazione viene misurata con le metriche che il browser ha in quel momento per quella famiglia: se il font vero non è ancora arrivato, il VDT viene misurato con un font di ripiego e il PDF non corrisponderà all’output canvas o HTML. La Sandbox lo fa esplicitamente prima di ogni rendering (vedi packages/postext-sandbox/src/viewport/PdfViewport.tsx).
#Esempio dal vivo: un PDF nel browser
Il flusso completo qui sopra, in esecuzione nel browser: il pen importa postext e postext-pdf da una CDN, carica i web font, costruisce il documento, incorpora le varianti di Fontsource tramite il provider di font e passa i byte a un link che apre il file in una nuova scheda e a un link di download. Il PDF che ottieni ha gli stessi a capo dell’output canvas e HTML, veri font incorporati e segnalibri di struttura.
import { buildDocument } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
const markdown = `# The Lantern
The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
## Two columns
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.`;
const config = {
page: { sizePreset: '17x24' },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// The PDF embeds real font files. Fontsource publishes one static WOFF2 per
// weight and style; decompress it to the TTF bytes pdf-lib can embed.
const fontProvider = async (family, weight, style) => {
const id = family.toLowerCase().replace(/\s+/g, '-');
const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`;
const res = await fetch(url);
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${url}`);
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
};
// Layout is measured with the browser's fonts, so load them before building:
// otherwise the PDF would not match the canvas or HTML output.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
// Same VDT, now translated to PDF points: identical line breaks and placement.
const bytes = await renderToPdf(doc, { fontProvider });
// A PDF viewer cannot run inside this sandboxed result frame,
// so hand the file to a new tab and to a download link.
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
document.getElementById('open').href = url;
document.getElementById('download').href = url;
document.getElementById('links').hidden = false;
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · ${(bytes.length / 1024).toFixed(0)} KB PDF`;index.html
<p id="status">Rendering…</p>
<p id="links" hidden>
<a id="open" target="_blank" rel="noopener">Open lantern.pdf in a new tab</a> ·
<a id="download" download="lantern.pdf">Download it</a>
</p>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
}Carica un editor interattivo da codepen.io. L’esempio importa da una CDN l’ultima versione di postext.
#Rendere il PDF in un worker
postext-pdf/worker sposta renderToPdf fuori dal thread principale. Il worker scrive il testo, le figure vettoriali, l’albero della struttura e il file stesso. Due compiti richiedono la pagina, quindi il worker li chiede al thread principale: scaricare i font e rasterizzare un SVG tramite un <img>. Per un libro di centinaia di pagine, il rendering richiede secondi che altrimenti bloccherebbero la pagina; per poche pagine, chiamare direttamente renderToPdf è più semplice.
import { createPdfWorker } from 'postext-pdf/worker';
const pdfWorker = createPdfWorker();
const bytes = await pdfWorker.render(docs, {
fontProvider, // runs on this thread
resourceBytes: new Map([['map.svg', svgBytes]]), // a Map; its buffers move to the worker
onProgress: ({ phase, pages, totalPages }) => showProgress(phase, pages, totalPages),
onWarning: (w) => console.info(w.message),
});
pdfWorker.dispose();render(docs, options)prende un documento o l’array dei documenti dei capitoli di un libro. Accetta le opzioni direnderToPdf, con due differenze.resourceBytesè unaMap<string, Uint8Array>i cui buffer vengono trasferiti, quindi passa copie dei byte che vuoi conservare.rasterizeSvg, se è fornito, viene eseguito sul thread principale; per impostazione predefinita se ne occupano l’Imagee il canvas della pagina stessa.- Un handle rende un documento alla volta.
dispose()termina il worker e rifiuta qualsiasi rendering ancora in sospeso. createPdfWorker({ worker })accetta unWorkercreato da te, per gli strumenti di build che controllano gli URL dei worker. Quel worker deve eseguirepostext-pdf/worker/entry.
Da una CDN. Per impostazione predefinita lo script del worker viene caricato dall’URL del pacchetto stesso (new URL('./pdf.worker.js', import.meta.url)). Una pagina su un’altra origine potrebbe non riuscire ad avviarlo: importato da esm.sh, createPdfWorker() solleva Failed to construct 'Worker': Script at 'https://esm.sh/postext-pdf@…/pdf.worker.js' cannot be accessed from origin …. Avvia invece un module worker della stessa origine che importa l’entry (se fissi una versione, fissa la stessa in entrambi gli URL):
import { createPdfWorker } from 'https://esm.sh/postext-pdf/worker';
const entry = URL.createObjectURL(new Blob(
["import 'https://esm.sh/postext-pdf/worker/entry';"],
{ type: 'text/javascript' },
));
const pdfWorker = createPdfWorker({ worker: new Worker(entry, { type: 'module' }) });Il worker di impaginazione di postext/worker richiede lo stesso involucro attorno a postext/worker/entry (vedi Eseguire l’impaginazione in un Web Worker).
#PDF pronti per la stampa
Per i flussi di lavoro di stampa in produzione, regola queste opzioni di configurazione prima del rendering:
page.cutLines.enabled: true: aggiunge l’abbondanza e i segni di taglio attorno al formato rifilato, e dà a ogni pagina un TrimBox e un BleedBox. Vedi Linee di taglio.print: { standard: 'pdfx4', outputProfile: 'fogra51' }(o'pdfx1a'): un file PDF/X con l’output intent, l’identificazione e i riquadri che controlla una tipografia; ogni colore e ogni immagine RGB separati tramite il profilo ICC, sovrastampa del 100 % K e grandi aree nere in nero ricco. Vedi Produzione per la stampa (configurazione).colorSpace: 'cmyk'(opdfGeneration: { forceColorSpace: true, colorSpace: 'cmyk' }): la stessa separazione senza l’identificazione PDF/X (i segni di taglio sono sempre in colore di registro). I master di stampa PDF vengono incorporati così come sono.page.dpi: 300: i px per pollice dell’impaginazione: una bitmap senza una risoluzione propria viene stampata a questa risoluzione alla sua dimensione naturale. Il preflight segnala le immagini sotto i 300 ppi alla dimensione di stampa.ColorValue.cmyk: un colore definito in CMYK viene stampato con i suoi valori esatti.{ pageNegative: true }inRenderToPdfOptions: inverte l’area del formato rifilato con un metodo di fusione Differenza (i segni di taglio restano non invertiti). Utile per i controlli di preflight sulla tipografia scuro su chiaro.
#Implementazione di riferimento
Il componente PdfViewport della Sandbox (packages/postext-sandbox/src/viewport/PdfViewport.tsx) collega i pezzi descritti sopra in un’anteprima dal vivo con i pulsanti per rigenerare, scaricare e stampare, ed è un buon punto di partenza per qualsiasi integrazione PDF nel browser. Costruisce il VDT tramite il worker di impaginazione condiviso (vedi Eseguire l’impaginazione in un Web Worker), così un clic su Ricalcola non blocca l’interfaccia mentre la pipeline è in esecuzione: il thread principale gestisce solo renderToPdf (che è già veloce una volta che il VDT esiste).
#Un libro in 3D (postext-folio)
postext-folio presenta sullo schermo un documento impaginato come un libro stampato aperto su una scrivania: doppie pagine secondo la regola del recto, e fogli che chi legge gira con i pulsanti ‹ ›, i tasti freccia, uno swipe, un clic su una pagina, oppure prendendo una pagina per il bordo e trascinandola. Ogni foglio si incurva in three.js secondo la sua carta e proietta un’ombra vera sulle pagine sottostanti. Il canvas WebGL disegna il libro allo stesso modo sia fermo sia mentre si gira, quindi una pagina non cambia mai aspetto quando si posa. È il visualizzatore delle ricette del Ricettario e della scheda Folio della Sandbox.
npm install postext postext-folio threeimport { buildDocument } from 'postext';
import { createFolioFromDocument } from 'postext-folio';
const doc = buildDocument({ markdown }, config);
const book = createFolioFromDocument(document.getElementById('book')!, doc, {
onChange: ({ pages }) => console.log('showing pages', pages),
});
// After an edit: the same viewer, on the same page.
book.setDocument(buildDocument({ markdown: edited }, config));- Le pagine vengono dipinte quando servono.
createFolioFromDocumentdipinge ogni pagina conrenderPageToCanvasesattamente ai pixel del dispositivo di uno slot di pagina (WebGL poi la mostra un texel per pixel, nitida come l’anteprima canvas), e solo le doppie pagine attorno a quella aperta (window, tre per lato per impostazione predefinita). Le pagine che escono da quella finestra vengono liberate, quindi un libro di mille pagine costa la memoria di poche. Un salto a una pagina lontana dipinge prima quella doppia pagina. Fino a dieci pagine di distanza i fogli si girano uno per uno; oltre, il blocco di pagine intermedie si solleva come un’unica lastra, spessa quanto quelle pagine (la somma dei loro spessori), e si posa dall’altra parte.setDocumentmantiene il dipinto di ogni pagina che nella nuova impaginazione risulta uguale ({ repaint: true }le ridipinge tutte, dopo l’arrivo di un’immagine). - Il documento definisce il libro. La sua prima pagina si apre da sola a destra quando è un recto (
pageIndexOffsetpari), un libro rilegato a destra (page.binding: 'right', o un documento verticale) si presenta specchiato e si gira verso sinistra, le pagine bianche prendono il colore di sfondo della pagina, e la larghezza del formato rifilato della pagina (pageWidthMm) scala lo spessore della carta e i piatti. Un capitolo impaginato con unacontinuationconta le altre pagine del libro (pageIndexOffsetprima,bookPageCountdopo) nello spessore dei due blocchi di pagine senza disegnarle (extraPages). - Il documento definisce l’aspetto. La carta, la rilegatura, la scrivania e la luce sono le impostazioni
foliodel documento (doc.config.folio). Una pagina composta dentro una sequenza:::paperporta la propria carta (VDTPage.paper), e il suo foglio viene disegnato con il colore, la superficie, lo spessore e la rigidità di quella carta. Conbinding.cover: 'pages'la prima pagina è il piatto anteriore e l’ultima, quando è un verso, il piatto posteriore (covers). Un formato da quotidiano ('broadsheet','berliner','tabloid','compact') le cui impostazioni non indicano né carta né rilegatura si presenta come carta da giornale piegata, anche quando l’ospite passa un propriofolio. - Il contenitore definisce la dimensione. Il libro lo riempie, con i pulsanti e il conteggio delle pagine nei margini, quindi dai un’altezza al contenitore; un ridimensionamento ridipinge le pagine alla nuova dimensione. Sotto i 560 px di larghezza mostra una pagina alla volta (
mode: 'auto';'single'e'double'forzano l’una o l’altra modalità): il dorso corre lungo il bordo interno della pagina e il foglio gli si gira sopra, un trascinamento verso il dorso va avanti, uno swipe in direzione opposta torna indietro, un tocco gira la pagina. - Che cosa fa il puntatore.
interaction(e poisetInteraction) stabilisce che cosa fanno sul libro il tasto sinistro, un dito o una penna:'hand'(il valore predefinito) prende e gira le pagine,'orbit'ruota la vista come fa un trascinamento con il tasto destro (per trackpad e tablet),'select'lascia il puntatore all’ospite, per esempio per selezionare il testo.pageAt(event)restituisce la pagina sotto un puntatore e il punto su di essa ({ page, x, y }, frazioni della pagina a partire dal suo angolo in alto a sinistra), sul libro così come lo si vede, inclinato o ruotato;pointOnScreen(point)fa il percorso inverso, per disegnare un cursore o una selezione sopra la pagina.refreshPage(src)mostra di nuovo un canvas di pagina che l’ospite ha ridisegnato sul posto. La Sandbox li usa tutti per selezionare il testo e seguire il cursore dell’editor sulle pagine 3D. - Una lente d’ingrandimento per i caratteri piccoli.
interaction: 'magnify'tiene una lente rotonda con un bordo nero sopra il libro dove si trova il puntatore (un dito la tiene sopra di sé mentre tocca lo schermo). Mostra il libro come lo vede l’occhio di chi legge, illuminato e curvo come giace, ingrandito al massimo al centro e incurvato verso il bordo. La rotellina,+e−cambiano l’ingrandimento (setMagnification(zoom), da 1,5 a 10; il valore predefinito mostra la pagina a circa 5,5 px CSS per millimetro) ed Esc la ripone;magnifier: { zoom, diameter }imposta entrambi fin dall’inizio.createFolioFromDocumentridipinge le pagine sotto la lente, abbastanza nitide per il suo centro, così il testo corrente di un quotidiano si legge;createFolioprende questi dipinti dadetail: { paint(index, deviceWidth), release() }. La Sandbox la mette sul pulsante Lente d’ingrandimento (M). La lente seleziona anche il testo: sopra le pagine il cursore è un cursore di testo,pageAtrestituisce il punto sotto il centro della lente (sopra il dito su uno schermo tattile), e nella Sandbox un clic lì colloca il cursore e un trascinamento seleziona. - Chi legge può guardarci intorno. Un trascinamento con il tasto destro fa ruotare la vista attorno al libro (fino a 70° dalla verticale), anche mentre i fogli si girano;
resetView()la riporta dolcemente altilte alloyawdelle impostazioni, egetView()restituisce la vista così come la si vede ora ({ tilt, yaw }, in gradi) da memorizzare come quelle impostazioni. La Sandbox li mette su due pulsanti, Reimposta la vista e Salva come vista predefinita. - I video si riproducono sulle pagine. Un clic sul poster di un video lo riproduce sulla pagina, in ogni modalità di
interaction, e continua a suonare mentre il suo foglio si gira; un altro clic lo mette in pausa, e si ferma quando il libro si posa su una doppia pagina che non lo mostra. Un video conplayer.autoplayparte da solo la prima volta che viene mostrata la sua doppia pagina; uno che si riproduce anche insieme agli altri (player.exclusive: false) parte, senza audio, ogni volta e si ferma quando la doppia pagina viene girata, anche più di uno alla volta. Opzionivideos,videoUrleonVideo, estopVideo()sul visualizzatore; vedi Formato del documento › Video sulle pagine di Folio. - Prima i font e le immagini. Come per
renderPage, i font che il documento usa devono essere caricati indocument.fontse le sue immagini delle risorse registrate conregisterResourceImageprima che le pagine vengano dipinte. - Accessibile. Il visualizzatore è un gruppo che può ricevere il focus e accetta ←/→ (specchiati per un libro rilegato a destra), Pagina su/giù, Home e Fine; i suoi pulsanti e il conteggio delle pagine hanno un’etichetta (
labelsli traduce), e ogni canvas di pagina porta un testoalt(alt: (index) => …). - Senza WebGL2, o quando chi legge chiede di ridurre il movimento, le doppie pagine semplicemente cambiano. I libri WebGL sono pesanti per un telefono (una texture per ogni lato di pagina): la Sandbox offre la scheda Folio solo dove WebGL2 è disponibile e il lato corto dello schermo è di almeno 600 px.
canFlip()dice se qui i fogli si gireranno in 3D: WebGL2, e nessuna riduzione del movimento.
#Aspetto
L’opzione appearance, e poi setAppearance, ridefiniscono ciò che dice il documento. Tutto ciò che resta fuori mantiene il valore del documento:
const book = createFolioFromDocument(container, doc, {
appearance: {
folio: {
tilt: 22,
paper: { type: 'bookWove', texture: 'laid' },
binding: { type: 'hardcover', coverColor: { hex: '#5a1f1f', model: 'hex' } },
surface: { type: 'walnut' },
lighting: { environment: 'lamp' },
},
// Photographed desks: a folder laid out as postext.dev's /folio/textures/
// (manifest.json and one folder per desk). Without it, procedural maps.
textureBaseUrl: '/folio/textures',
// The picture for `folio.binding.spineImage`: the resource's URL,
// or a drawn canvas or image.
spineImage: spineUrl,
},
});
// A settings panel: the book redrawn in place, nothing painted again.
book.setAppearance({ folio: { ...folio, lighting: { environment: 'daylight' } } });
book.resetView();| Campo | Che cosa fa |
|---|---|
folio | Le impostazioni folio: inclinazione, carta, rilegatura, superficie, illuminazione. Se è fornito, sostituisce quello del documento. |
pageWidthMm | La larghezza del formato rifilato di una pagina in mm, rispetto a cui si scalano lo spessore della carta e i piatti. Dal documento: la pagina rifilata ai suoi dpi. Predefinito 150 per createFolio. |
extraPages | : pagine del libro al di fuori di quelle fornite, contate per lo spessore dei blocchi di pagine e mai disegnate. |
covers | : la prima pagina fornita è la copertina anteriore, l’ultima la copertina posteriore (quando cade su un verso). Si girano come piatti e non viene disegnata alcuna custodia. Dal documento: binding.cover: 'pages' su un libro che comincia dalla sua prima pagina e finisce con l’ultima. |
spineImage | L’immagine stampata sul dorso, come URL, canvas o immagine. createFolioFromDocument non cerca le risorse: passa l’immagine della risorsa indicata da folio.binding.spineImage. Ignorato con la cucitura a punto metallico. |
textureBaseUrl | Dove vengono servite le texture fotografate delle scrivanie. Finché non si caricano, o senza di esso, la scrivania viene disegnata con mappe procedurali. |
createFolio(container, { pages }) è lo stesso visualizzatore su pagine qualsiasi: URL di immagini, elementi <img> o <canvas>, e "" per una pagina bianca; una pagina può essere { src, alt, paper }, con paper una carta in stile :::paper per quel foglio. PageFlipper è il solo motore three.js, per un ospite che dispone da sé il DOM delle proprie doppie pagine; FlatPageFlipper è lo sfogliamento piatto che precede il libro 3D, mantenuto per il tavolo luminoso del Ricettario. L’elenco completo delle opzioni è nel README del pacchetto.
#Esempio dal vivo: un libro in 3D
Il pen importa postext e postext-folio da una CDN, impagina un breve documento e lo apre come un libro. Prendi la pagina di destra per il bordo e trascinala.
import { buildDocument } from 'https://esm.sh/postext';
import { createFolioFromDocument } from 'https://esm.sh/postext-folio';
const paragraph = `The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved. The light it gave was small, but it was enough to find the step.`;
// Thirty-six short sections: about ten pages to turn.
const markdown = ['# The Lantern']
.concat(Array.from({ length: 36 }, (_, i) => `## Evening ${i + 1}\n\n${paragraph} ${paragraph}\n\n${paragraph}`))
.join('\n\n');
const config = {
page: { sizePreset: '17x24', dpi: 150 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
const status = document.getElementById('status');
// The book: drag a page by its edge, click it, or use ← → and the buttons.
// Pages are painted at the size they are shown, around the open spread only.
createFolioFromDocument(document.getElementById('book'), doc, {
onChange: ({ pages }) => {
status.textContent = `${doc.pages.length} pages · open at ${pages.map((i) => i + 1).join('–')}`;
},
});
status.textContent = `${doc.pages.length} pages · drag a page by its edge to turn it`;index.html
<p id="status">Laying out…</p>
<div id="book"></div>style.css
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #eee;
background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
min-height: 100vh;
}
#status {
margin: 12px 16px 0;
font-size: 14px;
opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
height: calc(100vh - 48px);
--postext-folio-accent: #f0b35a;
}Carica un editor interattivo da codepen.io. L’esempio importa da una CDN l’ultima versione di postext.
#Esempio dal vivo: immagini di pagina
createFolio con pagine disegnate su canvas, un’ultima pagina bianca e il colore della carta.
import { createFolio } from 'https://esm.sh/postext-folio';
// Any pages will do: image URLs, <img> or <canvas> elements, and "" for a
// blank page. Here, eight pages drawn on canvases.
function drawPage(n) {
const canvas = document.createElement('canvas');
canvas.width = 600;
canvas.height = 840;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbf8f1';
ctx.fillRect(0, 0, 600, 840);
ctx.fillStyle = `hsl(${n * 45} 45% 45%)`;
ctx.fillRect(60, 80, 480, 320);
ctx.fillStyle = '#222';
ctx.font = 'bold 56px Georgia, serif';
ctx.fillText(`Plate ${n}`, 60, 480);
ctx.font = '22px Georgia, serif';
for (let line = 0; line < 8; line++) ctx.fillRect(60, 530 + line * 30, line === 7 ? 260 : 480, 3);
ctx.textAlign = 'center';
ctx.fillText(String(n), 300, 800);
return { src: canvas, alt: `Plate ${n}` };
}
const pages = Array.from({ length: 8 }, (_, i) => drawPage(i + 1));
// A blank page at the end, drawn as paper.
pages.push('');
const status = document.getElementById('status');
createFolio(document.getElementById('book'), {
pages,
firstPageRecto: true, // page 1 opens alone, on the right
binding: 'left', // 'right' lays a right-to-left book mirrored
paper: '#fbf8f1',
onChange: (state) => {
status.textContent = `Showing ${state.pages.map((i) => i + 1).join('–')} of ${pages.length}`;
},
});
status.textContent = 'Drag a page by its edge, click it, or use ← →';index.html
<p id="status">Drawing pages…</p>
<div id="book"></div>style.css
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #eee;
background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
min-height: 100vh;
}
#status {
margin: 12px 16px 0;
font-size: 14px;
opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
height: calc(100vh - 48px);
--postext-folio-accent: #f0b35a;
}Carica un editor interattivo da codepen.io. L’esempio importa da una CDN l’ultima versione di postext.
#Libri EPUB (postext-epub)
postext-epub scrive un libro impaginato come file EPUB 3.3, nel browser o in Node, senza server. Legge gli stessi documenti di capitolo che renderToPdf riceve per un libro, quindi numeri di pagina, note, citazioni, rimandi, indice e indice analitico arrivano già risolti, e restituisce il file come byte. È il generatore dietro la scheda EPUB 3 della Sandbox.
npm install postext postext-epubpostext è una dipendenza peer, come per postext-pdf: aggiorna i due pacchetti insieme e, su una CDN, fissali alla stessa versione.
#Layout fisso e fluido
EPUB 3 definisce due rendition, impostate dalla proprietà rendition:layout del pacchetto; layout ne sceglie una:
layout: 'fixed' | layout: 'reflowable' | |
|---|---|---|
| Nome EPUB | pre-paginated (layout fisso, FXL) | reflowable, il valore predefinito di EPUB |
| Documenti di contenuto | Un documento XHTML per ogni pagina stampata, al formato rifilato della pagina in px CSS | Un documento XHTML per capitolo (una parte ne apre uno proprio) |
| Che cosa conserva | La pagina: colonne, elementi flottanti, testatine, aperture, a capo e posizioni, nei font incorporati. Il testo resta testo vero: selezionabile, ricercabile, leggibile ad alta voce | Il testo e la sua struttura: titoli, paragrafi ricostruiti dalle righe, elenchi, riquadri come aside, figure e tabelle dopo il testo che le cita, note, link, segnaposto delle pagine stampate. Un foglio di stile derivato dalla configurazione |
| A che cosa rinuncia | Alla scelta del carattere, del corpo e dei margini da parte di chi legge; su uno schermo piccolo la pagina viene ridotta | Alle colonne, alle testatine, al progetto grafico della pagina e agli a capo esatti |
| Doppie pagine e direzione | page-spread-left / page-spread-right in base alla parità e alla rilegatura; un libro rilegato a destra si legge da destra a sinistra | Direzione di lettura in base alla rilegatura; il cinese verticale mantiene vertical-rl, l’arabo è dir="rtl" |
| Adatto a | Pagine progettate: libri illustrati, manuali, cataloghi, riviste; schermi grandi | Testo continuo: romanzi, saggi, relazioni; telefoni e lettori e-ink |
Entrambe le rendition hanno la stessa navigazione: un indice ricavato dai titoli e dalle pagine di parte, un elenco delle pagine con le etichette delle pagine stampate, i punti di riferimento (copertina, indice stampato, inizio del corpo) e un NCX per i sistemi di lettura meno recenti.
#Scrivere un libro
import { openBundle, buildBundle } from 'postext';
import { renderToEpub } from 'postext-epub';
const bundle = await openBundle(fileBytes);
const docs = buildBundle(bundle); // one VDTDocument per chapter, in book order
const bytes = await renderToEpub(docs, {
layout: 'reflowable',
metadata: { title: 'Lantern', creators: ['Ada Lovelace'], language: 'en' },
fonts: bundle.fonts.map((f) => ({ family: f.family, weight: f.weight, style: f.style, bytes: new Uint8Array(f.bytes), format: f.format })),
resourceBytes: (fileId) => {
const data = bundle.files.get(fileId);
return data ? { bytes: data, mediaType: '' } : undefined;
},
onWarning: (w) => console.warn(w),
});Un singolo documento è un libro di un capitolo: renderToEpub([doc], options).
renderToEpub(docs, options): Promise<Uint8Array>scrive il file.optionsè{ layout, metadata, fonts?, svgFonts?, resourceBytes?, cover?, onProgress?, onWarning?, signal? }.metadata:titleelanguage(un tag BCP 47) sono obbligatori;subtitle,creators,identifier,date,publisher,rights,descriptionemodifiedsono facoltativi. Un ISBN nudo diventaurn:isbn:…. Senza unidentifieril libro riceve unurn:uuid:derivato dal titolo, dagli autori e dalla lingua, così una nuova versione dello stesso libro mantiene il suo posto nella biblioteca di chi legge. Passa anchemodifiedper ottenere un output identico byte per byte.fonts: i font da incorporare,{ family, weight, style, bytes, format, unicodeRange? }conformattrawoff2,woff,ttf,otf. Ogni font diventa un file e una regola@font-face; più file con il lorounicodeRangeformano un unico font (le porzioni di Google Fonts). Una famiglia, un peso o uno stile usati dalle pagine senza un font incorporato vengono segnalati comemissingFont, e i sistemi di lettura li sostituiscono con i propri. Incorpora solo font la cui licenza lo consente: un font conredistributable: falsenon viene mai scritto nel file (il libro può essere composto con esso, ma il suo file resta fuori, anche dalle immagini SVG).resourceBytes(fileId): le immagini che le pagine collocano, in modo sincrono o asincrono, come{ bytes, mediaType }; unmediaTypevuoto viene ricavato dai byte. Fornisci le bitmap come sono conservate e gli SVG come sorgente, non il master di stampa PDF (svg.pdfFileId). Ogni immagine viene conservata una sola volta. In un libro a un solo inchiostro (diagramStyle.singleInk) gli SVG vengono ricolorati nel file. Un SVG riceve incorporati i font che il suo testo nomina, perché un sistema di lettura lo mostra come un’immagine che non vede i font del libro (vedi Font nel testo SVG): dafonts(le porzioni che contengono i suoi caratteri), poi dasvgFonts.providerper una famiglia che il testo del libro non usa. Le famiglie di font marcatiredistributable: false, e quelle indicate dasvgFonts.withhold(family), restano fuori e vengono segnalate una volta ciascuna comefontWithheld; una famiglia senza font viene segnalata comesvgFontUnavailable, i font oltresvgFonts.maxBytes(2 MiB) comesvgFontsTooLarge.svgFonts.inline: false,diagramStyle.inlineFonts: falsee ilsvg.inlineFonts: falsedi una risorsa conservano i byte così come sono forniti. Un’immagine senza byte viene segnalata comemissingImagee lasciata come cornice vuota.cover:{ bytes, mediaType, alt? }, un’immagine JPEG, PNG, WebP o SVG. Il libro si apre allora su un documento di copertina che la contiene, ed essa diventa ilcover-imagedel pacchetto (la miniatura in una biblioteca). Senza copertina, il layout fisso indica la sua prima pagina come copertina e il libro a layout fluido non ha un’immagine di copertina.onProgress({ phase, done, total }):resources(font e immagini),documents(pagine per un layout fisso, capitoli per uno fluido), poipackage.signalinterrompe tra un passo e l’altro.readEpub(bytes)rilegge un file per un visualizzatore, senzaDOMParser: layout, metadati, direzione di lettura, ogni file per percorso, il manifest, lo spine, l’indice, l’elenco delle pagine, il viewport del layout fisso e la copertina. Il lettore della Sandbox è costruito su di essa.
Entrambe le rendition contengono i metadati di EPUB Accessibility 1.1 (modalità di accesso, funzionalità come l’indice e i numeri delle pagine stampate, rischi e un riepilogo) e per impostazione predefinita non dichiarano alcuna conformità WCAG. L’elenco completo delle opzioni e i limiti si trovano nel README del pacchetto.
#Verificare un file con EPUBCheck
W3C EPUBCheck è il validatore di riferimento per EPUB; le librerie digitali verificano con esso i file che ricevono. Una volta installato (brew install epubcheck, oppure la versione Java), epubcheck book.epub elenca errori, avvisi e note d’uso; le note d’uso che l’output di Postext lascia (CSS-028, OBS-001, HTM_062) sono solo informative. Nel repository di Postext, pnpm --filter postext-epub epubcheck verifica i libri di esempio della suite di test, node packages/postext-epub/scripts/epubcheck.mjs book.postext --layout both impagina un file .postext o una cartella di preset e verifica entrambe le rendition, e pnpm --filter postext-epub validate esegue l’intera matrice di libri (la guida, i preset della vetrina, i libri cinesi, arabi e del Ricettario), ciascuno dei quali passa senza errori né avvisi.
#Pacchetti (file .postext)
Un file .postext è un libro intero in un solo file: un archivio zip che contiene un manifest preset.json, un file markdown per capitolo, i contenuti delle risorse (bitmap, SVG, master di stampa PDF) e i file dei font che la configurazione nomina. La Sandbox lo esporta e lo importa e la skill per agenti lo consegna. Anche il pacchetto postext può crearlo e aprirlo, così un libro può passare tra questi strumenti e il tuo programma senza perdere nulla.
my-book.postext
├── preset.json manifest: name, locale, chapters, config, resources, fonts
├── chapters/01-dusk.md
├── chapters/02-night.md
├── resources/lantern.svg
└── fonts/ebgaramond-400-normal.woff2
Il manifest è descritto campo per campo nell’appendice Formato dei pacchetti di preset della Sandbox. Un file può contenere anche layouts.json, il numero di pagine calcolato dalla Sandbox, così lì il libro si apre già impaginato, oppure uno per ogni edizione di un libro multilingue (layouts.zh-Hant.json, letto per primo). openBundle li ignora.
L’API è esportata da postext stesso e dal sottopercorso postext/bundle, che aggiunge le funzioni di basso livello. Importa da postext quando devi anche disegnare. In questo modo gli adattatori dei pacchetti e i renderer condividono un’unica istanza del modulo, il che conta su una CDN come esm.sh, dove ogni punto di ingresso è una build separata.
#Aprire un pacchetto
openBundle riceve i byte del file (un Uint8Array, un ArrayBuffer, oppure un Blob / File da un <input type="file">) e restituisce tutto ciò di cui hanno bisogno il motore e i suoi backend:
import { openBundle } from 'postext';
const bundle = await openBundle(await file.arrayBuffer(), { locale: 'es' });
bundle.chapters; // [{ title, file, markdown }, …] in book order
bundle.config; // PostextConfig, ready for buildDocument
bundle.resources; // Resource[]
bundle.files; // Map<path, Uint8Array>: every file of the bundle| Campo | Che cosa contiene |
|---|---|
manifest | Il preset.json convalidato. |
id, name, description | Presi dal manifest. |
locale, locales | La lingua in cui è stato letto il contenuto, e ogni lingua che un pacchetto bilingue contiene. options.locale ne sceglie una: prima il tag esatto, poi la lingua di base, poi la lingua propria del pacchetto. |
chapters | { title, file, markdown } per capitolo. Un capitolo senza titolo nel manifest prende il testo del suo primo titolo #. |
config | La tavolozza di colori predefinita e i tipi di risorsa nella lingua del pacchetto, poi il config del manifest, poi le sostituzioni della lingua. La lingua del pacchetto è il locale qui sopra, quindi un pacchetto in una sola lingua riceve le proprie etichette qualunque cosa chieda options.locale. Un manifest che non indica nessuna lingua prende quella impostata dal suo config (locale, poi la lingua di sillabazione), altrimenti options.locale. customFonts elenca le famiglie di font del pacchetto. È la stessa configurazione con cui la Sandbox apre il pacchetto. |
resources | Le risorse, con le didascalie della lingua scelta. Una dimensione assente dal manifest viene letta dal file. |
fonts | Una voce per font: { family, weight, style, format, file, bytes }. |
files | Ogni file dell’archivio, indicizzato per percorso. |
thumbnail, canvasScope | Il percorso dell’immagine di copertina, e come il pacchetto chiede di essere visualizzato: il view del manifest, con sopra il localized[…].view della lingua servita. |
start | Il punto in cui inizia il libro, per un pacchetto che contiene una parte di un libro più lungo: lo start del manifest, oppure il localized[…].start della lingua servita. Assente per un libro che inizia dal suo principio. |
warnings | Problemi non bloccanti: un file di font non supportato, un master di stampa mancante. |
Il fileId di un contenuto è il suo percorso dentro il pacchetto. resource.svg.fileId, resource.bitmap.fileId e il fileId di ogni variante di customFonts si possono cercare direttamente in bundle.files. openBundle genera un errore quando i byte non sono uno zip, quando non c’è un preset.json valido (alla radice o sotto un’unica cartella di primo livello), o quando manca un file che il manifest nomina.
Pacchetti scritti da postext 1.4 o precedenti
Ogni manifest scritto da createBundle e dalla Sandbox porta configVersion: 14: le regole di configurazione per cui è stato scritto il suo config. Un manifest senza questo campo è stato scritto da postext 1.4 o da una versione precedente, che impostava diciotto cose in modo diverso:
- Interruzioni prima dei titoli (regole 3): fino alla 1.4, un oggetto
headingssenza interruzione per l’H1 non ne aveva nessuna (vedi Sostituzioni per livello). - La dimensione delle formule (regole 4): fino alla 1.4, le formule risultavano 1,131 volte più grandi di quanto indica
fontSizeScale(vedi Dimensione delle formule). - Lo spazio sotto una risorsa in linea (regole 5): fino alla 1.4, il testo dopo una figura o una tabella
placement.position: 'here'riprendeva alla linea successiva della griglia, senza la distanza degli elementi flottanti sotto di essa (vedilayout.inlineResourceGapin Impaginazione). - Lo spazio intorno a una risorsa in linea dentro un riquadro (regole 6): fino alla 1.4, una risorsa del genere stava a contatto con il testo del riquadro che la conteneva (vedi
layout.inlineResourceGapInBoxesin Impaginazione). - Stili in linea nei titoli (regole 6): fino alla 1.4, un titolo stampava le parole dei suoi segni
*italic*,**bold**e altri nel proprio stile semplice (vediheadings.inlineMarksin Titoli). - La dimensione di un capolettera (regole 6): fino alla 1.4, il
dropCapdi un testo di design senzafontSizeera alto quanto tutti i riquadri di riga che occupa, con la sommità sopra la prima riga (vedidropCapin Elementi di testo). - Lo spazio sotto una riga che termina con i due punti (regole 6): fino alla 1.4,
keepColonWithListconsiderava sufficiente per l’elenco una riga di spazio sotto la riga che termina con i due punti, e una prima voce di due righe che le regole di orfane e vedove tengono unita passava alla colonna successiva senza di essa (vedibodyText.colonListRoom). - Le righe che lascia il taglio di un riquadro (regole 6): fino alla 1.4, un riquadro diviso dentro un paragrafo o una voce di elenco poteva lasciarne una sola riga su un lato, purché ciascun lato del riquadro contenesse in tutto le sue
splitMinLinesrighe (vedilayout.boxChildSplitMinLinesin Impaginazione). - A capo dopo una lineetta (regole 7): fino alla 1.4, Knuth–Plass non terminava mai una riga dopo una lineetta lunga o media composta senza spazi tra due parole (
say—that’s), e il divisore riga per riga del testo formattato lo faceva solo tra due lettere (vedibodyText.breakAfterDashesin Testo corrente). - Testo in bandiera (regole 7): fino alla 1.4, il testo corrente in bandiera veniva composto riga per riga, riempiendo ogni riga prima della successiva, qualunque cosa dicesse
optimalLineBreaking(vedibodyText.optimalRaggedin Testo corrente). - La divisione sotto un titolo (regole 8): fino alla 1.4, il paragrafo sotto un titolo al piede di una colonna teneva lì tutte le righe che ci stavano, per quanto poche ne passassero alla colonna successiva (vedi
headings.keepWithNextSplitin Titoli). - Lo spazio sotto un contenitore
:::paragraphs(regole 8): fino alla 1.4, lo spazio dello stile veniva messo sotto l’ultimo paragrafo prima dell’aggancio alla griglia, lo spazio superiore del blocco successivo (ilmarginTopdi un titolo) veniva aggiunto sotto di esso, e la spaziatura tra paragrafi del testo veniva tralasciata (vedibodyText.paragraphContainerSpacingin Testo corrente). - A capo dopo il trattino di una parola composta (regole 8): fino alla 1.4, Knuth–Plass non terminava mai una riga giustificata dopo un trattino tra due lettere (
well-known) in un paragrafo senza formattazione in linea, mentre lo faceva in uno con formattazione (vedibodyText.breakAfterHyphensin Testo corrente). - Poesie senza separatore (regole 9): fino alla 1.22, una poesia
:::versele cui righe non contenevano||veniva composta come emistichi singoli, ogni riga centrata (vedibodyText.verse.layoutin Versi). - Un rientro di prima riga accanto a un rientro sporgente (regole 9): fino alla 1.22, l’
hangingIndentdi uno stile di paragrafo sostituiva il suofirstLineIndent, e la prima riga iniziava aindent(vedi Stili di paragrafo). - Una barra rovesciata a fine riga (regole 9): fino alla 1.22, una barra rovesciata che terminava una riga di un paragrafo, di una citazione o di una voce di elenco, e
\\prima di uno spazio, venivano stampati, e le righe si univano con uno spazio (vedibodyText.hardLineBreaksin Testo corrente). - Recinzioni di codice (regole 9): fino alla 1.22, una recinzione
```o~~~e le righe al suo interno venivano lette come Markdown: righe unite in paragrafi, una riga#diventava un titolo, le recinzioni venivano stampate (vedicodeStyle.blocksin Listati di codice). - Righe di versi che vanno a capo (regole 10): nella 1.23, una riga di una poesia composta riga per riga più larga della giustezza andava a capo con la sua spaziatura naturale tra le parole, per quanto poco sbordasse (vedi
bodyText.verse.tightenin Versi).
openBundle e readBundle leggono il config di un manifest del genere, e la configurazione localized di ogni lingua, tramite migrateConfig, che scrive esplicitamente le interruzioni impaginate dalla 1.4 e moltiplica la scala delle formule per 1,131 (dividendo per lo stesso valore i suoi margini delle formule in evidenza in em). Un manifest marcato da 3 a 7, scritto da una prerelease della 1.5, riceve solo i vincoli delle regole successive al suo marchio. Con 3 sono la dimensione delle formule, la distanza in linea, i cinque vincoli delle regole 6, i due delle regole 7 e i tre delle regole 8; con 4, la distanza in linea e i vincoli delle regole 6, 7 e 8; con 5, i vincoli delle regole 6, 7 e 8; con 6, i vincoli delle regole 7 e 8; con 7, solo i vincoli delle regole 8. La divisione sotto un titolo (pinLegacyHeadingSplit) viene scritta come headings.keepWithNextSplit: 'fill' sugli headings che i livelli lasciano in vigore, quando un capitolo letto ha un titolo e la configurazione non indica un valore proprio, mantiene attivo headings.keepWithNext e non disattiva bodyText.avoidOrphans. Gli a capo nelle parole composte (pinLegacyHyphenBreaks) vengono scritti come bodyText.breakAfterHyphens: false sul bodyText in vigore, quando un capitolo letto compone un trattino tra due lettere e la configurazione né lo imposta già né disattiva optimalLineBreaking. Lo spazio sotto i contenitori (pinLegacyParagraphContainerSpacing) viene scritto come bodyText.paragraphContainerSpacing: 'add' sul bodyText in vigore, quando la configurazione dichiara uno stile di paragrafo (in paragraphStyles o nelle sostituzioni del visualizzatore HTML), un capitolo letto apre un contenitore :::paragraphs su una riga a sé e la configurazione non lo imposta già. Gli a capo dopo le lineette (pinLegacyDashBreaks) vengono scritti come bodyText.breakAfterDashes: false sul bodyText in vigore, quando un capitolo letto compone una lineetta lunga o media senza spazi tra due parole (prima una lettera, una cifra o una punteggiatura di chiusura, dopo una lettera, una cifra o una parentesi o virgoletta di apertura; una virgoletta prima della lineetta conta quando prima della virgoletta c’è una lettera, una cifra, una punteggiatura di chiusura o uno spazio indivisibile, come in "no"—and, non in said "—Hola; un segno in linea che tocca la lineetta o la virgoletta, come il ** di **riddles.**—I, conta su entrambi i lati) e la configurazione non lo imposta già. La divisione in righe in bandiera (pinLegacyRaggedBreaking) viene scritta come bodyText.optimalRagged: false sul bodyText in vigore, quando la configurazione compone in bandiera una parte del testo corrente (un textAlign diverso da 'justify' sul testo corrente, su uno stile di paragrafo, sul corpo di un riquadro, sul corpo di una parte o sul corpo di uno stile di sezione (headingStyles[].bodyStyle), oppure nelle sostituzioni del visualizzatore HTML), non lo imposta già e non disattiva optimalLineBreaking. La distanza nei riquadri (pinLegacyBoxResourceGap) viene scritta come layout.inlineResourceGapInBoxes: false sul layout in vigore, quando una risorsa è inserita su una riga a sé dentro un :::callout dei capitoli letti e la configurazione non lo imposta già. Il taglio dei riquadri (pinLegacyBoxChildCut) viene scritto come layout.boxChildSplitMinLines: 1 sul layout in vigore, quando un capitolo letto apre un :::callout su una riga a sé e la configurazione non lo imposta già. I segni nei titoli (pinLegacyHeadingMarks) vengono scritti come headings.inlineMarks: false sugli headings che i livelli lasciano in vigore, quando un titolo dei capitoli letti contiene un segno (*, _, ^, ~, :smallcaps[ o un link nel suo testo) e la configurazione non indica un valore proprio. I capilettera (pinLegacyDropCapSize) ricevono la loro dimensione della 1.4 scritta esplicitamente come dropCap.fontSize, ovunque si trovino: nell’unità dell’interlinea dell’elemento quando questa è una lunghezza, altrimenti nell’unità del suo corpo. Lo spazio sotto la riga con i due punti (pinLegacyColonListRoom) viene scritto come bodyText.colonListRoom: 'line' sul bodyText in vigore, quando un elenco dei capitoli letti segue una riga che termina con i due punti (righe vuote in mezzo ammesse) e la configurazione né indica uno spazio né disattiva keepColonWithList. La distanza in linea (pinLegacyInlineGap) viene scritta come layout.inlineResourceGap: 'above' sul layout che i livelli lasciano in vigore, quando una riga dei capitoli letti inserisce una risorsa (::resource{id="…"} da solo sulla sua riga, come lo legge il parser: una menzione nel testo corrente o in un segmento di codice non conta) e la configurazione non indica una distanza propria. La dimensione viene vincolata sul math che i livelli lasciano in vigore (il math proprio di una lingua sostituisce quello condiviso), e solo quando i capitoli letti contengono un $: un pacchetto senza formule conserva il suo config come è scritto. Quando né il manifest né la lingua indicano un math, quello in vigore è il baseConfig di readBundle (quello del lettore), e anch’esso viene vincolato, perché la 1.4 componeva le formule del pacchetto a quella dimensione: un fontSizeScale: 1.5 di base si legge come 1,5 × 1,1312. Le interruzioni prima dei titoli della base vengono prese così come sono. Un pacchetto vecchio conserva quindi ciò che queste regole impaginavano, e bundle.config mostra le interruzioni, la dimensione delle formule, le distanze, i segni nei titoli, le dimensioni dei capilettera, lo spazio sotto la riga con i due punti, il taglio dei riquadri, gli a capo dopo le lineette, gli a capo nelle parole composte, la divisione in bandiera, la divisione sotto un titolo e lo spazio sotto i contenitori con cui viene impaginato. Le correzioni di impaginazione della 1.5 non hanno vincoli e si applicano a esso come a qualsiasi libro, quindi una pagina che toccano può ancora spostarsi (vedi Dimensione delle formule per l’elenco). Un preset.json scritto a mano per le regole attuali imposta "configVersion": 14; marcare con questo valore il manifest di un pacchetto vecchio è anche il modo, in una riga, per leggerlo con le regole attuali (un pacchetto senza versione perde allora anche il vincolo delle interruzioni prima dei titoli). Un manifest marcato 8, scritto da postext dalla 1.5 alla 1.22, riceve solo i quattro vincoli delle regole 9, come del resto ogni manifest più vecchio: la disposizione dei versi (pinLegacyVerseLayout) viene scritta come bodyText.verse.layout: 'bayt' sul bodyText in vigore, quando un capitolo letto compone una poesia :::verse la cui recinzione non indica una disposizione e le cui righe non contengono un separatore di emistichi, e la configurazione non lo imposta già; i rientri abbinati (pinLegacyPairedIndents) eliminano il firstLineIndent di ogni stile di paragrafo (in paragraphStyles o nelle sostituzioni del visualizzatore HTML) che imposta anche un hangingIndent diverso da zero; gli a capo forzati (pinLegacyHardBreaks) vengono scritti come bodyText.hardLineBreaks: false sul bodyText in vigore, quando un capitolo letto termina con una barra rovesciata una riga di un paragrafo, di una citazione o di una voce di elenco e il blocco prosegue sotto, oppure compone \\ prima di uno spazio e di altro testo (esclusi codice e formule in linea, formule in evidenza, titoli e poesie :::verse), e la configurazione non lo imposta già; e le recinzioni di codice (pinLegacyCodeBlocks) vengono scritte come codeStyle.blocks: false, quando un capitolo letto apre una recinzione ``` o ~~~ (tre o più caratteri, con al massimo tre spazi di rientro) e la configurazione non lo imposta già. Un manifest marcato 9, scritto da postext 1.23, riceve solo il vincolo delle regole 10, come del resto ogni manifest più vecchio: le righe di versi che vanno a capo (pinLegacyVerseTightening) vengono scritte come bodyText.verse.tighten: false sul bodyText in vigore, quando un capitolo letto compone una poesia riga per riga (una recinzione :::verse che indica layout=lines, oppure che non indica una disposizione su righe senza separatore di emistichi mentre la configurazione non compone queste poesie come bayt) e la configurazione non lo imposta già. Un manifest marcato 10, scritto da postext 1.24, riceve solo il vincolo delle regole 11, come del resto ogni manifest più vecchio: il bilanciamento di una griglia di caratteri (pinLegacyGridBalancing) viene scritto come headings.balancing.enabled: true sugli headings in vigore, quando la configurazione unita imposta cjk.grid.enabled nel testo orizzontale e non imposta essa stessa enabled, perché la 1.24 bilanciava una pagina del genere per impostazione predefinita. Le regole 11 impediscono anche gli a capo di un solo carattere nei titoli di opere, compongono i numeri cerchiati come caratteri cinesi e compongono il testo di design CJK con le regole del testo corrente (#637): un manifest marcato 10 o più vecchio riceve cjk.titleMinChars: 1 (pinLegacyTitleBreaks) quando un capitolo letto compone un titolo di opera (《 〈 o :book[), cjk.circledNumbers: 'western' (pinLegacyCircledNumbers) quando uno compone un numero cerchiato (U+2460–U+24FF, U+2776–U+2793), e cjk.composeDesignText: false (pinLegacyDesignText) quando un capitolo letto o la configurazione stessa contiene testo CJK, ciascuno sul cjk in vigore e solo quando la configurazione non lo imposta già. Tagliano anche le tabelle in linea e compongono :::columns nel testo corrente (#634): un manifest marcato 10 o più vecchio riceve tableStyle.splitInline: false (pinLegacyInlineTableSplit) sul tableStyle in vigore quando un capitolo letto inserisce una risorsa, e layout.flowColumns: false (pinLegacyFlowColumns) sul layout in vigore quando un capitolo letto apre una recinzione :::columns su una riga a sé, ciascuno solo quando la configurazione non lo imposta già. Offrono inoltre a un elemento flottante la cima della colonna di un’apertura a tutta larghezza (#639): un manifest marcato 10 o più vecchio riceve layout.floatsUnderOpener: false (pinLegacyOpenerHeadFloats) sul layout in vigore quando la configurazione unita imposta un livello o uno stile di titolo con span: 'page' e un capitolo letto ha un titolo, solo quando la configurazione non lo imposta già. Le regole 12 permettono a una pagina su una griglia di caratteri di trattenere le sue righe (#717): un manifest marcato 11 o più vecchio la cui configurazione unita attiva cjk.grid.enabled riceve bodyText.avoidWidows: true, e nella regione japan bodyText.avoidOrphans: true (pinLegacyGridBreakRules), sul bodyText in vigore, ciascuno solo quando la configurazione non lo imposta già. Mantengono anche due caratteri di una nota su due righe da ciascun lato di un a capo (#720): un manifest del genere riceve cjk.warichu.minChars: 1 (pinLegacyNoteBreaks) sul cjk in vigore quando un capitolo letto compone una nota :warichu[ o la configurazione unita imposta citations.notes: 'warichu', solo quando la configurazione non lo imposta già. E nella regione Giappone una parola occidentale prende lo spazio Han–latino accanto alle sue parentesi e ai suoi segni (#721): un manifest marcato 11 o più vecchio la cui configurazione unita si risolve nella regione Giappone riceve cjk.latinSpacingEdge: 'letters' (pinLegacyLatinSpacingEdge) sul cjk in vigore quando un capitolo letto contiene kana o kanji, solo quando la configurazione non lo imposta già. E una risorsa composta nel testo prende placement.captionSide come fa un elemento flottante di colonna (#729): un manifest marcato 11 o più vecchio la cui configurazione unita imposta layout.layoutType: 'oneAndHalf' riceve layout.inlineCaptionSide: false (pinLegacyInlineCaptionSide) quando un capitolo letto inserisce una risorsa, solo quando la configurazione non lo imposta già.
Le regole 12 cambiano anche il modo in cui si bilancia una colonna corta, e un manifest marcato 11 o più vecchio mantiene ciò con cui è stato impaginato. L’ordine delle leve (#707): riceve headings.balancing.maxLinesBeforeLoose impostato al suo maxLinesPerHeading, o a 4 quando non ne imposta nessuno (pinLegacyLooseOrder), sugli headings in vigore, quando un capitolo letto ha un titolo e la configurazione unita bilancia le sue colonne, consente a un titolo di occupare più di due righe e non imposta già la chiave. La riga sotto una figura su più colonne (#708): riceve headings.balancing.stretchAfterWideFloats: 'always' (pinLegacyWideFloatStretch) sugli headings in vigore, quando un capitolo letto inserisce o cita una risorsa o fa flottare un riquadro, e la configurazione unita bilancia le sue colonne, non disattiva stretchAfterFloats e non imposta già la chiave. Le interruzioni dentro i gruppi di un indice analitico (#712): riceve index.groups.splitMinEntries: 1 (pinLegacyIndexGroupSplit) sull’index in vigore, quando un capitolo letto compone una recinzione :::index su una riga a sé e la configurazione non imposta già la chiave. L’unità del taglio a livello (#711): riceve headings.balancing.trailingStep: 'grid' (pinLegacyTrailingStep) sugli headings in vigore, quando un capitolo letto può comporre testo fuori dall’interlinea del testo corrente (una recinzione :::, una recinzione di codice, una citazione, un elenco, una formula in evidenza o una citazione bibliografica), e la configurazione unita bilancia le sue colonne, mantiene attivo trailing o beforeSpan e non imposta già la chiave.
Le regole 12 vanno a capo agli spazi nell’etichetta di un rimando quando l’etichetta è fatta di parole (#702). Un manifest marcato 11 o più vecchio riceve crossRefs.breakLabels: false (pinLegacyRefLabelBreaks) sul crossRefs in vigore, quando un capitolo letto contiene un rimando (:ref{…}, \ref{…}, \eqref{…} o un @id di pandoc-crossref) e la configurazione non indica l’impostazione: ogni etichetta resta l’unica parola con cui era stata impaginata.
Le regole 12 compongono una parola senza lettere (46., 1367)) nella scrittura del testo che la circonda (#725). Un manifest marcato 11 o più vecchio riceve bodyText.scriptContext: false (pinLegacyScriptContext) sul bodyText in vigore, qualunque cosa contengano i suoi capitoli, quando la configurazione non indica l’impostazione: una parola del genere viene composta da sola, come era stata impaginata. La regola agisce in un font che disegna la punteggiatura in base alla scrittura, e quali font carica un libro non si sa quando se ne legge il manifest.
Le regole 13 trasformano in un’impostazione la lingua in cui viene composto il testo (#764), e le regole 14 rendono predefinita la lingua propria del documento (#770). Un manifest marcato 13 o più vecchio riceve bodyText.shapingLanguage: 'host' (pinLegacyShapingLanguage) sul bodyText in vigore, qualunque cosa contengano i suoi capitoli, quando la configurazione non indica l’impostazione; uno che indica un valore lo mantiene. Il suo testo viene misurato nella lingua della pagina che ospita l’impaginazione, e in nessuna lingua in un worker, come era stato impaginato. Un manifest marcato 13 è stato scritto da postext 1.29: la 1.29.1 e la 1.29.2 impaginavano un libro che non indica un valore nella lingua dell’applicazione ospite, come fa il vincolo; la 1.29.0, il cui valore predefinito era 'document', lo impaginava in quella del documento con un canvas e un PDF che non seguivano la misurazione, e anch’esso si legge con 'host'. Un manifest marcato 14 che non indica un valore viene impaginato nella lingua del documento.
Le regole 13 disegnano anche la casella di una voce di attività (- [ ], - [x]): un quadrato disegnato dal motore, con un segno di spunta quando la voce è completata, che occupa uno spazio proprio prima del testo della voce (#765). Un manifest marcato 12 o più vecchio riceve unorderedLists.taskBox: 'character' (pinLegacyTaskBox) sull’unorderedLists in vigore, quando un capitolo contiene una voce di attività e la configurazione non indica l’impostazione. La sua casella resta il carattere ☐ o ☑ composto nel carattere dell’elenco, e il testo della voce inizia dopo la larghezza di quel carattere, come era stato impaginato. Quella larghezza dipendeva dal font che conteneva il carattere, nella maggior parte dei libri un font di sistema, e non si conosce quando si legge il manifest.
import { CONFIG_VERSION, migrateConfig } from 'postext/bundle';
migrateConfig({ headings: { fontFamily: 'Georgia' } }, undefined, { content: 'A book with no maths.' });
// => { headings: { fontFamily: 'Georgia', levels: [{ level: 1, breakBefore: { enabled: false } }] } }
migrateConfig({ math: { fontSizeScale: 1.2 } }, 3);
// => { math: { fontSizeScale: 1.35746…, marginTop: { value: 0.7072, unit: 'em' }, marginBottom: { value: 0.7072, unit: 'em' } },
// layout: { inlineResourceGap: 'above', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 },
// headings: { inlineMarks: false, keepWithNextSplit: 'fill' }, bodyText: { colonListRoom: 'line', breakAfterDashes: false, breakAfterHyphens: false, verse: { layout: 'bayt', tighten: false }, hardLineBreaks: false }, codeStyle: { blocks: false } }
migrateConfig({ layout: { layoutType: 'single' } }, 4, { content: 'Text.\n\n::resource{id="fig"}' });
// => { layout: { layoutType: 'single', inlineResourceGap: 'above' } }
migrateConfig({ layout: { layoutType: 'single' } }, 5, { content: ':::callout\nText.\n\n::resource{id="fig"}\n:::' });
// => { layout: { layoutType: 'single', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 } }
migrateConfig({ bodyText: { textAlign: 'left' } }, 6, { content: 'I say—that is all.' });
// => { bodyText: { textAlign: 'left', breakAfterDashes: false, optimalRagged: false } }
migrateConfig({ paragraphStyles: [{ id: 'verse' }] }, 7, { content: ':::paragraphs{style="verse"}\nA line.\n:::' });
// => { paragraphStyles: [{ id: 'verse' }], bodyText: { paragraphContainerSpacing: 'add' } }
migrateConfig({ bodyText: { fontFamily: 'Georgia' } }, 7, { content: 'A well-known tale.' });
// => { bodyText: { fontFamily: 'Georgia', breakAfterHyphens: false } }
migrateConfig(config, CONFIG_VERSION); // today's rules: `config` itselfcontent è il markdown che la configurazione impagina (una stringa o un elenco di capitoli). Senza di esso la dimensione delle formule viene vincolata ogni volta che la matematica è attiva, lo spazio sotto i contenitori ogni volta che la configurazione dichiara uno stile di paragrafo, e le due distanze, i segni nei titoli, lo spazio sotto la riga con i due punti, il taglio dei riquadri, gli a capo dopo le lineette, la divisione sotto un titolo e gli a capo nelle parole composte sempre, perché il motore non può sapere se il libro contiene una formula, un contenitore :::paragraphs, una figura in linea, un titolo con segni, un elenco introdotto dai due punti, un riquadro, una lineetta senza spazi, un titolo o una parola composta. La divisione in bandiera viene vincolata dalla sola configurazione, con o senza contenuto. Migra una configurazione salvata una sola volta e salvala di nuovo con CONFIG_VERSION: il vincolo delle formule moltiplica la scala, quindi una configurazione migrata due volte crescerebbe due volte. Anche la disposizione dei versi e le recinzioni di codice vengono vincolate senza contenuto, perché il libro potrebbe contenere una poesia :::verse senza separatore o una recinzione, e i rientri abbinati vengono vincolati dalla sola configurazione. Lo stesso vale per le righe di versi che vanno a capo della 1.23, perché il libro potrebbe comporre una poesia riga per riga.
#Impaginare e disegnare un pacchetto
Quattro funzioni collegano un pacchetto aperto al motore e ai backend:
loadBundleFonts(bundle)registra i font del pacchetto indocument.fonts, e i loro byte nel registro dei font del motore per le immagini SVG (registerFontBytes). Attendine il completamento prima di impaginare, perché l’impaginazione misura il testo con i font di cui dispone il browser. Le famiglie che il pacchetto nomina ma non contiene (Google Fonts) devi comunque caricarle tu, come per qualsiasi altro documento.registerBundleImages(bundle)decodifica le immagini per il backend canvas (renderPage,renderToCanvas), poster dei video compresi.bundleImageUrl(bundle)è la funzione di risoluzioneresourceImageUrlperrenderToHtml, ebundleVideoUrl(bundle)la sua funzione di risoluzioneresourceVideoUrlper i file video contenuti in un pacchetto. Entrambe ricolorano le figure SVG quandodiagramStyle.singleInkè attivo, una volta sola: ricolorano il markup e contrassegnano le immagini perché nessun backend le tinga di nuovo (vedi Un solo inchiostro su canvas e in HTML). Entrambe incorporano inoltre in ogni SVG i font che il suo testo nomina, prima dai font propri del pacchetto, poi dai font registrati nel motore (vedi Font nel testo SVG);registerBundleImages(bundle, { onWarning })ebundleImageUrl(bundle, { onWarning })segnalano una famiglia senza font.buildBundle(bundle)impagina i capitoli in ordine e restituisce unVDTDocumentper capitolo. Ogni capitolo prosegue quello precedente: contatori dei titoli e delle risorse, la parte aperta, la parità delle pagine e la numerazione delle pagine. Un capitolo che stampa l’indice (:::toc) o l’indice analitico (:::index) riceve la struttura dell’intero libro. Accetta le stesse opzioni dibuildDocument, piùconfigper sostituire la configurazione del pacchetto,cacheper condividere una cache delle misure emetadata(vedi sotto).bundleResourceBytes(bundle)ebundleFontProvider(bundle, { decodeWoff2, fallback })sono le opzioniresourceBytesefontProviderdelrenderToPdfdipostext-pdf. Il fornitore di font sceglie dal pacchetto il peso più vicino dello stile richiesto. Per un font.woff2ha bisogno didecompressWoff2, e per una famiglia che il pacchetto non contiene chiamafallbackcon gli argomenti del renderer,requestcompreso, e passa avanti qualunque cosa restituisca. Un fallback che scarica solo il filelatindi una famiglia stampa come riquadri vuoti una famiglia cinese che il pacchetto non incorpora; uno che risponde con porzioni, comesliceFontProviderin Font cinesi, giapponesi e coreani, la stampa per intero.
import { openBundle, loadBundleFonts, registerBundleImages, buildBundle, renderPage,
bundleResourceBytes, bundleFontProvider } from 'postext';
import { renderToPdf, decompressWoff2 } from 'postext-pdf';
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle);
await registerBundleImages(bundle);
const docs = buildBundle(bundle); // one VDTDocument per chapter
const firstPage = renderPage(docs[0].pages[0], docs[0]); // a <canvas>
const pdf = await renderToPdf(docs, { // the whole book
fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
resourceBytes: bundleResourceBytes(bundle),
});Per impaginare da te un singolo capitolo, passa bundle.chapters[i].markdown, bundle.resources e bundle.config a buildDocument, come faresti per qualsiasi documento.
I metadati del libro. Come nella Sandbox, il front matter del primo capitolo è quello del libro: buildBundle passa il suo title, il suo author e il resto a ogni capitolo, così le testatine {title} e {author} valgono su ogni pagina e il doc.metadata di ogni capitolo li contiene. Un blocco di front matter in cima a un capitolo successivo viene ignorato: viene individuato dalle sue righe --- e svuotato senza essere analizzato, quindi un YAML che il parser rifiuterebbe non fa danni. options.metadata fornisce i valori che il front matter non imposta (prevale il front matter). Anche il numero di pagine del libro arriva a ogni capitolo: {bookTotalPages} lo stampa, mentre {totalPages} conta le pagine del capitolo (vedi Numero di pagine del libro).
const docs = buildBundle(bundle, { metadata: { author: 'A. Author' } });
docs[3].metadata.title; // the first chapter's `title:`Dove inizia il libro. Un pacchetto può contenere una parte di una pubblicazione più lunga: le pagine da 58 a 61 di un fascicolo, il capitolo 4 di un manuale. Il suo start dice che cosa viene prima, nei termini della continuation di buildDocument: le pagine prima della prima (pageIndexOffset, che decide su quale lato cade la pagina 1, e con esso i margini speculari e le testatine pari e dispari), la numerazione delle pagine in vigore (pageNumbering), i contatori dei titoli (headings), la parte aperta (part) e i contatori di risorse, enunciati, note a piè di pagina e righe. createBundle lo scrive come start di preset.json, openBundle lo restituisce come bundle.start, e buildBundle impagina con esso il primo capitolo, come farebbe buildDocument({ markdown, continuation: start }, config), e da lì concatena i capitoli successivi; anche {bookTotalPages} conta le pagine che precedono il libro. Un manifest senza start si legge come prima, e un lettore che non conosce il campo lo ignora. bookPageCount non ne fa parte: le pagine le conta il lettore. In un pacchetto con più lingue, localized[…].start dà a un’edizione un inizio proprio.
const { bytes } = await createBundle({
name: 'Field notes, chapter 4',
markdown,
config,
// Page 58, an even page, in chapter 4.
start: { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3 } },
});
const bundle = await openBundle(bytes);
bundle.start; // { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3, h2: 0, … } }
const [doc] = buildBundle(bundle);
doc.pages[0].pageLabel; // '58'#Esempio dal vivo: aprire un pacchetto
Il pen carica dal repository un libro di esempio di due capitoli (lantern.postext, con un carattere proprio, una figura SVG e una tabella). Registra i font e le immagini del pacchetto, impagina il libro con buildBundle e disegna ogni pagina. Make the PDF disegna gli stessi documenti con postext-pdf, incorporando i font del pacchetto. Scegli un tuo file .postext, per esempio esportato dalla Sandbox, per vederlo allo stesso modo.
import {
openBundle,
loadBundleFonts,
registerBundleImages,
buildBundle,
bundleResourceBytes,
bundleFontProvider,
renderPage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
// A two-chapter book with its own typeface, an SVG figure and a table.
const SAMPLE = 'https://cdn.jsdelivr.net/gh/drnachio/postext@main/docs/examples/open-bundle/lantern.postext';
const status = document.getElementById('status');
const pdfButton = document.getElementById('pdf');
let current = null;
async function show(data) {
// Chapters, config (fonts wired to the bundle's own files), resources and
// every file, keyed by its path inside the bundle.
const bundle = await openBundle(data);
// Layout measures text with the fonts the browser has: register the
// bundle's faces, and load the Google Fonts it names but does not carry
// (the default running heads use Open Sans; see the pen's CSS).
await loadBundleFonts(bundle);
await document.fonts.load('600 16px "Open Sans"');
await registerBundleImages(bundle);
// One VDTDocument per chapter, each continuing the one before it.
const docs = buildBundle(bundle);
const pages = docs.flatMap((doc) => doc.pages.map((page) => renderPage(page, doc)));
document.getElementById('pages').replaceChildren(...pages);
status.textContent = `${bundle.name} · ${bundle.chapters.length} chapter(s) · ${pages.length} page(s)`
+ (bundle.warnings.length ? ` · ${bundle.warnings.length} warning(s)` : '');
current = { bundle, docs };
pdfButton.disabled = false;
document.getElementById('links').hidden = true;
}
// Fonts the bundle does not carry come from Fontsource.
async function fontsource(family, weight, style) {
const id = family.toLowerCase().replace(/\s+/g, '-');
const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`);
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${family}`);
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}
pdfButton.addEventListener('click', async () => {
pdfButton.disabled = true;
status.textContent = 'Rendering the PDF…';
const { bundle, docs } = current;
const bytes = await renderToPdf(docs, {
fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
resourceBytes: bundleResourceBytes(bundle),
});
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
document.getElementById('open').href = url;
document.getElementById('download').href = url;
document.getElementById('links').hidden = false;
status.textContent = `${bundle.name} · ${(bytes.length / 1024).toFixed(0)} KB PDF`;
pdfButton.disabled = false;
});
document.getElementById('file').addEventListener('change', async (event) => {
const file = event.target.files[0];
if (!file) return;
status.textContent = `Opening ${file.name}…`;
await show(file).catch((err) => { status.textContent = `Could not open ${file.name}: ${err.message}`; });
});
const res = await fetch(SAMPLE);
await show(await res.arrayBuffer());index.html
<p>
<label>Open a .postext file: <input id="file" type="file" accept=".postext,application/zip"></label>
<button id="pdf" disabled>Make the PDF</button>
<span id="links" hidden>
<a id="open" target="_blank" rel="noopener">open it</a> ·
<a id="download" download="book.pdf">download it</a>
</span>
</p>
<p id="status">Loading the sample book…</p>
<div id="pages"></div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#pages {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
}
#pages canvas {
display: block;
width: 240px;
height: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Carica un editor interattivo da codepen.io. L’esempio importa da una CDN l’ultima versione di postext.
#Creare un pacchetto
createBundle scrive un file .postext a partire da un documento: i suoi capitoli, la configurazione, le risorse e i contenuti a cui fanno riferimento.
import { createBundle } from 'postext';
const { bytes, manifest, warnings } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters: [
{ markdown: '# Dusk\n\nIt is drawn in :ref{id="lantern"}.' },
{ title: 'Night', markdown: '# Night\n\n…' },
],
config,
resources: [{
id: 'lantern', typeId: 'figure', kind: 'svg', caption: 'The lantern.',
svg: { fileId: 'lantern.svg', width: 240, height: 150 },
createdAt: 0, updatedAt: 0,
}],
files: { 'lantern.svg': svgMarkup, 'garamond-regular': fontBytes },
});| Input | Significato |
|---|---|
name, id, description, locale | I metadati del manifest. id vale per impostazione predefinita uno slug di name. |
chapters o markdown | Il libro, un { title?, markdown } per capitolo, oppure un singolo documento. |
config | Il PostextConfig. I valori uguali a quelli predefiniti vengono lasciati fuori dal manifest. |
resources | Le risorse. Un’immagine indica il suo contenuto con bitmap.fileId / svg.fileId (e svg.pdfFileId per un master di stampa). |
files | I contenuti per fileId (un oggetto o una Map): le immagini a cui fanno riferimento le risorse e i file dei font a cui fanno riferimento le varianti di config.customFonts. I valori possono essere un Uint8Array, un ArrayBuffer, un Blob o una stringa (markup SVG). |
thumbnail | { data, mime }: un’immagine di copertina (PNG, JPEG, WebP, GIF o SVG). |
canvasScope | 'book' chiede ai visualizzatori di impaginare l’intero libro come un unico canvas. |
start | Il punto in cui inizia il libro, per un pacchetto che contiene una parte di un libro più lungo: la continuation con cui verrebbe costruito un singolo documento. Viene scritto come start del manifest. |
mtime | La data di modifica scritta su ogni file dell’archivio (una Date, un timestamp o una stringa di data). Se la ometti, è l’ora della chiamata, quindi due chiamate con lo stesso input producono byte diversi. Passa una data fissa e lo stesso input produce gli stessi byte, che puoi sottoporre a hash o confrontare. Uno zip conserva data e ora senza fuso orario, a passi di due secondi, dal 1980 al 2099, e la data viene scritta nell’ora locale della macchina. Per avere byte identici su ogni macchina, costruisci la data a partire da campi locali, come new Date(1980, 0, 1): un timestamp o una stringa che termina con Z indica un istante, che cade in un’ora locale diversa in ogni fuso orario ('1980-01-01T00:00:00Z' a ovest di UTC è ancora 1979). Una data fuori da quegli anni, in ora locale, genera un errore. |
localized | Altre lingue dello stesso libro, per tag di lingua: { es: { chapters?, config?, resources? } }. Gli input qui sopra diventano allora il contenuto di locale, che è obbligatorio. Vedi Pacchetti bilingui. |
Restituisce i bytes dell’archivio, il manifest scritto come preset.json, ogni file come files (percorso → byte) e un elenco di warnings. I file prendono il nome dall’id della loro risorsa (resources/lantern.svg) o dal nome del file del font (fonts/…), e i capitoli dal loro ordine e dal titolo (chapters/01-dusk.md). I font sono dichiarati nei fonts del manifest, mai dentro config.customFonts. Alcune cose vengono lasciate fuori, ciascuna con un avviso:
- una risorsa o un font il cui contenuto non è in
files - un font
.woff(il backend PDF non può incorporarlo) - una famiglia marcata
redistributable: false
Nel browser, passa bytes a un link di download: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })). In Node, scrivili con fs.writeFile. createBundle e openBundle non hanno bisogno di un DOM. Le dist usano percorsi di modulo senza estensione, quindi in Node puro, senza bundler, serve un hook di risoluzione. Il file docs/examples/open-bundle/build-sample.mjs del repository ne mostra uno in poche righe.
#Pacchetti bilingui
Un file .postext può contenere un libro in più lingue, e openBundle(bytes, { locale }) lo legge in una qualsiasi di esse. createBundle ne scrive uno a partire da localized: una voce per ogni lingua aggiuntiva, ciascuna con ciò che differisce dal contenuto principale (l’input locale):
const { bytes, manifest } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters: [{ markdown: '# Dusk\n\n…' }, { markdown: '# Night\n\n…' }],
config,
resources: [lanternFigure, hoursTable],
files: { 'lantern.svg': svgEn, 'lantern-es.svg': svgEs },
localized: {
es: {
chapters: [{ markdown: '# Anochecer\n\n…' }, { markdown: '# Noche\n\n…' }],
config: { headings: { levels: [{ level: 1, numberingTemplate: 'Capítulo {1}' }] } },
resources: [
{ id: 'lantern', caption: 'El farol.', svg: { fileId: 'lantern-es.svg', width: 240, height: 150 } },
{ id: 'hours', caption: 'Horas de luz.' },
],
},
},
});
const es = await openBundle(bytes, { locale: 'es' }); // Spanish chapters, config and captionschapters: il libro in quella lingua. I file dei capitoli vanno in una cartella per lingua (chapters/en/01-dusk.md,chapters/es/01-anochecer.md) e ichaptersdel manifest diventano una mappa lingua → capitoli. Una lingua senzachapterslegge quelli principali; quando nessuna lingua ha capitoli propri, restano un unico elenco.config: la configurazione per quella lingua. Ogni chiave di primo livello sostituisce per intero la chiave condivisa quando il pacchetto viene letto in quella lingua, quindiheadingsqui sopra sostituisce l’intero oggettoheadings. Le chiavi omesse, o uguali a quelle condivise, sono condivise e non vengono scritte, quindi passare la configurazione completa della lingua funziona come passare le poche chiavi che cambiano. Una chiave impostata ai valori predefiniti mentre quella condivisa non lo è (layout: {}) viene scritta così com’è, quindi azzera il valore condiviso. I font sono condivisi: le famiglie deicustomFontsdi una lingua si aggiungono aifontsdel pacchetto.resources: i testi delle risorse condivise, abbinati perid:caption,note,altTexte iltabledi una tabella. Un’immagine che contiene parole può avere una grafica propria:bitmap.fileIdosvg.fileId(esvg.pdfFileId) indicano un altro contenuto infiles, scritto comeresources/es/lantern.svg. Gli altri campi, come il tipo o la collocazione, sono condivisi. Un id che non è tra leresourcesviene lasciato fuori con un avviso, e un’immagine mancante per una lingua lascia quella lingua sull’immagine condivisa, anche in questo caso con un avviso.
Il manifest elenca ogni lingua in locales (['en', 'es']), mantiene quella principale come locale e conserva le altre sotto localized. openBundle senza lingua legge la lingua principale.
Quale lingua riceve chi legge. openBundle(bytes, { locale }) serve la lingua esatta, altrimenti la sua lingua di base (es-MX legge es), altrimenti quella principale, e bundle.locale indica quale ha servito. Capitoli e testi provengono sempre dalla stessa lingua. La lingua principale mantiene i testi condivisi anche quando localized contiene una sua variante regionale: un pacchetto pt-PT con una voce pt-BR legge le didascalie brasiliane solo per pt-BR, e quelle condivise per pt-PT e pt.
#Esempio dal vivo: creare un pacchetto
Il pen costruisce un libro di due capitoli con una figura SVG ed elenca i file che createBundle ha scritto insieme al manifest. Offre l’archivio da scaricare, poi lo riapre con openBundle e ne disegna la prima pagina: il giro completo in poche righe. Importa il file scaricato nella Sandbox per continuare a lavorarci lì.
import { createBundle, openBundle, registerBundleImages, buildBundle, renderPage } from 'https://esm.sh/postext';
// A picture resource names its payload by fileId; the bytes (here, SVG
// markup) go in `files` under that same id.
const lanternSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 150">
<rect width="240" height="150" fill="#f3efe6"/>
<path d="M100 36 h40 l8 14 h-56 z" fill="#2f3e46"/>
<rect x="98" y="50" width="44" height="58" rx="4" fill="#f6c453" stroke="#2f3e46" stroke-width="4"/>
<circle cx="120" cy="79" r="11" fill="#fff4c2"/>
<path d="M94 108 h52 l-6 12 h-40 z" fill="#2f3e46"/>
</svg>`;
const resources = [{
id: 'lantern',
typeId: 'figure',
kind: 'svg',
caption: 'The lantern by the door.',
svg: { fileId: 'lantern.svg', width: 240, height: 150 },
createdAt: 0,
updatedAt: 0,
}];
const text = 'The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.';
// One entry per chapter; a chapter without a title takes its first # heading.
const chapters = [
{ markdown: `# Dusk\n\n${text} It is drawn in :ref{id="lantern"}.\n\n${text}\n\n${text}` },
{ markdown: `# Night\n\n${text}\n\n${text}` },
];
const config = {
layout: { layoutType: 'double' },
// Two short chapters that run on, with no blank verso between them (an
// H1 otherwise opens on a fresh recto), as in the open-bundle sample.
headings: { levels: [{ level: 1, numberingTemplate: 'Chapter {1}', breakBefore: { enabled: false } }] },
};
// Everything a .postext file holds: manifest, chapters, resources, fonts.
const { bytes, manifest, files, warnings } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters,
config,
resources,
files: { 'lantern.svg': lanternSvg },
});
if (warnings.length) console.warn(warnings);
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }));
document.getElementById('download').href = url;
document.getElementById('actions').hidden = false;
document.getElementById('files').replaceChildren(...Object.entries(files).map(([path, data]) => {
const li = document.createElement('li');
li.textContent = `${path} (${data.length} B)`;
return li;
}));
document.getElementById('manifest').textContent = JSON.stringify(manifest, null, 2);
// Round trip: open the file just written, the way any program would.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const bundle = await openBundle(bytes);
await registerBundleImages(bundle);
const [firstChapter] = buildBundle(bundle);
document.getElementById('page').replaceChildren(renderPage(firstChapter.pages[0], firstChapter));
document.getElementById('status').textContent =
`${bundle.name}: ${bundle.chapters.length} chapters, ${(bytes.length / 1024).toFixed(1)} KB`;index.html
<p id="status">Building the bundle…</p>
<p id="actions" hidden>
<a id="download" download="lantern.postext">Download lantern.postext</a> ·
<a href="https://postext.dev/en/sandbox" target="_blank" rel="noopener">open the Sandbox</a> and import it (Projects → New → Import .postext…)
</p>
<div id="output">
<section>
<h3>Files in the bundle</h3>
<ul id="files"></ul>
<h3>preset.json</h3>
<pre id="manifest"></pre>
</section>
<section>
<h3>Opened again: page 1</h3>
<div id="page"></div>
</section>
</div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#output {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
#output section {
flex: 1 1 280px;
min-width: 0;
}
h3 {
margin: 8px 0;
font-size: 14px;
}
ul {
margin: 0;
padding-left: 20px;
font-family: ui-monospace, monospace;
font-size: 13px;
}
pre {
max-height: 320px;
overflow: auto;
padding: 8px;
background: #fff;
font-size: 12px;
}
#page canvas {
display: block;
max-width: 100%;
height: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Carica un editor interattivo da codepen.io. L’esempio importa da una CDN l’ultima versione di postext.
#Lavorare con i pacchetti
Dato che la Sandbox, la skill per agenti e il pacchetto postext leggono e scrivono tutti lo stesso file, un file .postext è un modo comodo per passare un libro da uno strumento all’altro:
- Parti da un pacchetto. Porta una pubblicazione esistente con la skill per agenti, oppure progetta un libro nella Sandbox ed esportalo (Scarica (.postext) nel menu ⋯ della sua riga nel pannello Libri). Carica il file dal tuo programma con
openBundleper disegnarlo su canvas, in HTML o in PDF. Tieni il file come sorgente del libro: modifica i capitoli, la configurazione o le risorse nel codice e riscrivilo concreateBundle, oppure ricaricalo semplicemente ogni volta che cambia. - Correggi e rifinisci nella Sandbox. Quando qualcosa nell’output del tuo programma va sistemato (una figura che finisce sulla pagina sbagliata, uno stile di titolo, il bilanciamento delle colonne), esporta con
createBundleciò che il tuo programma impagina. Importa quel file nella Sandbox (Libri → Nuovo → Apri un file .postext…), correggi il testo, il progetto grafico o le figure con l’anteprima dal vivo, il pannello Verifiche e la vista PDF, poi esportalo di nuovo. Il tuo programma carica allora il file corretto conopenBundle. Oppure riporta le modifiche nel tuo codice: ilconfigdel manifest contiene solo i valori diversi da quelli predefiniti, quindi si legge come un breve diff.
#API di basso livello
postext/bundle esporta anche i mattoni su cui si basano openBundle e createBundle, per le applicazioni ospiti che conservano o servono i pacchetti a modo loro (una cartella decompressa servita via HTTP, record in un database):
openBundleZip(bytes)/zipBundle(files, { mtime }): il livello dell’archivio. L’apertura tollera una cartella di primo livello e ignora le voci__MACOSXe i file nascosti. I percorsi che escono dal pacchetto vengono rifiutati.mtimedata i file come fa l’input dicreateBundle.readBundle(manifest, readFile, options)trasforma un manifest più una callbackreadFile(path)in capitoli, configurazione, risorse, immagini e font.optionsimposta la lingua, come vengono chiamati gli id dei file (ids), la configurazione di base (baseConfig, sotto quella del manifest; per impostazione predefinita la tavolozza e i tipi di risorsa dibundleBaseConfignella lingua del pacchetto, che restituisceresolveBundleConfigLocale(manifest, locale), e un’applicazione ospite che passa il propriobaseConfigdovrebbe localizzarlo in quella lingua; sotto un manifest precedente aconfigVersion: 4il suomathviene vincolato insieme a quello del pacchetto, sotto uno precedente alla 5 la distanza in linea del suolayout, sotto uno precedente alla 6 la distanza nei riquadri del suolayout, lo spazio sotto la riga con i due punti del suobodyText, i segni in linea dei suoiheadingse le dimensioni dei suoi capilettera, sotto uno precedente alla 7 gli a capo dopo le lineette e la divisione in bandiera del suobodyText, e sotto uno precedente alla 8 la divisione sotto un titolo dei suoiheadingse gli a capo nelle parole composte e lo spazio sotto i contenitori:::paragraphsdel suobodyText, vedi Pacchetti scritti da postext 1.4 o precedenti) e come vengono misurate le dimensioni intrinseche.readResolutionlegge dal file la risoluzione di ogni bitmap e la mette inbitmap.fileResolution; vale true per impostazione predefinita quando il pacchetto impostalayout.bitmapResolution: 'file'.planBundle(meta, content)/resolveBundleFiles(plan, sources): il lato della scrittura, diviso in un piano puro (nomi dei file e manifest) e nella risoluzione dei byte tramite le callbackreadBlob/readFont.isBundleManifest(value), i selettori di lingua (pickChapterSpecs,pickLocaleOverrides,pickBundleView,resolveBundleLocale,resolveBundleConfigLocale),svgSize/bitmapSize/bitmapInfo(i pixel di una bitmap e la risoluzione che il suo file dichiara) e i tipi del formato (BundleManifest,BundleResourceSpec,BundleFontFamilySpec, …).CONFIG_VERSION,migrateConfig(config, configVersion, { content }),pinLegacyHeadingBreaks(config),pinLegacyMathSize(config),pinLegacyInlineGap(config),pinLegacyBoxResourceGap(config),pinLegacyHeadingMarks(config),pinLegacyDropCapSize(config),pinLegacyColonListRoom(config),pinLegacyBoxChildCut(config),pinLegacyDashBreaks(config),pinLegacyRaggedBreaking(config),pinLegacyHeadingSplit(config),pinLegacyParagraphContainerSpacing(config),pinLegacyHyphenBreaks(config)eLEGACY_MATH_SIZE(0,5 ÷ 0,442): una configurazione salvata nei termini attuali (vedi Pacchetti scritti da postext 1.4 o precedenti).readBundlela applica; anche un’applicazione ospite che conserva le configurazioni a modo suo può farlo, una volta per ogni copia salvata.
La Sandbox è costruita su queste funzioni. Vi aggiunge i propri id di archiviazione e i record di pagine layouts.json.