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