stationery 0.8.0 → 0.10.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +251 -37
  4. data/lib/stationery/canvas.rb +3 -1
  5. data/lib/stationery/{svg → css}/selector.rb +1 -1
  6. data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
  7. data/lib/stationery/css.rb +20 -0
  8. data/lib/stationery/document.rb +35 -7
  9. data/lib/stationery/elements/forms.rb +11 -6
  10. data/lib/stationery/elements/rich.rb +21 -6
  11. data/lib/stationery/elements.rb +20 -7
  12. data/lib/stationery/errors.rb +4 -0
  13. data/lib/stationery/fonts/fallback.rb +1 -1
  14. data/lib/stationery/fonts/font.rb +28 -18
  15. data/lib/stationery/forms/acro_form.rb +95 -25
  16. data/lib/stationery/forms/appearance.rb +92 -30
  17. data/lib/stationery/forms/field.rb +20 -11
  18. data/lib/stationery/forms/metrics.rb +3 -17
  19. data/lib/stationery/forms/typeface.rb +113 -0
  20. data/lib/stationery/html/css.rb +276 -0
  21. data/lib/stationery/html/document.rb +10 -2
  22. data/lib/stationery/html/tokenizer.rb +15 -4
  23. data/lib/stationery/html/tree_builder/blocks.rb +90 -40
  24. data/lib/stationery/html/tree_builder/collector.rb +9 -3
  25. data/lib/stationery/html/tree_builder.rb +44 -4
  26. data/lib/stationery/layout/flow.rb +29 -0
  27. data/lib/stationery/layout/image.rb +21 -7
  28. data/lib/stationery/layout/mark.rb +2 -0
  29. data/lib/stationery/layout/node.rb +4 -0
  30. data/lib/stationery/layout/table/cell.rb +14 -4
  31. data/lib/stationery/layout/table.rb +72 -14
  32. data/lib/stationery/layout/text.rb +47 -10
  33. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  34. data/lib/stationery/markdown/block_parser.rb +9 -3
  35. data/lib/stationery/markdown/document.rb +8 -2
  36. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  37. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  38. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  39. data/lib/stationery/markdown/inline_parser.rb +9 -3
  40. data/lib/stationery/minitest.rb +4 -0
  41. data/lib/stationery/pdf/assembler.rb +31 -11
  42. data/lib/stationery/pdf/conformance.rb +7 -5
  43. data/lib/stationery/pdf/serializer.rb +3 -1
  44. data/lib/stationery/pdf/signature/cms.rb +95 -0
  45. data/lib/stationery/pdf/signature/timestamp.rb +120 -0
  46. data/lib/stationery/pdf/signature.rb +174 -0
  47. data/lib/stationery/pdf/types.rb +4 -0
  48. data/lib/stationery/pdf/writer.rb +73 -19
  49. data/lib/stationery/rich/nesting.rb +30 -0
  50. data/lib/stationery/rich/nodes.rb +37 -11
  51. data/lib/stationery/rich/renderer/boxes.rb +109 -0
  52. data/lib/stationery/rich/renderer/indents.rb +43 -0
  53. data/lib/stationery/rich/renderer/inlines.rb +9 -2
  54. data/lib/stationery/rich/renderer.rb +49 -16
  55. data/lib/stationery/svg/document.rb +8 -4
  56. data/lib/stationery/svg/parser.rb +51 -11
  57. data/lib/stationery/svg/style.rb +4 -6
  58. data/lib/stationery/svg/walker.rb +2 -0
  59. data/lib/stationery/tagging/element.rb +1 -1
  60. data/lib/stationery/testing/inspector.rb +98 -0
  61. data/lib/stationery/testing/matchers.rb +25 -0
  62. data/lib/stationery/text/breaks.rb +123 -0
  63. data/lib/stationery/text/paragraph.rb +8 -0
  64. data/lib/stationery/text/runs_builder.rb +4 -0
  65. data/lib/stationery/text/wrapper.rb +69 -11
  66. data/lib/stationery/version.rb +1 -1
  67. data/lib/stationery/warnings.rb +13 -0
  68. data/lib/stationery.rb +6 -2
  69. metadata +13 -3
@@ -107,6 +107,22 @@ module Stationery
107
107
  config[:factur_x] = { xml: source, **options }
108
108
  end
109
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. `timestamp:` is the URL of a time-stamping
117
+ # authority whose token makes it PAdES baseline B-T. See PDF::Signature.
118
+ def sign(**options, &block)
119
+ raise ArgumentError, "sign needs certificate: and key:, or a block answering them" unless block || options.any?
120
+
121
+ options = PDF::Signature.check(options)
122
+ PDF::Signature.new(**options) unless block || PDF::Signature.deferred?(options)
123
+ config[:sign] = block || options
124
+ end
125
+
110
126
  # Encrypts every render with the standard security handler; see
111
127
  # PDF::Encryption::StandardSecurity for the options.
112
128
  def encrypt(**)
@@ -138,18 +154,28 @@ module Stationery
138
154
  def page_options = self.class.config[:page]
139
155
  def metadata = self.class.config[:metadata]
140
156
 
157
+ # The PDF as a binary String, also written to `target` when given: a
158
+ # path or an IO. With a block the file is streamed to it in pieces as it
159
+ # is written and the call answers the number of bytes: the first bytes
160
+ # leave before the last page is assembled and no output buffer is built.
161
+ # Layout runs in full first either way, so peak memory does not drop.
141
162
  def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
142
163
  tagged: self.class.config[:tagged], page_labels: self.class.config[:page_labels], attachments: [],
143
164
  xmp: metadata[:xmp] != false, conformance: self.class.config[:conformance],
144
- factur_x: self.class.config[:factur_x])
165
+ factur_x: self.class.config[:factur_x], sign: self.class.config[:sign], &block)
145
166
  invoice = PDF::FacturX.for(factur_x, self)
146
167
  attachments = PDF::Attachments.merge(self.class.config[:attachments], attachments, invoice&.attachment)
147
168
  conformance = PDF::Conformance.for(invoice ? invoice.conformance(conformance) : conformance)
148
169
  conformance&.validate!(encrypt:, metadata:, attachments:)
170
+ signature = PDF::Signature.for(sign, self)
171
+ raise ArgumentError, "a signed document cannot be streamed to a block: sign needs the whole file" if
172
+ signature && block
173
+ raise ArgumentError, "pass a target or a block, not both" if target && block
174
+
149
175
  options = { strict:, debug:, encrypt:, tagged: tagged || conformance&.pdf_ua?, page_labels:, attachments:,
150
- xmp: xmp || !conformance.nil?, conformance:, invoice: }
176
+ xmp: xmp || !conformance.nil?, conformance:, invoice:, signature:, sink: block }
151
177
  Stationery.instrument("render.stationery", document: self.class.name) do |event|
152
- write(render_pdf(event, **options), target)
178
+ block ? render_pdf(event, **options) : write(render_pdf(event, **options), target)
153
179
  end
154
180
  end
155
181
 
@@ -192,22 +218,24 @@ module Stationery
192
218
  raise WarningsError, warnings if strict && warnings.any?
193
219
 
194
220
  pdf = assemble(pages, resources, outline, tagging:, conformance:, **assembly)
195
- event[:bytes] = pdf.bytesize
221
+ event[:bytes] = byte_count(pdf)
196
222
  pdf
197
223
  end
198
224
 
199
225
  def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:, attachments:, xmp:, conformance:,
200
- invoice:)
226
+ invoice:, signature:, sink:)
201
227
  encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
202
228
  assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, xmp:,
203
229
  lang: metadata[:lang], page_labels: PDF::PageLabels.entries(page_labels),
204
230
  attachments:, conformance:, xmp_extensions: invoice&.xmp_extensions || {},
205
- xmp_schemas: [invoice&.xmp_schema].compact)
231
+ xmp_schemas: [invoice&.xmp_schema].compact, signature:, sink:)
206
232
  Stationery.instrument("write.stationery", document: self.class.name) do |event|
207
- assembler.render.tap { |pdf| event[:bytes] = pdf.bytesize }
233
+ assembler.render.tap { |pdf| event[:bytes] = byte_count(pdf) }
208
234
  end
209
235
  end
210
236
 
237
+ def byte_count(pdf) = pdf.is_a?(String) ? pdf.bytesize : pdf
238
+
211
239
  def paginate(root, book:, resources:, warnings:, debug:, tagging:)
212
240
  Stationery.instrument("paginate.stationery", document: self.class.name) do |event|
213
241
  regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
@@ -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,37 @@ 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. `<style>`
14
+ # rules and inline styles are read for the properties HTML::Css knows;
15
+ # the rest is reported once as an UnsupportedCss warning.
16
+ def html(source, max_depth: 64, **)
12
17
  require_relative "../html/document"
13
- rich(HTML.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:, links:)
18
+ css = HTML::Css::Report.new
19
+ blocks = HTML.parse(source.to_s, max_depth:, css:) { |depth| nesting_limit(depth, max_depth) }
20
+ if css.any?
21
+ @_builder.warnings << Warnings::UnsupportedCss.new(properties: css.properties,
22
+ selectors: css.selectors)
23
+ end
24
+ rich(blocks, **)
14
25
  end
15
26
 
16
27
  # 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)
28
+ def markdown(source, max_depth: 64, **)
18
29
  require_relative "../markdown/document"
19
- rich(Markdown.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:, links:)
30
+ rich(Markdown.parse(source.to_s, max_depth:) { |depth| nesting_limit(depth, max_depth) }, **)
20
31
  end
21
32
 
22
33
  private
23
34
 
24
- def rich(blocks, **)
35
+ def nesting_limit(depth, limit)
36
+ @_builder.warnings << Warnings::NestingLimit.new(depth:, limit:)
37
+ end
38
+
39
+ def rich(blocks, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false, links: nil)
25
40
  require_relative "../rich/renderer"
26
- Rich::Renderer.new(self, @_builder, **).render(blocks)
41
+ Rich::Renderer.new(self, @_builder, styles:, gap:, images:, base_path:, bookmarks:, links:).render(blocks)
27
42
  end
28
43
  end
29
44
  end
@@ -3,18 +3,21 @@
3
3
  module Stationery
4
4
  # The element DSL available inside every component's view_template.
5
5
  module Elements
6
- PARAGRAPH_DEFAULTS = { align: :left, leading: 0 }.freeze
6
+ PARAGRAPH_DEFAULTS = { align: :left, leading: 0, orphans: 1, widows: 1 }.freeze
7
7
 
8
8
  # A paragraph. Plain strings are always literal; pass `markup: true` to
9
9
  # read inline tags, or a block to build styled runs in Ruby. `heading: 1..6`
10
- # tags it as a heading in a tagged PDF.
10
+ # tags it as a heading in a tagged PDF. `orphans:` and `widows:` are the
11
+ # fewest lines a page break may leave behind and carry over (default 1).
11
12
  def text(content = nil, markup: false, keep_with_next: nil, break_inside: nil, anchor: nil, bookmark: nil,
12
13
  heading: nil, **options, &)
13
- settings = PARAGRAPH_DEFAULTS.merge(@_builder.text_defaults.slice(:align, :leading)).merge(options)
14
+ settings = PARAGRAPH_DEFAULTS.merge(@_builder.text_defaults.slice(:align, :leading, :orphans, :widows))
15
+ .merge(options)
14
16
  style = @_builder.style(options)
15
17
  runs = text_runs(content, style, markup, &)
16
18
  node = Layout::Text.new(runs, context: @_builder.context(style), align: settings[:align],
17
- leading: settings[:leading], tag: Tagging::Element.new(Tagging.heading(heading)))
19
+ leading: settings[:leading], orphans: settings[:orphans], widows: settings[:widows],
20
+ tag: Tagging::Element.new(Tagging.heading(heading)))
18
21
  node.keep_with_next = keep_with_next
19
22
  node.break_inside = break_inside
20
23
  @_builder.add(mark(node, anchor, bookmark))
@@ -95,9 +98,7 @@ module Stationery
95
98
  name = source.to_s.lstrip.start_with?("<") ? "inline" : File.basename(source.to_s)
96
99
  source = File.read(source.to_s) unless name == "inline"
97
100
  document = SVG::Document.parse(source)
98
- if document.unsupported.any?
99
- @_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
100
- end
101
+ svg_warnings(document, name)
101
102
  node = Layout::Svg.new(document, width:, height:, color:, context: @_builder.context, alt:)
102
103
  @_builder.add(align ? Layout::Flow.new([node], align:) : node)
103
104
  end
@@ -154,6 +155,16 @@ module Stationery
154
155
 
155
156
  private
156
157
 
158
+ # What the drawing uses that is not drawn, and nesting that was flattened.
159
+ def svg_warnings(document, name)
160
+ if document.unsupported.any?
161
+ @_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
162
+ end
163
+ return unless document.nesting
164
+
165
+ @_builder.warnings << Warnings::NestingLimit.new(depth: document.nesting, limit: SVG::Parser::MAX_DEPTH)
166
+ end
167
+
157
168
  # `bookmark:` is a title, or { title:, level:, open: }.
158
169
  def mark(node, anchor, bookmark)
159
170
  names = [anchor&.to_s]
@@ -175,10 +186,12 @@ module Stationery
175
186
  end
176
187
  end
177
188
 
189
+ # A Hash is a cell with options: `{ content:, background:, borders:, padding:, … }`.
178
190
  def cell_content(content)
179
191
  case content
180
192
  when Proc then container(&content)
181
193
  when Component then container { render content }
194
+ when Hash then content.merge(content: cell_content(content[:content]))
182
195
  else content
183
196
  end
184
197
  end
@@ -21,6 +21,10 @@ module Stationery
21
21
  end
22
22
  end
23
23
 
24
+ # Raised when a signature cannot be made as asked: the time-stamping
25
+ # authority is unreachable, refuses, or answers for something else.
26
+ class SignatureError < Error; end
27
+
24
28
  # Raised when a render cannot keep the conformance it claims (PDF/A,
25
29
  # PDF/UA): `levels` are the claimed levels, `issues` what breaks them.
26
30
  class ConformanceError < Error
@@ -12,7 +12,7 @@ module Stationery
12
12
  # stay with their base; whitespace a font lacks draws as a blank of the
13
13
  # right width (see Font::WHITESPACE).
14
14
  class Fallback
15
- CARRIED = /[\p{Space}­‌‍︀-️\p{M}]/
15
+ CARRIED = /[\p{Space}­​‌‍︀-️\p{M}]/
16
16
 
17
17
  def self.carried?(char) = CARRIED.match?(char)
18
18
 
@@ -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)