Live preview
The same document can be painted as HTML for a page instead of as a PDF. Layout runs once, with PDFKit's measurements, so what the page shows is where the PDF puts things: the same lines, the same breaks, the same pages.
import { renderHtml } from "@grandbusta/spyde";
const html = renderHtml(invoiceDocument(data)); // a fragment: one <style>, one <div> per pageA small example
Two texts on a grey background, on a 220 by 110 point page:
const doc = column([
text("Invoice #1042", { font: "Helvetica-Bold", size: 14, field: "number" }),
text("Thanks for your business.", { color: "#666666" }),
], { gap: 4, padding: 8, background: "#f2f2f2" });
renderHtml(doc, { size: [220, 110], margins: 10, pretty: true });This is the exact output, 1.1 KB. pretty adds the indentation; without it the same markup comes back on fewer lines.
<style>
.spyde-page{position:relative;overflow:hidden;background:#fff}
.spyde-text{position:absolute;overflow:hidden;white-space:nowrap}
.spyde-rect,.spyde-image,.spyde-clip{position:absolute}
.spyde-clip{overflow:hidden}
.spyde-image{display:block}
.spyde-s1{font-family:Helvetica, Arial, sans-serif;font-weight:bold;font-style:normal;font-size:14pt;line-height:16.8pt;color:#000000;text-align:left}
.spyde-s2{font-family:Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;font-size:12pt;line-height:14.4pt;color:#666666;text-align:left}
</style>
<div class="spyde-page" data-page="1" style="width:220pt;height:110pt">
<div class="spyde-clip" style="left:10pt;top:10pt;width:200pt;height:90pt">
<div class="spyde-rect" style="left:0pt;top:0pt;width:200pt;height:51.2pt;background:#f2f2f2"></div>
<div class="spyde-text spyde-s1" data-field="number" style="left:8pt;top:8pt;width:184pt;height:16.8pt">
<div>Invoice #1042</div>
</div>
<div class="spyde-text spyde-s2" style="left:8pt;top:28.8pt;width:184pt;height:14.4pt">
<div>Thanks for your business.</div>
</div>
</div>
</div>Reading it: one style class per distinct text style, the page, the margin clip, the background as a rect, then each text with its lines. Every number is a point from the layout pass. The first text carries data-field="number" because its style set field.
The fragment
- One
<style>block first, with the fixed rules and one generated class per distinct text style in the document. Omit it withstylesheet: "none". - Then one positioned
<div>per page. Every element carries its class names and inline geometry in points, one to one with the layout. - A text is a box with one block per line as PDFKit wrapped it, with wrapping off. The browser never re-wraps.
- Every value is escaped. The fragment contains no script. Drop it into any page with
innerHTML. - Images are data URIs by default, or whatever
imageSrc(src)returns. Map them to URLs in a real app so the browser can cache them. pretty: trueindents the output for reading.classPrefixchanges thespyde-prefix.
What matches the PDF: lines, wrapping widths, alignment, page breaks, every box position. What does not: glyph shapes, which come from the browser's fonts. Register a font file to have both draw identical glyphs.
Fields
Mark the texts that show a form's fields:
text(invoice.number, { field: "number" })The HTML carries data-field="number" on that element, so a page can find the element that shows a given value. The PDF ignores it.
examples/preview in the repository is a complete node:http server and page that renders a form's data as HTML on one route and as a PDF on another. npm run preview runs it.
The layout as data
renderDisplayList(tree, options) returns the same layout as data: pages of text, rect, image, and clip ops with absolute positions. It is what the HTML painter consumes, and it is public for other painters and for tests that want to inspect a layout without producing a PDF.