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
@@ -2,15 +2,24 @@
2
2
 
3
3
  require_relative "nodes"
4
4
  require_relative "styles"
5
+ require_relative "renderer/indents"
5
6
  require_relative "renderer/inlines"
6
7
  require_relative "renderer/links"
8
+ require_relative "renderer/boxes"
7
9
 
8
10
  module Stationery
9
11
  module Rich
10
12
  # Draws rich-text blocks with a component's element DSL. Images come from
11
13
  # `images:` (a callable given the src, returning a path, an IO or nil) or
12
- # from files under `base_path:`; remote URLs are never fetched.
14
+ # from files under `base_path:`; remote URLs are never fetched. Block
15
+ # quotes and lists nested deeper than Indents::LIMIT stop indenting, which
16
+ # is reported as a NestingLimit warning. A block's `style` (from the
17
+ # source's CSS) adds margins, a box, an alignment or a page break around
18
+ # what `styles:` draws; a block without one draws exactly as before.
13
19
  class Renderer
20
+ include Indents
21
+ include Boxes
22
+
14
23
  REMOTE = /\A[a-z][a-z0-9+.-]*:/i
15
24
  LINK_SCHEMES = %w[http https mailto tel].freeze
16
25
 
@@ -23,22 +32,31 @@ module Stationery
23
32
  @base_path = base_path && File.expand_path(base_path.to_s)
24
33
  @bookmarks = bookmarks
25
34
  @links = Links.new(links || LINK_SCHEMES, builder.warnings)
35
+ @indents = 0
26
36
  end
27
37
 
28
- def render(blocks) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
38
+ def render(blocks)
39
+ depth = indents_of(blocks)
40
+ @builder.warnings << Warnings::NestingLimit.new(depth:, limit: Indents::LIMIT) if depth > Indents::LIMIT
41
+ group(blocks)
42
+ end
29
43
 
30
44
  private
31
45
 
46
+ def group(blocks) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
47
+
32
48
  def block(block)
33
49
  case block
34
- when Paragraph then paragraph(block.inlines, **@styles[:p])
35
- when Heading then heading(block)
36
- when List then list(block)
37
- when Blockquote then @component.box(role: :blockquote, **@styles[:blockquote]) { render(block.blocks) }
38
- when CodeBlock then code(block)
50
+ when Paragraph then styled(block.style) { paragraph(block.inlines, **@styles[:p], **aligned(block.style)) }
51
+ when Heading then styled(block.style) { heading(block) }
52
+ when List then styled(block.style) { indented(block) { list(block) } }
53
+ when Blockquote then spaced(block.style) { indented(block) { blockquote(block) } }
54
+ when CodeBlock then spaced(block.style) { code(block) }
39
55
  when Rule then @component.rule(**@styles[:hr])
40
- when Table then table(block)
56
+ when Table then styled(block.style.except(:border, :width)) { table(block) }
41
57
  when Image then image(block)
58
+ when Container then styled(block.style) { group(block.blocks) }
59
+ when PageBreak then @component.page_break
42
60
  end
43
61
  end
44
62
 
@@ -50,7 +68,8 @@ module Stationery
50
68
  style = @styles[:"h#{heading.level}"]
51
69
  size = style[:size] || (@builder.style.size * style.fetch(:scale, 1))
52
70
  bookmark = heading_bookmark(heading)
53
- paragraph(heading.inlines, **style.except(:scale), size:, heading: heading.level, **({ bookmark: } if bookmark))
71
+ paragraph(heading.inlines, **style.except(:scale), size:, heading: heading.level,
72
+ **aligned(heading.style), **({ bookmark: } if bookmark))
54
73
  end
55
74
 
56
75
  def heading_bookmark(heading)
@@ -59,29 +78,38 @@ module Stationery
59
78
  { title: heading.inlines.grep(Inline).map(&:text).join, level: heading.level }
60
79
  end
61
80
 
81
+ def blockquote(quote)
82
+ @component.box(role: :blockquote, **@styles[:blockquote], **box_options(quote.style)) { group(quote.blocks) }
83
+ end
84
+
62
85
  def list(list)
63
86
  options = list.ordered ? { **@styles[:ol], start: list.start || 1 } : @styles[:ul]
64
87
  @component.public_send(list.ordered ? :ol : :ul, **options) do
65
88
  list.items.each do |blocks|
66
- @component.li { @component.text_style(**@styles[:li]) { render(blocks) } }
89
+ @component.li { @component.text_style(**@styles[:li]) { group(blocks) } }
67
90
  end
68
91
  end
69
92
  end
70
93
 
71
94
  def code(block)
72
95
  font = @styles[:code][:font]
73
- @component.box(**@styles[:pre]) { @component.text(block.text, **({ font: } if font)) }
96
+ @component.box(**@styles[:pre], **box_options(block.style)) do
97
+ @component.text(block.text, **({ font: } if font))
98
+ end
74
99
  end
75
100
 
76
101
  def table(table)
77
102
  header = table.rows.first&.all?(&:header) ? 1 : 0
78
103
  rows = table.rows.map { |row| row.map { |cell| cell_content(cell) } }
79
- @component.table(rows, header:, cell: @styles[:table][:cell])
104
+ cell = @styles[:table][:cell].merge(border_options(table.style[:border]))
105
+ sized(table.style[:width]) { |width| @component.table(rows, header:, cell:, **width, **column_widths(table)) }
80
106
  end
81
107
 
82
108
  def cell_content(cell)
83
109
  style = cell.header ? @styles[:table][:header] : {}
84
- -> { @component.text_style(**style, align: cell.align) { render(cell.blocks) } }
110
+ content = -> { @component.text_style(**style, align: cell.align) { group(cell.blocks) } }
111
+ options = cell_options(cell.style)
112
+ options.empty? ? content : { content:, **options }
85
113
  end
86
114
 
87
115
  def image(node)
@@ -89,7 +117,7 @@ module Stationery
89
117
  return unless source
90
118
 
91
119
  loaded = Images.load(source)
92
- @component.image(loaded, alt: node.alt, **dimensions(node, loaded))
120
+ @component.image(loaded, alt: node.alt, **aligned(node.style), **dimensions(node, loaded))
93
121
  rescue UnsupportedImage => e
94
122
  skip(node.src, e.message)
95
123
  end
@@ -105,9 +133,14 @@ module Stationery
105
133
  File.file?(path) ? path : skip(src, "not found")
106
134
  end
107
135
 
136
+ # A CSS width wins over the width and height attributes (the height
137
+ # then follows the aspect ratio); a percentage is a share of the space.
108
138
  def dimensions(node, loaded)
109
- width = node.width
110
- height = node.height
139
+ styled = node.style[:width]
140
+ return { width: styled } if fraction?(styled)
141
+
142
+ width = styled || node.width
143
+ height = styled ? nil : node.height
111
144
  limit = @styles[:img][:max_width]
112
145
  natural = width || (height ? loaded.width * height.fdiv(loaded.height) : loaded.width)
113
146
  return { width:, height: } unless limit && natural > limit
@@ -16,16 +16,20 @@ module Stationery
16
16
  tspan use symbol clipPath]).freeze
17
17
 
18
18
  def self.parse(source)
19
- root = Parser.parse(source)
19
+ nesting = nil
20
+ root = Parser.parse(source) { |depth| nesting = depth }
20
21
  raise Error, "not an svg document" unless root&.name == "svg"
21
22
 
22
- new(root)
23
+ new(root, nesting:)
23
24
  end
24
25
 
25
- attr_reader :view_box, :unsupported
26
+ # `nesting` is how deep the source nested when that was deeper than
27
+ # Parser::MAX_DEPTH (and was flattened there), else nil.
28
+ attr_reader :view_box, :unsupported, :nesting
26
29
 
27
- def initialize(root)
30
+ def initialize(root, nesting: nil)
28
31
  @root = root
32
+ @nesting = nesting
29
33
  @view_box = read_view_box
30
34
  @sheet = Stylesheet.parse(style_text)
31
35
  @gradients = Gradient.collect(root, @sheet)
@@ -8,10 +8,15 @@ module Stationery
8
8
  # Comments, processing instructions and doctypes are skipped; text content
9
9
  # (CDATA included) is kept only inside text, tspan and style, as String
10
10
  # children with entities decoded and each whitespace run one space.
11
+ #
12
+ # An element nested deeper than MAX_DEPTH is not descended into: it takes
13
+ # its place in the deepest element kept and so does what it holds, so
14
+ # nothing that walks the tree recurses deeper than that.
11
15
  module Parser
12
16
  Element = Data.define(:name, :attributes, :children)
13
17
 
14
18
  TEXT = %w[text tspan style].freeze
19
+ MAX_DEPTH = 128
15
20
 
16
21
  IGNORED = /<!--.*?-->|<\?.*?\?>|<!DOCTYPE[^>]*>/m
17
22
  CDATA = /<!\[CDATA\[(.*?)\]\]>/m
@@ -21,17 +26,19 @@ module Stationery
21
26
 
22
27
  module_function
23
28
 
24
- def parse(source)
29
+ # The root element. The block is given how deep the source nested when
30
+ # that was deeper than `max_depth`.
31
+ def parse(source, max_depth: MAX_DEPTH)
25
32
  source = source.to_s.gsub(IGNORED, "").gsub(CDATA) { Regexp.last_match(1).gsub(/[&<>]/, ESCAPES) }
26
33
  scanner = StringScanner.new(source)
27
- root = Element.new("document", {}, [])
28
- stack = [root]
34
+ tree = Tree.new(max_depth)
29
35
  while (content = scanner.scan_until(/(?=<)/))
30
- keep(stack.last, content)
36
+ keep(tree.open, content)
31
37
  tag = scanner.scan(TAG) or (scanner.getch && next)
32
- handle(stack, *tag.match(TAG).captures)
38
+ tree.tag(*tag.match(TAG).captures)
33
39
  end
34
- root.children.first
40
+ yield tree.deepest if block_given? && tree.deepest > max_depth
41
+ tree.root
35
42
  end
36
43
 
37
44
  # Yields the element and every element below it, depth first.
@@ -46,12 +53,45 @@ module Stationery
46
53
  element.children << Stationery::Text::Entities.decode(content).gsub(/\s+/, " ")
47
54
  end
48
55
 
49
- def handle(stack, closing, name, attributes, self_closing)
50
- return stack.pop if closing == "/" && stack.size > 1
56
+ # The elements open while the source is read. `hoisted` counts the open
57
+ # ones too deep to descend into; their end tags are counted off again.
58
+ class Tree
59
+ attr_reader :deepest
51
60
 
52
- element = Element.new(name, attributes.scan(ATTRIBUTE).to_h { |k, *v| [k, v.compact.first] }, [])
53
- stack.last.children << element
54
- stack << element if self_closing.empty?
61
+ def initialize(max_depth)
62
+ @document = Element.new("document", {}, [])
63
+ @stack = [@document]
64
+ @max_depth = max_depth
65
+ @hoisted = 0
66
+ @deepest = 0
67
+ end
68
+
69
+ def root = @document.children.first
70
+ def open = @stack.last
71
+
72
+ def tag(closing, name, attributes, self_closing)
73
+ return close if closing == "/"
74
+
75
+ element = Element.new(name, attributes.scan(ATTRIBUTE).to_h { |k, *v| [k, v.compact.first] }, [])
76
+ open.children << element
77
+ descend(element) if self_closing.empty?
78
+ end
79
+
80
+ private
81
+
82
+ def close
83
+ return @hoisted -= 1 if @hoisted.positive?
84
+
85
+ @stack.pop if @stack.size > 1
86
+ end
87
+
88
+ def descend(element)
89
+ depth = @stack.size + @hoisted
90
+ @deepest = depth if depth > @deepest
91
+ return @stack << element if @stack.size <= @max_depth
92
+
93
+ @hoisted += 1
94
+ end
55
95
  end
56
96
  end
57
97
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Stationery
4
4
  module SVG
5
+ Selector = CSS::Selector
6
+ Stylesheet = CSS::Stylesheet
7
+
5
8
  # Presentation attributes an element inherits from its ancestors, and the
6
9
  # transform it draws with. An element's own values cascade: presentation
7
10
  # attributes, then stylesheet rules, then its inline `style`. `color` sets
@@ -27,12 +30,7 @@ module Stationery
27
30
  NAMED.fetch(value.downcase, value)
28
31
  end
29
32
 
30
- def self.declarations(style)
31
- style.to_s.split(";").filter_map do |declaration|
32
- key, value = declaration.split(":", 2).map(&:strip)
33
- [key, value] if key && value
34
- end.to_h
35
- end
33
+ def self.declarations(style) = CSS.declarations(style)
36
34
 
37
35
  attr_reader :values, :matrix, :color
38
36
 
@@ -11,6 +11,7 @@ module Stationery
11
11
  class Walker
12
12
  RENDERED = (Shapes::NAMES + %w[text g use svg]).freeze
13
13
  VIEWPORTS = %w[symbol svg].freeze
14
+ MAX_USES = 32 # a use of a use of a use …
14
15
 
15
16
  attr_reader :issues
16
17
 
@@ -69,6 +70,7 @@ module Stationery
69
70
  target, id = target_of(element)
70
71
  return unless target
71
72
  return issue("use: circular reference ##{id}") if chain.include?(id)
73
+ return issue("use: nested deeper than #{MAX_USES}") if chain.size >= MAX_USES
72
74
 
73
75
  attributes = element.attributes
74
76
  placed = style.transformed([1, 0, 0, 1, Shapes.f(attributes, "x"), Shapes.f(attributes, "y")])
@@ -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
 
@@ -12,6 +12,8 @@ module Stationery
12
12
  # Needs the pdf-reader gem, loaded on first use.
13
13
  class Inspector
14
14
  UTF16_BOM = "\xFE\xFF".b
15
+ # id-aa-signatureTimeStampToken: a signature's RFC 3161 timestamp.
16
+ TIMESTAMP_TOKEN = "1.2.840.113549.1.9.16.2.14"
15
17
  XMP_PROPERTY = %r{^\s*<((?!rdf:)[\w.-]+:[\w.-]+)>(.*?)</\1>$}
16
18
  XMP_ITEM = %r{<rdf:li[^>]*>(.*?)</rdf:li>}
17
19
  XMP_ENTITIES = { "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"' }.freeze
@@ -133,6 +135,22 @@ module Stationery
133
135
  entries.each_slice(2).map { |_name, ref| attachment(objects.deref_hash(ref)) }
134
136
  end
135
137
 
138
+ # Every signed signature field: `{ field:, name:, reason:, location:,
139
+ # signed_at:, subfilter:, byte_range:, signer:, valid:, timestamp: }`.
140
+ # `signer` is the signing certificate's subject ("CN=…,O=…"). `valid` is
141
+ # whether the signature covers the whole file and verifies against the
142
+ # certificate it carries; the certificate's own trust is not judged.
143
+ # `timestamp` is nil, or `{ time:, tsa:, valid: }` for the RFC 3161 token
144
+ # a signature carries: `valid` when the token is over this signature
145
+ # and verifies against the TSA certificate it carries.
146
+ def signatures
147
+ form = catalog[:AcroForm]
148
+ return [] unless form
149
+
150
+ require "openssl"
151
+ signature_fields(objects.deref_hash(form)[:Fields], nil)
152
+ end
153
+
136
154
  # The structure tree of a tagged PDF as nested arrays; see StructureReader.
137
155
  def structure = @structure ||= StructureReader.new(reader).tree
138
156
 
@@ -167,6 +185,86 @@ module Stationery
167
185
  description: spec[:Desc] && decode(spec[:Desc]), relationship: }
168
186
  end
169
187
 
188
+ def signature_fields(refs, prefix)
189
+ Array(objects.deref_array(refs)).flat_map do |ref|
190
+ field = objects.deref_hash(ref)
191
+ name = [prefix, field[:T] && decode(field[:T])].compact.join(".")
192
+ kids = Array(objects.deref_array(field[:Kids])).select { |kid| objects.deref_hash(kid).key?(:T) }
193
+ next signature_fields(kids, name) if kids.any?
194
+
195
+ field[:FT] == :Sig && field[:V] ? [signature(name, objects.deref_hash(field[:V]))] : []
196
+ end
197
+ end
198
+
199
+ def signature(field, value)
200
+ range = objects.deref_array(value[:ByteRange])
201
+ text = value.slice(:Name, :Reason, :Location).transform_values { |string| decode(string) }
202
+ { field:, name: text[:Name], reason: text[:Reason], location: text[:Location],
203
+ signed_at: signing_time(value[:M]), subfilter: value[:SubFilter], byte_range: range, **verdict(range) }
204
+ end
205
+
206
+ def signing_time(date)
207
+ digits = date.to_s[/\AD:(\d{14})/, 1]
208
+ digits && Time.utc(*digits.unpack("a4a2a2a2a2a2").map(&:to_i))
209
+ end
210
+
211
+ # The signature is read from the file by its byte range, not from the
212
+ # parsed dictionary: it is the one string encryption leaves alone.
213
+ def verdict(range)
214
+ contents = pdf.byteslice(range[1], range[2] - range[1]).to_s
215
+ der = PDF::Signature.der([contents[1..-2]].pack("H*"))
216
+ cms = OpenSSL::PKCS7.new(der)
217
+ signed = pdf.byteslice(range[0], range[1]) + pdf.byteslice(range[2], range[3])
218
+ flags = OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::BINARY
219
+ whole = range[0].zero? && range[2] + range[3] == pdf.bytesize && contents.start_with?("<")
220
+ { signer: signer(cms), valid: whole && cms.verify([], OpenSSL::X509::Store.new, signed, flags),
221
+ timestamp: timestamp(der) }
222
+ rescue OpenSSL::OpenSSLError, ArgumentError, TypeError
223
+ { signer: nil, valid: false, timestamp: nil }
224
+ end
225
+
226
+ # The timestamp token among the first SignerInfo's unsigned attributes,
227
+ # judged against that SignerInfo's signature value; nil without one.
228
+ def timestamp(der)
229
+ info = OpenSSL::ASN1.decode(der).value[1].value[0].value[4].value[0].value
230
+ unsigned = info[6..].to_a.find { |item| item.tag == 1 && item.tag_class == :CONTEXT_SPECIFIC }
231
+ attribute = unsigned&.value&.find { |one| one.value[0].oid == TIMESTAMP_TOKEN }
232
+ attribute && timestamp_verdict(attribute.value[1].value.first, info[5].value)
233
+ end
234
+
235
+ # The token verified as a TimeStampResp would be, against the signature
236
+ # value it should cover. The certificates it carries are the trust
237
+ # anchors (a TSA sends its chain, often without the root), so this
238
+ # says the token is sound, not that the TSA is one to trust.
239
+ def timestamp_verdict(token, signature)
240
+ granted = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(0)])
241
+ response = OpenSSL::Timestamp::Response.new(OpenSSL::ASN1::Sequence.new([granted, token]).to_der)
242
+ info = response.token_info
243
+ covers = info.message_imprint == OpenSSL::Digest.digest(info.algorithm, signature)
244
+ { time: info.gen_time, tsa: signer(response.token), valid: covers && timestamp_verifies?(response) }
245
+ end
246
+
247
+ def timestamp_verifies?(response)
248
+ info = response.token_info
249
+ request = OpenSSL::Timestamp::Request.new
250
+ request.algorithm = info.algorithm
251
+ request.message_imprint = info.message_imprint
252
+ request.nonce = info.nonce if info.nonce
253
+ store = OpenSSL::X509::Store.new
254
+ store.flags = OpenSSL::X509::V_FLAG_PARTIAL_CHAIN
255
+ response.token.certificates.to_a.each { |certificate| store.add_cert(certificate) }
256
+ response.verify(request, store)
257
+ true
258
+ rescue OpenSSL::OpenSSLError
259
+ false
260
+ end
261
+
262
+ def signer(cms)
263
+ info = cms.signers.first
264
+ certificate = cms.certificates.find { |one| one.serial == info.serial && one.issuer == info.issuer }
265
+ certificate&.subject&.to_utf8
266
+ end
267
+
170
268
  def xmp_value(body)
171
269
  items = body.scan(XMP_ITEM).flatten.map { |item| xmp_text(item) }
172
270
  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
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Text
5
+ # Where a line may break inside a word: between two ideographic
6
+ # characters (UAX #14 class ID: CJK ideographs, kana, Hangul, fullwidth
7
+ # forms), never before a closing mark or a character that must not start
8
+ # a line (、。」ー and the small kana: classes CL, CP, EX and NS), never
9
+ # after an opening bracket (「(: class OP). A word is cut into units at
10
+ # those points; Latin text stays one unit, so its breaking is unchanged.
11
+ module Breaks
12
+ ZERO_WIDTH_SPACE = "​"
13
+
14
+ IDEOGRAPHIC = [
15
+ 0x1100..0x11FF, # Hangul Jamo
16
+ 0x2E80..0x2FDF, # CJK radicals
17
+ 0x3005..0x3007, # 々 〆 〇
18
+ 0x3021..0x3029, # Hangzhou numerals
19
+ 0x3031..0x3035, # kana repeat marks
20
+ 0x3038..0x303C,
21
+ 0x3041..0x309A, # Hiragana (the small kana are taken out below)
22
+ 0x30A1..0x30FA, # Katakana
23
+ 0x3100..0x312F, # Bopomofo
24
+ 0x3130..0x318F, # Hangul compatibility Jamo
25
+ 0x3190..0x31FF, # Kanbun, Bopomofo extended, CJK strokes, Katakana phonetic extensions
26
+ 0x3200..0x33FF, # enclosed CJK, CJK compatibility
27
+ 0x3400..0x4DBF, # CJK extension A
28
+ 0x4E00..0x9FFF, # CJK unified ideographs
29
+ 0xA000..0xA4CF, # Yi
30
+ 0xA960..0xA97F, # Hangul Jamo extended A
31
+ 0xAC00..0xD7AF, # Hangul syllables
32
+ 0xF900..0xFAFF, # CJK compatibility ideographs
33
+ 0xFE30..0xFE4F, # CJK compatibility forms
34
+ 0xFF10..0xFF19, # fullwidth digits
35
+ 0xFF21..0xFF3A, 0xFF41..0xFF5A, # fullwidth Latin
36
+ 0xFF66..0xFF6F, 0xFF71..0xFF9D, # halfwidth Katakana (ー and the small ones are NS)
37
+ 0x1B000..0x1B16F, # Kana supplement and extended
38
+ 0x20000..0x3134F # CJK extensions B–G
39
+ ].freeze
40
+
41
+ # Never at the start of a line: closing brackets and quotes, sentence
42
+ # and clause punctuation, prolonged sound and iteration marks, small kana.
43
+ CLOSING = [
44
+ 0x3001..0x3003, 0x3009, 0x300B, 0x300D, 0x300F, 0x3011, 0x3015, 0x3017, 0x3019, 0x301B, 0x301C,
45
+ 0x301F, 0x303B, 0x309B..0x309E, 0x30A0, 0x30FB..0x30FE,
46
+ 0x3041, 0x3043, 0x3045, 0x3047, 0x3049, 0x3063, 0x3083, 0x3085, 0x3087, 0x308E, 0x3095, 0x3096,
47
+ 0x30A1, 0x30A3, 0x30A5, 0x30A7, 0x30A9, 0x30C3, 0x30E3, 0x30E5, 0x30E7, 0x30EE, 0x30F5, 0x30F6,
48
+ 0x31F0..0x31FF,
49
+ 0xFE50..0xFE52, 0xFE54..0xFE57, 0xFE5A, 0xFE5C, 0xFE5E,
50
+ 0xFF01, 0xFF09, 0xFF0C, 0xFF0E, 0xFF1A, 0xFF1B, 0xFF1F, 0xFF3D, 0xFF5D, 0xFF60, 0xFF61, 0xFF63,
51
+ 0xFF64, 0xFF65, 0xFF67..0xFF6F, 0xFF70, 0xFF9E, 0xFF9F,
52
+ 0x2025, 0x2026
53
+ ].freeze
54
+
55
+ # Never at the end of a line: opening brackets and quotes.
56
+ OPENING = [
57
+ 0x3008, 0x300A, 0x300C, 0x300E, 0x3010, 0x3014, 0x3016, 0x3018, 0x301A, 0x301D,
58
+ 0xFE59, 0xFE5B, 0xFE5D,
59
+ 0xFF08, 0xFF3B, 0xFF5B, 0xFF5F, 0xFF62
60
+ ].freeze
61
+
62
+ CLOSING_CODES = CLOSING.flat_map { |entry| Array(entry) }.to_h { |code| [code, true] }.freeze
63
+ OPENING_CODES = OPENING.flat_map { |entry| Array(entry) }.to_h { |code| [code, true] }.freeze
64
+
65
+ module_function
66
+
67
+ def ideographic?(char)
68
+ code = char.ord
69
+ !CLOSING_CODES.key?(code) && IDEOGRAPHIC.any? { |range| range.cover?(code) }
70
+ end
71
+
72
+ def closing?(char) = CLOSING_CODES.key?(char.ord)
73
+ def opening?(char) = OPENING_CODES.key?(char.ord)
74
+
75
+ # Whether `text` holds any character these rules know about, the cheap
76
+ # test that keeps Latin text on the fast path.
77
+ def cjk?(text) = text.match?(CJK)
78
+
79
+ CJK = /[ᄀ-ᇿ⺀-⿟、-ヿ㄀-鿿ꀀ-꓏ꥠ-꥿가-힯豈-﫿︰-﹞!-゚\u{1B000}-\u{1B16F}\u{20000}-\u{3134F}‥…]/
80
+
81
+ # The word's break units in order, each `[text, glued]`: `glued` is true
82
+ # for a unit that must stay with the unit before it (it starts with a
83
+ # closing mark), so the caller joins it to whatever came before, even
84
+ # across styled runs. A word without CJK characters is one unit.
85
+ def units(word)
86
+ return [[word, false]] unless cjk?(word)
87
+
88
+ units = []
89
+ current = +""
90
+ glued = false
91
+ open = false # current holds opening brackets only, waiting for what they open
92
+ word.each_char do |char|
93
+ if closing?(char)
94
+ glued = true if current.empty?
95
+ elsif starts_unit?(char, current, open)
96
+ units << [current.freeze, glued] unless current.empty?
97
+ current = +""
98
+ glued = false
99
+ end
100
+ current << char
101
+ open = opening?(char) && (open || current.length == 1)
102
+ end
103
+ units << [current.freeze, glued] unless current.empty?
104
+ units
105
+ end
106
+
107
+ # Whether `char` opens a new unit after `current`: an opening bracket or
108
+ # an ideographic character does unless `current` is only opening
109
+ # brackets; Latin text does after a unit that ends in a CJK character
110
+ # (an opening bracket or Latin followed by a closing mark stay glued).
111
+ def starts_unit?(char, current, open)
112
+ return false if current.empty? || open
113
+ return true if opening?(char) || ideographic?(char)
114
+
115
+ cjk?(current[-1]) && !(closing?(current[-1]) && !ideographic_unit?(current))
116
+ end
117
+
118
+ # A unit that carries an ideographic character (as opposed to Latin text
119
+ # followed by a closing mark, such as "abc。").
120
+ def ideographic_unit?(text) = text.each_char.any? { |char| ideographic?(char) }
121
+ end
122
+ end
123
+ end
@@ -34,6 +34,14 @@ module Stationery
34
34
  [with_lines(@lines.first(count)), with_lines(@lines.drop(count))]
35
35
  end
36
36
 
37
+ # [first `count` lines, the rest]; nil for an empty side.
38
+ def split_at(count)
39
+ return [nil, self] if count <= 0
40
+ return [self, nil] if count >= @lines.size
41
+
42
+ [with_lines(@lines.first(count)), with_lines(@lines.drop(count))]
43
+ end
44
+
37
45
  # A paragraph that fits `max_height`: truncated to whole lines, shrunk
38
46
  # (never below 4pt) or left as is for :visible.
39
47
  def fit(max_height, overflow:)
@@ -46,6 +46,10 @@ module Stationery
46
46
  def size(value, text = nil, &) = styled({ size: value }, text, &)
47
47
  def font(family, text = nil, &) = styled({ family: family.to_s }, text, &)
48
48
  def link(url, text = nil, &) = styled({ link: url.to_s }, text, &)
49
+ # The current size multiplied: `scale(1.5) { … }` is one and a half times the text around it.
50
+ def scale(factor, text = nil, &) = styled({ size: @styles.last.size * factor }, text, &)
51
+ # Any style fields at once, also to switch a mark off: `with({ weight: :regular }) { … }`.
52
+ def with(overrides, text = nil, &) = styled(overrides, text, &)
49
53
 
50
54
  private
51
55