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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +128 -23
  4. data/lib/stationery/canvas.rb +3 -1
  5. data/lib/stationery/document.rb +20 -4
  6. data/lib/stationery/elements/forms.rb +11 -6
  7. data/lib/stationery/elements/rich.rb +13 -6
  8. data/lib/stationery/elements.rb +11 -3
  9. data/lib/stationery/fonts/font.rb +28 -18
  10. data/lib/stationery/forms/acro_form.rb +95 -25
  11. data/lib/stationery/forms/appearance.rb +92 -30
  12. data/lib/stationery/forms/field.rb +20 -11
  13. data/lib/stationery/forms/metrics.rb +3 -17
  14. data/lib/stationery/forms/typeface.rb +113 -0
  15. data/lib/stationery/html/document.rb +8 -2
  16. data/lib/stationery/html/tree_builder.rb +34 -4
  17. data/lib/stationery/layout/image.rb +9 -1
  18. data/lib/stationery/layout/table/cell.rb +14 -4
  19. data/lib/stationery/layout/table.rb +72 -14
  20. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  21. data/lib/stationery/markdown/block_parser.rb +9 -3
  22. data/lib/stationery/markdown/document.rb +8 -2
  23. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  24. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  25. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  26. data/lib/stationery/markdown/inline_parser.rb +9 -3
  27. data/lib/stationery/minitest.rb +4 -0
  28. data/lib/stationery/pdf/assembler.rb +19 -9
  29. data/lib/stationery/pdf/conformance.rb +7 -5
  30. data/lib/stationery/pdf/serializer.rb +3 -1
  31. data/lib/stationery/pdf/signature/cms.rb +87 -0
  32. data/lib/stationery/pdf/signature.rb +167 -0
  33. data/lib/stationery/pdf/types.rb +4 -0
  34. data/lib/stationery/rich/nesting.rb +30 -0
  35. data/lib/stationery/rich/renderer/indents.rb +42 -0
  36. data/lib/stationery/rich/renderer.rb +22 -6
  37. data/lib/stationery/svg/document.rb +8 -4
  38. data/lib/stationery/svg/parser.rb +51 -11
  39. data/lib/stationery/svg/walker.rb +2 -0
  40. data/lib/stationery/tagging/element.rb +1 -1
  41. data/lib/stationery/testing/inspector.rb +55 -0
  42. data/lib/stationery/testing/matchers.rb +25 -0
  43. data/lib/stationery/text/wrapper.rb +12 -3
  44. data/lib/stationery/version.rb +1 -1
  45. data/lib/stationery/warnings.rb +4 -0
  46. data/lib/stationery.rb +3 -0
  47. metadata +6 -1
@@ -39,7 +39,7 @@ module Stationery
39
39
  @type = type
40
40
  @alt = alt
41
41
  @kind = kind
42
- @attributes = attributes.transform_values(&:dup)
42
+ @attributes = attributes.empty? ? {} : attributes.transform_values(&:dup)
43
43
  @kids = []
44
44
  end
45
45
 
@@ -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.delete(SOFT_HYPHEN)
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.delete(SOFT_HYPHEN).length }
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.delete(SOFT_HYPHEN), segment.style) }
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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -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.8.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