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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +133 -10
- data/lib/stationery/canvas.rb +19 -0
- data/lib/stationery/cli/render.rb +19 -4
- data/lib/stationery/document.rb +53 -9
- data/lib/stationery/errors.rb +13 -0
- data/lib/stationery/images/jpeg.rb +2 -1
- data/lib/stationery/minitest.rb +12 -0
- data/lib/stationery/pdf/assembler.rb +36 -6
- data/lib/stationery/pdf/attachments.rb +76 -0
- data/lib/stationery/pdf/conformance.rb +139 -0
- data/lib/stationery/pdf/factur_x.rb +117 -0
- data/lib/stationery/pdf/icc/LICENSE.md +14 -0
- data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
- data/lib/stationery/pdf/stream.rb +5 -3
- data/lib/stationery/pdf/xmp.rb +149 -0
- data/lib/stationery/resources.rb +3 -0
- data/lib/stationery/svg/clip_path.rb +69 -0
- data/lib/stationery/svg/document.rb +26 -22
- data/lib/stationery/svg/painter.rb +11 -0
- data/lib/stationery/svg/region.rb +25 -0
- data/lib/stationery/svg/shapes.rb +2 -0
- data/lib/stationery/svg/style.rb +25 -14
- data/lib/stationery/svg/viewport.rb +78 -0
- data/lib/stationery/svg/walker.rb +142 -0
- data/lib/stationery/testing/inspector.rb +68 -0
- data/lib/stationery/testing/matchers.rb +67 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +4 -0
- data/lib/stationery.rb +8 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 21d52a1f02122474b5316373c70965424f3479973757219d2b7b87ed453e8642
|
|
4
|
+
data.tar.gz: eaa7be7242e9f38e270cb23a533bac9038963c3e61c44c1e57ebaefe0288ef29
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
|
|
349
|
-
|
|
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`, `
|
|
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
|
|
712
|
-
`image`, `
|
|
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:
|
|
723
|
-
signing (`signature_field` is an empty field)
|
|
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
|
|
data/lib/stationery/canvas.rb
CHANGED
|
@@ -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
|
|
61
|
-
#
|
|
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|
|
|
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
|
data/lib/stationery/document.rb
CHANGED
|
@@ -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,
|
|
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:,
|
|
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,
|
|
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
|
data/lib/stationery/errors.rb
CHANGED
|
@@ -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,
|
|
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))
|
data/lib/stationery/minitest.rb
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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(
|
|
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
|