stationery 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +114 -17
  4. data/examples/price_card.rb +85 -0
  5. data/lib/stationery/cli/layout_text.rb +14 -2
  6. data/lib/stationery/cli/render.rb +7 -6
  7. data/lib/stationery/cli/verify.rb +98 -0
  8. data/lib/stationery/cli.rb +1 -0
  9. data/lib/stationery/document.rb +32 -7
  10. data/lib/stationery/elements/forms.rb +4 -2
  11. data/lib/stationery/forms/acro_form.rb +1 -1
  12. data/lib/stationery/forms/appearance.rb +26 -21
  13. data/lib/stationery/forms/auto_size.rb +94 -0
  14. data/lib/stationery/forms/field.rb +45 -3
  15. data/lib/stationery/monochrome/painting.rb +10 -0
  16. data/lib/stationery/pdf/assembler.rb +5 -3
  17. data/lib/stationery/pdf/conformance.rb +14 -1
  18. data/lib/stationery/pdf/object_streams.rb +41 -0
  19. data/lib/stationery/pdf/writer.rb +59 -5
  20. data/lib/stationery/raster/render.rb +1 -1
  21. data/lib/stationery/skill/recipes/form.md.erb +9 -6
  22. data/lib/stationery/skill/recipes/invoice.md.erb +13 -9
  23. data/lib/stationery/skill/recipes/label.md.erb +4 -2
  24. data/lib/stationery/skill/recipes/report.md.erb +9 -4
  25. data/lib/stationery/testing/inspector.rb +8 -5
  26. data/lib/stationery/testing/layout.rb +18 -1
  27. data/lib/stationery/verify/allowlist.rb +29 -0
  28. data/lib/stationery/verify/command.rb +80 -0
  29. data/lib/stationery/verify/comparison.rb +191 -0
  30. data/lib/stationery/verify/engine.rb +63 -0
  31. data/lib/stationery/verify/engines/mupdf.rb +44 -0
  32. data/lib/stationery/verify/engines/pdfium.rb +35 -0
  33. data/lib/stationery/verify/engines/pdfjs.rb +41 -0
  34. data/lib/stationery/verify/engines/pdfkit.rb +20 -0
  35. data/lib/stationery/verify/engines/poppler.rb +83 -0
  36. data/lib/stationery/verify/engines/qpdf.rb +46 -0
  37. data/lib/stationery/verify/expectation.rb +44 -0
  38. data/lib/stationery/verify/report.rb +49 -0
  39. data/lib/stationery/verify/scripts/pdfium.py +99 -0
  40. data/lib/stationery/verify/scripts/pdfjs.mjs +108 -0
  41. data/lib/stationery/verify/scripts/pdfkit.swift +80 -0
  42. data/lib/stationery/verify.rb +89 -0
  43. data/lib/stationery/version.rb +1 -1
  44. data/lib/stationery.rb +2 -0
  45. metadata +21 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08814231457b8700e5c8a03e29c1234d219adcfbf29cbc49de819f05667bf994'
4
- data.tar.gz: 0cdb872927ead42db1e41ab558c828acabd73d7cba89c121edf46a58aafbf26d
3
+ metadata.gz: a56459cfc65f70dab12835ced22ff09d88d5c7a66b8ea045ccb0776f75da27b6
4
+ data.tar.gz: 44889ebe589bed206a1b04940e6b24bad97f3be38f2b53179d8c98522c1ff7f0
5
5
  SHA512:
6
- metadata.gz: 583090cf9f95cecf4e53ed6a535c9e71d8c62797e455228f70036497554e0b0e594a8b3b74d60212cef2734334611379b8ac301a1411c6eee7597bcf58d720a0
7
- data.tar.gz: 42541eb732431081e0ff9a00d66e6d0071c46bed006402b5ebee3fc87d7cd679098b77f03692b00b275aa04f20eeefc82a2e50fb949af9ac0e27664dc472bde2
6
+ metadata.gz: 7c2af80a6c317cff9cea03b5259c63d6a2bfa74730371713b490713fc0e8cb311d5b644d4a239ff49843f312cbdb20504dc9f363195df1af2784948f5af0250a
7
+ data.tar.gz: 2591078b7e474565a8f35338dac311800f5c175576428756ba111fef71dc1e68e4b971efe86e7c64b6458c0b162087b1b32e75da6f4d703df735aee7dc45bafc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Text fields and selects take `align:`, `color:` and `font_size: :auto` (#216): `align:` (`:left`, `:center`, `:right`) is written as `/Q`, `color:` (any colour the gem takes, black by default) as the colour operator of `/DA`, and `font_size: :auto` as `0 Tf`, within `min_font_size:` (4 by default) and `max_font_size:` (the field's height by default). The gem's own appearance aligns, colours and sizes the value the same way, so a field looks the same before and after a viewer redraws it: one line at the largest tenth of a point that fits the field's height and width, a `comb:` field's widest character in its cell, a `multiline:` value wrapped to the width and fitted to the height. `comb:` ignores `align:`. `text_field "price", value: "4 990 kr", align: :center, color: "#DC2626", font_size: :auto, height: 110`. A field that uses none of them is written as it was. Under `monochrome` a `color:` is checked like any other colour (`Warnings::NotMonochrome`, or black with `snap: true`); `Inspector#layout` and `stationery inspect` report a text field's or choice's `align:`, `font_size:` and `color:`. A price card example (`examples/price_card.rb`). `Forms::Field`, `Forms::Appearance`, `Forms::AutoSize`, `Testing::Layout`. **Behaviour change:** under PDF/A, a CMYK colour in a form field's appearance or `/DA` is reported as `Warnings::ConformanceIssue` (`CMYK colour in form field "price"`), its `border:` and `background:` included, which were not checked before.
6
+ - The skill (`stationery skill install`) and the README it is written from are corrected (#209): the `max_pages` example renders before it reads `warnings`; a preview reads `params.fetch("id") { … }`, so its default is not looked up when `id` is given; the streamed-table example preloads what its rows read; the invoice recipe computes VAT with `Rational`, not Float, and says when a document may derive its totals; the report recipe escapes the chart labels it writes into SVG (`ERB::Util.html_escape`); the form recipe gives its fields a `tooltip:` and says a field without one is announced by its field name; the label recipe sizes a native QR code by the printer's dpi (a module of at most 720 / dpi points) and says what a barcode left in the picture loses. Statements that overstated are qualified: `encrypt` under a conformance level raises `ArgumentError`, not `ConformanceError`; `NeedAppearances` is left out under a conformance level or a signature; what the untrusted-input guarantees cover; and what a B-T timestamp proves once the certificate has expired. Run `stationery skill install` again to update an installed copy.
7
+ - A tagged render is written with object streams (#206): its structure elements, and every other object that is not a stream, are packed 200 to a deflated `/Type /ObjStm`, 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, 8 pages, is 115 KB tagged where it was 501 KB, 36 objects where it was 4,544; the tagged examples are 9 to 65% smaller. It is on by default for a render that is tagged (`tagged`, `to_pdf(tagged: true)`, `conformance :pdf_ua1`), and `object_streams false` at class level or `to_pdf(object_streams: false)` writes the classic table for a reader that cannot read object streams; `to_pdf(object_streams: true)` packs an untagged render too. Streams, the `/Encrypt` dictionary and a signature's dictionary stay out, so an encrypted, a signed and an incremental render are packed too, and PDF/A-2b, PDF/A-3b and PDF/UA-1 pass veraPDF packed. `to_png` and `to_zpl` refuse `object_streams:` as a PDF option. `Document#page_count` is how many pages the last render laid out, which `stationery render` now reports. In `PDF::Writer` (`object_streams:`, `add_unpacked`) and `PDF::ObjectStreams`.
8
+ - `stationery verify` checks PDFs in the engines viewers are built on (#210): qpdf (the file's structure), Poppler (Evince, Okular), MuPDF, PDFium (Chrome, Edge), pdf.js (Firefox) and PDFKit (Preview, Safari). Each engine reads the file and what it reads is held against what pdf-reader reads of it: the page count, each page's text lines (found, not compared: NFKC, no whitespace, no soft hyphens, a list item's marker may be read apart, as a column of markers), that a page with text or an image paints something, links and their targets, outline titles, form fields with their values and appearances, attachments, signatures (and `pdfsig`'s verdict), a tagged file's structure tree, and every font embedded with a ToUnicode map. Any error or warning an engine gives fails the check, but for the three messages `Stationery::Verify::Allowlist` names with why each is the check's environment and not the file. `stationery verify invoice.pdf report.rb --engines qpdf,pdfjs --password P --json` renders a Ruby file first, as `inspect` does, runs every engine installed without `--engines`, and exits 0, 1 or 2 (usage); it prints one line per file and engine, the problems under it, and the engines not installed with the line that installs each. The engines are tools you install (`brew install qpdf poppler mupdf`, `pip install pypdfium2`, `npm i pdfjs-dist`; PDFKit on macOS), or run in the image `ghcr.io/zoolutions/stationery-verify`, published with each release, which has every engine but PDFKit: `docker run --rm -v "$PWD:/data:ro" ghcr.io/zoolutions/stationery-verify /data/invoice.pdf`. The gem gains no dependency: `stationery verify` needs `pdf-reader`, as `inspect` does, and `require "stationery"` does not load it, so a render allocates and writes what it did. `bundle exec rake verify:readers` renders every example, the conformance renders, an encrypted invoice, a packed invoice and an unpacked tagged report to `tmp/readers/` and checks them; CI runs it through the image on Linux and with qpdf, Poppler, MuPDF and PDFKit on macOS. `Testing::Inspector.new(subject, password:)` opens an encrypted file. `Stationery::Verify`, `Stationery::CLI::Verify`.
9
+ - **Behaviour changes:** a tagged render is packed: its catalog, pages, annotations and structure elements are inside deflated object streams, so a test or tool that searches the file's bytes for them (`/StructParent`, `/Type /Page`) finds nothing (`document.page_count` has the pages), and the file ends in a cross-reference stream, not `xref` and `trailer`. A reader of PDF 1.4 and earlier cannot open it; `object_streams: false` writes it as 0.12.0 did, byte for byte. A tagged render streamed to a block is a few bytes longer than the String, as each flush closes an object stream. Untagged documents are byte for byte what they were in 0.12.0.
10
+
3
11
  ## 0.12.0 (2026-09-29)
4
12
 
5
13
  Links that conform; labels and printing (page sizes in units, print hints, monochrome, page limits, `to_png`, JPEG pixels, `to_zpl` and barcodes); floats that keep to their page; speed and memory, with tables read as pages reach them; and AI first: a skill that ships with the gem, examples with their code, the docs over MCP, and `stationery inspect`.
data/README.md CHANGED
@@ -96,7 +96,7 @@ InvoicePdf.new(invoice).to_pdf("a.pdf") # also writes a path or an IO
96
96
  `examples/` has a complete, runnable invoice, annual report, letter, packing slip, shipping label, fillable form, postcard collage, magazine article, event flyer, two-column newsletter and Factur-X e-invoice,
97
97
  and one per thing people build: a 2,000-row price list written `incremental`, a contract with initials
98
98
  on every page and signature fields, a landscape certificate, a two-column résumé with its body in HTML,
99
- a restaurant menu in `columns` with floats, a till receipt on an 80 mm roll, a PDF/UA-1 accessible report and a notice in English, German
99
+ a restaurant menu in `columns` with floats, a till receipt on an 80 mm roll, a shop-window price card whose fields centre, colour and size their values, a PDF/UA-1 accessible report and a notice in English, German
100
100
  and Swedish, each hyphenated in its language
101
101
  ([previews and live PDFs](https://stationery.zoolutions.llc/docs/examples)); `bundle exec rake examples`
102
102
  renders them all, or render one with `stationery render examples/report.rb`.
@@ -139,10 +139,10 @@ ls "$(bundle show stationery)/examples" # or: gem contents stationery
139
139
  | `keep_with_next: true \| points` | On `text`, `box` or `group`: never end a page with this node; with a number, keep at least that many points of what follows with it. |
140
140
  | `text_style(**style) { }` | Default text style for a block. |
141
141
  | `canvas(height:) { \|canvas, rect\| }` | Draw directly: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, images, links; `rotate(degrees, around:) { }` and `transform([a, b, c, d, e, f]) { }` blocks. |
142
- | `text_field(name, value:, width:, height:, multiline:, max_length:, comb:, read_only:, required:, font_size:, border:, background:, radius:, tooltip:, at:)` | An interactive text input (AcroForm). `width:` is `:full` or points; dotted names (`"address.city"`) group fields. See [Forms](#forms). |
142
+ | `text_field(name, value:, width:, height:, multiline:, max_length:, comb:, read_only:, required:, font_size:, min_font_size:, max_font_size:, align:, color:, border:, background:, radius:, tooltip:, at:)` | An interactive text input (AcroForm). `width:` is `:full` or points; `font_size:` is points or `:auto`; `align:` is `:left`, `:center` or `:right`; dotted names (`"address.city"`) group fields. See [Forms](#forms). |
143
143
  | `checkbox(name, checked:, size:, label:, at:)` | An interactive check box, with an optional label drawn to its right. |
144
144
  | `radio(name, value, checked:, size:, label:, at:)` | One choice of a radio group: radios sharing `name` form one field whose value is the checked `value`. |
145
- | `select(name, options:, value:, width:, height:, editable:, at:)` | A drop-down (combo box); `editable: true` also accepts typed values. |
145
+ | `select(name, options:, value:, width:, height:, editable:, font_size:, min_font_size:, max_font_size:, align:, color:, at:)` | A drop-down (combo box); `editable: true` also accepts typed values. |
146
146
  | `signature_field(name, width:, height:, label:, at:)` | An empty signature field for the signer to fill, drawn as a rule over the label. |
147
147
  | `html(source, styles:, gap:, images:, base_path:, bookmarks:, links:, max_depth:)` | Rich text from HTML (ActionText/Trix, CMS output): paragraphs, headings, lists, quotes, code, rules, tables, images, inline marks and links, styled by a CSS subset (`<style>` rules and inline `style`). See [HTML and Markdown](#html-and-markdown). |
148
148
  | `markdown(source, styles:, gap:, images:, base_path:, bookmarks:, links:, max_depth:)` | The same from CommonMark (plus GFM tables and strikethrough). |
@@ -305,8 +305,9 @@ HTML
305
305
  #### Untrusted input
306
306
 
307
307
  `html` and `markdown` are meant for content you did not write (a CMS body, a comment, an
308
- ActionText field), and nothing in that content can reach outside the document or take the
309
- process down:
308
+ ActionText field). Nothing in that content can reach outside the document, and parsing it is
309
+ bounded: nesting is capped and time grows with the input's length. How much input you accept,
310
+ and so how many pages it renders to, is yours to bound:
310
311
 
311
312
  - **No requests.** Remote images are never fetched; an image is read only from `images:` or from
312
313
  under `base_path:`, and a path that climbs out of it is skipped (`SkippedImage`).
@@ -385,6 +386,7 @@ text_field "applicant.name", value: @applicant.name, required: true
385
386
  text_field "applicant.notes", multiline: true, height: 60
386
387
  text_field "applicant.pin", comb: 6 # six cells; sets max_length
387
388
  select "applicant.country", options: %w[Sweden Norway Denmark], value: "Sweden"
389
+ text_field "price", value: "4 990 kr", align: :center, color: "#DC2626", font_size: :auto, height: 110
388
390
  radio "plan", "basic", label: "Basic" # radios sharing a name form one group
389
391
  radio "plan", "pro", checked: true, label: "Pro"
390
392
  checkbox "terms", checked: false, label: "I accept the terms"
@@ -396,9 +398,10 @@ signature_field "signature", label: "Signature of the applicant"
396
398
  applied per character, so a value in any script the fonts cover is drawn (`/V` holds it as
397
399
  Unicode). A character no font has is drawn as `.notdef` inside a `Span` whose `ActualText` is the
398
400
  character, as in any other text (or as the font's stand-in under `conformance` with
399
- `missing_glyphs: :replace`), so the appearance still extracts as written. Check marks and radio dots are paths. `NeedAppearances` is set too, so viewers redraw
400
- edited values; ZapfDingbats is listed for the ones that redraw a button's mark, never embedded
401
- and never used by the appearances themselves.
401
+ `missing_glyphs: :replace`), so the appearance still extracts as written. Check marks and radio
402
+ dots are paths. `NeedAppearances` is set too, so viewers redraw edited values, except under a
403
+ conformance level or a signature, which leave it out; ZapfDingbats is listed for the ones that
404
+ redraw a button's mark, never embedded and never used by the appearances themselves.
402
405
  - A field that can be edited keeps printable ASCII and Latin-1 in its font beyond the value it shows
403
406
  (and a select the glyphs of every option), about 10 KB per font, so the text a viewer redraws
404
407
  after an edit has its glyphs; what is typed outside that range falls back to the viewer's own
@@ -408,9 +411,19 @@ signature_field "signature", label: "Signature of the applicant"
408
411
  - Dotted names build the field hierarchy viewers show as groups; widgets sharing a name are one field
409
412
  with several widgets. A name used as both a field and a group raises `ArgumentError`.
410
413
  - `read_only:`, `required:`, `multiline:`, `max_length:` and `comb:` set the matching field flags.
414
+ - `align:` (`:left`, `:center`, `:right`), `color:` and `font_size: :auto` style a text field's or a
415
+ select's value, both in the file and in the appearance, so the field looks the same before and
416
+ after a viewer redraws it: `align:` is written as `/Q` and the value is placed within the box less
417
+ its padding; `color:` (any colour the gem takes, black by default) is the colour operator of `/DA`;
418
+ `font_size: :auto` writes `0 Tf` and the appearance draws the largest size, in tenths of a point,
419
+ at which one line fits the field's height and width (a `comb:` field's cells; a `multiline:`
420
+ field wrapped to its width and height), within `min_font_size:` (4 by default) and
421
+ `max_font_size:` (the height by default). A `comb:` field ignores `align:`, its cells place each
422
+ character. Under `monochrome`, `color:` is checked like any other colour; under PDF/A a CMYK one
423
+ is reported. A field that uses none of them is written as it was.
411
424
  - A radio group's value is its checked choice's `value` (`Off` when none is checked); a select box
412
425
  lists its `options:` and draws the chosen `value`; a signature field is left unsigned for the
413
- signer, unless `sign field:` signs it (see [Digital signatures](#digital-signatures)).
426
+ signer, unless `sign` names it with `field:` (see [Digital signatures](#digital-signatures)).
414
427
  - `document.fields` returns `{ name => value }` for the last render (a check box's value is `true` or
415
428
  `false`, an unchecked radio group's and a signature field's `nil`). Encrypted documents keep their fields fillable.
416
429
  - In tests, `document.fields` is what the form was filled with, and `have_pdf_text("Astrid", fields:
@@ -495,6 +508,8 @@ render Callout.new(color: "#F3F4F6") { text "Amount due" }
495
508
  max_pages 1
496
509
  end
497
510
 
511
+ label = ShippingLabel.new
512
+ label.to_pdf # warnings are there after a render
498
513
  label.warnings.map(&:message)
499
514
  # => ["the document may have 1 page and needs 2: page 2 starts with a Code 128 barcode"]
500
515
  ```
@@ -514,6 +529,8 @@ render Callout.new(color: "#F3F4F6") { text "Amount due" }
514
529
  are included. `to_pdf(strict: true)`, or `strict` at class level, raises `Stationery::WarningsError`
515
530
  (with `#warnings`) instead of writing a PDF that produced any; `to_pdf(strict: false)` opts one
516
531
  render out again.
532
+ - After a render, `document.page_count` is how many pages it laid out: a tagged file's page
533
+ dictionaries are packed into object streams, where a search of its bytes does not find them.
517
534
 
518
535
  ### Printing
519
536
 
@@ -672,7 +689,7 @@ ShelfLabel.new.to_png(monochrome: { dpi: 203, snap: true }) # one bit to a dot,
672
689
  - **Options.** `dpi:` (96, or the monochrome dpi), `pages:`, `monochrome:`, `debug:` (the layout
673
690
  rectangles), `strict:`, `shaper:` and `max_pages:` as `to_pdf` has them. What only a PDF has (`sign:`, `encrypt:`,
674
691
  `conformance:`, `attachments:`, `print:`, `tagged:`, `page_labels:`, `xmp:`, `factur_x:`,
675
- `incremental:`, `missing_glyphs:`) raises `ArgumentError` when it is passed, and is left alone when
692
+ `incremental:`, `missing_glyphs:`, `object_streams:`) raises `ArgumentError` when it is passed, and is left alone when
676
693
  the class declares it, so a signed or encrypted document still has pictures.
677
694
  - **Speed.** A 100 × 150 mm label at 203 dpi (800 × 1200 dots) takes about 30 ms, a one-page invoice
678
695
  at 96 dpi about 0.1 s, a page of dithered photographs at 203 dpi about 0.7 s, a label with a
@@ -875,6 +892,13 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
875
892
  `canvas.tag(element) { … }`.
876
893
  - A missing `lang` is a `Warnings::MissingLanguage`. All four are warnings, so `strict` catches them,
877
894
  and `conformance :pdf_ua1` raises on the first three.
895
+ - A tagged render packs its structure elements, and every other object that is not a stream, into
896
+ deflated object streams (`/Type /ObjStm`, 200 objects each) with a cross-reference stream in place
897
+ 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
898
+ 501 KB. Streams, the `/Encrypt` dictionary and a signature dictionary stay out; PDF/A-2, PDF/A-3 and
899
+ PDF/UA-1 allow them. `object_streams false` at class level, or `to_pdf(object_streams: false)`,
900
+ writes the classic table for a reader that cannot read them; `to_pdf(object_streams: true)` packs
901
+ an untagged render too.
878
902
  - Untagged documents (the default) are written exactly as before.
879
903
 
880
904
  Check the tree in tests with `have_structure` and `have_tagged_content` (see [Testing](#testing)):
@@ -984,6 +1008,66 @@ docker run --rm -v "$PWD:/data:ro" verapdf/cli --format text -v --flavour 3b /da
984
1008
 
985
1009
  In tests, `have_conformance(:pdf_a3b)` checks the claim (not the validity: that is veraPDF's job).
986
1010
 
1011
+ ### Viewers: stationery verify
1012
+
1013
+ veraPDF answers whether a file keeps PDF/A or PDF/UA, not whether it works where people open it.
1014
+ A file can pass PDF/A-3b and still trip a viewer, and open everywhere and still fail PDF/A. No
1015
+ validator answers "works in every viewer", so `stationery verify` reads the file with the engines
1016
+ the viewers are built on and holds what each reads against what the file holds, as pdf-reader
1017
+ reads it (`stationery verify` needs the `pdf-reader` gem, as `inspect` does):
1018
+
1019
+ | Engine | Viewers | Checks |
1020
+ |---|---|---|
1021
+ | qpdf | (the file's structure) | `--check` without an error or a warning; the page count |
1022
+ | Poppler | Evince, Okular, the Linux desktop | pages; text; each page with content paints; every font embedded, with a ToUnicode map; attachments; signatures (`pdfsig`); a tagged file's structure tree |
1023
+ | MuPDF | SumatraPDF, mobile viewers | pages; text; each page with content paints |
1024
+ | PDFium | Chrome, Edge | pages; text; painting; links; outline; form fields and their appearances; signatures |
1025
+ | pdf.js | Firefox | pages; text; painting operators; links; outline; form fields; attachments; a tagged file's structure tree |
1026
+ | PDFKit | Preview, Safari (macOS) | pages; text; painting; links; outline; form fields |
1027
+
1028
+ Any error or warning from any engine fails the check. Text is found, not compared: both sides lose
1029
+ their whitespace and soft hyphens and are NFKC-normalized, and each text line of a page must be in
1030
+ what the engine reads of it, whole; a list's item may have its marker read apart, as a column of
1031
+ markers before the items, since the page draws them apart. A page drawn blank on purpose passes;
1032
+ one with text or an image that an engine paints all white fails. The few messages that are the
1033
+ check's environment and not the file (pdf.js warns it has no OffscreenCanvas in Node, pdfsig that
1034
+ the machine has no certificate store, Debian's mutool that it was built without colour management)
1035
+ are listed with why in `Stationery::Verify::Allowlist`.
1036
+
1037
+ ```sh
1038
+ stationery verify invoice.pdf report.pdf # every engine installed
1039
+ stationery verify invoice.rb # render it first
1040
+ stationery verify invoice.pdf --engines qpdf,poppler # these, and fail if one is missing
1041
+ stationery verify locked.pdf --password 1234 --json
1042
+ ```
1043
+
1044
+ It exits 0 when every engine passes every file, 1 otherwise (or when no engine is installed), 2 on
1045
+ a usage error, and prints one line per file and engine, the problems under it, then the engines not
1046
+ installed with the line that installs each. The engines are tools you install; the gem depends on
1047
+ none of them:
1048
+
1049
+ ```sh
1050
+ brew install qpdf poppler mupdf # apt-get install qpdf poppler-utils mupdf-tools
1051
+ pip install pypdfium2 # Python 3.11+; STATIONERY_PYTHON=/path/to/python names another
1052
+ npm i pdfjs-dist # STATIONERY_PDFJS=/path/to/node_modules/pdfjs-dist
1053
+ ```
1054
+
1055
+ PDFKit needs macOS and Swift (`xcode-select --install`). Poppler and MuPDF take `--password` on
1056
+ their command lines, where other processes of the machine can see it; qpdf reads it from a file and
1057
+ the scripted engines from the environment. pdfsig takes no password, so an encrypted file's
1058
+ signatures are not read by Poppler. A Docker image has every engine but PDFKit:
1059
+
1060
+ ```sh
1061
+ docker run --rm -v "$PWD:/data:ro" ghcr.io/zoolutions/stationery-verify /data/invoice.pdf
1062
+ ```
1063
+
1064
+ `bundle exec rake verify:readers` renders every example, the renders `verify:conformance`
1065
+ validates, an encrypted invoice, an invoice packed with object streams and a tagged report without
1066
+ them to `tmp/readers/`, and checks them (`ENGINES=qpdf,pdfkit` to choose). CI checks them with the
1067
+ image on Linux and with qpdf, Poppler, MuPDF and PDFKit on macOS. Adobe Acrobat cannot be driven
1068
+ in CI: before a release, the maintainer opens a fixed set of them in Acrobat Reader on macOS and
1069
+ Windows (see `AGENTS.md`).
1070
+
987
1071
  ### Factur-X / ZUGFeRD e-invoices
988
1072
 
989
1073
  A Factur-X (in Germany: ZUGFeRD) invoice is one PDF that people read and accounting software
@@ -1063,9 +1147,10 @@ ContractPdf.new.to_pdf(sign: { certificate:, key:, passphrase: "…" }) # per re
1063
1147
  time (`Time.now`).
1064
1148
  - `timestamp: "https://tsa.example/tsr"` asks an RFC 3161 time-stamping authority (TSA) to sign the
1065
1149
  signature value and the time it saw it, and carries that token in the signature's unsigned
1066
- attributes: PAdES baseline B-T. A reader can then trust the signing time, and the signature, after
1067
- the signer's certificate has expired. It is one HTTP request per render, with Ruby's own
1068
- `net/http`; `timestamp: { url:, username:, password:, hash: :sha256 }` adds HTTP basic auth or
1150
+ attributes: PAdES baseline B-T. The token proves when the signature existed, by a clock other
1151
+ than the signer's. Whether a viewer still accepts the signature once the signer's certificate
1152
+ has expired depends on its trust settings and on revocation data (B-LT), which the file does
1153
+ not carry. It is one HTTP request per render, with Ruby's own `net/http`; `timestamp: { url:, username:, password:, hash: :sha256 }` adds HTTP basic auth or
1069
1154
  another digest (`:sha384`, `:sha512`), and `client:` (a callable given the request DER and
1070
1155
  answering the response DER) replaces the HTTP client. A TSA that cannot be reached, refuses, or
1071
1156
  answers for another request or another digest raises `Stationery::SignatureError`: the document
@@ -1187,7 +1272,8 @@ reach them, and holds the rows of the page being filled and the one after them,
1187
1272
  its first page:
1188
1273
 
1189
1274
  ```ruby
1190
- rows = [%w[Order Customer Total]].each + Order.find_each.lazy.map { |o| [o.number, o.customer, o.total] }
1275
+ orders = Order.preload(:customer).find_each.lazy.map { |o| [o.number, o.customer.name, o.total] }
1276
+ rows = [%w[Order Customer Total]].each + orders
1191
1277
  table(rows, header: true, widths: [80, 0.5, 90]) do |t|
1192
1278
  t.row(0).weight = :bold
1193
1279
  t.columns(2).align = :right
@@ -1256,7 +1342,7 @@ document.
1256
1342
  # spec/pdfs/previews/invoice_pdf_preview.rb
1257
1343
  class InvoicePdfPreview < Stationery::Preview
1258
1344
  def paid = InvoicePdf.new(Invoice.paid.first)
1259
- def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id", Invoice.overdue.first.id)))
1345
+ def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id") { Invoice.overdue.first.id }))
1260
1346
  end
1261
1347
  ```
1262
1348
 
@@ -1341,6 +1427,16 @@ Page 1 595.3 x 841.9 pt
1341
1427
  329.2 596.5 65.6 x 11.6 mailto:hello@acme.test
1342
1428
  ```
1343
1429
 
1430
+ ```sh
1431
+ stationery verify invoice.pdf # in every engine installed
1432
+ stationery verify invoice.rb --engines qpdf,pdfjs --json
1433
+ ```
1434
+
1435
+ `verify` reads PDFs, or the documents Ruby files define, with the engines
1436
+ viewers are built on (qpdf, Poppler, MuPDF, PDFium, pdf.js, PDFKit) and fails
1437
+ when one of them errs, warns or reads something the file does not hold. See
1438
+ [Viewers](#viewers-stationery-verify).
1439
+
1344
1440
  ```sh
1345
1441
  stationery fonts list # packs, licenses, what is in vendor/fonts
1346
1442
  stationery fonts install noto_sans liberation_serif # into vendor/fonts/<pack>/
@@ -1785,8 +1881,9 @@ A text line is a run of one font and size on one baseline, so a word set in bold
1785
1881
  own; lines read top to bottom, then left to right. An image is `{ x:, y:, width:, height:, pixels:
1786
1882
  [w, h] }` in the order drawn (the rectangle it fills, before any clip); a link has its `uri:`, or the
1787
1883
  `page:` and `top:` it goes to; a field has its full `name:`, `type:` (`:text`, `:choice`,
1788
- `:checkbox`, `:radio`, `:button`, `:signature`), `value:` and, for a button, the `state:` that turns
1789
- it on. `outline` is the bookmarks as `{ title:, page:, top:, children: }`. `metadata` leaves out the
1884
+ `:checkbox`, `:radio`, `:button`, `:signature`), `value:`, for a button the `state:` that turns
1885
+ it on, and for a text field or a choice the `align:`, `font_size:` (`:auto` for `0 Tf`) and `color:`
1886
+ (`"#RRGGBB"`) its value is drawn in. `outline` is the bookmarks as `{ title:, page:, top:, children: }`. `metadata` leaves out the
1790
1887
  dates, which change with every render, so two renders of one document have the same layout.
1791
1888
  `warnings` are the render's messages when the subject is a document.
1792
1889
 
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A shop-window price card a person fills in on screen and prints: the
4
+ # product's name centred and shrunk to fit its line (`font_size: :auto`),
5
+ # the price large, centred and red, the price per unit right-aligned in grey
6
+ # and a select of units. The fields have no border and no background, so the
7
+ # printed card shows the values alone; a viewer that redraws a field after
8
+ # an edit keeps its alignment, colour and auto-size (/Q and /DA). Run it to
9
+ # write examples/price_card.pdf:
10
+ #
11
+ # ruby -Ilib examples/price_card.rb
12
+ # ruby -Ilib exe/stationery render examples/price_card.rb
13
+ require "stationery"
14
+
15
+ class ExamplePriceCard < Stationery::Document
16
+ INK = "#0F172A"
17
+ SALE = "#DC2626"
18
+ MUTED = "#64748B"
19
+ HAIRLINE = "#E2E8F0"
20
+ UNITS = ["per piece", "per kg", "per litre", "per pack"].freeze
21
+ BARE = { border: nil, background: nil }.freeze
22
+
23
+ page size: :a5, layout: :landscape, margin: 36
24
+ default_text size: 12, color: INK
25
+ metadata title: "Price card", author: "Corner Coffee Shop", creator: "stationery example", lang: "en"
26
+
27
+ footer do
28
+ rule height: 0.5, color: HAIRLINE
29
+ spacer 6
30
+ text "All prices include 25% VAT · Corner Coffee Shop, Stortorget 4, Lund", size: 8, color: MUTED,
31
+ align: :center
32
+ end
33
+
34
+ def self.preview
35
+ new(product: "Espresso machine Deluxe 3000 with milk frother", price: "4 990 kr",
36
+ unit_price: "Was 5 990 kr · save 1 000 kr", unit: "per piece")
37
+ end
38
+
39
+ def initialize(product: "", price: "", unit_price: "", unit: UNITS.first)
40
+ super()
41
+ @product = product
42
+ @price = price
43
+ @unit_price = unit_price
44
+ @unit = unit
45
+ end
46
+
47
+ def view_template
48
+ text "Corner Coffee Shop", size: 16, weight: :bold, align: :center
49
+ spacer 4
50
+ rule height: 1.5, color: INK
51
+ spacer 10
52
+ label "Product"
53
+ text_style(weight: :bold) do
54
+ text_field "product", value: @product, height: 44, font_size: :auto, max_font_size: 32, align: :center,
55
+ tooltip: "Product", **BARE
56
+ end
57
+ rule height: 0.5, color: HAIRLINE
58
+ spacer 10
59
+ label "Price"
60
+ text_style(weight: :bold) do
61
+ text_field "price", value: @price, height: 150, font_size: :auto, max_font_size: 120, align: :center,
62
+ color: SALE, tooltip: "Price", **BARE
63
+ end
64
+ spacer 6
65
+ row(gap: 12) do
66
+ column(width: 150) do
67
+ select "unit", options: UNITS, value: @unit, height: 22, align: :center, tooltip: "Unit", **BARE
68
+ end
69
+ column do
70
+ text_field "note", value: @unit_price, height: 22, align: :right, color: MUTED, tooltip: "Note", **BARE
71
+ end
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def label(title) = text(title.upcase, size: 8, color: MUTED, letter_spacing: 1, align: :center)
78
+ end
79
+
80
+ if $PROGRAM_NAME == __FILE__
81
+ card = ExamplePriceCard.preview
82
+ card.to_pdf(File.expand_path("price_card.pdf", __dir__))
83
+ warn card.warnings.map(&:message) if card.warnings.any?
84
+ puts "wrote examples/price_card.pdf"
85
+ end
@@ -53,7 +53,7 @@ module Stationery
53
53
  "#{box(image)} #{image[:pixels].join(" x ")} px"
54
54
  end,
55
55
  *section("Links (x, y, width x height, target)", page[:links]) { |link| "#{box(link)} #{target(link)}" },
56
- *section("Fields (x, y, width x height, name, type, value)", page[:fields]) do |field|
56
+ *section("Fields (x, y, width x height, name, type, value, alignment, size, colour)", page[:fields]) do |field|
57
57
  "#{box(field)} #{field_text(field)}"
58
58
  end]
59
59
  end
@@ -84,7 +84,19 @@ module Stationery
84
84
 
85
85
  def field_text(field)
86
86
  value = field[:value].is_a?(Array) ? field[:value].inspect : field[:value]&.inspect
87
- [field[:name], field[:type], field[:state] && "on: #{field[:state]}", value].compact.join(" ")
87
+ [field[:name], field[:type], field[:state] && "on: #{field[:state]}", value, *style(field)].compact.join(" ")
88
+ end
89
+
90
+ # What is not the default of a field's alignment, size and colour.
91
+ def style(field)
92
+ return [] unless field.key?(:align)
93
+
94
+ size = field[:font_size]
95
+ [field[:align] == :left ? nil : field[:align],
96
+ if size == :auto then "auto"
97
+ elsif size != Forms::Field::DEFAULTS[:font_size] then "#{number(size)} pt"
98
+ end,
99
+ field[:color] == "#000000" ? nil : field[:color]]
88
100
  end
89
101
 
90
102
  def structure
@@ -67,7 +67,7 @@ module Stationery
67
67
  output = @options[:zpl] ? to_zpl(document) : to_pdf(document)
68
68
  return FAILURE unless warnings_ok?(document)
69
69
 
70
- write(output, target)
70
+ write(output, target, document)
71
71
  pictures(document, target) if @options[:png]
72
72
  OK
73
73
  end
@@ -149,20 +149,21 @@ module Stationery
149
149
  false
150
150
  end
151
151
 
152
- def write(pdf, target)
152
+ def write(pdf, target, document)
153
153
  if target == "-"
154
154
  @out.binmode
155
155
  @out.write(pdf)
156
156
  else
157
157
  File.binwrite(target, pdf)
158
- @out.puts "wrote #{target} (#{count(pdf)}, #{pdf.bytesize} bytes)"
158
+ @out.puts "wrote #{target} (#{count(pdf, document)}, #{pdf.bytesize} bytes)"
159
159
  end
160
160
  end
161
161
 
162
- # "1 page", "3 labels": what the file holds.
163
- def count(output)
162
+ # "1 page", "3 labels": what the file holds. The pages are the
163
+ # render's: a tagged file's are packed, and encrypted too with it.
164
+ def count(output, document)
164
165
  name = @options[:zpl] ? "label" : "page"
165
- count = output.scan(@options[:zpl] ? "^XA" : %r{/Type /Page\b}).size
166
+ count = @options[:zpl] ? output.scan("^XA").size : document.page_count
166
167
  "#{count} #{name}#{"s" unless count == 1}"
167
168
  end
168
169
 
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tmpdir"
5
+
6
+ module Stationery
7
+ class CLI
8
+ # `stationery verify FILE…`: reads each PDF in the engines viewers are
9
+ # built on (Stationery::Verify) and fails when any of them errs, warns
10
+ # or reads something other than the file holds. A Ruby file is rendered
11
+ # first, as `render` finds its document. Needs the pdf-reader gem, and
12
+ # at least one engine installed.
13
+ class Verify < Render
14
+ SUMMARY = "Check PDFs (or the documents Ruby files define) in the engines viewers are built on"
15
+
16
+ def run(argv)
17
+ parser.parse!(argv)
18
+ return OK if @options[:help]
19
+ raise OptionParser::MissingArgument, "FILE" if argv.empty?
20
+
21
+ require "stationery/verify"
22
+ verify(argv, engines)
23
+ rescue OptionParser::ParseError => e
24
+ @err.puts "stationery verify: #{e.message}", parser
25
+ USAGE
26
+ end
27
+
28
+ private
29
+
30
+ def parser
31
+ @parser ||= OptionParser.new do |opts|
32
+ opts.banner = "Usage: stationery verify FILE… [options] (each FILE a .pdf, or a .rb defining a document)"
33
+ opts.on("--engines LIST", Array, "The engines to run (default: every one installed): " \
34
+ "#{%w[qpdf poppler mupdf pdfium pdfjs pdfkit].join(",")}") do |names|
35
+ @options[:engines] = names
36
+ end
37
+ opts.on("--password PASSWORD", "The user password of encrypted files (other local processes can see",
38
+ "it on the command lines of Poppler and MuPDF)") { |password| @options[:password] = password }
39
+ opts.on("--json", "Print the report as JSON") { @options[:json] = true }
40
+ opts.on("-c", "--class NAME", "The document class to render when a Ruby FILE defines several") do |name|
41
+ @options[:class] = name
42
+ end
43
+ opts.on("-h", "--help", "Show this help") do
44
+ @out.puts opts
45
+ @options[:help] = true
46
+ end
47
+ end
48
+ end
49
+
50
+ def engines
51
+ Stationery::Verify.adapters(*[@options[:engines]].compact)
52
+ rescue ArgumentError => e
53
+ raise OptionParser::InvalidArgument, e.message
54
+ end
55
+
56
+ def verify(files, engines)
57
+ installed!(engines)
58
+ Dir.mktmpdir("stationery-verify") do |dir|
59
+ labels = files.to_h { |file| [pdf(file, dir), file] }
60
+ report = Stationery::Verify.run(labels.keys, engines:, password: @options[:password])
61
+ report = report.with(results: report.results.map { |result| result.with(file: labels.fetch(result.file)) })
62
+ @out.puts(@options[:json] ? JSON.pretty_generate(report.to_h) : report.lines)
63
+ report.passed? ? OK : FAILURE
64
+ end
65
+ rescue Stationery::Error => e
66
+ raise Error, e.message
67
+ end
68
+
69
+ def installed!(engines)
70
+ missing = engines.reject(&:available?)
71
+ return if missing.empty? || (!@options[:engines] && missing.size < engines.size)
72
+
73
+ installs = missing.map { |engine| "#{engine.name} (#{engine.install})" }.join(", ")
74
+ raise Error, "not installed: #{installs}" if @options[:engines]
75
+
76
+ raise Error, "no engine is installed; install one of: #{installs}"
77
+ end
78
+
79
+ # The PDF to read for FILE: itself, or the render of the document a
80
+ # Ruby FILE defines, written into `dir`.
81
+ def pdf(file, dir)
82
+ path = File.expand_path(file)
83
+ raise Error, "no such file: #{file}" unless File.file?(path)
84
+ return render_into(path, dir) if File.extname(path) == ".rb"
85
+ raise Error, "not a PDF or a Ruby file: #{file}" unless File.binread(path, 5) == "%PDF-"
86
+
87
+ path
88
+ end
89
+
90
+ def render_into(path, dir)
91
+ document = instantiate(pick(load_documents(path), path))
92
+ File.join(Dir.mktmpdir(nil, dir), "#{File.basename(path, ".rb")}.pdf").tap { |target| document.to_pdf(target) }
93
+ end
94
+ end
95
+
96
+ register "verify", Verify
97
+ end
98
+ end
@@ -77,3 +77,4 @@ require_relative "cli/examples"
77
77
  require_relative "cli/layout_text"
78
78
  require_relative "cli/inspect"
79
79
  require_relative "cli/skill"
80
+ require_relative "cli/verify"
@@ -131,6 +131,15 @@ module Stationery
131
131
  config[:tagged] = value
132
132
  end
133
133
 
134
+ # Packs every object that is not a stream (the structure elements of a
135
+ # tagged PDF, most of all) into deflated object streams, with a
136
+ # cross-reference stream: PDF 1.5, which PDF/A-2, PDF/A-3 and PDF/UA-1
137
+ # allow. On for a tagged render unless set; `object_streams false`
138
+ # writes the classic table for a reader that cannot read them.
139
+ def object_streams(value = true) # rubocop:disable Style/OptionalBooleanParameter
140
+ config[:object_streams] = value
141
+ end
142
+
134
143
  # Claims PDF/A-2b, PDF/A-3b and/or PDF/UA-1 (`conformance :pdf_a3b,
135
144
  # :pdf_ua1`) and writes what the level asks for; a render that cannot
136
145
  # keep the claim raises. See PDF::Conformance. `missing_glyphs:` is what
@@ -197,8 +206,9 @@ module Stationery
197
206
  end
198
207
  end
199
208
 
200
- # `fields` is every form field's name and value from the last render.
201
- attr_reader :warnings, :fields
209
+ # `fields` is every form field's name and value from the last render;
210
+ # `page_count` is how many pages it laid out.
211
+ attr_reader :warnings, :fields, :page_count
202
212
 
203
213
  def page_options = self.class.config[:page]
204
214
  def metadata = self.class.config[:metadata]
@@ -225,13 +235,17 @@ module Stationery
225
235
  #
226
236
  # `max_pages:` replaces the class's limit (see .max_pages) for one
227
237
  # render; nil takes it away.
238
+ #
239
+ # `object_streams:` packs what is not a stream into object streams (see
240
+ # .object_streams); nil, the default, packs a tagged render.
228
241
  def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
229
242
  tagged: self.class.config[:tagged], page_labels: self.class.config[:page_labels], attachments: [],
230
243
  xmp: metadata[:xmp] != false, conformance: self.class.config[:conformance],
231
244
  factur_x: self.class.config[:factur_x], sign: self.class.config[:sign],
232
245
  shaper: self.class.config[:shaping][:shaper], incremental: self.class.config[:incremental],
233
246
  print: PDF::PrintHints::NONE, missing_glyphs: self.class.config[:missing_glyphs],
234
- monochrome: self.class.config[:monochrome], max_pages: self.class.config[:max_pages], &block)
247
+ monochrome: self.class.config[:monochrome], max_pages: self.class.config[:max_pages],
248
+ object_streams: self.class.config[:object_streams], &block)
235
249
  invoice = PDF::FacturX.for(factur_x, self)
236
250
  attachments = PDF::Attachments.merge(self.class.config[:attachments], attachments, invoice&.attachment)
237
251
  conformance = PDF::Conformance.for(invoice ? invoice.conformance(conformance) : conformance, missing_glyphs:)
@@ -243,6 +257,7 @@ module Stationery
243
257
  raise ArgumentError, "pass a target or a block, not both" if target && block
244
258
 
245
259
  options = { strict:, debug:, encrypt:, tagged: tagged || conformance&.pdf_ua?, page_labels:, attachments:,
260
+ object_streams: packed?(object_streams, tagged, conformance),
246
261
  xmp: xmp || !conformance.nil?, conformance:, invoice:, signature:, sink: block, shaper:, print:,
247
262
  incremental: incremental && !conformance && !signature && !(strict && block),
248
263
  monochrome: Monochrome.for(self.class.config[:monochrome], monochrome), max_pages: }
@@ -297,6 +312,7 @@ module Stationery
297
312
  destinations = Structure.resolve(pages, warnings:, book:, canvases:)
298
313
  yield builder.outline.resolve(destinations) if block_given?
299
314
  @warnings = warnings
315
+ @page_count = pages.size
300
316
  pages
301
317
  end
302
318
 
@@ -359,8 +375,8 @@ module Stationery
359
375
  end
360
376
 
361
377
  def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:, attachments:, xmp:, conformance:,
362
- invoice:, signature:, sink:, writer:, print:)
363
- assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, tagging:, xmp:, writer:,
378
+ invoice:, signature:, sink:, writer:, print:, object_streams:)
379
+ assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, tagging:, xmp:, writer:, object_streams:,
364
380
  encryption: writer ? nil : encryption(encrypt),
365
381
  lang: metadata[:lang], page_labels: PDF::PageLabels.entries(page_labels),
366
382
  attachments:, conformance:, xmp_extensions: invoice&.xmp_extensions || {},
@@ -370,6 +386,13 @@ module Stationery
370
386
  end
371
387
  end
372
388
 
389
+ # Object streams when asked for, else when the render is tagged.
390
+ def packed?(object_streams, tagged, conformance)
391
+ return object_streams unless object_streams.nil?
392
+
393
+ tagged || conformance&.pdf_ua? || false
394
+ end
395
+
373
396
  def byte_count(pdf) = pdf.is_a?(String) ? pdf.bytesize : pdf
374
397
  def encryption(options) = options && PDF::Encryption::StandardSecurity.new(**options)
375
398
 
@@ -377,8 +400,10 @@ module Stationery
377
400
  # every body at once, to the writer the rest of the file follows on; any
378
401
  # other seals the pages nothing paints on again (no header, footer or
379
402
  # page template, and no conformance audit to read them).
380
- def sealer_for(incremental, conformance, encrypt:, sink:, **)
381
- return PDF::PageSealer.new(writer: PDF::Writer.new(encryption: encryption(encrypt), sink:)) if incremental
403
+ def sealer_for(incremental, conformance, encrypt:, sink:, object_streams:, **)
404
+ if incremental
405
+ return PDF::PageSealer.new(writer: PDF::Writer.new(encryption: encryption(encrypt), sink:, object_streams:))
406
+ end
382
407
 
383
408
  config = self.class.config
384
409
  PDF::PageSealer.new(final: conformance.nil? && config[:templates].empty? && config[:regions].empty?)