Testing
RSpec matchers and Minitest assertions that read a rendered PDF back, built on Stationery::Testing::Inspector.
Matchers and assertions#
From the README.
stationery/rspec and stationery/minitest read a rendered PDF back for assertions. They need the pdf-reader gem, which stationery itself does not depend on:
Reference#
| RSpec | Minitest | Passes | when |
|---|---|---|---|
have_pdf_text(str_or_regexp, fields: false) | assert_pdf_text / refute_pdf_text | the text of all pages contains it (with fields: true, what the form fields show is part of the text) | |
have_pdf_text_on_page(n, str_or_regexp, fields: false) | — | page n (1-based) contains it | |
have_page_count(n) | assert_page_count | the PDF has n pages | |
have_pdf_link(url_or_regexp) | assert_pdf_link | a URI link annotation matches | |
have_image_count(n) | assert_image_count | n image XObjects are embedded | |
have_bookmark(title) | assert_bookmark | an outline entry has that title | |
have_pdf_language(lang) | assert_pdf_language | the catalog /Lang (from metadata lang:) equals it | |
have_page_labels(labels) | assert_page_labels | the /PageLabels tree names the pages exactly so (nil for a page before the first range) | |
have_print_preference(**hints) | assert_print_preference | the catalog has every print hint given, as print takes them (nil for a hint it must not have) | |
have_pdf_colors(*colors) | assert_pdf_colors | Inspector#colors is exactly those colours, in any order ("#000000" for a monochrome render) | |
have_attachment(name, mime:, relationship:) | assert_pdf_attachment | an embedded file has that name (and type / relationship when given) | |
have_conformance(*levels) | assert_pdf_conformance | the XMP packet claims every level (:pdf_a2b, :pdf_a3b, :pdf_ua1) | |
have_factur_x(profile:) | assert_factur_x | the XMP packet names a Factur-X invoice and its XML is embedded (of that profile when given) | |
have_signature(name:, valid:) | assert_pdf_signature | a signature covers the whole file and verifies against its certificate (by that signer when given; valid: false for one that does not) | |
have_no_warnings | assert_no_pdf_warnings | the render produced no warnings (documents only) |
Failure messages show what was found — an excerpt of the text, the links, the bookmarks or every warning message — so a failing spec explains itself.
Pictures of a render#
to_png: a PNG of each page, to look at or to compare. From the README.
to_png paints the pages to_pdf lays out on pixels instead, in Ruby, with nothing to install: a preview, a picture to look at in a spec or a pull request, or what a one-bit printer will print.
pngs = Invoice.new(invoice).to_png # => ["\x89PNG…", …], one String per page, 96 dpi
Invoice.new(invoice).to_png("invoice.png") # one page: invoice.png; several: invoice-1.png, invoice-2.png, …
Invoice.new(invoice).to_png(dpi: 144, pages: 1) # pages: a number, a Range or an Array of them
ShelfLabel.new.to_png(monochrome: { dpi: 203, snap: true }) # one bit to a dot, 1-bit PNG- What is drawn: text from the outlines of its glyphs (TrueType, CFF, WOFF; synthetic bold and oblique, letter spacing, rise, shaped runs), fills and strokes with their caps, joins (miter limit 10) and dashes, even-odd and nonzero fills, clips, transforms and rotations, opacity, SVG linear and radial gradients, PNG, WebP and JPEG images, headers, footers, page templates (a background layer under the page) and page numbers. A colour picture is 24-bit RGB on white, anti-aliased; a text's origin, a stroke's horizontal and vertical edges and an upright image's edges are put on the pixel grid as poppler puts them, so it is close to
pdftoppm -r <dpi>of the same PDF: the examples differ in 0.0 to 0.5% of their pixels at 72 dpi, in the hinting of a few glyphs and the edges of images. - What is not: a lossless, arithmetic-coded, hierarchical or 12-bit JPEG, which is not decoded, is drawn as a grey box with a cross and reported (
Warnings::SkippedImage); form fields draw nothing (their widget is the PDF viewer's); links, bookmarks and tagging are not visible anyway. - Monochrome.
monochrome:takes whatto_pdftakes, and the class'smonochromeapplies unlessmonochrome: falseis given. The picture is drawn at the monochromedpi:(adpi:given toto_pngreplaces it), a pixel in or out by its centre with no anti-aliasing, and written as a 1-bit PNG. The rules are those of the PDF (colours reported or snapped, thin lines, the dot grid, images dithered at the dots they cover); a page with grey left on it (a colour reported and kept withoutsnap:) is dithered as images are. An upright image is drawn dot for dot as the one-bit image the PDF embeds, with its corner on the nearest dot: the PDF leaves the image's box where the layout put it, and its size in dots is the image's own, where poppler stretches it by a dot when both edges of the box round outwards. Black and white matchpdftoppm -monoin all but 0.1 to 2% of the dots; dithered areas differ dot by dot, since poppler halftones where stationery dithers. - Options.
dpi:(96, or the monochrome dpi),pages:,monochrome:,debug:(the layout rectangles),strict:,shaper:andmax_pages:asto_pdfhas them. What only a PDF has (sign:,encrypt:,conformance:,attachments:,print:,tagged:,page_labels:,xmp:,factur_x:,incremental:,missing_glyphs:,object_streams:) raisesArgumentErrorwhen it is passed, and is left alone when the class declares it, so a signed or encrypted document still has pictures. - Speed. A 100 × 150 mm label at 203 dpi (800 × 1200 dots) takes about 30 ms, a one-page invoice at 96 dpi about 0.1 s, a page of dithered photographs at 203 dpi about 0.7 s, a label with a 12-megapixel JPEG photo about 1 s (Ruby 3.4 with YJIT, Apple M-series). Each page is kept as the list of what it was asked to draw until every page is painted, then drawn and encoded one at a time, so a long document does not hold a bitmap per page.
Stationery::Raster has the pieces: Raster::Canvases and Raster::Canvas record, Raster::Painter replays onto a Raster::Surface through Raster::Scanner (coverage), Raster::Stroker and Raster::Flattener, and Raster::PNG writes the file.
Inspector#
For anything the matchers do not cover.
pdf = Stationery::Testing::Inspector.new(InvoicePdf.new(invoice))
pdf.page_count # => 2
pdf.page_texts # => ["Invoice INV-7 …", "…"]
pdf.text # all pages, joined with newlines
pdf.text(fields: true) # with what the form fields show, as page_texts(fields: true)
pdf.links # => ["mailto:[email protected]"]
pdf.internal_links # named destinations the document links to
pdf.image_count # => 1
pdf.bookmarks # => ["Introduction", "Highlights", …]
pdf.metadata # => { Title: "Invoice", … }
pdf.xmp # the XMP packet, or nil with metadata xmp: false
pdf.xmp_values # => { "dc:title" => "Invoice", "dc:creator" => ["Acme"], "xmp:CreateDate" => "…", … }
pdf.lang # => "en", the catalog /Lang, or nil
pdf.page_labels # => ["i", "ii", "1", "2"] from page_labels, [] without
pdf.print_preferences # => { scaling: :none, copies: 2, pages: [1..3] } from print, {} without
pdf.colors # => ["#000000", "#FF0000", "image"], what the pages fill and stroke with
pdf.attachments # => [{ name: "factur-x.xml", mime: "text/xml", bytes: "<…>", … }]
pdf.conformance # => [:pdf_a3b, :pdf_ua1], the levels claimed in XMP
pdf.factur_x # => { profile: :en16931, filename: "factur-x.xml", version: "1.0", xml: "<?xml …" } or nil
pdf.signatures # => [{ field: "approval", name: "Acme Legal", signer: "CN=…", signed_at: …, valid: true,
# timestamp: { time: …, tsa: "CN=…", valid: true }, … }]
pdf.warnings # the document's warnings after rendering it
pdf.layout # => { metadata:, outline:, structure:, warnings:, pages: [{ number:, width:, height:,
# text: [{ x:, y:, font:, size:, text: }], images:, links:, fields: }], … }layout is what stationery inspect prints, in points from the top-left corner; see the README section above and CLI.
The subject is a document (rendered once), PDF bytes, a file path or an IO.