stationery 0.7.0 → 0.8.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f9e83f23437589a026e0a9a2fde163a39a2c4151c434afc5bdbea38a98891dd
4
- data.tar.gz: 67e42695ae205e05049441490b89b908f6468ba94e00c095c1de8eaeae986d65
3
+ metadata.gz: 21d52a1f02122474b5316373c70965424f3479973757219d2b7b87ed453e8642
4
+ data.tar.gz: eaa7be7242e9f38e270cb23a533bac9038963c3e61c44c1e57ebaefe0288ef29
5
5
  SHA512:
6
- metadata.gz: 40756fe30e4c2d400fab7402e490609cf313e8ae06c5317489f6b0337f96fc5ad00d8f2c8b05b348812955b109cc0f9685f9c1668b8f307d1fd16ac9492facad
7
- data.tar.gz: 1a7938f3b608b68df057d3385d5824dc6a57338978a76ad39bed42701838ee2a408db1c2d30daccf3a834b03bea9f6d1441ef6405691dff0090611d26cbe05a0
6
+ metadata.gz: 0dd292c0de3767e870563e954a9ea1b466beed56af675209d69baa09f7c44e337117a5b7256e41b4a35e39f694f0cfc721f2729cd1af8f7dc6081712a4263034
7
+ data.tar.gz: 73286d789a65a258a026fc65fe8bbc1287dfd2df6ee13d07177cac13beba4f0e7bbcbdfc4bbafc4b3643d762efeb6a3cfb9cb6ab019e6a14c1351a920bc998db
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 (2026-09-28)
4
+
5
+ Compliance: PDF/A-3b, PDF/UA-1 and Factur-X e-invoices, verified with veraPDF and Mustang in CI.
6
+
7
+ - Factur-X / ZUGFeRD: `factur_x(profile: :en16931) { @invoice.to_cii_xml }` (a block, a method name or the XML itself; also `to_pdf(factur_x:)`) embeds the invoice XML as `factur-x.xml` (`xrechnung.xml` for `:xrechnung`) with the relationship the profile requires, writes the `fx:` XMP properties with their extension-schema description and turns on PDF/A-3b. Profiles `:minimum`, `:basic_wl`, `:basic`, `:en16931`, `:extended`, `:xrechnung`. The gem does not build the XML; `examples/e_invoice.rb` shows a minimal EN 16931 document. `Inspector#factur_x`, `have_factur_x`, `assert_factur_x`; `rake verify:factur_x` validates with Mustang.
8
+ - PDF/A and PDF/UA: `conformance :pdf_a3b` (or `:pdf_a2b`) and `conformance :pdf_ua1`, combinable, at class level or `to_pdf(conformance:)`. PDF/A writes an sRGB output intent (the ICC's `sRGB2014.icc`, bundled with its licence), `pdfaid` identification and printable link annotations, and refuses encryption and, under A-2b, attachments. PDF/UA turns `tagged` on, requires `metadata title:` and `lang:`, writes `pdfuaid:part 1`, tab order and link descriptions, and raises `ConformanceError` for a figure without `alt:`. CMYK colour under PDF/A is reported as `Warnings::ConformanceIssue`. Form fields are refused under every level until their appearances embed their fonts. `Inspector#conformance`, `have_conformance`, `assert_pdf_conformance`; `rake verify:conformance` runs veraPDF through Docker, and CI runs it on the examples.
9
+ - XMP metadata: every document carries an XMP packet mirroring the Info dictionary (`dc:title`, `dc:creator`, `dc:description`, `dc:subject`, `dc:language`, `xmp:CreateDate`/`ModifyDate`/`MetadataDate`, `xmp:CreatorTool`, `pdf:Producer`, `pdf:Keywords`) as an uncompressed `/Metadata` stream; `metadata xmp: false` or `to_pdf(xmp: false)` opts out. `Inspector#xmp` and `#xmp_values`.
10
+ - Embedded files: `attach_file "terms.pdf", data, mime:, description:, relationship:, modified_at:` at class level, `to_pdf(attachments: [...])` per render. Each file is a `/Filespec` in the catalog's `/EmbeddedFiles` name tree and `/AF`, with `/AFRelationship`, size, checksum and date; streams are compressed and encrypted with the document. `Inspector#attachments`, `have_attachment`, `assert_pdf_attachment`.
11
+ - SVG: `use` (`href` or `xlink:href`, onto shapes, groups, text, symbols and other `use` elements, with cycle detection), `symbol` and nested `svg` viewports (`viewBox`, `preserveAspectRatio` with `meet`/`slice`/`none`, clipped unless `overflow="visible"`), and `clipPath` (`clip-path="url(#id)"` on shapes, groups, text and `use`; `clip-rule`, `clipPathUnits="objectBoundingBox"`, nested clip paths). The `color` property feeds `currentColor`, so one sprite symbol can be drawn in several colours. `image`, `mask`, `pattern`, `filter` and `textPath` stay unsupported and reported.
12
+ - CLI: `stationery render file.rb` renders the document the file itself defines, so a file that subclasses a document from another file no longer asks for `--class`.
13
+
3
14
  ## 0.7.0 (2026-09-28)
4
15
 
5
16
  Typography and images: hyphenation, OpenType features, oversized-image warnings, page labels.
data/README.md CHANGED
@@ -70,7 +70,7 @@ InvoicePdf.new(invoice).to_pdf # => "%PDF-1.7…" (binary String)
70
70
  InvoicePdf.new(invoice).to_pdf("a.pdf") # also writes a path or an IO
71
71
  ```
72
72
 
73
- `examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form, postcard collage and event flyer
73
+ `examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form, postcard collage, event flyer and Factur-X e-invoice
74
74
  ([previews and live PDFs](https://stationery.zoolutions.llc/docs/examples)); `bundle exec rake examples`
75
75
  renders them all, or render one with `stationery render examples/report.rb`.
76
76
 
@@ -85,7 +85,7 @@ renders them all, or render one with `stationery render examples/report.rb`.
85
85
  | `row(gap:, align:, break_inside:) { column(width:) { } }` | Columns side by side. `width:` is points, a fraction (`0.5`), `:auto` or `nil` (equal share). Splits across pages like a box, every column at once; a row with a fixed-height column never splits; columns with `min_height:` do. |
86
86
  | `table(rows, widths:, width:, header:, split_rows:, cell:) { \|t\| }` | Tables. Cells are strings, layout nodes, procs built with the DSL (`-> { image logo }`) or components. Style with `t.row(0)`, `t.rows(-1)`, `t.column(1)`, `t.columns(1..)`, chained, plus `t.zebra`. Header rows repeat after a page break. A cell may be `{ content:, colspan:, rowspan: }` plus any cell option; rows list only the cells they start, as in HTML, and pages never break through a rowspan. Spans are set in the rows, not through selections. A row taller than the page continues on the next page, cut through its cells, with the header repeated; `split_rows: true` cuts any row that reaches the page bottom instead of moving it whole. |
87
87
  | `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:, max_ppi:, downscale:)` | JPEG or PNG, aspect preserved. `fit: [w, h]` scales to fit inside; `fit: :cover` fills `width:` × `height:` and crops around the centre. `radius:` rounds the corners; `rotate:` turns it (degrees, clockwise) without changing the space it takes. Drawn at more than twice `max_ppi:` (300) it is reported as oversized; `downscale: true` resamples a PNG to that resolution instead. |
88
- | `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:`. Linear and radial gradients (`fill="url(#id)"`, `href` chains, both gradient units); `text`/`tspan` in the document's fonts; `<style>` stylesheets (element, class, id and `*` selectors). |
88
+ | `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:` (or the `color` an element sets). Linear and radial gradients (`fill="url(#id)"`, `href` chains, both gradient units); `text`/`tspan` in the document's fonts; `<style>` stylesheets (element, class, id and `*` selectors); `use`, `symbol` sprites and nested `svg` viewports (`viewBox`, `preserveAspectRatio`); `clipPath` (both `clipPathUnits`). |
89
89
  | `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
90
90
  | `stack(gap:, align:) { }` | A base with layers painted over it: ordinary children set the height, `layer` children float over them and take no space. Moves to the next page whole. |
91
91
  | `layer(top:, right:, bottom:, left:, width:, height:, **box) { }` | Inside `stack`: a box placed by insets from the stack's edges, in points or as a fraction (`0.4`, `1/3r`) of its width/height; negative insets overhang. Takes every box option (`rotate:`, `shadow:`, `radius:`, …). |
@@ -294,6 +294,27 @@ InvoicePdf.new(invoice).to_pdf(encrypt: nil) # plain
294
294
  `:aes_128` for older viewers; `:rc4_128` only for legacy readers that need it.
295
295
  - Every string and stream is encrypted, the document info included.
296
296
 
297
+ ### Embedded files
298
+
299
+ ```ruby
300
+ class InvoicePdf < Stationery::Document
301
+ attach_file "factur-x.xml", invoice_xml, mime: "text/xml", description: "Factur-X",
302
+ relationship: :alternative # every render
303
+ end
304
+
305
+ InvoicePdf.new(invoice).to_pdf(attachments: [{ name: "terms.pdf", data: terms, mime: "application/pdf" }])
306
+ ```
307
+
308
+ - Each file becomes a `/Filespec` with an `/EmbeddedFile` stream (`/Subtype` from `mime:`, `/Params`
309
+ with size, MD5 checksum and `modified_at:`), listed in the catalog's `/EmbeddedFiles` name tree
310
+ and its `/AF` array; `description:` is the `/Desc` viewers show.
311
+ - `relationship:` writes `/AFRelationship`: `:alternative` (a machine-readable twin, as Factur-X
312
+ and ZUGFeRD require), `:source`, `:data`, `:supplement` or `:unspecified` (default).
313
+ - Per-render `attachments:` are added to the class-level ones; the same name twice raises
314
+ `ArgumentError`. An encrypted document encrypts the embedded streams too.
315
+ - For an e-invoice, [`factur_x`](#factur-x--zugferd-e-invoices) embeds the XML, claims PDF/A-3b
316
+ and writes the identification in one line.
317
+
297
318
  ### Accessibility (tagged PDF)
298
319
 
299
320
  ```ruby
@@ -331,6 +352,10 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
331
352
  - Headers, footers and page templates are pagination artifacts; backgrounds, borders and rules drawn
332
353
  outside any element are layout artifacts.
333
354
  - `metadata lang:` writes the catalog's `/Lang`; the title is shown instead of the file name.
355
+ - Every document carries an XMP packet (`/Metadata`, uncompressed) mirroring the Info dictionary:
356
+ `dc:title`, `dc:creator`, `dc:description`, `dc:subject`, `dc:language`, the `xmp:` dates and
357
+ `pdf:Producer`. PDF/A and PDF/UA identification lives there (see
358
+ [PDF/A and PDF/UA](#pdfa-and-pdfua)); `metadata xmp: false` or `to_pdf(xmp: false)` leaves it out.
334
359
  - An image or drawing without `alt:` (`Warnings::MissingAlt`) and a missing `lang`
335
360
  (`Warnings::MissingLanguage`) are warnings, so `strict` catches them.
336
361
  - Untagged documents (the default) are written exactly as before.
@@ -345,8 +370,94 @@ expect(ReportPdf.new).to have_structure(
345
370
  )
346
371
  ```
347
372
 
348
- The PDF is not labelled PDF/UA (no XMP `pdfuaid` metadata), and nothing checks colour contrast or
349
- reading order you build out of positioned boxes (`box(at:)` joins the reading order where it paints).
373
+ `tagged` alone does not label the file; `conformance :pdf_ua1` does (see
374
+ [PDF/A and PDF/UA](#pdfa-and-pdfua)). Nothing checks colour contrast or the reading order you build
375
+ out of positioned boxes (`box(at:)` joins the reading order where it paints).
376
+
377
+ ### PDF/A and PDF/UA
378
+
379
+ ```ruby
380
+ class InvoicePdf < Stationery::Document
381
+ conformance :pdf_a3b # archival: :pdf_a2b or :pdf_a3b
382
+ end
383
+
384
+ class ReportPdf < Stationery::Document
385
+ conformance :pdf_a3b, :pdf_ua1 # archival and accessible
386
+ metadata title: "Annual report 2026", lang: "en"
387
+ end
388
+
389
+ InvoicePdf.new(invoice).to_pdf(conformance: nil) # one render without the claim, or with another
390
+ ```
391
+
392
+ A level is only claimed when the file keeps it, so what cannot conform raises instead of being
393
+ mislabelled: `ArgumentError` for options that contradict the level, `Stationery::ConformanceError`
394
+ (`levels`, `issues`) for content.
395
+
396
+ - **PDF/A-2b and PDF/A-3b** (ISO 19005, level B: the look is reproducible): an sRGB
397
+ `OutputIntent` with the embedded ICC profile (the ICC's `sRGB2014.icc`), `pdfaid:part` and
398
+ `pdfaid:conformance` in the XMP packet (written even with `xmp: false`), the print flag on every
399
+ annotation. Fonts are always embedded and subsetted, and transparency is allowed from part 2 on.
400
+ `encrypt:` raises. Embedded files need `:pdf_a3b` (part 2 only embeds PDF/A files, so
401
+ `attach_file` raises there). CMYK colours and CMYK JPEGs are not covered by the sRGB intent and
402
+ are reported as `Warnings::ConformanceIssue`, so `strict` refuses them.
403
+ - **PDF/UA-1** (ISO 14289): turns `tagged` on, needs `metadata title:` and `lang:`, writes
404
+ `pdfuaid:part`, shows the title in the viewer, orders tabs by structure (`/Tabs /S`) and gives
405
+ every link annotation a description (`/Contents`: the URL, or the target page). A figure without
406
+ `alt:` raises; mark decoration with `alt: false`. Encryption is allowed.
407
+ - Combined, the XMP packet also describes the `pdfuaid` schema to PDF/A (`pdfaExtension:schemas`).
408
+ - Interactive form fields raise under every level: their appearances draw with the standard
409
+ Helvetica and ZapfDingbats, which are not embedded.
410
+ - Without `conformance` nothing changes: the output is byte for byte what it was.
411
+
412
+ `bundle exec rake verify:conformance` renders `examples/invoice.rb` as PDF/A-3b and
413
+ `examples/report.rb` as PDF/A-3b plus PDF/UA-1 and validates them with
414
+ [veraPDF](https://verapdf.org) through Docker (`verapdf/cli`); CI runs it on every push. Validate
415
+ your own documents the same way:
416
+
417
+ ```sh
418
+ docker run --rm -v "$PWD:/data:ro" verapdf/cli --format text -v --flavour 3b /data/invoice.pdf
419
+ ```
420
+
421
+ In tests, `have_conformance(:pdf_a3b)` checks the claim (not the validity: that is veraPDF's job).
422
+
423
+ ### Factur-X / ZUGFeRD e-invoices
424
+
425
+ A Factur-X (in Germany: ZUGFeRD) invoice is one PDF that people read and accounting software
426
+ books: a PDF/A-3b file with the invoice as Cross Industry Invoice XML embedded in it.
427
+
428
+ ```ruby
429
+ class InvoicePdf < Stationery::Document
430
+ metadata title: "Invoice", lang: "en"
431
+ factur_x(profile: :en16931) { @invoice.to_cii_xml } # evaluated in the document, per render
432
+ # factur_x :invoice_xml # or a method name
433
+ # factur_x File.read("factur-x.xml") # or the XML itself
434
+
435
+ def initialize(invoice) = (super(); @invoice = invoice)
436
+ end
437
+
438
+ InvoicePdf.new(invoice).to_pdf(factur_x: { xml:, profile: :extended }) # per render; nil for none
439
+ ```
440
+
441
+ - `factur_x` claims `conformance :pdf_a3b` (added to declared levels such as `:pdf_ua1`;
442
+ `:pdf_a2b` raises), embeds the XML as `factur-x.xml` (`text/xml`, described as "Factur-X
443
+ Invoice", stamped with its modification date) and writes the `fx:` identification in XMP
444
+ (`DocumentType INVOICE`, `DocumentFileName`, `Version`, `ConformanceLevel`) with the extension
445
+ schema description PDF/A asks for.
446
+ - `profile:` is `:minimum`, `:basic_wl`, `:basic`, `:en16931` (default), `:extended` or
447
+ `:xrechnung` (embedded as `xrechnung.xml`). The XML is the page's `:alternative`, except for
448
+ `:minimum` and `:basic_wl`, whose XML is `:data` beside it. `filename:`, `version:` ("1.0")
449
+ and `relationship:` override the defaults (ZUGFeRD 1.0 used `ZUGFeRD-invoice.xml`).
450
+ - Stationery carries the XML, it does not write or validate it: anything that does not start
451
+ with `<?xml` or `<rsm:CrossIndustryInvoice` raises `ArgumentError`, and what is inside is your
452
+ invoicing code's. `examples/e_invoice.rb` builds a minimal EN 16931 document from the example
453
+ invoice's lines.
454
+ - Everything PDF/A-3b asks applies: no `encrypt:`, no form fields.
455
+ - Without `factur_x` nothing changes.
456
+
457
+ `bundle exec rake verify:factur_x` validates `examples/e_invoice.rb` with the
458
+ [Mustang](https://www.mustangproject.org) validator through Docker: the PDF/A-3 container and the
459
+ XML against the EN 16931 schema and business rules. CI runs it with `verify:conformance`. In
460
+ tests, `have_factur_x(profile: :en16931)` checks that the invoice is named in XMP and embedded.
350
461
 
351
462
  ### Debugging
352
463
 
@@ -598,6 +709,9 @@ RSpec.describe InvoicePdf do
598
709
  it { is_expected.to have_no_warnings }
599
710
  it { is_expected.to have_pdf_language("en") } # the catalog /Lang from `metadata lang:`
600
711
  it { is_expected.to have_page_labels(%w[i ii 1 2]) } # from `page_labels`
712
+ it { is_expected.to have_attachment("factur-x.xml", mime: "text/xml", relationship: :alternative) }
713
+ it { is_expected.to have_conformance(:pdf_a3b) } # the level claimed in XMP, from `conformance`
714
+ it { is_expected.to have_factur_x(profile: :en16931) } # the e-invoice XML, named in XMP and embedded
601
715
  it { is_expected.to have_tagged_content } # a tagged PDF with every text tagged or an artifact
602
716
  it { is_expected.to have_structure([[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]) }
603
717
  end
@@ -620,6 +734,9 @@ class InvoicePdfTest < Minitest::Test
620
734
  assert_no_pdf_warnings pdf
621
735
  assert_pdf_language pdf, "en"
622
736
  assert_page_labels pdf, %w[i ii 1 2]
737
+ assert_pdf_attachment pdf, "factur-x.xml", mime: "text/xml"
738
+ assert_pdf_conformance pdf, :pdf_a3b
739
+ assert_factur_x pdf, profile: :en16931
623
740
  assert_tagged_content pdf
624
741
  assert_pdf_structure pdf, [[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]
625
742
  end
@@ -631,7 +748,10 @@ The matcher names carry a `pdf_` prefix so they never clash with Capybara's
631
748
  titles. The RSpec matchers compose like the built-ins: `.and` / `.or`, and inside
632
749
  `all`, `include` or `match`. For anything else, `Stationery::Testing::Inspector.new(subject)`
633
750
  exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
634
- `image_count`, `bookmarks`, `metadata`, `lang`, `page_labels`, `warnings`, `tagged?`, `untagged_text` and
751
+ `image_count`, `bookmarks`, `metadata`, `xmp` (the packet), `xmp_values` (`{ "dc:title" => …, "dc:creator" => […] }`),
752
+ `lang`, `page_labels`, `attachments`, `conformance` (`[:pdf_a3b, :pdf_ua1]`), `factur_x`
753
+ (`{ profile:, filename:, version:, xml: }`), `warnings`, `tagged?`,
754
+ `untagged_text` and
635
755
  `structure` — a tagged PDF's structure tree as nested arrays, each element's text
636
756
  read from its marked content: `[type, "text"]`, `[type, [children]]` (its own text
637
757
  between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
@@ -708,8 +828,10 @@ in the chain has are drawn as `.notdef` and reported. Text runs left to right; h
708
828
  patterns are bundled for English, German and Swedish only (a soft hyphen works in any language),
709
829
  and justification only widens spaces.
710
830
 
711
- SVG covers the shapes, gradients, text and stylesheets icon sets use, nothing else: `use`,
712
- `image`, `clipPath`, `mask`, `pattern`, `filter` and `textPath` are skipped and reported.
831
+ SVG covers the shapes, gradients, text, stylesheets, `use`/`symbol` sprites and clip paths that icon
832
+ sets and exports use, nothing else: `image`, `mask`, `pattern`, `filter` and `textPath` are skipped
833
+ and reported, text inside a `clipPath` does not clip, and the shapes of a clip path join into one
834
+ path, so overlapping shapes wound in opposite directions cancel where they overlap.
713
835
  Images are JPEG and PNG (non-interlaced) and never fetched from a URL; a JPEG is embedded at its
714
836
  source resolution (only PNGs can be downscaled), so an oversized one is reported, not resized. `html` and `markdown` render structure and inline marks, not CSS: only `text-align`
715
837
  and inline `font-weight`/`font-style` are read, and raw HTML inside Markdown stays literal text.
@@ -719,9 +841,10 @@ splits only when every column can; a rotated box and a `stack` move to the next
719
841
  does not wrap around images. Link and form-widget rectangles stay in page space inside `rotate`
720
842
  and `transform`, and `shadow:` is stacked rectangles, not a blur.
721
843
 
722
- PDF: tagged output is not labelled PDF/UA (no XMP), and there is no PDF/A or PDF/X, no digital
723
- signing (`signature_field` is an empty field), no embedded files or JavaScript.
724
- Form-field appearances use Helvetica (Windows-1252), not the document's fonts.
844
+ PDF: PDF/A-2b, PDF/A-3b and PDF/UA-1 only (no PDF/A-1, no level A or U, no PDF/UA-2, no PDF/X); no
845
+ digital signing (`signature_field` is an empty field) and no JavaScript.
846
+ Form-field appearances use Helvetica (Windows-1252), not the document's fonts, which also keeps
847
+ forms out of PDF/A and PDF/UA documents.
725
848
 
726
849
  ## License
727
850
 
@@ -67,6 +67,25 @@ module Stationery
67
67
  end
68
68
  end
69
69
 
70
+ # A path the block traces in top-left space, under `transform` when given,
71
+ # for #clip_to.
72
+ def outline(transform: nil, &)
73
+ Path.new(self, transform:).tap(&)
74
+ end
75
+
76
+ # Paints the block inside `outlines` (see #outline), joined into one
77
+ # clipping path under the nonzero rule or, with `even_odd:`, the even-odd
78
+ # one. Without a path to clip to, nothing is inside and the block is skipped.
79
+ def clip_to(outlines, even_odd: false)
80
+ paths = outlines.map(&:to_s).reject(&:empty?)
81
+ return if paths.empty?
82
+
83
+ save do
84
+ emit(*paths, even_odd ? "W* n" : "W n")
85
+ yield self
86
+ end
87
+ end
88
+
70
89
  def fill_rect(x, y, w, h, color:, opacity: nil)
71
90
  shape(fill: color, opacity:) { |p| p.rect(x, y, w, h) }
72
91
  end
@@ -57,19 +57,19 @@ module Stationery
57
57
  OK
58
58
  end
59
59
 
60
- # Documents that are new after loading FILE or whose view_template lives
61
- # in it (so a file already loaded by the host still resolves).
60
+ # Documents that are new after loading FILE or that it defines (so a
61
+ # file already loaded by the host still resolves).
62
62
  def load_documents(path)
63
63
  before = descendants(Document)
64
64
  load(path)
65
65
  after = descendants(Document)
66
- (after - before) | after.select { |klass| template_file(klass) == path }
66
+ (after - before) | after.select { |klass| defined_in?(klass, path) }
67
67
  end
68
68
 
69
69
  def pick(candidates, path)
70
70
  return named_class if @options[:class]
71
71
 
72
- candidates = candidates.reject { |klass| template_file(klass).nil? }
72
+ candidates = local(candidates.reject { |klass| template_file(klass).nil? }, path)
73
73
  raise Error, "no Stationery::Document defined in #{path}" if candidates.empty?
74
74
  return candidates.first if candidates.one?
75
75
 
@@ -77,6 +77,21 @@ module Stationery
77
77
  raise Error, "several documents defined in #{path} (#{names}); choose one with --class NAME"
78
78
  end
79
79
 
80
+ # The documents FILE itself defines, when it also loaded others (a
81
+ # document extending one from a file it requires).
82
+ def local(candidates, path)
83
+ defined_here = candidates.select { |klass| defined_in?(klass, path) }
84
+ defined_here.any? ? defined_here : candidates
85
+ end
86
+
87
+ # Whether FILE opens the class or holds its own view_template.
88
+ def defined_in?(klass, path)
89
+ method = klass.instance_method(:view_template)
90
+ return true if method.owner == klass && method.source_location&.first == path
91
+
92
+ !klass.name.nil? && Object.const_source_location(klass.name)&.first == path
93
+ end
94
+
80
95
  def named_class
81
96
  klass = Object.const_get(@options[:class])
82
97
  raise Error, "#{klass} is not a Stationery::Document" unless klass.is_a?(Class) && klass < Document
@@ -24,7 +24,7 @@ module Stationery
24
24
  superclass.config.transform_values(&:dup)
25
25
  else
26
26
  { page: { size: :letter, margin: 36 }, families: {}, fallbacks: [], text: {}, metadata: {},
27
- templates: [], regions: [], strict: false, tagged: false, images: {} }
27
+ templates: [], regions: [], strict: false, tagged: false, images: {}, attachments: [] }
28
28
  end
29
29
  end
30
30
 
@@ -58,6 +58,14 @@ module Stationery
58
58
  config[:page_labels] = spec
59
59
  end
60
60
 
61
+ # Embeds a file in every render: `attach_file "invoice.xml", xml,
62
+ # mime: "text/xml", description: "Factur-X", relationship: :alternative`.
63
+ # `relationship:` is :alternative, :source, :data, :supplement or
64
+ # :unspecified (the /AFRelationship); `modified_at:` a Time.
65
+ def attach_file(name, data, **)
66
+ config[:attachments] << PDF::Attachments.build(name, data, **)
67
+ end
68
+
61
69
  # Bitmap defaults: `max_ppi:` (300; nil disables) is the resolution
62
70
  # above twice of which a drawn image is reported as oversized, and
63
71
  # `downscale: true` resamples PNGs to it. See Layout::Image.
@@ -77,6 +85,28 @@ module Stationery
77
85
  config[:tagged] = value
78
86
  end
79
87
 
88
+ # Claims PDF/A-2b, PDF/A-3b and/or PDF/UA-1 (`conformance :pdf_a3b,
89
+ # :pdf_ua1`) and writes what the level asks for; a render that cannot
90
+ # keep the claim raises. See PDF::Conformance.
91
+ def conformance(*levels)
92
+ PDF::Conformance.for(levels)
93
+ config[:conformance] = levels.flatten
94
+ end
95
+
96
+ # Makes every render a Factur-X / ZUGFeRD e-invoice: PDF/A-3b with the
97
+ # invoice XML embedded and identified in XMP. `xml` is the Cross
98
+ # Industry Invoice as a String, or a method name or block answering it
99
+ # for the document being rendered: `factur_x(profile: :en16931) {
100
+ # invoice.to_cii }`. See PDF::FacturX for profiles and options.
101
+ def factur_x(xml = nil, **options, &block)
102
+ source = xml || block
103
+ raise ArgumentError, "factur_x needs the invoice XML, a method name or a block" unless source
104
+
105
+ PDF::FacturX.profile(options.fetch(:profile, :en16931))
106
+ PDF::FacturX.new(source, **options) if source.is_a?(String)
107
+ config[:factur_x] = { xml: source, **options }
108
+ end
109
+
80
110
  # Encrypts every render with the standard security handler; see
81
111
  # PDF::Encryption::StandardSecurity for the options.
82
112
  def encrypt(**)
@@ -109,9 +139,17 @@ module Stationery
109
139
  def metadata = self.class.config[:metadata]
110
140
 
111
141
  def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
112
- tagged: self.class.config[:tagged], page_labels: self.class.config[:page_labels])
142
+ tagged: self.class.config[:tagged], page_labels: self.class.config[:page_labels], attachments: [],
143
+ xmp: metadata[:xmp] != false, conformance: self.class.config[:conformance],
144
+ factur_x: self.class.config[:factur_x])
145
+ invoice = PDF::FacturX.for(factur_x, self)
146
+ attachments = PDF::Attachments.merge(self.class.config[:attachments], attachments, invoice&.attachment)
147
+ conformance = PDF::Conformance.for(invoice ? invoice.conformance(conformance) : conformance)
148
+ conformance&.validate!(encrypt:, metadata:, attachments:)
149
+ options = { strict:, debug:, encrypt:, tagged: tagged || conformance&.pdf_ua?, page_labels:, attachments:,
150
+ xmp: xmp || !conformance.nil?, conformance:, invoice: }
113
151
  Stationery.instrument("render.stationery", document: self.class.name) do |event|
114
- write(render_pdf(event, strict:, debug:, encrypt:, tagged:, page_labels:), target)
152
+ write(render_pdf(event, **options), target)
115
153
  end
116
154
  end
117
155
 
@@ -136,7 +174,7 @@ module Stationery
136
174
  def builder_for(book) = Builder.new(book:, text: self.class.config[:text], images: self.class.config[:images])
137
175
 
138
176
  # The PDF bytes; `event` is the render.stationery payload it fills in.
139
- def render_pdf(event, strict:, debug:, encrypt:, tagged:, page_labels:)
177
+ def render_pdf(event, strict:, debug:, tagged:, conformance:, **assembly)
140
178
  tagging = Tagging::Tree.new if tagged
141
179
  warnings = Warnings.new
142
180
  book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
@@ -147,18 +185,24 @@ module Stationery
147
185
  outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:, tagging:))
148
186
  tagging&.audit(pages, warnings, lang: metadata[:lang])
149
187
  @warnings = warnings
188
+ conformance&.audit!(pages, resources:, warnings:)
150
189
  @fields = Forms::AcroForm.values(pages)
151
190
  event[:pages] = pages.size
152
191
  event[:warnings] = warnings.size
153
192
  raise WarningsError, warnings if strict && warnings.any?
154
193
 
155
- assemble(pages, resources, outline, encrypt:, tagging:, page_labels:).tap { |pdf| event[:bytes] = pdf.bytesize }
194
+ pdf = assemble(pages, resources, outline, tagging:, conformance:, **assembly)
195
+ event[:bytes] = pdf.bytesize
196
+ pdf
156
197
  end
157
198
 
158
- def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:)
199
+ def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:, attachments:, xmp:, conformance:,
200
+ invoice:)
159
201
  encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
160
- assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:,
161
- lang: metadata[:lang], page_labels: PDF::PageLabels.entries(page_labels))
202
+ assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, xmp:,
203
+ lang: metadata[:lang], page_labels: PDF::PageLabels.entries(page_labels),
204
+ attachments:, conformance:, xmp_extensions: invoice&.xmp_extensions || {},
205
+ xmp_schemas: [invoice&.xmp_schema].compact)
162
206
  Stationery.instrument("write.stationery", document: self.class.name) do |event|
163
207
  assembler.render.tap { |pdf| event[:bytes] = pdf.bytesize }
164
208
  end
@@ -184,7 +228,7 @@ module Stationery
184
228
  end
185
229
 
186
230
  def info
187
- metadata.except(:lang).to_h do |key, value|
231
+ metadata.except(:lang, :xmp).to_h do |key, value|
188
232
  [INFO_KEYS.fetch(key.to_sym) { key.to_sym }, value.is_a?(Array) ? value.join(", ") : value]
189
233
  end
190
234
  end
@@ -20,4 +20,17 @@ module Stationery
20
20
  super("#{lines.size} #{noun}:\n#{lines.join("\n")}")
21
21
  end
22
22
  end
23
+
24
+ # Raised when a render cannot keep the conformance it claims (PDF/A,
25
+ # PDF/UA): `levels` are the claimed levels, `issues` what breaks them.
26
+ class ConformanceError < Error
27
+ attr_reader :levels, :issues
28
+
29
+ def initialize(levels, issues)
30
+ @levels = levels
31
+ @issues = issues
32
+ super("not #{levels.map { |level| Stationery::PDF::Conformance.label(level) }.join(" + ")}:\n" \
33
+ "#{issues.map { |issue| " #{issue}" }.join("\n")}")
34
+ end
35
+ end
23
36
  end
@@ -21,9 +21,10 @@ module Stationery
21
21
  end
22
22
 
23
23
  def inspect = "#<#{self.class} #{@width}x#{@height}>"
24
+ def color_space = COLOR_SPACES.fetch(@components)
24
25
 
25
26
  def build(writer)
26
- dictionary = Images.xobject(@width, @height, COLOR_SPACES.fetch(@components), @bits).merge(Filter: :DCTDecode)
27
+ dictionary = Images.xobject(@width, @height, color_space, @bits).merge(Filter: :DCTDecode)
27
28
  # Adobe CMYK JPEGs store inverted values.
28
29
  dictionary[:Decode] = [1, 0] * 4 if @components == 4 && @adobe
29
30
  writer.add(PDF::Stream.new(@data, dictionary))
@@ -22,6 +22,18 @@ module Stationery
22
22
  assert_pdf(Matchers::HavePageLabels.new(labels), subject, msg)
23
23
  end
24
24
 
25
+ def assert_pdf_attachment(subject, name, msg = nil, **)
26
+ assert_pdf(Matchers::HaveAttachment.new(name, **), subject, msg)
27
+ end
28
+
29
+ def assert_pdf_conformance(subject, levels, msg = nil)
30
+ assert_pdf(Matchers::HaveConformance.new(*levels), subject, msg)
31
+ end
32
+
33
+ def assert_factur_x(subject, msg = nil, profile: nil)
34
+ assert_pdf(Matchers::HaveFacturX.new(profile:), subject, msg)
35
+ end
36
+
25
37
  def assert_no_pdf_warnings(subject, msg = nil) = assert_pdf(Matchers::HaveNoWarnings.new, subject, msg)
26
38
  def assert_pdf_structure(subject, tree, msg = nil) = assert_pdf(Matchers::HaveStructure.new(tree), subject, msg)
27
39
  def assert_tagged_content(subject, msg = nil) = assert_pdf(Matchers::HaveTaggedContent.new, subject, msg)
@@ -6,12 +6,22 @@ module Stationery
6
6
  class Assembler
7
7
  # `tagging:` (a Tagging::Tree) writes the structure tree of a tagged PDF;
8
8
  # `lang:` is the document's natural language; `page_labels:` is the
9
- # /PageLabels number tree from PageLabels.entries.
9
+ # /PageLabels number tree from PageLabels.entries; `attachments:` are
10
+ # PDF::Attachment files to embed; `xmp:` (true) writes the XMP packet,
11
+ # with `xmp_extensions:` as further schemas and `xmp_schemas:` describing
12
+ # them to PDF/A (see XMP); `conformance:` (a PDF::Conformance) adds what
13
+ # PDF/A and PDF/UA ask of the file.
10
14
  def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil,
11
- page_labels: nil)
15
+ page_labels: nil, attachments: [], xmp: true, xmp_extensions: {}, xmp_schemas: [],
16
+ conformance: nil)
17
+ @xmp_schemas = xmp_schemas
18
+ @conformance = conformance
12
19
  @tagging = tagging
13
20
  @lang = lang
14
21
  @page_labels = page_labels
22
+ @attachments = attachments
23
+ @xmp = xmp
24
+ @xmp_extensions = xmp_extensions
15
25
  @pages = pages
16
26
  @resources = resources
17
27
  @info = info
@@ -29,8 +39,12 @@ module Stationery
29
39
  @pages.each_with_index { |page, index| write_page(writer, page, kids[index], tree, refs) }
30
40
  writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
31
41
  outlines = OutlineWriter.new(writer, @outline, kids).write
32
- root = writer.add(catalog(tree, outlines, @form.write).merge(accessibility(writer)))
33
- writer.render(root:, info: writer.add(info_dictionary))
42
+ now = Time.now
43
+ info = info_dictionary(now)
44
+ entries = catalog(tree, outlines, @form.write)
45
+ .merge(accessibility(writer), metadata(writer, info, now), Attachments.write(writer, @attachments))
46
+ entries.merge!(@conformance.catalog_entries(writer)) if @conformance
47
+ writer.render(root: writer.add(entries), info: writer.add(info))
34
48
  end
35
49
 
36
50
  private
@@ -59,6 +73,7 @@ module Stationery
59
73
  dictionary[:Annots] = page.annotations.map { |annot| annotation_ref(writer, annot, ref) }
60
74
  end
61
75
  dictionary.merge!(@structure.page_entries(page)) if @structure
76
+ dictionary.merge!(@conformance.page_entries(page)) if @conformance
62
77
  writer.set(ref, dictionary)
63
78
  end
64
79
 
@@ -77,6 +92,7 @@ module Stationery
77
92
 
78
93
  ref = writer.reserve
79
94
  dictionary = annotation(annotation)
95
+ dictionary = dictionary.merge(@conformance.annotation_entries(annotation)) if @conformance
80
96
  dictionary = dictionary.merge(@structure.annotation(annotation, ref)) if @structure
81
97
  writer.set(ref, dictionary)
82
98
  end
@@ -90,12 +106,26 @@ module Stationery
90
106
  { Type: :Annot, Subtype: :Link, Rect: link[:rect], Border: [0, 0, 0], **target }
91
107
  end
92
108
 
93
- def info_dictionary
109
+ def info_dictionary(now)
94
110
  info = { Producer: "Stationery #{VERSION}" }.merge(@info.compact)
95
111
  info = info.transform_values { |value| TextString.new(value.to_s) }
96
- info[:CreationDate] = TextString.new(Time.now.utc.strftime("D:%Y%m%d%H%M%SZ"))
112
+ info[:CreationDate] = TextString.new(now.utc.strftime("D:%Y%m%d%H%M%SZ"))
97
113
  info
98
114
  end
115
+
116
+ def extensions = (@conformance&.xmp_extensions || {}).merge(@xmp_extensions)
117
+ def schemas = (@conformance&.xmp_schemas || []) + @xmp_schemas
118
+
119
+ # The XMP packet as an uncompressed /Metadata stream, mirroring the Info
120
+ # dictionary at the same instant. An encrypted document encrypts it like
121
+ # every other stream (/EncryptMetadata defaults to true).
122
+ def metadata(writer, info, now)
123
+ return {} unless @xmp
124
+
125
+ values = info.except(:CreationDate).transform_values(&:value)
126
+ packet = XMP.packet(info: values, lang: @lang, time: now, extensions:, schemas:)
127
+ { Metadata: writer.add(Stream.new(packet, { Type: :Metadata, Subtype: :XML }, compress: false)) }
128
+ end
99
129
  end
100
130
  end
101
131
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/md5"
4
+
5
+ module Stationery
6
+ module PDF
7
+ # A file embedded in the document: its name, bytes, MIME type, an
8
+ # optional description and how it relates to the document (the
9
+ # /AFRelationship a Factur-X invoice or a PDF/A-3 attachment declares).
10
+ Attachment = Data.define(:name, :data, :mime, :description, :relationship, :modified_at)
11
+
12
+ # Builds and writes embedded files: each becomes a /Filespec with an
13
+ # /EmbeddedFile stream, listed in the catalog's /Names /EmbeddedFiles
14
+ # tree and its /AF array.
15
+ module Attachments
16
+ RELATIONSHIPS = { alternative: :Alternative, source: :Source, data: :Data, supplement: :Supplement,
17
+ unspecified: :Unspecified }.freeze
18
+
19
+ module_function
20
+
21
+ def build(name, data, mime: "application/octet-stream", description: nil, relationship: :unspecified,
22
+ modified_at: nil)
23
+ name = name.to_s
24
+ raise ArgumentError, "an attachment needs a name" if name.empty?
25
+ raise ArgumentError, "attachment #{name.inspect} needs its data as a String" unless data.is_a?(String)
26
+ unless RELATIONSHIPS.key?(relationship)
27
+ raise ArgumentError, "unknown attachment relationship #{relationship.inspect}; use one of " \
28
+ "#{RELATIONSHIPS.keys.map(&:inspect).join(", ")}"
29
+ end
30
+
31
+ Attachment.new(name:, data: data.b, mime: mime.to_s, description: description&.to_s, relationship:,
32
+ modified_at:)
33
+ end
34
+
35
+ # One list from class-level attachments and per-render hashes
36
+ # (`{ name:, data:, mime:, … }`); the same name twice raises.
37
+ def merge(*lists)
38
+ lists.flatten.compact.map { |item| item.is_a?(Attachment) ? item : from_hash(item) }.tap do |all|
39
+ duplicate = all.map(&:name).tally.find { |_, count| count > 1 }&.first
40
+ raise ArgumentError, "attachment #{duplicate.inspect} is given twice" if duplicate
41
+ end
42
+ end
43
+
44
+ def from_hash(hash)
45
+ hash = hash.to_h.transform_keys(&:to_sym)
46
+ build(hash[:name], hash[:data], **hash.except(:name, :data))
47
+ end
48
+
49
+ # The catalog entries for the files, written through `writer`; empty without any.
50
+ def write(writer, attachments)
51
+ return {} if attachments.nil? || attachments.empty?
52
+
53
+ refs = attachments.sort_by(&:name).map do |attachment|
54
+ [attachment.name, writer.add(filespec(writer, attachment))]
55
+ end
56
+ { Names: { EmbeddedFiles: { Names: refs.flatten } }, AF: refs.map(&:last) }
57
+ end
58
+
59
+ def filespec(writer, attachment)
60
+ spec = { Type: :Filespec, F: attachment.name.b, UF: TextString.new(attachment.name),
61
+ AFRelationship: RELATIONSHIPS.fetch(attachment.relationship),
62
+ EF: { F: writer.add(stream(attachment)) } }
63
+ spec[:Desc] = TextString.new(attachment.description) if attachment.description
64
+ spec
65
+ end
66
+
67
+ def stream(attachment)
68
+ params = { Size: attachment.data.bytesize, CheckSum: HexString.new(Digest::MD5.digest(attachment.data)) }
69
+ if attachment.modified_at
70
+ params[:ModDate] = TextString.new(attachment.modified_at.utc.strftime("D:%Y%m%d%H%M%SZ"))
71
+ end
72
+ Stream.new(attachment.data, { Type: :EmbeddedFile, Subtype: attachment.mime.to_sym, Params: params })
73
+ end
74
+ end
75
+ end
76
+ end