stationery 0.12.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +3 -0
- data/README.md +104 -16
- data/examples/price_card.rb +85 -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 +26 -21
- data/lib/stationery/forms/auto_size.rb +94 -0
- data/lib/stationery/forms/field.rb +45 -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: a56459cfc65f70dab12835ced22ff09d88d5c7a66b8ea045ccb0776f75da27b6
|
|
4
|
+
data.tar.gz: 44889ebe589bed206a1b04940e6b24bad97f3be38f2b53179d8c98522c1ff7f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c2af80a6c317cff9cea03b5259c63d6a2bfa74730371713b490713fc0e8cb311d5b644d4a239ff49843f312cbdb20504dc9f363195df1af2784948f5af0250a
|
|
7
|
+
data.tar.gz: 2591078b7e474565a8f35338dac311800f5c175576428756ba111fef71dc1e68e4b971efe86e7c64b6458c0b162087b1b32e75da6f4d703df735aee7dc45bafc
|
data/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
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.
|
|
5
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`.
|
|
6
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.
|
|
7
10
|
|
|
8
11
|
## 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 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)
|
|
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,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:`
|
|
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
|
```
|
|
@@ -993,6 +1008,66 @@ docker run --rm -v "$PWD:/data:ro" verapdf/cli --format text -v --flavour 3b /da
|
|
|
993
1008
|
|
|
994
1009
|
In tests, `have_conformance(:pdf_a3b)` checks the claim (not the validity: that is veraPDF's job).
|
|
995
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
|
+
|
|
996
1071
|
### Factur-X / ZUGFeRD e-invoices
|
|
997
1072
|
|
|
998
1073
|
A Factur-X (in Germany: ZUGFeRD) invoice is one PDF that people read and accounting software
|
|
@@ -1072,9 +1147,10 @@ ContractPdf.new.to_pdf(sign: { certificate:, key:, passphrase: "…" }) # per re
|
|
|
1072
1147
|
time (`Time.now`).
|
|
1073
1148
|
- `timestamp: "https://tsa.example/tsr"` asks an RFC 3161 time-stamping authority (TSA) to sign the
|
|
1074
1149
|
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
|
-
|
|
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
|
|
1078
1154
|
another digest (`:sha384`, `:sha512`), and `client:` (a callable given the request DER and
|
|
1079
1155
|
answering the response DER) replaces the HTTP client. A TSA that cannot be reached, refuses, or
|
|
1080
1156
|
answers for another request or another digest raises `Stationery::SignatureError`: the document
|
|
@@ -1196,7 +1272,8 @@ reach them, and holds the rows of the page being filled and the one after them,
|
|
|
1196
1272
|
its first page:
|
|
1197
1273
|
|
|
1198
1274
|
```ruby
|
|
1199
|
-
|
|
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
|
|
1200
1277
|
table(rows, header: true, widths: [80, 0.5, 90]) do |t|
|
|
1201
1278
|
t.row(0).weight = :bold
|
|
1202
1279
|
t.columns(2).align = :right
|
|
@@ -1265,7 +1342,7 @@ document.
|
|
|
1265
1342
|
# spec/pdfs/previews/invoice_pdf_preview.rb
|
|
1266
1343
|
class InvoicePdfPreview < Stationery::Preview
|
|
1267
1344
|
def paid = InvoicePdf.new(Invoice.paid.first)
|
|
1268
|
-
def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id"
|
|
1345
|
+
def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id") { Invoice.overdue.first.id }))
|
|
1269
1346
|
end
|
|
1270
1347
|
```
|
|
1271
1348
|
|
|
@@ -1350,6 +1427,16 @@ Page 1 595.3 x 841.9 pt
|
|
|
1350
1427
|
329.2 596.5 65.6 x 11.6 mailto:hello@acme.test
|
|
1351
1428
|
```
|
|
1352
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
|
+
|
|
1353
1440
|
```sh
|
|
1354
1441
|
stationery fonts list # packs, licenses, what is in vendor/fonts
|
|
1355
1442
|
stationery fonts install noto_sans liberation_serif # into vendor/fonts/<pack>/
|
|
@@ -1794,8 +1881,9 @@ A text line is a run of one font and size on one baseline, so a word set in bold
|
|
|
1794
1881
|
own; lines read top to bottom, then left to right. An image is `{ x:, y:, width:, height:, pixels:
|
|
1795
1882
|
[w, h] }` in the order drawn (the rectangle it fills, before any clip); a link has its `uri:`, or the
|
|
1796
1883
|
`page:` and `top:` it goes to; a field has its full `name:`, `type:` (`:text`, `:choice`,
|
|
1797
|
-
`:checkbox`, `:radio`, `:button`, `:signature`), `value
|
|
1798
|
-
it on
|
|
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
|
|
1799
1887
|
dates, which change with every render, so two renders of one document have the same layout.
|
|
1800
1888
|
`warnings` are the render's messages when the subject is a document.
|
|
1801
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
|
|
@@ -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 with `min_font_size:` and
|
|
13
|
+
# `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` 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.auto_size? ? AutoSize.new(field, @type, width, height).size : field.font_size
|
|
27
34
|
@contents = contents
|
|
28
35
|
keep
|
|
29
36
|
end
|
|
@@ -34,14 +41,18 @@ 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
|
-
# value with. nil for a field without variable text.
|
|
44
|
+
# What the field's /DA says: the font, size (0 for auto) and colour a
|
|
45
|
+
# viewer redraws the value with. nil for a field without variable text.
|
|
39
46
|
def default_appearance
|
|
40
47
|
return unless @field.variable_text?
|
|
41
48
|
|
|
42
|
-
"/#{@type.name} #{
|
|
49
|
+
"/#{@type.name} #{@field.auto_size? ? 0 : num(size)} Tf #{text_color}"
|
|
43
50
|
end
|
|
44
51
|
|
|
52
|
+
# What it paints: its content streams and its /DA, for a check of the
|
|
53
|
+
# colours they use (see PDF::Conformance).
|
|
54
|
+
def paints = [*(@contents.is_a?(Hash) ? @contents.values : @contents), default_appearance].compact
|
|
55
|
+
|
|
45
56
|
# One stream, or a Hash of streams by appearance state for buttons.
|
|
46
57
|
# `fonts` are the references by resource name.
|
|
47
58
|
def streams(fonts)
|
|
@@ -54,7 +65,7 @@ module Stationery
|
|
|
54
65
|
private
|
|
55
66
|
|
|
56
67
|
def options = @field.options
|
|
57
|
-
def
|
|
68
|
+
def text_color = @field.color == Field::BLACK ? BLACK : @field.color.fill
|
|
58
69
|
def num(value) = PDF::Serializer.number(value.is_a?(Float) && value == value.round ? value.round : value)
|
|
59
70
|
|
|
60
71
|
def contents
|
|
@@ -98,29 +109,23 @@ module Stationery
|
|
|
98
109
|
return comb_cells if options[:comb]
|
|
99
110
|
return multiline_runs if options[:multiline]
|
|
100
111
|
|
|
101
|
-
|
|
112
|
+
value = @field.value.to_s
|
|
113
|
+
[[start(value), middle_baseline, value]]
|
|
102
114
|
end
|
|
103
115
|
|
|
104
116
|
def multiline_runs
|
|
105
117
|
top = @height - PADDING - @type.ascent(size)
|
|
106
|
-
wrap(@field.value.to_s, @width - (2 * PADDING)).each_with_index.map do |line, index|
|
|
107
|
-
[
|
|
118
|
+
AutoSize.wrap(@type, @field.value.to_s, @width - (2 * PADDING), size).each_with_index.map do |line, index|
|
|
119
|
+
[start(line), top - (index * size * LEADING), line]
|
|
108
120
|
end
|
|
109
121
|
end
|
|
110
122
|
|
|
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
|
|
123
|
+
# Where a line starts: aligned within the box less its padding, as a
|
|
124
|
+
# viewer does (PDF 32000-1, 12.7.3.3).
|
|
125
|
+
def start(line)
|
|
126
|
+
return PADDING if @field.align == :left
|
|
127
|
+
|
|
128
|
+
PADDING + Geometry.align_offset(@field.align, @width - (2 * PADDING), @type.width(line, size))
|
|
124
129
|
end
|
|
125
130
|
|
|
126
131
|
def comb_cells
|
|
@@ -131,7 +136,7 @@ module Stationery
|
|
|
131
136
|
end
|
|
132
137
|
|
|
133
138
|
def variable_text(runs)
|
|
134
|
-
ops = ["/Tx BMC", "q", "1 1 #{num(@width - 2)} #{num(@height - 2)} re W n",
|
|
139
|
+
ops = ["/Tx BMC", "q", "1 1 #{num(@width - 2)} #{num(@height - 2)} re W n", text_color]
|
|
135
140
|
runs.reject { |_, _, text| text.empty? }.each do |x, y, text|
|
|
136
141
|
ops.push("BT", "#{num(x)} #{num(y)} Td", *@type.show(text, size), "ET")
|
|
137
142
|
end
|