Pages, headers and footers
Page sizes and margins, header and footer regions that reserve space, page templates, print hints, monochrome mode, ZPL for label printers and debug outlines.
Page size and margins#
page size: :a4, margin: [40, 44, 56, 44], layout: :landscape| Size | Points (portrait) |
|---|---|
:a3 | 841.89 × 1190.55 |
:a4 | 595.28 × 841.89 |
:a5 | 419.53 × 595.28 |
:a6 | 297.64 × 419.53 |
:a7 | 209.76 × 297.64 |
:b5 | 498.9 × 708.66 |
:letter (default) | 612 × 792 |
:legal | 612 × 1008 |
:tabloid | 792 × 1224 |
:dl (envelope, 110 × 220 mm) | 311.81 × 623.62 |
:c5 (envelope, 162 × 229 mm) | 459.21 × 649.13 |
:c6 (envelope, 114 × 162 mm) | 323.15 × 459.21 |
:label_4x6 (4 × 6 in) | 288 × 432 |
:label_4x3 | 288 × 216 |
:label_4x2 | 288 × 144 |
:label_100x150 (100 × 150 mm) | 283.46 × 425.2 |
:label_100x50 | 283.46 × 141.73 |
[width, height] | any size, in points |
["102mm", "74mm"], "4in x 6in" | any size, with its units |
layout: :landscape swaps width and height: envelopes are listed short edge first, so it is the side the address is on. margin: (default 36) takes one value, [y, x], [top, x, bottom], [top, right, bottom, left] or { top:, right:, bottom:, left:, x:, y: }, each in points or as a length with its unit ("3mm").
A size or a margin that cannot be read raises ArgumentError when the class is defined, with what page takes.
One page only: max_pages#
For a label, a receipt or a card.
Content that does not fit a page continues on the next, as a letter or a report should. A label, a receipt or a card must not, and max_pages says so:
class ShippingLabel < Stationery::Document
page size: :label_4x6, margin: mm(4)
max_pages 1
end
label = ShippingLabel.new
label.to_pdf
label.warnings.map(&:message)
# => ["the document may have 1 page and needs 2: page 2 starts with a Code 128 barcode"]
label.to_pdf(max_pages: nil) # one render without the limitA render that needs more pages than the limit lays them all out and reports Warnings::TooManyPages (limit, pages, moved), naming what the first page past the limit starts with: text by its first line, quoted and cut at 40 characters, anything else by its kind (a table, an image, a QR code, a box). Under strict it raises.
| Render | More pages than max_pages |
|---|---|
to_pdf | every page written, the warning reported (strict raises) |
to_png | a picture of every page, the warning reported (strict raises) |
to_zpl | raises Stationery::WarningsError, strict or not: no label is written, since each page is one more label printed |
to_pdf(max_pages:), to_png(max_pages:) and to_zpl(max_pages:) replace the limit for one render, nil takes it away. It is inherited; max_pages nil in a subclass takes it away, and a subclass's page keeps it. The declaration writes nothing: a document that fits is byte for byte what it is without it. Anything but a positive Integer or nil raises ArgumentError where it is written.
Millimetres and inches#
Units for labels, envelopes and pre-printed forms.
Every size and position is in points (1/72 inch). mm, cm, inch and pt convert to them, in the class body and in view_template of every component and document:
class Label < Stationery::Document
page size: [mm(102), mm(74)], margin: mm(3)
def view_template
box(at: [mm(20), mm(45)], width: mm(60)) { text "Fragile" }
end
endThey are private methods, Numeric is not patched, and a method of your own with one of the names wins. Any other class gets them with include Stationery::Units, or calls Stationery::Units.mm(102).
page also reads lengths written with their unit:
page size: ["102mm", "74mm"], margin: "3mm"
page size: "4in x 6in", margin: ["0.25in", "0.5in"]
page size: "102 x 74 mm" # the first length takes the unit of the second| Part | Accepted |
|---|---|
| Number | digits, with a fraction after a point: 102, 101.6. No sign, exponent or comma |
| Unit | mm, cm, in, pt, in either case |
| Between the two lengths of a size | x or × |
| Spaces | around numbers, units and the x, or none: "4inx6in" |
Stationery::Units.points("3mm") reads one length. Unit strings are read by page (and Stationery::Page.new); elements take points, so write padding: mm(3) there.
page_template#
Runs on every page after pagination.
page_template do |page|
box(at: [page.margin[3], page.height - 34], width: page.content_box.width) do
text "Page #{page.number} of #{page.count}", size: 7, align: :right
end
end
page_template(layer: :background) do |page|
canvas(height: page.height, at: [0, 0], width: page.width) do |c, rect|
c.fill_rect(rect.x, rect.y, rect.width, rect.height, color: "#FFFBEB")
end
endThe block receives page.number, page.count, page.width, page.height, page.margin ([top, right, bottom, left]) and page.content_box (x, y, width, height). With a header or footer, content_box excludes their space — it is the body's area.
Layers#
Foreground and background.
page_template paints on the foreground by default, over the body. layer: :background paints under the content — full-bleed backgrounds, watermarks, letterheads. Use box(outset:) to bleed a single box's background into the margins instead.
Anchors drawn by page templates resolve to the first page; bookmarks inside page templates are ignored. Warnings raised while templates draw are included in document.warnings.
Page labels#
How viewers name the pages.
page_labels maps a 1-based first page to the range that starts there. Each range takes style: (:decimal, :roman, :roman_lower, :alpha, :alpha_lower, or nil for a prefix only), start: (the number the range counts from, default 1) and prefix:. Pages before the first range keep their physical number in the viewer.
class Report < Stationery::Document
page_labels 1 => { style: :roman_lower }, # i, ii, iii
4 => { style: :decimal }, # 1, 2, 3, …
12 => { style: :alpha, prefix: "Appendix " } # Appendix A, Appendix B
end
Report.new.to_pdf(page_labels: nil) # one render without labelsThe labels are written as the catalog's /PageLabels number tree; Inspector#page_labels and have_page_labels read them back per page.
Printing#
Scaling, copies, duplex and the print dialog: hints to the viewer.
A PDF can say how it wants to be printed. For a page that is the medium itself (a label, a card, a pre-printed form) the defaults of a print dialog are the wrong ones: "fit to page" prints a 100 × 60 mm label at 94 %.
class ShelfLabel < Stationery::Document
page size: :label_100x50, margin: "3mm"
print scaling: :none, copies: 2, pick_tray_by_size: true, duplex: :simplex
end
ShelfLabel.new.to_pdf(print: { copies: 1 }) # laid over the hints of the class
ShelfLabel.new.to_pdf(print: { duplex: nil }) # without one of them
ShelfLabel.new.to_pdf(print: nil) # without any (false does the same)| Option | Values | Written as |
|---|---|---|
scaling: | :none, :default | /PrintScaling /None, /AppDefault |
copies: | an Integer of 1 or more | /NumCopies |
pick_tray_by_size: | true, false | /PickTrayByPDFSize |
duplex: | :simplex, :long_edge, :short_edge | /Duplex /Simplex, /DuplexFlipLongEdge, /DuplexFlipShortEdge |
pages: | a Range of page numbers from 1, or a list of them: 1..3, [1..1, 3..4], 2.. | /PrintPageRange |
dialog: | :on_open | /OpenAction << /S /Named /N /Print >> |
- The first five are entries of the catalog's
/ViewerPreferences(ISO 32000-1, 12.2, table 150), beside the/DisplayDocTitleof a tagged document.dialog: :on_openasks the viewer to open its print dialog when the file is opened, aswindow.print()does for a page, without JavaScript. printat class level adds to what the class inherits, andto_pdf(print:)to what the class declares. Aniltakes a hint away (print copies: nilin a subclass).- Every option is checked where it is written, the class body or the call of
to_pdf, and raisesArgumentErrornaming what it takes:print scaling: is :none or :default, not :fit. Ranges that overlap are refused. pages:counts from 1, as the file does, and is written in page order. How many pages there are is known when the document is rendered: a range is cut at the last page (2..and2..99both end there) and one that starts after it is left out, because a viewer drops a range that names a page the document does not have./ViewerPreferencesis allowed under PDF/A and PDF/UA. The print dialog is not an action PDF/A has:dialog: :on_openwithconformance :pdf_a2bor:pdf_a3braisesStationery::ConformanceError(ISO 19005-2/3, 6.5.1; veraPDF rule 6.5.1-2). PDF/UA-1 alone takes it.- The hints are written by every render:
incremental:, signed and encrypted ones too. printin a class body is this declaration. Insideview_templateand every other method of a document it is stillKernel#print.Inspector#print_preferencesreads them back asprinttakes them,pagesas a list of ranges, andhave_print_preference(scaling: :none)andassert_print_preferenceassert them.
They are hints: a viewer follows the ones it knows, and whoever prints can overrule them in the dialog. The table says what each viewer does and how that is known. Nothing in it was tried in a running viewer: "documented" is what the vendor's PDF reference says, "source" what the viewer's source code did when it was read on 2026-09-28, "reported" what a user wrote in the issue named.
| Viewer | scaling: | copies: | duplex: | pages: | pick_tray_by_size: | dialog: |
|---|---|---|---|---|---|---|
| Acrobat, Acrobat Reader | sets the dialog (documented) | sets the dialog, 2 to 5 (documented) | sets the dialog (documented) | sets the dialog (documented) | sets the dialog (documented) | opens the dialog (reported, pdf.js 11442) |
| Chrome, Edge (PDFium) | preset of the print preview (source) | preset of the print preview (source) | preset of the print preview (source) | not read (source) | not read (source) | starts printing (source) |
| Firefox (pdf.js) | read, not used by the viewer (source; bug 1243580) | read, not used (source) | read, not used (source) | read, not used (source) | read, not used (source) | starts printing (source) |
| Preview (macOS) | not tested | not tested | not tested | not tested | not tested | not tested |
| Evince, Okular (poppler) | not tested | not tested | not tested | not tested | not tested | not tested |
Monochrome#
Black or nothing: thermal label printers and other one-bit devices.
A thermal label printer (203 or 300 dpi), a receipt printer or an e-paper display prints black or nothing. Whatever else the PDF holds, the driver or the printer turns into dots of its own choosing: grey text comes out speckled, a light rule or background vanishes or turns into a dot pattern, and a line thinner than one dot prints or not depending on where it lands. monochrome makes those choices where they can be seen, before anything is printed:
class ShelfLabel < Stationery::Document
page size: [mm(100), mm(60)], margin: mm(4)
monochrome dpi: 203 # report what a 203 dpi printer cannot print as it is
end
ShelfLabel.new.to_pdf(monochrome: { snap: true }) # change it instead, for this render
ShelfLabel.new.to_pdf(monochrome: false) # as it was, byte for byte| Option | Default | What it does |
|---|---|---|
dpi: | 203 | the printer's dots per inch: one dot is 72/dpi pt (0.355 pt at 203 dpi, 0.24 pt at 300) |
snap: | false | change colours and thin lines instead of reporting them |
threshold: | 0.5 | under snap:, a fill whose tone is darker is painted black, a lighter one is not painted |
dither: | :floyd_steinberg | how a bitmap becomes dots: :floyd_steinberg, :ordered (an 8 × 8 Bayer pattern) or :threshold (cut at half) |
- Colours. Every colour that is not black or white, and anything painted at an
opacity:below 1, is aWarnings::NotMonochromenaming the colour and what painted it:text,a rule(arule, an underline, a strikethrough: a filled rectangle 3 pt thick or less),a background(any other fill),a border or line(any stroke),a gradientoran image, once per colour, kind and page:text in #888888 on page 1 is not black or white.strictraises on them. It sees whatever is painted, by an element, an SVG or acanvas { }block alike. snap: true. Text, rules, borders and lines are painted black, except white ones, which stay white (white text on a black box). A fill is painted black when its tone is darker thanthreshold:and left out when it is lighter; the tone is the colour's luma (ITU-R BT.601), an opacity laid over white paper (black at 0.3 is a tone of 0.7). A gradient is black or left out by the average tone of its stops. Opacity is taken away. Nothing is reported.- Line widths. A stroke or a rule thinner than a dot (
width × the scale of a transform) is aWarnings::ThinLineand is left as it is; withsnap: trueit is widened to a dot. - The dot grid. Outside any transform, a filled rectangle (a rule, a background, an underline) and a stroke made of horizontal and vertical lines (a border, a table's cell borders, a line) are put on the printer's grid, counted from the top left corner of the page: their edges on whole dots and their widths whole numbers of dots, so a hairline is as wide on every label. A curve, a rounded corner, a slanted line and anything under a transform keeps its geometry.
- Images. A PNG, a lossless WebP or a JPEG is greyed (a transparent pixel is white paper), resampled to the dots it covers at
dpi:and dithered, and embedded as a one-bit DeviceGray image (/BitsPerComponent 1): what prints is the pattern chosen here, not the driver's. A JPEG is decoded for it in Ruby, no larger than the dots need; one of a kind that is not decoded (lossless, arithmetic-coded, hierarchical, 12-bit) is embedded as it is and reported (an image in JPEG 640x480). monochromeat class level is inherited and adds to what the class inherits;monochrome falsetakes it away.to_pdf(monochrome:)takestrue,falseor options laid over the class's.- Monochrome renders keep PDF/A and PDF/UA: the one-bit image is DeviceGray, which the sRGB output intent covers (checked with veraPDF, PDF/A-3b and PDF/UA-1).
Inspector#colorslists what a PDF paints with, so a spec can hold it:expect(pdf).to have_pdf_colors("#000000").
What it does not do: it does not dither a lossless, arithmetic-coded, hierarchical or 12-bit JPEG, does not look at form fields (their widget draws them, not the page), does not put dashes, line caps or curves on the grid, and does not make small text bolder. It changes nothing without monochrome. The rules live in Stationery::Monochrome::Rules and Monochrome::Grid, apart from the PDF canvas, and to_png and to_zpl apply the same ones (see below).
Label printers: to_zpl#
ZPL II for Zebra and compatible thermal printers.
A thermal label printer (Zebra, and the many makes that emulate ZPL II) is driven in its own language, not PDF. to_zpl writes the document as ZPL: each page is drawn one bit to a dot, as to_png(monochrome: …) draws it, and sent as one graphic field, so the label is the layout to_pdf produces, in any font the document uses. One document class serves the preview on screen and the printer.
class ShippingLabel < Stationery::Document
page size: "4in x 6in", margin: mm(4)
max_pages 1 # a second page raises instead of printing a second label
monochrome dpi: 203
end
label = ShippingLabel.new(parcel)
label.to_zpl # => "^XA^PW812^LL1218^LH0,0^FO0,0^GFA,…^XZ\n", one label per page
label.to_zpl("label.zpl", dpi: 300, copies: 2)
label.to_zpl(compression: :hex, pages: 1) # plain hex, for printers that do not take Z64How the ZPL reaches the printer is the application's business: a socket to port 9100, a print server, a browser print agent. For example, over the network:
require "socket"
TCPSocket.open("printer.local", 9100) { |socket| label.to_zpl(socket) }- What is written. Per page
^XA,^PWand^LL(the page's width and length in dots,Raster.pixels(points, dpi)),^LH0,0,^FO0,0, one^GFAgraphic field,^FS,^PQ(copies) and^XZ, then a newline. The field's dots are 1 for black, each row padded to a byte with white.compression: :z64(the default) deflates the rows with zlib and writes them in Base64, followed by the CRC Zebra's software writes (CRC-16/XMODEM of the Base64 text, checked against a ZebraDesigner print file);:hexwrites them as plain hex, about 20 times larger. - Always monochrome. A label printer prints one bit, so
to_zplrenders with the class'smonochromesettings when it declares them and with the defaults when it does not (snap: false,dither: :floyd_steinberg): a colour is reported asWarnings::NotMonochromeand dithered, asto_png(monochrome: true)shows it.monochrome:takes options laid over them ({ snap: true });monochrome: falseraises. dpi:is the printer's: 152, 203, 300 or 600 (6, 8, 12 or 24 dots a millimetre); anything else raisesArgumentErrornaming them. It defaults to the class'smonochrome dpi:, else 203, and replaces it when given, soto_zpl(dpi: 300)andto_png(monochrome: { dpi: 300 })are the same dots.copies:is^PQ, by default the class'sprint copies:, else 1.pages:a page number, a Range or an Array, in the order given;targeta path or anything answeringwrite(a socket).- One label. Content that does not fit the label goes to a next page, and every page is a label. Declare
max_pages 1(see Pages) and a document that needs a second page writes no ZPL:to_zplraisesStationery::WarningsErrorwithWarnings::TooManyPages, strict or not, because the harm of a second label is done when it prints, where no one reads a warning.to_zpl(max_pages: nil)writes them all;to_pdfandto_pngof the same document keep every page and warn, so the page that spilled can be looked at. strict:,debug:,shaper:andmax_pages:are those ofto_pdf; what only a PDF has raises, as forto_png.- Speed. A 4 × 6 in label takes about 25 ms at 203 dpi and 35 ms at 300 dpi (Ruby 3.4, Apple M-series), and is 10 to 20 KB of ZPL.
- The labels were read back and compared with
to_pngdot for dot, and rendered by Labelary, a ZPL viewer, identically. They were not printed on a printer by the gem's specs.
Barcodes the printer draws. A barcode is part of the picture unless it asks otherwise: barcode "SX0042771903", native: true, or to_zpl(native: true) for every barcode that does not say native: false. Such a barcode is left out of the graphic field and written after it as the printer's own command, where the picture had it, so the printer puts the bars on its own dot grid:
^FO146,874^BY4^BCN,124,N,N,N,N^FD>:SX>50042771903^FS Code 128, its code sets named
^FO65,197^BY3^BEN,113,N,N^FD400638133393^FS EAN-13, the printer adds the check digit
^FO650,625^BQN,2,4^FDMM,B0034https://track.example/SX0042771903^FS QR code, byte mode, level M
^FO42,42^BXN,8,200,14,14,6,_,1^FDSX0042771903^FS Data Matrix (ECC 200), 14 × 14^BYis the module in whole dots, the height in dots, and data ZPL would read as a command is escaped with^FH. A QR code's^FOis 10 dots above it, since^BQdraws that far below its origin. A Data Matrix is asked for at its own size, so it takes the same square.- It stays in the picture when the printer could not draw it as it is: rotated or otherwise transformed, clipped, lighter than half grey, a module over 10 dots, a QR code of UTF-8 text (its ECI has no field in
^BQ), or a Data Matrix holding_(the escape character^BXis given). - Rendered by Labelary, the Code 128 is the same dots as
to_pngdraws; the EAN-13's guard bars come out 13 dots longer, as the printer draws them; a QR code covers the same modules' square, its modules chosen by the printer's own encoder (another mask); a Data Matrix of digits and capitals is the same dots, one of other text another encodation in the same square. All decode (ZBar, dmtxread) to the data. - Rules and boxes are not written as
^GB: a box drawn natively would print over white text on it, which the picture keeps.
stationery render label.rb --zpl --dpi 300 writes label.zpl (see CLI).
Embedded files#
Attachments, for Factur-X and friends.
attach_file name, data, mime:, description:, relationship:, modified_at: embeds a file in every render; to_pdf(attachments: [{ name:, data:, … }]) adds more for one render. Each file is a /Filespec with an /EmbeddedFile stream, listed in the catalog's /EmbeddedFiles name tree and /AF array. relationship: writes /AFRelationship: :alternative (the machine-readable twin Factur-X and ZUGFeRD require), :source, :data, :supplement or :unspecified (default). The same name twice raises ArgumentError; an encrypted document encrypts the embedded streams too.
class InvoicePdf < Stationery::Document
attach_file "factur-x.xml", invoice_xml, mime: "text/xml", description: "Factur-X",
relationship: :alternative
endInspector#attachments reads them back as { name:, mime:, bytes:, description:, relationship: }; have_attachment and assert_pdf_attachment assert one by name, type and relationship.
For an e-invoice, factur_x does all of it in one line: it embeds the XML, claims PDF/A-3b and writes the Factur-X identification in XMP.
Accessibility (tagged PDF)#
A structure tree for screen readers.
class ReportPdf < Stationery::Document
tagged # or to_pdf(tagged: true) for one render
metadata title: "Q3 report", lang: "en-US"
def view_template
text "Quarterly report", size: 20, heading: 1
text "Revenue grew in every region."
image "chart.png", width: 300, alt: "Revenue by region, Q1 to Q3"
box(role: :note, padding: 8) { text "Figures are unaudited." }
end
endA tagged PDF carries a structure tree screen readers and reflowing viewers follow, in paint order and across page breaks: a paragraph continued on the next page stays one P.
textis aP;heading: 1..6makes itH1–H6.image/svgare aFigurewith/Altfromalt:and a bounding box;alt: falsemarks one decorative (an artifact, not announced).box(role:)groups its content::section(Sect),:div,:blockquote,:note,:caption,:article(Art),:part. Boxes without a role, rows, columns, groups and wraps add no element.- Lists are
L(with/ListNumberingfrom the bullet shape or number format) >LI>Lbl(a text marker; drawn bullets are artifacts) andLBody. - Tables are
Table>TR>TH(header rows,/Scope /Column) orTD, with/ColSpanand/RowSpan. A table split across pages stays oneTable; its repeated header rows are artifacts. - Linked text is a
Linkinside its paragraph holding the link annotation (/OBJR,/StructParent);box(link:)groups its content in aLink. table_of_contentsisTOC>TOCI>Link(the title and its annotation) andReference(the page number).html/markdowntag headingsH1–H6(with or withoutbookmarks: true) and block quotesBlockQuote, and give images theiralt. Inhtml,<img alt="">is decoration asalt: falseis (an<img>without the attribute is a description missing). Markdown has no way to say so:is a description missing, and decoration goes throughhtmlorimage.- Headers, footers and page templates are pagination artifacts, but for their links: a
link:one paints (text(link:), markup,html,markdown,box(link:)) is aLinkof theDocumentholding its text and its annotation, read after the content of its page, in the order header, footer, page templates. The rest of the region stays an artifact. Backgrounds, borders and rules drawn outside any element are layout artifacts. metadata lang:writes the catalog's/Lang; the title is shown instead of the file name.- Every document carries an XMP packet (
/Metadata, uncompressed) mirroring the Info dictionary:dc:title,dc:creator,dc:description,dc:subject,dc:language, thexmp:dates andpdf:Producer. PDF/A and PDF/UA identification lives there (see PDF/A and PDF/UA);metadata xmp: falseorto_pdf(xmp: false)leaves it out. - An image or drawing without
alt:, or with a blank one (alt: "", whitespace alone, which veraPDF would accept), is aWarnings::MissingAlt;alt: falseis how decoration is marked. - A heading level that is skipped is a
Warnings::SkippedHeading(level,allowed,page): the first heading isheading: 1and a heading is at most one level below the heading before it (1, then3skips2). Going back up is free (3, then1). Headings are read in the order of the structure tree, inside sections, lists, table cells, columns and floats; those of headers, footers and page templates are artifacts and do not count. - A link annotation that belongs to no
Linkelement is aWarnings::UntaggedLink(target,place,page): a link in the header row a table repeats on its next pages, which is an artifact (placeis:artifact), andcanvas.linkwithout atag:(:canvasin the body,:header,:footeror:page_templateon the canvas of one). Everylink:is tagged, in the body and in headers, footers and page templates; on a canvas of the body, pass theLinkelement that holds what the link draws:canvas.link(x, y, w, h, url, tag: element)aftercanvas.tag(element) { … }. - A missing
langis aWarnings::MissingLanguage. All four are warnings, sostrictcatches them, andconformance :pdf_ua1raises on the first three. - A tagged render packs its structure elements, and every other object that is not a stream, into deflated object streams (
/Type /ObjStm, 200 objects each) with a cross-reference stream in place of the table and trailer (ISO 32000-1, 7.5.7 and 7.5.8). A table of 300 rows is 115 KB instead of 501 KB. Streams, the/Encryptdictionary and a signature dictionary stay out; PDF/A-2, PDF/A-3 and PDF/UA-1 allow them.object_streams falseat class level, orto_pdf(object_streams: false), writes the classic table for a reader that cannot read them;to_pdf(object_streams: true)packs an untagged render too. - Untagged documents (the default) are written exactly as before.
Check the tree in tests with have_structure and have_tagged_content (see Testing):
expect(ReportPdf.new).to have_tagged_content # tagged, and no text outside marked content
expect(ReportPdf.new).to have_structure(
[[:Document, [[:H1, "Quarterly report"], [:P, "Revenue grew in every region."],
[:Figure, "Revenue by region, Q1 to Q3"], [:Note, [[:P, "Figures are unaudited."]]]]]]
)tagged alone does not label the file; conformance :pdf_ua1 does (see PDF/A and PDF/UA). Nothing checks colour contrast or the reading order you build out of positioned boxes (box(at:) joins the reading order where it paints).
Debug outlines#
See every layout rectangle.
to_pdf(debug: true) outlines every layout rectangle on top of the content: boxes (red, padding dashed), row columns (blue), flow slots (grey, dotted), table cells (green, padding dashed), positioned boxes (pink), images (teal) and the page content box (cyan). Pass an Array to outline only some kinds:
Invoice.new.to_pdf("invoice.pdf", debug: %i[cell cell_padding])