stationery 0.8.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 +11 -0
- data/README.md +128 -23
- data/lib/stationery/canvas.rb +3 -1
- data/lib/stationery/document.rb +20 -4
- 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/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/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 +4 -0
- data/lib/stationery/pdf/assembler.rb +19 -9
- data/lib/stationery/pdf/conformance.rb +7 -5
- 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/types.rb +4 -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/document.rb +8 -4
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/walker.rb +2 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +55 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/text/wrapper.rb +12 -3
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +4 -0
- data/lib/stationery.rb +3 -0
- metadata +6 -1
|
@@ -133,6 +133,19 @@ module Stationery
|
|
|
133
133
|
entries.each_slice(2).map { |_name, ref| attachment(objects.deref_hash(ref)) }
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
# Every signed signature field: `{ field:, name:, reason:, location:,
|
|
137
|
+
# signed_at:, subfilter:, byte_range:, signer:, valid: }`. `signer` is
|
|
138
|
+
# the signing certificate's subject ("CN=…,O=…"). `valid` is whether
|
|
139
|
+
# the signature covers the whole file and verifies against the
|
|
140
|
+
# certificate it carries; the certificate's own trust is not judged.
|
|
141
|
+
def signatures
|
|
142
|
+
form = catalog[:AcroForm]
|
|
143
|
+
return [] unless form
|
|
144
|
+
|
|
145
|
+
require "openssl"
|
|
146
|
+
signature_fields(objects.deref_hash(form)[:Fields], nil)
|
|
147
|
+
end
|
|
148
|
+
|
|
136
149
|
# The structure tree of a tagged PDF as nested arrays; see StructureReader.
|
|
137
150
|
def structure = @structure ||= StructureReader.new(reader).tree
|
|
138
151
|
|
|
@@ -167,6 +180,48 @@ module Stationery
|
|
|
167
180
|
description: spec[:Desc] && decode(spec[:Desc]), relationship: }
|
|
168
181
|
end
|
|
169
182
|
|
|
183
|
+
def signature_fields(refs, prefix)
|
|
184
|
+
Array(objects.deref_array(refs)).flat_map do |ref|
|
|
185
|
+
field = objects.deref_hash(ref)
|
|
186
|
+
name = [prefix, field[:T] && decode(field[:T])].compact.join(".")
|
|
187
|
+
kids = Array(objects.deref_array(field[:Kids])).select { |kid| objects.deref_hash(kid).key?(:T) }
|
|
188
|
+
next signature_fields(kids, name) if kids.any?
|
|
189
|
+
|
|
190
|
+
field[:FT] == :Sig && field[:V] ? [signature(name, objects.deref_hash(field[:V]))] : []
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def signature(field, value)
|
|
195
|
+
range = objects.deref_array(value[:ByteRange])
|
|
196
|
+
text = value.slice(:Name, :Reason, :Location).transform_values { |string| decode(string) }
|
|
197
|
+
{ field:, name: text[:Name], reason: text[:Reason], location: text[:Location],
|
|
198
|
+
signed_at: signing_time(value[:M]), subfilter: value[:SubFilter], byte_range: range, **verdict(range) }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def signing_time(date)
|
|
202
|
+
digits = date.to_s[/\AD:(\d{14})/, 1]
|
|
203
|
+
digits && Time.utc(*digits.unpack("a4a2a2a2a2a2").map(&:to_i))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The signature is read from the file by its byte range, not from the
|
|
207
|
+
# parsed dictionary: it is the one string encryption leaves alone.
|
|
208
|
+
def verdict(range)
|
|
209
|
+
contents = pdf.byteslice(range[1], range[2] - range[1]).to_s
|
|
210
|
+
cms = OpenSSL::PKCS7.new(PDF::Signature.der([contents[1..-2]].pack("H*")))
|
|
211
|
+
signed = pdf.byteslice(range[0], range[1]) + pdf.byteslice(range[2], range[3])
|
|
212
|
+
flags = OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::BINARY
|
|
213
|
+
whole = range[0].zero? && range[2] + range[3] == pdf.bytesize && contents.start_with?("<")
|
|
214
|
+
{ signer: signer(cms), valid: whole && cms.verify([], OpenSSL::X509::Store.new, signed, flags) }
|
|
215
|
+
rescue OpenSSL::OpenSSLError, ArgumentError, TypeError
|
|
216
|
+
{ signer: nil, valid: false }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def signer(cms)
|
|
220
|
+
info = cms.signers.first
|
|
221
|
+
certificate = cms.certificates.find { |one| one.serial == info.serial && one.issuer == info.issuer }
|
|
222
|
+
certificate&.subject&.to_utf8
|
|
223
|
+
end
|
|
224
|
+
|
|
170
225
|
def xmp_value(body)
|
|
171
226
|
items = body.scan(XMP_ITEM).flatten.map { |item| xmp_text(item) }
|
|
172
227
|
return xmp_text(body) if items.empty?
|
|
@@ -183,6 +183,30 @@ module Stationery
|
|
|
183
183
|
end
|
|
184
184
|
end
|
|
185
185
|
|
|
186
|
+
class HaveSignature < Base
|
|
187
|
+
def initialize(name: nil, valid: true)
|
|
188
|
+
super(name)
|
|
189
|
+
@valid = valid
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def description
|
|
193
|
+
"have #{@valid ? "a valid" : "an invalid"} signature#{" by #{show(@expected)}" if @expected}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def match?(pdf)
|
|
199
|
+
pdf.signatures.any? { |one| (@expected.nil? || one[:name] == @expected) && one[:valid] == @valid }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def actual
|
|
203
|
+
signatures = @inspector.signatures
|
|
204
|
+
return "got none" if signatures.empty?
|
|
205
|
+
|
|
206
|
+
"got #{signatures.map { |one| "#{one[:name].inspect} (#{one[:valid] ? "valid" : "invalid"})" }.join(", ")}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
186
210
|
class HaveNoWarnings < Base
|
|
187
211
|
def description = "have no warnings"
|
|
188
212
|
|
|
@@ -227,6 +251,7 @@ module Stationery
|
|
|
227
251
|
def have_attachment(name, **) = HaveAttachment.new(name, **)
|
|
228
252
|
def have_conformance(*levels) = HaveConformance.new(*levels)
|
|
229
253
|
def have_factur_x(profile: nil) = HaveFacturX.new(profile:)
|
|
254
|
+
def have_signature(name: nil, valid: true) = HaveSignature.new(name:, valid:)
|
|
230
255
|
def have_no_warnings = HaveNoWarnings.new
|
|
231
256
|
def have_structure(expected) = HaveStructure.new(expected)
|
|
232
257
|
def have_tagged_content = HaveTaggedContent.new
|
|
@@ -26,6 +26,7 @@ module Stationery
|
|
|
26
26
|
@current = []
|
|
27
27
|
@pending_space = []
|
|
28
28
|
@fallback = fallback_style
|
|
29
|
+
@widths = {}.compare_by_identity
|
|
29
30
|
items(runs).each { |item| place(item) }
|
|
30
31
|
finish unless @current.empty? && @lines.any? && !@ended_with_newline
|
|
31
32
|
@lines
|
|
@@ -97,6 +98,8 @@ module Stationery
|
|
|
97
98
|
# its hyphen) fits `available`; nil when the word offers no break that
|
|
98
99
|
# fits. Soft hyphens name the breaks and suppress the patterns.
|
|
99
100
|
def hyphenated(word, available)
|
|
101
|
+
return unless @explicit || word.first&.style&.hyphenate
|
|
102
|
+
|
|
100
103
|
chars = word.flat_map { |segment| segment.text.chars.map { |char| Segment.new(char, segment.style) } }
|
|
101
104
|
break_points(chars).reverse_each do |index|
|
|
102
105
|
head = chars.first(index).reject { |segment| segment.text == SOFT_HYPHEN }
|
|
@@ -164,7 +167,7 @@ module Stationery
|
|
|
164
167
|
def fragments(segments)
|
|
165
168
|
x = 0
|
|
166
169
|
segments.chunk_while { |a, b| a.style == b.style }.filter_map do |group|
|
|
167
|
-
text = group.map(&:text).join
|
|
170
|
+
text = plain(group.map(&:text).join)
|
|
168
171
|
next if text.empty?
|
|
169
172
|
|
|
170
173
|
font, face = @book.resolve(group.first.style)
|
|
@@ -178,12 +181,18 @@ module Stationery
|
|
|
178
181
|
end
|
|
179
182
|
|
|
180
183
|
def line_width = width(@current)
|
|
181
|
-
def characters(segments) = segments.sum { |segment| segment.text
|
|
184
|
+
def characters(segments) = segments.sum { |segment| plain(segment.text).length }
|
|
182
185
|
|
|
186
|
+
# A line is summed again for every word that joins it; its segments
|
|
187
|
+
# are the same objects each time, so each is measured once per wrap.
|
|
183
188
|
def width(segments)
|
|
184
|
-
segments.sum { |segment| measure(segment.text
|
|
189
|
+
segments.sum { |segment| @widths[segment] ||= measure(plain(segment.text), segment.style) }
|
|
185
190
|
end
|
|
186
191
|
|
|
192
|
+
# The text without its soft hyphens; the same String when it has none,
|
|
193
|
+
# which is nearly always, so measuring a word allocates nothing.
|
|
194
|
+
def plain(text) = text.include?(SOFT_HYPHEN) ? text.delete(SOFT_HYPHEN) : text
|
|
195
|
+
|
|
187
196
|
def measure(text, style)
|
|
188
197
|
font = @book.resolve(style).first
|
|
189
198
|
font.width_of(text, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
|
data/lib/stationery/version.rb
CHANGED
data/lib/stationery/warnings.rb
CHANGED
|
@@ -43,6 +43,10 @@ module Stationery
|
|
|
43
43
|
def message = %(link "#{href}" dropped: scheme not allowed)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
NestingLimit = Data.define(:depth, :limit) do
|
|
47
|
+
def message = "content nested #{depth} levels deep was flattened below level #{limit}"
|
|
48
|
+
end
|
|
49
|
+
|
|
46
50
|
DuplicateAnchor = Data.define(:name, :page) do
|
|
47
51
|
def message = %(anchor "#{name}" on page #{page} is already defined)
|
|
48
52
|
end
|
data/lib/stationery.rb
CHANGED
|
@@ -66,7 +66,10 @@ require_relative "stationery/pdf/attachments"
|
|
|
66
66
|
require_relative "stationery/pdf/xmp"
|
|
67
67
|
require_relative "stationery/pdf/conformance"
|
|
68
68
|
require_relative "stationery/pdf/factur_x"
|
|
69
|
+
require_relative "stationery/pdf/signature"
|
|
70
|
+
require_relative "stationery/pdf/signature/cms"
|
|
69
71
|
require_relative "stationery/forms/metrics"
|
|
72
|
+
require_relative "stationery/forms/typeface"
|
|
70
73
|
require_relative "stationery/forms/field"
|
|
71
74
|
require_relative "stationery/forms/appearance"
|
|
72
75
|
require_relative "stationery/forms/acro_form"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: stationery
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -88,6 +88,7 @@ files:
|
|
|
88
88
|
- lib/stationery/forms/appearance.rb
|
|
89
89
|
- lib/stationery/forms/field.rb
|
|
90
90
|
- lib/stationery/forms/metrics.rb
|
|
91
|
+
- lib/stationery/forms/typeface.rb
|
|
91
92
|
- lib/stationery/geometry.rb
|
|
92
93
|
- lib/stationery/html/document.rb
|
|
93
94
|
- lib/stationery/html/tokenizer.rb
|
|
@@ -158,6 +159,8 @@ files:
|
|
|
158
159
|
- lib/stationery/pdf/outline_writer.rb
|
|
159
160
|
- lib/stationery/pdf/page_labels.rb
|
|
160
161
|
- lib/stationery/pdf/serializer.rb
|
|
162
|
+
- lib/stationery/pdf/signature.rb
|
|
163
|
+
- lib/stationery/pdf/signature/cms.rb
|
|
161
164
|
- lib/stationery/pdf/stream.rb
|
|
162
165
|
- lib/stationery/pdf/types.rb
|
|
163
166
|
- lib/stationery/pdf/writer.rb
|
|
@@ -168,8 +171,10 @@ files:
|
|
|
168
171
|
- lib/stationery/railtie.rb
|
|
169
172
|
- lib/stationery/regions.rb
|
|
170
173
|
- lib/stationery/resources.rb
|
|
174
|
+
- lib/stationery/rich/nesting.rb
|
|
171
175
|
- lib/stationery/rich/nodes.rb
|
|
172
176
|
- lib/stationery/rich/renderer.rb
|
|
177
|
+
- lib/stationery/rich/renderer/indents.rb
|
|
173
178
|
- lib/stationery/rich/renderer/inlines.rb
|
|
174
179
|
- lib/stationery/rich/renderer/links.rb
|
|
175
180
|
- lib/stationery/rich/styles.rb
|