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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +108 -16
- data/examples/price_card.rb +87 -0
- data/lib/stationery/cli/layout_text.rb +14 -2
- data/lib/stationery/cli/verify.rb +98 -0
- data/lib/stationery/cli.rb +1 -0
- data/lib/stationery/elements/forms.rb +4 -2
- data/lib/stationery/forms/appearance.rb +27 -21
- data/lib/stationery/forms/auto_size.rb +94 -0
- data/lib/stationery/forms/field.rb +49 -3
- data/lib/stationery/monochrome/painting.rb +10 -0
- data/lib/stationery/pdf/conformance.rb +14 -1
- data/lib/stationery/skill/recipes/form.md.erb +9 -6
- data/lib/stationery/skill/recipes/invoice.md.erb +13 -9
- data/lib/stationery/skill/recipes/label.md.erb +4 -2
- data/lib/stationery/skill/recipes/report.md.erb +9 -4
- data/lib/stationery/testing/inspector.rb +8 -5
- data/lib/stationery/testing/layout.rb +18 -1
- data/lib/stationery/verify/allowlist.rb +29 -0
- data/lib/stationery/verify/command.rb +80 -0
- data/lib/stationery/verify/comparison.rb +191 -0
- data/lib/stationery/verify/engine.rb +63 -0
- data/lib/stationery/verify/engines/mupdf.rb +44 -0
- data/lib/stationery/verify/engines/pdfium.rb +35 -0
- data/lib/stationery/verify/engines/pdfjs.rb +41 -0
- data/lib/stationery/verify/engines/pdfkit.rb +20 -0
- data/lib/stationery/verify/engines/poppler.rb +83 -0
- data/lib/stationery/verify/engines/qpdf.rb +46 -0
- data/lib/stationery/verify/expectation.rb +44 -0
- data/lib/stationery/verify/report.rb +49 -0
- data/lib/stationery/verify/scripts/pdfium.py +99 -0
- data/lib/stationery/verify/scripts/pdfjs.mjs +108 -0
- data/lib/stationery/verify/scripts/pdfkit.swift +80 -0
- data/lib/stationery/verify.rb +89 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery.rb +1 -0
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43646ec58820f24924dfbcaaf0bdd8170d5251699f8a0e052560b03dde2d343a
|
|
4
|
+
data.tar.gz: 651b72e03ce4cd1d31cf713e729b58024bc9145fe7d3c31ea3732e87436771ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
309
|
-
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
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:`
|
|
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.
|
|
1076
|
-
the signer's
|
|
1077
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
|
1798
|
-
it on
|
|
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
|
data/lib/stationery/cli.rb
CHANGED
|
@@ -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
|
|
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
|
|
38
|
-
#
|
|
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} #{
|
|
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
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
#
|
|
112
|
-
#
|
|
113
|
-
def
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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",
|
|
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
|