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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +251 -23
- data/lib/stationery/canvas.rb +22 -1
- data/lib/stationery/cli/render.rb +19 -4
- data/lib/stationery/document.rb +69 -9
- data/lib/stationery/elements/forms.rb +11 -6
- data/lib/stationery/elements/rich.rb +13 -6
- data/lib/stationery/elements.rb +11 -3
- data/lib/stationery/errors.rb +13 -0
- 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/document.rb +8 -2
- data/lib/stationery/html/tree_builder.rb +34 -4
- data/lib/stationery/images/jpeg.rb +2 -1
- data/lib/stationery/layout/image.rb +9 -1
- data/lib/stationery/layout/table/cell.rb +14 -4
- data/lib/stationery/layout/table.rb +72 -14
- 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 +16 -0
- data/lib/stationery/pdf/assembler.rb +51 -11
- data/lib/stationery/pdf/attachments.rb +76 -0
- data/lib/stationery/pdf/conformance.rb +141 -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/serializer.rb +3 -1
- data/lib/stationery/pdf/signature/cms.rb +87 -0
- data/lib/stationery/pdf/signature.rb +167 -0
- data/lib/stationery/pdf/stream.rb +5 -3
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/pdf/xmp.rb +149 -0
- data/lib/stationery/resources.rb +3 -0
- data/lib/stationery/rich/nesting.rb +30 -0
- data/lib/stationery/rich/renderer/indents.rb +42 -0
- data/lib/stationery/rich/renderer.rb +22 -6
- data/lib/stationery/svg/clip_path.rb +69 -0
- data/lib/stationery/svg/document.rb +34 -26
- data/lib/stationery/svg/painter.rb +11 -0
- data/lib/stationery/svg/parser.rb +51 -11
- 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 +144 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +123 -0
- data/lib/stationery/testing/matchers.rb +92 -0
- data/lib/stationery/text/wrapper.rb +12 -3
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +8 -0
- data/lib/stationery.rb +11 -0
- metadata +16 -1
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,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,
|
|
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:,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
20
|
+
def markdown(source, max_depth: 64, **)
|
|
18
21
|
require_relative "../markdown/document"
|
|
19
|
-
rich(Markdown.parse(source.to_s
|
|
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
|
|
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,
|
|
33
|
+
Rich::Renderer.new(self, @_builder, styles:, gap:, images:, base_path:, bookmarks:, links:).render(blocks)
|
|
27
34
|
end
|
|
28
35
|
end
|
|
29
36
|
end
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -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
|
-
|
|
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]
|
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
|
|
@@ -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)
|