stationery 0.7.0 → 0.9.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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +251 -23
  4. data/lib/stationery/canvas.rb +22 -1
  5. data/lib/stationery/cli/render.rb +19 -4
  6. data/lib/stationery/document.rb +69 -9
  7. data/lib/stationery/elements/forms.rb +11 -6
  8. data/lib/stationery/elements/rich.rb +13 -6
  9. data/lib/stationery/elements.rb +11 -3
  10. data/lib/stationery/errors.rb +13 -0
  11. data/lib/stationery/fonts/font.rb +28 -18
  12. data/lib/stationery/forms/acro_form.rb +95 -25
  13. data/lib/stationery/forms/appearance.rb +92 -30
  14. data/lib/stationery/forms/field.rb +20 -11
  15. data/lib/stationery/forms/metrics.rb +3 -17
  16. data/lib/stationery/forms/typeface.rb +113 -0
  17. data/lib/stationery/html/document.rb +8 -2
  18. data/lib/stationery/html/tree_builder.rb +34 -4
  19. data/lib/stationery/images/jpeg.rb +2 -1
  20. data/lib/stationery/layout/image.rb +9 -1
  21. data/lib/stationery/layout/table/cell.rb +14 -4
  22. data/lib/stationery/layout/table.rb +72 -14
  23. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  24. data/lib/stationery/markdown/block_parser.rb +9 -3
  25. data/lib/stationery/markdown/document.rb +8 -2
  26. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  27. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  28. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  29. data/lib/stationery/markdown/inline_parser.rb +9 -3
  30. data/lib/stationery/minitest.rb +16 -0
  31. data/lib/stationery/pdf/assembler.rb +51 -11
  32. data/lib/stationery/pdf/attachments.rb +76 -0
  33. data/lib/stationery/pdf/conformance.rb +141 -0
  34. data/lib/stationery/pdf/factur_x.rb +117 -0
  35. data/lib/stationery/pdf/icc/LICENSE.md +14 -0
  36. data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
  37. data/lib/stationery/pdf/serializer.rb +3 -1
  38. data/lib/stationery/pdf/signature/cms.rb +87 -0
  39. data/lib/stationery/pdf/signature.rb +167 -0
  40. data/lib/stationery/pdf/stream.rb +5 -3
  41. data/lib/stationery/pdf/types.rb +4 -0
  42. data/lib/stationery/pdf/xmp.rb +149 -0
  43. data/lib/stationery/resources.rb +3 -0
  44. data/lib/stationery/rich/nesting.rb +30 -0
  45. data/lib/stationery/rich/renderer/indents.rb +42 -0
  46. data/lib/stationery/rich/renderer.rb +22 -6
  47. data/lib/stationery/svg/clip_path.rb +69 -0
  48. data/lib/stationery/svg/document.rb +34 -26
  49. data/lib/stationery/svg/painter.rb +11 -0
  50. data/lib/stationery/svg/parser.rb +51 -11
  51. data/lib/stationery/svg/region.rb +25 -0
  52. data/lib/stationery/svg/shapes.rb +2 -0
  53. data/lib/stationery/svg/style.rb +25 -14
  54. data/lib/stationery/svg/viewport.rb +78 -0
  55. data/lib/stationery/svg/walker.rb +144 -0
  56. data/lib/stationery/tagging/element.rb +1 -1
  57. data/lib/stationery/testing/inspector.rb +123 -0
  58. data/lib/stationery/testing/matchers.rb +92 -0
  59. data/lib/stationery/text/wrapper.rb +12 -3
  60. data/lib/stationery/version.rb +1 -1
  61. data/lib/stationery/warnings.rb +8 -0
  62. data/lib/stationery.rb +11 -0
  63. metadata +16 -1
@@ -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,43 @@ 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
+
110
+ # Signs every render with a digital signature over the whole file:
111
+ # `sign certificate: pem, key: pem, reason: "Approved"`. A value may be
112
+ # a block or callable answering it for the document being rendered (a
113
+ # method name for certificate:, key:, chain: and passphrase:), and a
114
+ # block may answer all of them, so keys are read when they are needed.
115
+ # `field:` names the signature_field it fills; without it the
116
+ # signature is invisible. See PDF::Signature.
117
+ def sign(**options, &block)
118
+ raise ArgumentError, "sign needs certificate: and key:, or a block answering them" unless block || options.any?
119
+
120
+ options = PDF::Signature.check(options)
121
+ PDF::Signature.new(**options) unless block || PDF::Signature.deferred?(options)
122
+ config[:sign] = block || options
123
+ end
124
+
80
125
  # Encrypts every render with the standard security handler; see
81
126
  # PDF::Encryption::StandardSecurity for the options.
82
127
  def encrypt(**)
@@ -109,9 +154,18 @@ module Stationery
109
154
  def metadata = self.class.config[:metadata]
110
155
 
111
156
  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])
157
+ tagged: self.class.config[:tagged], page_labels: self.class.config[:page_labels], attachments: [],
158
+ xmp: metadata[:xmp] != false, conformance: self.class.config[:conformance],
159
+ factur_x: self.class.config[:factur_x], sign: self.class.config[:sign])
160
+ invoice = PDF::FacturX.for(factur_x, self)
161
+ attachments = PDF::Attachments.merge(self.class.config[:attachments], attachments, invoice&.attachment)
162
+ conformance = PDF::Conformance.for(invoice ? invoice.conformance(conformance) : conformance)
163
+ conformance&.validate!(encrypt:, metadata:, attachments:)
164
+ options = { strict:, debug:, encrypt:, tagged: tagged || conformance&.pdf_ua?, page_labels:, attachments:,
165
+ xmp: xmp || !conformance.nil?, conformance:, invoice:,
166
+ signature: PDF::Signature.for(sign, self) }
113
167
  Stationery.instrument("render.stationery", document: self.class.name) do |event|
114
- write(render_pdf(event, strict:, debug:, encrypt:, tagged:, page_labels:), target)
168
+ write(render_pdf(event, **options), target)
115
169
  end
116
170
  end
117
171
 
@@ -136,7 +190,7 @@ module Stationery
136
190
  def builder_for(book) = Builder.new(book:, text: self.class.config[:text], images: self.class.config[:images])
137
191
 
138
192
  # The PDF bytes; `event` is the render.stationery payload it fills in.
139
- def render_pdf(event, strict:, debug:, encrypt:, tagged:, page_labels:)
193
+ def render_pdf(event, strict:, debug:, tagged:, conformance:, **assembly)
140
194
  tagging = Tagging::Tree.new if tagged
141
195
  warnings = Warnings.new
142
196
  book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
@@ -147,18 +201,24 @@ module Stationery
147
201
  outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:, tagging:))
148
202
  tagging&.audit(pages, warnings, lang: metadata[:lang])
149
203
  @warnings = warnings
204
+ conformance&.audit!(pages, resources:, warnings:)
150
205
  @fields = Forms::AcroForm.values(pages)
151
206
  event[:pages] = pages.size
152
207
  event[:warnings] = warnings.size
153
208
  raise WarningsError, warnings if strict && warnings.any?
154
209
 
155
- assemble(pages, resources, outline, encrypt:, tagging:, page_labels:).tap { |pdf| event[:bytes] = pdf.bytesize }
210
+ pdf = assemble(pages, resources, outline, tagging:, conformance:, **assembly)
211
+ event[:bytes] = pdf.bytesize
212
+ pdf
156
213
  end
157
214
 
158
- def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:)
215
+ def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:, attachments:, xmp:, conformance:,
216
+ invoice:, signature:)
159
217
  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))
218
+ assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, xmp:,
219
+ lang: metadata[:lang], page_labels: PDF::PageLabels.entries(page_labels),
220
+ attachments:, conformance:, xmp_extensions: invoice&.xmp_extensions || {},
221
+ xmp_schemas: [invoice&.xmp_schema].compact, signature:)
162
222
  Stationery.instrument("write.stationery", document: self.class.name) do |event|
163
223
  assembler.render.tap { |pdf| event[:bytes] = pdf.bytesize }
164
224
  end
@@ -184,7 +244,7 @@ module Stationery
184
244
  end
185
245
 
186
246
  def info
187
- metadata.except(:lang).to_h do |key, value|
247
+ metadata.except(:lang, :xmp).to_h do |key, value|
188
248
  [INFO_KEYS.fetch(key.to_sym) { key.to_sym }, value.is_a?(Array) ? value.join(", ") : value]
189
249
  end
190
250
  end
@@ -3,44 +3,49 @@
3
3
  module Stationery
4
4
  # Interactive form fields. Each takes a name (dotted names group fields,
5
5
  # "address.city") and lays out like a box, or at a fixed page position with
6
- # `at: [x, y]`.
6
+ # `at: [x, y]`. Their appearances are set in the text style in effect (the
7
+ # document's fonts, embedded), and `tooltip:` names a field for assistive
8
+ # technology (its label or name by default).
7
9
  module Elements
8
10
  # A text input. `width:` is :full or points; `multiline:`, `max_length:`,
9
11
  # `comb:` (a cell count, or true with max_length:), `read_only:`,
10
12
  # `required:`, `font_size:`, `border:`, `background:` and `radius:`.
11
13
  def text_field(name, value: "", width: :full, height: 22, at: nil, **)
12
- field_node(Forms::Field.new(:text, name, value: value.to_s, **), width:, height:, at:)
14
+ field_node(Forms::Field.new(:text, name, value: value.to_s, typeface: field_typeface, **), width:, height:, at:)
13
15
  end
14
16
 
15
17
  # A check box `size` points square, with an optional label to its right.
16
18
  def checkbox(name, checked: false, size: 12, label: nil, at: nil, **)
17
- field = Forms::Field.new(:checkbox, name, value: checked == true, **)
19
+ field = Forms::Field.new(:checkbox, name, value: checked == true, typeface: field_typeface, tooltip: label, **)
18
20
  field_node(field, width: size, height: size, at:, label:)
19
21
  end
20
22
 
21
23
  # One choice of the radio group `name`; its `value` becomes the group's
22
24
  # value when checked.
23
25
  def radio(name, value, checked: false, size: 12, label: nil, at: nil, **)
24
- field = Forms::Field.new(:radio, name, value: value.to_s, checked:, **)
26
+ field = Forms::Field.new(:radio, name, value: value.to_s, checked:, typeface: field_typeface, **)
25
27
  field_node(field, width: size, height: size, at:, label:)
26
28
  end
27
29
 
28
30
  # A drop-down (combo box) of `options`; `editable: true` also accepts
29
31
  # typed values.
30
32
  def select(name, options:, value: nil, width: :full, height: 22, at: nil, **)
31
- field = Forms::Field.new(:select, name, value: value&.to_s, options: options.map(&:to_s), **)
33
+ field = Forms::Field.new(:select, name, value: value&.to_s, options: options.map(&:to_s),
34
+ typeface: field_typeface, **)
32
35
  field_node(field, width:, height:, at:)
33
36
  end
34
37
 
35
38
  # An empty signature field for the signer to fill, drawn as a rule over
36
39
  # the label.
37
40
  def signature_field(name, width: :full, height: 40, label: "Signature", at: nil, **)
38
- field = Forms::Field.new(:signature, name, label: label.to_s, **)
41
+ field = Forms::Field.new(:signature, name, label: label.to_s, typeface: field_typeface, **)
39
42
  field_node(field, width:, height:, at:)
40
43
  end
41
44
 
42
45
  private
43
46
 
47
+ def field_typeface = Forms::Typeface.new(@_builder.book, @_builder.style)
48
+
44
49
  def field_node(field, width:, height:, at:, label: nil)
45
50
  label &&= field_label(label)
46
51
  node = Layout::Field.new(field, height:, width:, label:)
@@ -8,22 +8,29 @@ module Stationery
8
8
  # under `base_path:`. Remote images are never fetched. `links:` lists the
9
9
  # URL schemes written as links (default http, https, mailto and tel;
10
10
  # `#anchor` always is); `:all` keeps every href for trusted sources.
11
- def html(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false, links: nil)
11
+ # Elements nested deeper than `max_depth:` are flattened into the deepest
12
+ # one kept (text stays, structure goes) and reported as a NestingLimit
13
+ # warning, so content from users cannot exhaust the stack.
14
+ def html(source, max_depth: 64, **)
12
15
  require_relative "../html/document"
13
- rich(HTML.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:, links:)
16
+ rich(HTML.parse(source.to_s, max_depth:) { |depth| nesting_limit(depth, max_depth) }, **)
14
17
  end
15
18
 
16
19
  # CommonMark (plus GFM tables and strikethrough); options as for html.
17
- def markdown(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false, links: nil)
20
+ def markdown(source, max_depth: 64, **)
18
21
  require_relative "../markdown/document"
19
- rich(Markdown.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:, links:)
22
+ rich(Markdown.parse(source.to_s, max_depth:) { |depth| nesting_limit(depth, max_depth) }, **)
20
23
  end
21
24
 
22
25
  private
23
26
 
24
- def rich(blocks, **)
27
+ def nesting_limit(depth, limit)
28
+ @_builder.warnings << Warnings::NestingLimit.new(depth:, limit:)
29
+ end
30
+
31
+ def rich(blocks, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false, links: nil)
25
32
  require_relative "../rich/renderer"
26
- Rich::Renderer.new(self, @_builder, **).render(blocks)
33
+ Rich::Renderer.new(self, @_builder, styles:, gap:, images:, base_path:, bookmarks:, links:).render(blocks)
27
34
  end
28
35
  end
29
36
  end
@@ -95,9 +95,7 @@ module Stationery
95
95
  name = source.to_s.lstrip.start_with?("<") ? "inline" : File.basename(source.to_s)
96
96
  source = File.read(source.to_s) unless name == "inline"
97
97
  document = SVG::Document.parse(source)
98
- if document.unsupported.any?
99
- @_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
100
- end
98
+ svg_warnings(document, name)
101
99
  node = Layout::Svg.new(document, width:, height:, color:, context: @_builder.context, alt:)
102
100
  @_builder.add(align ? Layout::Flow.new([node], align:) : node)
103
101
  end
@@ -154,6 +152,16 @@ module Stationery
154
152
 
155
153
  private
156
154
 
155
+ # What the drawing uses that is not drawn, and nesting that was flattened.
156
+ def svg_warnings(document, name)
157
+ if document.unsupported.any?
158
+ @_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
159
+ end
160
+ return unless document.nesting
161
+
162
+ @_builder.warnings << Warnings::NestingLimit.new(depth: document.nesting, limit: SVG::Parser::MAX_DEPTH)
163
+ end
164
+
157
165
  # `bookmark:` is a title, or { title:, level:, open: }.
158
166
  def mark(node, anchor, bookmark)
159
167
  names = [anchor&.to_s]
@@ -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
@@ -33,9 +33,10 @@ module Stationery
33
33
  @pairs = {}
34
34
  @glyphs = {}
35
35
  @blanks = {}
36
- @shapes = {}
37
- @advances = {}
38
- @kerns = {}
36
+ @shapes = {}.compare_by_identity
37
+ @metrics = {}.compare_by_identity
38
+ @keys = { true => {}, false => {} }
39
+ @frozen_keys = { true => {}.compare_by_identity, false => {}.compare_by_identity }
39
40
  @cid_keyed = ttf.cff? && ttf.cff.cid_keyed?
40
41
  end
41
42
 
@@ -49,10 +50,12 @@ module Stationery
49
50
  def width_of(text, size, letter_spacing: 0, kerning: false, ligatures: true, features: NO_FEATURES)
50
51
  ligatures &&= letter_spacing.zero?
51
52
  key = shape_key(ligatures, features)
52
- width = scale(advance_units(text, key), size) + (letter_spacing * shape(text, key).first.size)
53
+ advance, kern = metrics(text, key)
54
+ width = scale(advance, size)
55
+ width += letter_spacing * shape(text, key).first.size unless letter_spacing.zero?
53
56
  return width unless kerning
54
57
 
55
- width + (kerning_units(text, key) * size / 1000.0)
58
+ width + (kern * size / 1000.0)
56
59
  end
57
60
 
58
61
  # The GSUB feature tags this font can apply.
@@ -126,14 +129,22 @@ module Stationery
126
129
  private
127
130
 
128
131
  # The feature tags to substitute with: `liga` when ligatures are on, plus
129
- # the requested features. Frozen and shared, so it keys the memos cheaply.
132
+ # the requested features. One Array per set of tags, so the memos are
133
+ # keyed by its identity: measuring a word hashes the word and nothing
134
+ # else. A style's frozen tags are looked up by identity too; any other
135
+ # Array is normalised first.
130
136
  def shape_key(ligatures, features)
131
- features = Text::Style.features(features) unless features.frozen? && features.all?(String)
132
- return features unless ligatures
133
- return Gsub::LIGA if features.empty?
137
+ return ligatures ? Gsub::LIGA : NO_FEATURES if features.empty?
134
138
 
135
- @keys ||= {}
136
- @keys[features] ||= (features + Gsub::LIGA).sort.freeze
139
+ ligatures = ligatures ? true : false
140
+ return @frozen_keys[ligatures][features] ||= tags_for(ligatures, features) if features.frozen?
141
+
142
+ tags_for(ligatures, features)
143
+ end
144
+
145
+ def tags_for(ligatures, features)
146
+ features = Text::Style.features(features)
147
+ @keys[ligatures][features] ||= (ligatures ? (features + Gsub::LIGA).sort : features).freeze
137
148
  end
138
149
 
139
150
  # [gids, source text of each glyph, extra advance in font units after
@@ -170,17 +181,16 @@ module Stationery
170
181
  width - space
171
182
  end
172
183
 
173
- def advance_units(text, tags)
174
- (@advances[tags] ||= {})[text] ||= begin
184
+ # [advance in font units, kerning in thousandths of an em] of a string,
185
+ # remembered together so a measurement is one lookup.
186
+ def metrics(text, tags)
187
+ (@metrics[tags] ||= {})[text] ||= begin
175
188
  gids, _, blanks = shape(text, tags)
176
- gids.sum { |gid| @ttf.advance(gid) } + blanks.sum { |units| units || 0 }
189
+ [gids.sum { |gid| @ttf.advance(gid) } + blanks.sum { |units| units || 0 },
190
+ gids.each_cons(2).sum { |left, right| pair(left, right) }].freeze
177
191
  end
178
192
  end
179
193
 
180
- def kerning_units(text, tags)
181
- (@kerns[tags] ||= {})[text] ||= shape(text, tags).first.each_cons(2).sum { |left, right| pair(left, right) }
182
- end
183
-
184
194
  # Kerning between two glyphs in thousandths of an em.
185
195
  def pair(left, right)
186
196
  @pairs[(left << 16) | right] ||= @ttf.kerning.adjust(left, right) * 1000.0 / @ttf.units_per_em
@@ -3,14 +3,24 @@
3
3
  module Stationery
4
4
  module Forms
5
5
  # Collects the widgets pages place and writes the document's interactive
6
- # form: one field per name, parent fields for dotted names, the shared
7
- # Helvetica and ZapfDingbats resources and the catalog's /AcroForm.
6
+ # form: one field per name, parent fields for dotted names, the fonts the
7
+ # appearances draw with as default resources and the catalog's /AcroForm.
8
+ #
9
+ # The appearances themselves need no standard font: text is set in the
10
+ # document's embedded fonts and marks are paths. Only a form that asks
11
+ # viewers to regenerate appearances lists ZapfDingbats, which is what
12
+ # they redraw a check box or radio button's /MK caption with.
13
+ #
14
+ # A `signature:` (a PDF::Signature) becomes the value of the signature
15
+ # field it names, or of a field of its own whose widget nobody sees.
8
16
  class AcroForm
9
- FONTS = { Helv: :Helvetica, ZaDb: :ZapfDingbats }.freeze
17
+ SYMBOLS = { ZaDb: { Type: :Font, Subtype: :Type1, BaseFont: :ZapfDingbats }.freeze }.freeze
18
+ # SignaturesExist and AppendOnly: what a signed form tells viewers.
19
+ SIGNED = 3
10
20
 
11
- # A widget placed on a page: its field, PDF-space rect, page and the
12
- # reference the page's /Annots already points at.
13
- Widget = Data.define(:field, :rect, :page, :ref, :extra)
21
+ # A widget placed on a page: its field, appearance, PDF-space rect, page
22
+ # and the reference the page's /Annots already points at.
23
+ Widget = Data.define(:field, :appearance, :rect, :page, :ref, :extra)
14
24
 
15
25
  # A name segment: widgets when it is a field, children when a group.
16
26
  Node = Struct.new(:name, :widgets, :children)
@@ -24,35 +34,89 @@ module Stationery
24
34
  end
25
35
  end
26
36
 
27
- def initialize(writer)
37
+ # `fonts` are the document's embedded fonts by resource name.
38
+ # `need_appearances: false` leaves out the flag asking viewers to
39
+ # regenerate every appearance (PDF/A and PDF/UA forbid it, and a
40
+ # signed file must not be redrawn).
41
+ def initialize(writer, fonts: {}, need_appearances: true, signature: nil)
28
42
  @writer = writer
43
+ @embedded = fonts
44
+ @need_appearances = need_appearances
45
+ @signature = signature
29
46
  @widgets = []
30
47
  end
31
48
 
32
- # Reserves the widget annotation for `field` at `rect` on `page`.
33
- # `extra` entries for the widget dictionary may be computed from its
34
- # reference (a tagged PDF's /StructParent).
35
- def add(field, rect, page)
49
+ # Reserves the widget annotation (a page annotation holding the field
50
+ # as :widget, its :appearance and :rect) on `page`. `extra` entries for
51
+ # the widget dictionary may be computed from its reference (a tagged
52
+ # PDF's /StructParent).
53
+ def add(annotation, page)
36
54
  ref = @writer.reserve
37
- @widgets << Widget.new(field, rect, page, ref, block_given? ? yield(ref) : {})
55
+ appearance = annotation[:appearance] || Appearance.new(annotation[:widget], *size_of(annotation[:rect]))
56
+ @widgets << Widget.new(annotation[:widget], appearance, annotation[:rect], page, ref,
57
+ block_given? ? yield(ref) : {})
58
+ ref
59
+ end
60
+
61
+ # Reserves the widget of a signature that fills no field: without a
62
+ # size or an appearance, printable and locked, on `page`. `taken` are
63
+ # the names the document's fields use.
64
+ def sign(page, taken: [])
65
+ name = (1..).lazy.map { |number| "Signature#{number}" }.find { |candidate| !taken.include?(candidate) }
66
+ ref = @writer.reserve
67
+ @widgets << Widget.new(Field.new(:signature, name, label: ""), nil, [0, 0, 0, 0], page, ref, {})
38
68
  ref
39
69
  end
40
70
 
41
71
  # Writes every field; returns the /AcroForm dictionary, or nil without
42
- # fields.
72
+ # fields. /DR lists the fonts the appearances use, /DA selects the first.
43
73
  def write
74
+ @signed = signed
44
75
  return if @widgets.empty?
45
76
 
46
- @fonts = FONTS.transform_values { |base| @writer.add(font(base)) }
77
+ @fonts = fonts
47
78
  fields = tree.children.values.map { |node| write_node(node, nil) }
48
- { Fields: fields, NeedAppearances: true, DA: "/#{Field::FONT} 0 Tf 0 g", DR: { Font: @fonts } }
79
+ form = { Fields: fields }
80
+ form[:SigFlags] = SIGNED if @signed
81
+ form[:NeedAppearances] = true if @need_appearances
82
+ form[:DA] = "/#{@fonts.keys.first} 0 Tf 0 g" if @fonts.any?
83
+ resources = @fonts.merge(symbols)
84
+ form[:DR] = { Font: resources } if resources.any?
85
+ form
49
86
  end
50
87
 
51
88
  private
52
89
 
53
- def font(base)
54
- font = { Type: :Font, Subtype: :Type1, BaseFont: base }
55
- base == :Helvetica ? font.merge(Encoding: :WinAnsiEncoding) : font
90
+ def size_of(rect) = [rect[2] - rect[0], rect[3] - rect[1]]
91
+
92
+ # The signature dictionary's reference, once the field it fills is
93
+ # known to exist; nil without a signature.
94
+ def signed
95
+ return unless @signature
96
+ return @writer.add(@signature.dictionary) if @widgets.any? { |widget| signs?(widget) }
97
+
98
+ raise ArgumentError, %(sign field: names "#{@signature.field}", but the document has no such signature_field)
99
+ end
100
+
101
+ def signs?(widget)
102
+ return widget.appearance.nil? if @signature.invisible?
103
+
104
+ widget.field.kind == :signature && widget.field.name == @signature.field
105
+ end
106
+
107
+ def symbols
108
+ return {} unless @need_appearances && @widgets.any? { |widget| widget.field.type == :Btn }
109
+
110
+ SYMBOLS.transform_values { |font| @writer.add(font) }
111
+ end
112
+
113
+ # The fonts the appearances name: the embedded ones, and the standard
114
+ # Helvetica when a field without a font book draws with it.
115
+ def fonts
116
+ names = @widgets.flat_map { |widget| widget.appearance&.font_names.to_a }.uniq
117
+ names.to_h do |name|
118
+ [name, @embedded.fetch(name) { @writer.add(Standard::FONT) }]
119
+ end
56
120
  end
57
121
 
58
122
  def tree
@@ -91,21 +155,27 @@ module Stationery
91
155
  # field's kids. A radio group's value is its checked choice.
92
156
  def write_field(widgets, own)
93
157
  field = widgets.first.field
158
+ default_appearance = widgets.first.appearance&.default_appearance
159
+ value = @signed && signs?(widgets.first) ? { V: @signed } : {}
94
160
  unless field.radio? || !widgets.one?
95
- return @writer.set(widgets.first.ref, own.merge(field.field_entries, widget(widgets.first)))
161
+ entries = field.field_entries(default_appearance:)
162
+ return @writer.set(widgets.first.ref, own.merge(entries, value, widget(widgets.first)))
96
163
  end
97
164
 
98
165
  ref = @writer.reserve
99
- value = field.radio? ? widgets.map(&:field).find(&:checked?)&.on_state || :Off : nil
100
- widgets.each { |kid| @writer.set(kid.ref, widget(kid, value).merge(Parent: ref)) }
101
- entries = value ? field.field_entries(value) : field.field_entries
102
- @writer.set(ref, own.merge(entries, Kids: widgets.map(&:ref)))
166
+ choice = field.radio? ? widgets.map(&:field).find(&:checked?)&.on_state || :Off : nil
167
+ widgets.each { |kid| @writer.set(kid.ref, widget(kid, choice).merge(Parent: ref)) }
168
+ entries = choice ? field.field_entries(choice, default_appearance:) : field.field_entries(default_appearance:)
169
+ @writer.set(ref, own.merge(entries, value, Kids: widgets.map(&:ref)))
103
170
  end
104
171
 
105
172
  def widget(widget, group_value = nil)
106
- x1, y1, x2, y2 = widget.rect
173
+ unless widget.appearance
174
+ return { Type: :Annot, Subtype: :Widget, F: PDF::Signature::INVISIBLE, Rect: widget.rect, P: widget.page }
175
+ end
176
+
107
177
  state = group_value && (widget.field.on_state == group_value ? group_value : :Off)
108
- entries = widget.field.widget_entries(x2 - x1, y2 - y1, @fonts, state:)
178
+ entries = widget.field.widget_entries(widget.appearance, @fonts, state:)
109
179
  normal = entries.dig(:AP, :N)
110
180
  normal = normal.is_a?(Hash) ? normal.transform_values { |stream| @writer.add(stream) } : @writer.add(normal)
111
181
  entries.merge(AP: { N: normal }, Rect: widget.rect, P: widget.page, **widget.extra)