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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +251 -37
- data/lib/stationery/canvas.rb +3 -1
- data/lib/stationery/{svg → css}/selector.rb +1 -1
- data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
- data/lib/stationery/css.rb +20 -0
- data/lib/stationery/document.rb +35 -7
- data/lib/stationery/elements/forms.rb +11 -6
- data/lib/stationery/elements/rich.rb +21 -6
- data/lib/stationery/elements.rb +20 -7
- data/lib/stationery/errors.rb +4 -0
- data/lib/stationery/fonts/fallback.rb +1 -1
- data/lib/stationery/fonts/font.rb +28 -18
- data/lib/stationery/forms/acro_form.rb +95 -25
- data/lib/stationery/forms/appearance.rb +92 -30
- data/lib/stationery/forms/field.rb +20 -11
- data/lib/stationery/forms/metrics.rb +3 -17
- data/lib/stationery/forms/typeface.rb +113 -0
- data/lib/stationery/html/css.rb +276 -0
- data/lib/stationery/html/document.rb +10 -2
- data/lib/stationery/html/tokenizer.rb +15 -4
- data/lib/stationery/html/tree_builder/blocks.rb +90 -40
- data/lib/stationery/html/tree_builder/collector.rb +9 -3
- data/lib/stationery/html/tree_builder.rb +44 -4
- data/lib/stationery/layout/flow.rb +29 -0
- data/lib/stationery/layout/image.rb +21 -7
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +4 -0
- data/lib/stationery/layout/table/cell.rb +14 -4
- data/lib/stationery/layout/table.rb +72 -14
- data/lib/stationery/layout/text.rb +47 -10
- data/lib/stationery/markdown/block_parser/containers.rb +21 -0
- data/lib/stationery/markdown/block_parser.rb +9 -3
- data/lib/stationery/markdown/document.rb +8 -2
- data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
- data/lib/stationery/markdown/inline_parser/links.rb +17 -12
- data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
- data/lib/stationery/markdown/inline_parser.rb +9 -3
- data/lib/stationery/minitest.rb +4 -0
- data/lib/stationery/pdf/assembler.rb +31 -11
- data/lib/stationery/pdf/conformance.rb +7 -5
- data/lib/stationery/pdf/serializer.rb +3 -1
- data/lib/stationery/pdf/signature/cms.rb +95 -0
- data/lib/stationery/pdf/signature/timestamp.rb +120 -0
- data/lib/stationery/pdf/signature.rb +174 -0
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/pdf/writer.rb +73 -19
- data/lib/stationery/rich/nesting.rb +30 -0
- data/lib/stationery/rich/nodes.rb +37 -11
- data/lib/stationery/rich/renderer/boxes.rb +109 -0
- data/lib/stationery/rich/renderer/indents.rb +43 -0
- data/lib/stationery/rich/renderer/inlines.rb +9 -2
- data/lib/stationery/rich/renderer.rb +49 -16
- data/lib/stationery/svg/document.rb +8 -4
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/style.rb +4 -6
- data/lib/stationery/svg/walker.rb +2 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/text/breaks.rb +123 -0
- data/lib/stationery/text/paragraph.rb +8 -0
- data/lib/stationery/text/runs_builder.rb +4 -0
- data/lib/stationery/text/wrapper.rb +69 -11
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +13 -0
- data/lib/stationery.rb +6 -2
- metadata +13 -3
data/lib/stationery/document.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
28
|
+
def markdown(source, max_depth: 64, **)
|
|
18
29
|
require_relative "../markdown/document"
|
|
19
|
-
rich(Markdown.parse(source.to_s
|
|
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
|
|
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,
|
|
41
|
+
Rich::Renderer.new(self, @_builder, styles:, gap:, images:, base_path:, bookmarks:, links:).render(blocks)
|
|
27
42
|
end
|
|
28
43
|
end
|
|
29
44
|
end
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -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))
|
|
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],
|
|
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
|
-
|
|
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
|
data/lib/stationery/errors.rb
CHANGED
|
@@ -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}
|
|
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
|
-
@
|
|
38
|
-
@
|
|
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
|
-
|
|
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 + (
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
@
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
7
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
33
|
-
#
|
|
34
|
-
# reference (a tagged
|
|
35
|
-
|
|
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
|
-
|
|
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 =
|
|
77
|
+
@fonts = fonts
|
|
47
78
|
fields = tree.children.values.map { |node| write_node(node, nil) }
|
|
48
|
-
{ Fields: fields
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
widgets.each { |kid| @writer.set(kid.ref, widget(kid,
|
|
101
|
-
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
|
-
|
|
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(
|
|
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)
|