stationery 0.12.1 → 0.12.3

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +108 -16
  4. data/examples/price_card.rb +87 -0
  5. data/lib/stationery/cli/layout_text.rb +14 -2
  6. data/lib/stationery/cli/verify.rb +98 -0
  7. data/lib/stationery/cli.rb +1 -0
  8. data/lib/stationery/elements/forms.rb +4 -2
  9. data/lib/stationery/forms/appearance.rb +27 -21
  10. data/lib/stationery/forms/auto_size.rb +94 -0
  11. data/lib/stationery/forms/field.rb +49 -3
  12. data/lib/stationery/monochrome/painting.rb +10 -0
  13. data/lib/stationery/pdf/conformance.rb +14 -1
  14. data/lib/stationery/skill/recipes/form.md.erb +9 -6
  15. data/lib/stationery/skill/recipes/invoice.md.erb +13 -9
  16. data/lib/stationery/skill/recipes/label.md.erb +4 -2
  17. data/lib/stationery/skill/recipes/report.md.erb +9 -4
  18. data/lib/stationery/testing/inspector.rb +8 -5
  19. data/lib/stationery/testing/layout.rb +18 -1
  20. data/lib/stationery/verify/allowlist.rb +29 -0
  21. data/lib/stationery/verify/command.rb +80 -0
  22. data/lib/stationery/verify/comparison.rb +191 -0
  23. data/lib/stationery/verify/engine.rb +63 -0
  24. data/lib/stationery/verify/engines/mupdf.rb +44 -0
  25. data/lib/stationery/verify/engines/pdfium.rb +35 -0
  26. data/lib/stationery/verify/engines/pdfjs.rb +41 -0
  27. data/lib/stationery/verify/engines/pdfkit.rb +20 -0
  28. data/lib/stationery/verify/engines/poppler.rb +83 -0
  29. data/lib/stationery/verify/engines/qpdf.rb +46 -0
  30. data/lib/stationery/verify/expectation.rb +44 -0
  31. data/lib/stationery/verify/report.rb +49 -0
  32. data/lib/stationery/verify/scripts/pdfium.py +99 -0
  33. data/lib/stationery/verify/scripts/pdfjs.mjs +108 -0
  34. data/lib/stationery/verify/scripts/pdfkit.swift +80 -0
  35. data/lib/stationery/verify.rb +89 -0
  36. data/lib/stationery/version.rb +1 -1
  37. data/lib/stationery.rb +1 -0
  38. metadata +20 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab0f207d1a92bc13494a61e91f50d1af6df807978f878f4cf613536d96f072b6
4
- data.tar.gz: feb1a51466ae5020b1afcc0f5e7be4fd0389c8217e14b06d18044f61a5f5e67b
3
+ metadata.gz: 43646ec58820f24924dfbcaaf0bdd8170d5251699f8a0e052560b03dde2d343a
4
+ data.tar.gz: 651b72e03ce4cd1d31cf713e729b58024bc9145fe7d3c31ea3732e87436771ae
5
5
  SHA512:
6
- metadata.gz: 2707d1a55f7bef5d7f22a6a6117b45bb5f73c41641da1ae9786b37c463ff68323c24ec75d5cf3ab88274b5b8d600cde5fe49d456f776a29afdbf152624d15a28
7
- data.tar.gz: f6954e7b30c7e211c0d9f83055b9cdac5cb628773c8aba34ec38fce37ba0bb39ac958e0a245fb840200be0da43722175ecb3615a48278958b300aa562fb91a6a
6
+ metadata.gz: daf443749aecd4818744fc05248a138b65b14226e318bf5877ff14618b34255b45f5ce1126562aee70722386f83544502bd8769edc5125809d3add8904003146
7
+ data.tar.gz: 72fc71f0732f1e9b202eb5a48cfedf47cf499808bb150dffb67a88f425e200f4bbd6b038aab2e89ab280e0cf9594b2d6e2045de85b27ef3e8c02eee332d4d15b
data/CHANGELOG.md CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Text fields and selects take `font_size: :fit` (#218): the size `font_size: :auto` draws (one line, a `comb:` field's cells or a `multiline:` value, within `min_font_size:` and `max_font_size:`), written in `/DA` as that number instead of `0`, so a viewer that redraws the field keeps the size; Firefox's editable input, which showed `:auto` at 9 px, now shows the number, capped at the field's height ÷ 1.35 and set in the browser's own face. A longer value typed afterwards does not shrink, and an empty field is written at its largest size, so a field left empty to be filled in wants `:auto`. A field placed in several places writes its first widget's size. `Inspector#layout` and `stationery inspect` report the number. `text_field "price", value: "4 990 kr", font_size: :fit, max_font_size: 96, height: 110`. The price card example uses it. A field that does not is written as it was. `Forms::Field`, `Forms::Appearance`.
6
+ - 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.
7
+ - 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.
5
8
  - 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`.
9
+ - `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`.
6
10
  - **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.
7
11
 
8
12
  ## 0.12.0 (2026-09-29)
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, `:auto` or `:fit`; `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,23 @@ 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). `font_size: :fit` sizes the value the same way and writes
422
+ the number it chose in `/DA`, so every viewer reads a size, where `0 Tf` showed 9 px in Firefox's
423
+ editable input (which still caps a size at the field's height ÷ 1.35 and sets it in its own face).
424
+ A longer value typed afterwards does not shrink, and an empty field is sized to its bound, so a
425
+ field left empty to be filled in wants `:auto`. `min_font_size:` and `max_font_size:` go with
426
+ either. A `comb:` field ignores `align:`, its cells place each character. Under `monochrome`, `color:` is checked like any other colour; under PDF/A a CMYK one
427
+ is reported. A field that uses none of them is written as it was.
411
428
  - A radio group's value is its checked choice's `value` (`Off` when none is checked); a select box
412
429
  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)).
430
+ signer, unless `sign` names it with `field:` (see [Digital signatures](#digital-signatures)).
414
431
  - `document.fields` returns `{ name => value }` for the last render (a check box's value is `true` or
415
432
  `false`, an unchecked radio group's and a signature field's `nil`). Encrypted documents keep their fields fillable.
416
433
  - In tests, `document.fields` is what the form was filled with, and `have_pdf_text("Astrid", fields:
@@ -495,6 +512,8 @@ render Callout.new(color: "#F3F4F6") { text "Amount due" }
495
512
  max_pages 1
496
513
  end
497
514
 
515
+ label = ShippingLabel.new
516
+ label.to_pdf # warnings are there after a render
498
517
  label.warnings.map(&:message)
499
518
  # => ["the document may have 1 page and needs 2: page 2 starts with a Code 128 barcode"]
500
519
  ```
@@ -993,6 +1012,66 @@ docker run --rm -v "$PWD:/data:ro" verapdf/cli --format text -v --flavour 3b /da
993
1012
 
994
1013
  In tests, `have_conformance(:pdf_a3b)` checks the claim (not the validity: that is veraPDF's job).
995
1014
 
1015
+ ### Viewers: stationery verify
1016
+
1017
+ veraPDF answers whether a file keeps PDF/A or PDF/UA, not whether it works where people open it.
1018
+ A file can pass PDF/A-3b and still trip a viewer, and open everywhere and still fail PDF/A. No
1019
+ validator answers "works in every viewer", so `stationery verify` reads the file with the engines
1020
+ the viewers are built on and holds what each reads against what the file holds, as pdf-reader
1021
+ reads it (`stationery verify` needs the `pdf-reader` gem, as `inspect` does):
1022
+
1023
+ | Engine | Viewers | Checks |
1024
+ |---|---|---|
1025
+ | qpdf | (the file's structure) | `--check` without an error or a warning; the page count |
1026
+ | 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 |
1027
+ | MuPDF | SumatraPDF, mobile viewers | pages; text; each page with content paints |
1028
+ | PDFium | Chrome, Edge | pages; text; painting; links; outline; form fields and their appearances; signatures |
1029
+ | pdf.js | Firefox | pages; text; painting operators; links; outline; form fields; attachments; a tagged file's structure tree |
1030
+ | PDFKit | Preview, Safari (macOS) | pages; text; painting; links; outline; form fields |
1031
+
1032
+ Any error or warning from any engine fails the check. Text is found, not compared: both sides lose
1033
+ their whitespace and soft hyphens and are NFKC-normalized, and each text line of a page must be in
1034
+ what the engine reads of it, whole; a list's item may have its marker read apart, as a column of
1035
+ markers before the items, since the page draws them apart. A page drawn blank on purpose passes;
1036
+ one with text or an image that an engine paints all white fails. The few messages that are the
1037
+ check's environment and not the file (pdf.js warns it has no OffscreenCanvas in Node, pdfsig that
1038
+ the machine has no certificate store, Debian's mutool that it was built without colour management)
1039
+ are listed with why in `Stationery::Verify::Allowlist`.
1040
+
1041
+ ```sh
1042
+ stationery verify invoice.pdf report.pdf # every engine installed
1043
+ stationery verify invoice.rb # render it first
1044
+ stationery verify invoice.pdf --engines qpdf,poppler # these, and fail if one is missing
1045
+ stationery verify locked.pdf --password 1234 --json
1046
+ ```
1047
+
1048
+ It exits 0 when every engine passes every file, 1 otherwise (or when no engine is installed), 2 on
1049
+ a usage error, and prints one line per file and engine, the problems under it, then the engines not
1050
+ installed with the line that installs each. The engines are tools you install; the gem depends on
1051
+ none of them:
1052
+
1053
+ ```sh
1054
+ brew install qpdf poppler mupdf # apt-get install qpdf poppler-utils mupdf-tools
1055
+ pip install pypdfium2 # Python 3.11+; STATIONERY_PYTHON=/path/to/python names another
1056
+ npm i pdfjs-dist # STATIONERY_PDFJS=/path/to/node_modules/pdfjs-dist
1057
+ ```
1058
+
1059
+ PDFKit needs macOS and Swift (`xcode-select --install`). Poppler and MuPDF take `--password` on
1060
+ their command lines, where other processes of the machine can see it; qpdf reads it from a file and
1061
+ the scripted engines from the environment. pdfsig takes no password, so an encrypted file's
1062
+ signatures are not read by Poppler. A Docker image has every engine but PDFKit:
1063
+
1064
+ ```sh
1065
+ docker run --rm -v "$PWD:/data:ro" ghcr.io/zoolutions/stationery-verify /data/invoice.pdf
1066
+ ```
1067
+
1068
+ `bundle exec rake verify:readers` renders every example, the renders `verify:conformance`
1069
+ validates, an encrypted invoice, an invoice packed with object streams and a tagged report without
1070
+ them to `tmp/readers/`, and checks them (`ENGINES=qpdf,pdfkit` to choose). CI checks them with the
1071
+ image on Linux and with qpdf, Poppler, MuPDF and PDFKit on macOS. Adobe Acrobat cannot be driven
1072
+ in CI: before a release, the maintainer opens a fixed set of them in Acrobat Reader on macOS and
1073
+ Windows (see `AGENTS.md`).
1074
+
996
1075
  ### Factur-X / ZUGFeRD e-invoices
997
1076
 
998
1077
  A Factur-X (in Germany: ZUGFeRD) invoice is one PDF that people read and accounting software
@@ -1072,9 +1151,10 @@ ContractPdf.new.to_pdf(sign: { certificate:, key:, passphrase: "…" }) # per re
1072
1151
  time (`Time.now`).
1073
1152
  - `timestamp: "https://tsa.example/tsr"` asks an RFC 3161 time-stamping authority (TSA) to sign the
1074
1153
  signature value and the time it saw it, and carries that token in the signature's unsigned
1075
- attributes: PAdES baseline B-T. A reader can then trust the signing time, and the signature, after
1076
- the signer's certificate has expired. It is one HTTP request per render, with Ruby's own
1077
- `net/http`; `timestamp: { url:, username:, password:, hash: :sha256 }` adds HTTP basic auth or
1154
+ attributes: PAdES baseline B-T. The token proves when the signature existed, by a clock other
1155
+ than the signer's. Whether a viewer still accepts the signature once the signer's certificate
1156
+ has expired depends on its trust settings and on revocation data (B-LT), which the file does
1157
+ 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
1078
1158
  another digest (`:sha384`, `:sha512`), and `client:` (a callable given the request DER and
1079
1159
  answering the response DER) replaces the HTTP client. A TSA that cannot be reached, refuses, or
1080
1160
  answers for another request or another digest raises `Stationery::SignatureError`: the document
@@ -1196,7 +1276,8 @@ reach them, and holds the rows of the page being filled and the one after them,
1196
1276
  its first page:
1197
1277
 
1198
1278
  ```ruby
1199
- rows = [%w[Order Customer Total]].each + Order.find_each.lazy.map { |o| [o.number, o.customer, o.total] }
1279
+ orders = Order.preload(:customer).find_each.lazy.map { |o| [o.number, o.customer.name, o.total] }
1280
+ rows = [%w[Order Customer Total]].each + orders
1200
1281
  table(rows, header: true, widths: [80, 0.5, 90]) do |t|
1201
1282
  t.row(0).weight = :bold
1202
1283
  t.columns(2).align = :right
@@ -1265,7 +1346,7 @@ document.
1265
1346
  # spec/pdfs/previews/invoice_pdf_preview.rb
1266
1347
  class InvoicePdfPreview < Stationery::Preview
1267
1348
  def paid = InvoicePdf.new(Invoice.paid.first)
1268
- def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id", Invoice.overdue.first.id)))
1349
+ def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id") { Invoice.overdue.first.id }))
1269
1350
  end
1270
1351
  ```
1271
1352
 
@@ -1350,6 +1431,16 @@ Page 1 595.3 x 841.9 pt
1350
1431
  329.2 596.5 65.6 x 11.6 mailto:hello@acme.test
1351
1432
  ```
1352
1433
 
1434
+ ```sh
1435
+ stationery verify invoice.pdf # in every engine installed
1436
+ stationery verify invoice.rb --engines qpdf,pdfjs --json
1437
+ ```
1438
+
1439
+ `verify` reads PDFs, or the documents Ruby files define, with the engines
1440
+ viewers are built on (qpdf, Poppler, MuPDF, PDFium, pdf.js, PDFKit) and fails
1441
+ when one of them errs, warns or reads something the file does not hold. See
1442
+ [Viewers](#viewers-stationery-verify).
1443
+
1353
1444
  ```sh
1354
1445
  stationery fonts list # packs, licenses, what is in vendor/fonts
1355
1446
  stationery fonts install noto_sans liberation_serif # into vendor/fonts/<pack>/
@@ -1794,8 +1885,9 @@ A text line is a run of one font and size on one baseline, so a word set in bold
1794
1885
  own; lines read top to bottom, then left to right. An image is `{ x:, y:, width:, height:, pixels:
1795
1886
  [w, h] }` in the order drawn (the rectangle it fills, before any clip); a link has its `uri:`, or the
1796
1887
  `page:` and `top:` it goes to; a field has its full `name:`, `type:` (`:text`, `:choice`,
1797
- `:checkbox`, `:radio`, `:button`, `:signature`), `value:` and, for a button, the `state:` that turns
1798
- it on. `outline` is the bookmarks as `{ title:, page:, top:, children: }`. `metadata` leaves out the
1888
+ `:checkbox`, `:radio`, `:button`, `:signature`), `value:`, for a button the `state:` that turns
1889
+ it on, and for a text field or a choice the `align:`, `font_size:` (`:auto` for `0 Tf`, the number for `:fit`) and `color:`
1890
+ (`"#RRGGBB"`) its value is drawn in. `outline` is the bookmarks as `{ title:, page:, top:, children: }`. `metadata` leaves out the
1799
1891
  dates, which change with every render, so two renders of one document have the same layout.
1800
1892
  `warnings` are the render's messages when the subject is a document.
1801
1893
 
@@ -0,0 +1,87 @@
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, the price large,
5
+ # centred and red, the price per unit right-aligned in grey and a select of
6
+ # units. The name and price are fitted by the gem and the size it chose is
7
+ # written to the file (`font_size: :fit`), so every viewer shows them at
8
+ # that size. The fields have no border and no background, so the printed
9
+ # card shows the values alone; a viewer that redraws a field after an edit
10
+ # keeps its alignment, colour and size (/Q and /DA). Run it to write
11
+ # examples/price_card.pdf:
12
+ #
13
+ # ruby -Ilib examples/price_card.rb
14
+ # ruby -Ilib exe/stationery render examples/price_card.rb
15
+ require "stationery"
16
+
17
+ class ExamplePriceCard < Stationery::Document
18
+ INK = "#0F172A"
19
+ SALE = "#DC2626"
20
+ MUTED = "#64748B"
21
+ HAIRLINE = "#E2E8F0"
22
+ UNITS = ["per piece", "per kg", "per litre", "per pack"].freeze
23
+ BARE = { border: nil, background: nil }.freeze
24
+
25
+ page size: :a5, layout: :landscape, margin: 36
26
+ default_text size: 12, color: INK
27
+ metadata title: "Price card", author: "Corner Coffee Shop", creator: "stationery example", lang: "en"
28
+
29
+ footer do
30
+ rule height: 0.5, color: HAIRLINE
31
+ spacer 6
32
+ text "All prices include 25% VAT · Corner Coffee Shop, Stortorget 4, Lund", size: 8, color: MUTED,
33
+ align: :center
34
+ end
35
+
36
+ def self.preview
37
+ new(product: "Espresso machine Deluxe 3000 with milk frother", price: "4 990 kr",
38
+ unit_price: "Was 5 990 kr · save 1 000 kr", unit: "per piece")
39
+ end
40
+
41
+ def initialize(product: "", price: "", unit_price: "", unit: UNITS.first)
42
+ super()
43
+ @product = product
44
+ @price = price
45
+ @unit_price = unit_price
46
+ @unit = unit
47
+ end
48
+
49
+ def view_template
50
+ text "Corner Coffee Shop", size: 16, weight: :bold, align: :center
51
+ spacer 4
52
+ rule height: 1.5, color: INK
53
+ spacer 10
54
+ label "Product"
55
+ text_style(weight: :bold) do
56
+ text_field "product", value: @product, height: 44, font_size: :fit, max_font_size: 32, align: :center,
57
+ tooltip: "Product", **BARE
58
+ end
59
+ rule height: 0.5, color: HAIRLINE
60
+ spacer 10
61
+ label "Price"
62
+ text_style(weight: :bold) do
63
+ text_field "price", value: @price, height: 150, font_size: :fit, max_font_size: 120, align: :center,
64
+ color: SALE, tooltip: "Price", **BARE
65
+ end
66
+ spacer 6
67
+ row(gap: 12) do
68
+ column(width: 150) do
69
+ select "unit", options: UNITS, value: @unit, height: 22, align: :center, tooltip: "Unit", **BARE
70
+ end
71
+ column do
72
+ text_field "note", value: @unit_price, height: 22, align: :right, color: MUTED, tooltip: "Note", **BARE
73
+ end
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def label(title) = text(title.upcase, size: 8, color: MUTED, letter_spacing: 1, align: :center)
80
+ end
81
+
82
+ if $PROGRAM_NAME == __FILE__
83
+ card = ExamplePriceCard.preview
84
+ card.to_pdf(File.expand_path("price_card.pdf", __dir__))
85
+ warn card.warnings.map(&:message) if card.warnings.any?
86
+ puts "wrote examples/price_card.pdf"
87
+ 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
@@ -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"
@@ -9,7 +9,9 @@ module Stationery
9
9
  module Elements
10
10
  # A text input. `width:` is :full or points; `multiline:`, `max_length:`,
11
11
  # `comb:` (a cell count, or true with max_length:), `read_only:`,
12
- # `required:`, `font_size:`, `border:`, `background:` and `radius:`.
12
+ # `required:`, `font_size:` (points, or :auto or :fit with `min_font_size:`
13
+ # and `max_font_size:`), `align:` (:left, :center, :right), `color:`,
14
+ # `border:`, `background:` and `radius:`.
13
15
  def text_field(name, value: "", width: :full, height: 22, at: nil, **)
14
16
  field_node(Forms::Field.new(:text, name, value: value.to_s, typeface: field_typeface, **), width:, height:, at:)
15
17
  end
@@ -28,7 +30,7 @@ module Stationery
28
30
  end
29
31
 
30
32
  # A drop-down (combo box) of `options`; `editable: true` also accepts
31
- # typed values.
33
+ # typed values. `font_size:`, `align:` and `color:` as a text field's.
32
34
  def select(name, options:, value: nil, width: :full, height: 22, at: nil, **)
33
35
  field = Forms::Field.new(:select, name, value: value&.to_s, options: options.map(&:to_s),
34
36
  typeface: field_typeface, **)
@@ -16,14 +16,21 @@ module Stationery
16
16
  # The check mark's corners in the unit square (y down) and its stroke.
17
17
  CHECK = { points: [[0.22, 0.52], [0.42, 0.72], [0.78, 0.28]], stroke: 0.12 }.freeze
18
18
  DOT = 0.45
19
+ # What /DA and the appearance say when the value is black.
20
+ BLACK = "0 g"
19
21
  SIGNATURE = { rule: 14, label_size: 7, label_baseline: 4, label_gray: 0.42 }.freeze
20
22
 
23
+ # The size the value is drawn at: the field's, or what `font_size:
24
+ # :auto` or `:fit` fits (see AutoSize).
25
+ attr_reader :size
26
+
21
27
  # `resources` are the render's, where the field's fonts get their names.
22
28
  def initialize(field, width, height, resources = nil)
23
29
  @field = field
24
30
  @width = width
25
31
  @height = height
26
32
  @type = field.typeface.with(resources)
33
+ @size = field.sized? ? AutoSize.new(field, @type, width, height).size : field.font_size
27
34
  @contents = contents
28
35
  keep
29
36
  end
@@ -34,14 +41,19 @@ module Stationery
34
41
  # The resource names of the fonts it draws with or keeps for editing.
35
42
  def font_names = @type.names
36
43
 
37
- # What the field's /DA says: the font and size a viewer redraws the
38
- # value with. nil for a field without variable text.
44
+ # What the field's /DA says: the font, size (0 for `:auto`, the fitted
45
+ # number for `:fit`) and colour a viewer redraws the value with. nil for
46
+ # a field without variable text.
39
47
  def default_appearance
40
48
  return unless @field.variable_text?
41
49
 
42
- "/#{@type.name} #{num(@field.font_size)} Tf 0 g"
50
+ "/#{@type.name} #{@field.auto_size? ? 0 : num(size)} Tf #{text_color}"
43
51
  end
44
52
 
53
+ # What it paints: its content streams and its /DA, for a check of the
54
+ # colours they use (see PDF::Conformance).
55
+ def paints = [*(@contents.is_a?(Hash) ? @contents.values : @contents), default_appearance].compact
56
+
45
57
  # One stream, or a Hash of streams by appearance state for buttons.
46
58
  # `fonts` are the references by resource name.
47
59
  def streams(fonts)
@@ -54,7 +66,7 @@ module Stationery
54
66
  private
55
67
 
56
68
  def options = @field.options
57
- def size = @field.font_size
69
+ def text_color = @field.color == Field::BLACK ? BLACK : @field.color.fill
58
70
  def num(value) = PDF::Serializer.number(value.is_a?(Float) && value == value.round ? value.round : value)
59
71
 
60
72
  def contents
@@ -98,29 +110,23 @@ module Stationery
98
110
  return comb_cells if options[:comb]
99
111
  return multiline_runs if options[:multiline]
100
112
 
101
- [[PADDING, middle_baseline, @field.value.to_s]]
113
+ value = @field.value.to_s
114
+ [[start(value), middle_baseline, value]]
102
115
  end
103
116
 
104
117
  def multiline_runs
105
118
  top = @height - PADDING - @type.ascent(size)
106
- wrap(@field.value.to_s, @width - (2 * PADDING)).each_with_index.map do |line, index|
107
- [PADDING, top - (index * size * LEADING), line]
119
+ AutoSize.wrap(@type, @field.value.to_s, @width - (2 * PADDING), size).each_with_index.map do |line, index|
120
+ [start(line), top - (index * size * LEADING), line]
108
121
  end
109
122
  end
110
123
 
111
- # The value broken into lines no wider than `width`, keeping its own
112
- # line breaks; a word wider than a line stands alone.
113
- def wrap(text, width)
114
- text.split("\n", -1).flat_map do |paragraph|
115
- paragraph.split.each_with_object([+""]) do |word, lines|
116
- candidate = lines.last.empty? ? word : "#{lines.last} #{word}"
117
- if lines.last.empty? || @type.width(candidate, size) <= width
118
- lines[-1] = candidate
119
- else
120
- lines << word
121
- end
122
- end
123
- end
124
+ # Where a line starts: aligned within the box less its padding, as a
125
+ # viewer does (PDF 32000-1, 12.7.3.3).
126
+ def start(line)
127
+ return PADDING if @field.align == :left
128
+
129
+ PADDING + Geometry.align_offset(@field.align, @width - (2 * PADDING), @type.width(line, size))
124
130
  end
125
131
 
126
132
  def comb_cells
@@ -131,7 +137,7 @@ module Stationery
131
137
  end
132
138
 
133
139
  def variable_text(runs)
134
- ops = ["/Tx BMC", "q", "1 1 #{num(@width - 2)} #{num(@height - 2)} re W n", "0 g"]
140
+ ops = ["/Tx BMC", "q", "1 1 #{num(@width - 2)} #{num(@height - 2)} re W n", text_color]
135
141
  runs.reject { |_, _, text| text.empty? }.each do |x, y, text|
136
142
  ops.push("BT", "#{num(x)} #{num(y)} Td", *@type.show(text, size), "ET")
137
143
  end