stationery 0.9.0 → 0.10.1

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +146 -35
  4. data/lib/stationery/{svg → css}/selector.rb +1 -1
  5. data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
  6. data/lib/stationery/css.rb +20 -0
  7. data/lib/stationery/document.rb +21 -9
  8. data/lib/stationery/elements/rich.rb +10 -2
  9. data/lib/stationery/elements.rb +9 -4
  10. data/lib/stationery/errors.rb +4 -0
  11. data/lib/stationery/fonts/fallback.rb +1 -1
  12. data/lib/stationery/fonts/font.rb +5 -2
  13. data/lib/stationery/fonts/glyph_run.rb +35 -5
  14. data/lib/stationery/html/css.rb +276 -0
  15. data/lib/stationery/html/document.rb +5 -3
  16. data/lib/stationery/html/tokenizer.rb +15 -4
  17. data/lib/stationery/html/tree_builder/blocks.rb +90 -40
  18. data/lib/stationery/html/tree_builder/collector.rb +9 -3
  19. data/lib/stationery/html/tree_builder.rb +11 -1
  20. data/lib/stationery/layout/flow.rb +29 -0
  21. data/lib/stationery/layout/image.rb +12 -6
  22. data/lib/stationery/layout/mark.rb +2 -0
  23. data/lib/stationery/layout/node.rb +4 -0
  24. data/lib/stationery/layout/text.rb +47 -10
  25. data/lib/stationery/pdf/assembler.rb +14 -4
  26. data/lib/stationery/pdf/signature/cms.rb +21 -13
  27. data/lib/stationery/pdf/signature/timestamp.rb +120 -0
  28. data/lib/stationery/pdf/signature.rb +16 -9
  29. data/lib/stationery/pdf/writer.rb +73 -19
  30. data/lib/stationery/rich/nodes.rb +37 -11
  31. data/lib/stationery/rich/renderer/boxes.rb +109 -0
  32. data/lib/stationery/rich/renderer/indents.rb +1 -0
  33. data/lib/stationery/rich/renderer/inlines.rb +9 -2
  34. data/lib/stationery/rich/renderer.rb +32 -15
  35. data/lib/stationery/svg/style.rb +4 -6
  36. data/lib/stationery/testing/inspector.rb +49 -6
  37. data/lib/stationery/testing/marked_text.rb +34 -4
  38. data/lib/stationery/text/breaks.rb +123 -0
  39. data/lib/stationery/text/paragraph.rb +8 -0
  40. data/lib/stationery/text/runs_builder.rb +4 -0
  41. data/lib/stationery/text/wrapper.rb +57 -8
  42. data/lib/stationery/version.rb +1 -1
  43. data/lib/stationery/warnings.rb +9 -0
  44. data/lib/stationery.rb +3 -2
  45. metadata +8 -3
@@ -5,15 +5,26 @@ require "digest/md5"
5
5
  module Stationery
6
6
  module PDF
7
7
  # Collects numbered objects and writes them as a PDF file with a classic
8
- # cross-reference table.
8
+ # cross-reference table. `render` returns the file as a String with the
9
+ # objects in numbered order. With a `sink:` (anything answering
10
+ # `call(bytes)`) the file goes there in pieces instead: `flush` writes every
11
+ # object set so far and drops it, so objects leave in the order they were
12
+ # flushed and the first bytes leave before the last page is assembled.
9
13
  class Writer
10
14
  HEADER = "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".b
15
+ WRITTEN = Object.new.freeze
11
16
 
12
17
  # `encryption` (an Encryption::StandardSecurity) encrypts every string
13
18
  # and stream except those of its own /Encrypt dictionary.
14
- def initialize(encryption: nil)
19
+ def initialize(encryption: nil, sink: nil)
15
20
  @objects = []
21
+ @offsets = []
16
22
  @encryption = encryption
23
+ @sink = sink
24
+ @buffer = HEADER.dup unless sink
25
+ @size = HEADER.bytesize
26
+ @digest = Digest::MD5.new if sink && !encryption
27
+ announce(HEADER) if sink
17
28
  end
18
29
 
19
30
  # Hands out a reference now and lets the object be set later, so a page
@@ -24,6 +35,8 @@ module Stationery
24
35
  end
25
36
 
26
37
  def set(ref, value)
38
+ raise Error, "object #{ref.id} is already written" if @objects[ref.id - 1].equal?(WRITTEN)
39
+
27
40
  @objects[ref.id - 1] = value
28
41
  ref
29
42
  end
@@ -32,43 +45,84 @@ module Stationery
32
45
  set(reserve, value)
33
46
  end
34
47
 
48
+ # With a sink: writes every object set and not yet written, lowest number
49
+ # first, and drops it. References to objects still unset stay valid, as
50
+ # the cross-reference table is written last. Without a sink it does
51
+ # nothing: the String keeps its objects in numbered order.
52
+ def flush
53
+ return unless @sink
54
+
55
+ @encrypt ||= reserve if @encryption
56
+ write_pending
57
+ end
58
+
59
+ # Finishes the file: the remaining objects, the cross-reference table
60
+ # and the trailer. Returns the whole file as a String when there is no
61
+ # sink, else the number of bytes handed to it.
35
62
  def render(root:, info:)
63
+ set(@encrypt ||= reserve, @encryption.dictionary) if @encryption
36
64
  if (missing = @objects.index(nil))
37
65
  raise Error, "object #{missing + 1} reserved but never set"
38
66
  end
39
67
 
40
- @encrypt = @encryption && add(@encryption.dictionary)
41
- out = HEADER.dup
42
- offsets = @objects.each_with_index.map { |object, index| write_object(out, index + 1, object) }
43
- xref = out.bytesize
44
- id = HexString.new(@encryption ? @encryption.file_id : Digest::MD5.digest(out))
68
+ write_pending
69
+ xref = @size
70
+ id = HexString.new(file_id)
45
71
 
46
- out << "xref\n0 #{@objects.size + 1}\n0000000000 65535 f \n"
47
- offsets.each { |offset| out << format("%010d 00000 n \n", offset) }
48
- out << "trailer\n" << Serializer.dump(trailer(root, info, id))
49
- out << "\nstartxref\n#{xref}\n%%EOF\n"
72
+ emit("xref\n0 #{@objects.size + 1}\n0000000000 65535 f \n")
73
+ @offsets.each { |offset| emit(format("%010d 00000 n \n", offset)) }
74
+ emit("trailer\n#{Serializer.dump(trailer(root, info, id))}")
75
+ emit("\nstartxref\n#{xref}\n%%EOF\n")
76
+ @buffer || @size
50
77
  end
51
78
 
52
79
  private
53
80
 
81
+ def write_pending
82
+ @objects.each_with_index do |object, index|
83
+ next if object.nil? || object.equal?(WRITTEN)
84
+
85
+ write_object(index + 1, object)
86
+ @objects[index] = WRITTEN
87
+ end
88
+ end
89
+
54
90
  def trailer(root, info, id)
55
91
  trailer = { Size: @objects.size + 1, Root: root, Info: info, ID: [id, id] }
56
92
  @encrypt ? trailer.merge(Encrypt: @encrypt) : trailer
57
93
  end
58
94
 
59
- def write_object(out, number, object)
60
- offset = out.bytesize
95
+ def write_object(number, object)
96
+ @offsets[number - 1] = @size
61
97
  crypt = crypt_for(number)
62
- out << "#{number} 0 obj\n"
98
+ emit("#{number} 0 obj\n")
63
99
  if object.is_a?(Stream)
64
100
  data = crypt ? crypt.call(object.data) : object.data
65
- out << Serializer.dump(object.dictionary.merge(Length: data.bytesize), crypt)
66
- out << "\nstream\n" << data << "\nendstream"
101
+ emit(Serializer.dump(object.dictionary.merge(Length: data.bytesize), crypt))
102
+ emit("\nstream\n")
103
+ emit(data)
104
+ emit("\nendstream")
67
105
  else
68
- out << Serializer.dump(object, crypt)
106
+ emit(Serializer.dump(object, crypt))
69
107
  end
70
- out << "\nendobj\n"
71
- offset
108
+ emit("\nendobj\n")
109
+ end
110
+
111
+ def emit(bytes)
112
+ @size += bytes.bytesize
113
+ @sink ? announce(bytes) : @buffer << bytes
114
+ end
115
+
116
+ def announce(bytes)
117
+ @digest&.update(bytes)
118
+ @sink.call(bytes)
119
+ end
120
+
121
+ # The encryption's own identifier, else a digest of the file so far.
122
+ def file_id
123
+ return @encryption.file_id if @encryption
124
+
125
+ @digest ? @digest.digest : Digest::MD5.digest(@buffer)
72
126
  end
73
127
 
74
128
  # The Serializer hook: encrypts a string or stream with this object's key.
@@ -4,7 +4,9 @@ module Stationery
4
4
  # The block model rich-text parsers (HTML, Markdown) produce and elements render.
5
5
  module Rich
6
6
  # A run of text with its marks: any of bold:, italic:, underline:, strike:, code:, link: (href),
7
- # script: (:sub/:sup) and break: true, a hard line break whose text is "".
7
+ # script: (:sub/:sup), color: (a colour), size: (points), scale: (a factor of the text size)
8
+ # and break: true, a hard line break whose text is "". A false bold:, italic:, underline: or
9
+ # strike: switches the mark off inside an element that has it.
8
10
  Inline = Data.define(:text, :marks) do
9
11
  def self.break = new(text: "", marks: { break: true })
10
12
 
@@ -12,15 +14,39 @@ module Stationery
12
14
  def break? = marks[:break] == true
13
15
  end
14
16
 
15
- Paragraph = Data.define(:inlines)
16
- Heading = Data.define(:level, :inlines)
17
- List = Data.define(:ordered, :start, :items)
18
- Blockquote = Data.define(:blocks)
19
- CodeBlock = Data.define(:text, :language)
17
+ # Every block carries a `style` Hash from the source's CSS (see HTML::Css):
18
+ # align:, background:, padding:, margin:/margin_top:/margin_bottom:,
19
+ # border:, width:, break_before:, break_after:, keep_together:. Empty
20
+ # for Markdown and for HTML without styles.
21
+ Paragraph = Data.define(:inlines, :style) do
22
+ def initialize(inlines:, style: {}) = super
23
+ end
24
+ Heading = Data.define(:level, :inlines, :style) do
25
+ def initialize(level:, inlines:, style: {}) = super
26
+ end
27
+ List = Data.define(:ordered, :start, :items, :style) do
28
+ def initialize(ordered:, start:, items:, style: {}) = super
29
+ end
30
+ Blockquote = Data.define(:blocks, :style) do
31
+ def initialize(blocks:, style: {}) = super
32
+ end
33
+ CodeBlock = Data.define(:text, :language, :style) do
34
+ def initialize(text:, language:, style: {}) = super
35
+ end
20
36
  Rule = Data.define
21
- Table = Data.define(:rows)
22
- Cell = Data.define(:header, :align, :blocks)
23
- Image = Data.define(:src, :alt, :width, :height)
37
+ Table = Data.define(:rows, :style) do
38
+ def initialize(rows:, style: {}) = super
39
+ end
40
+ Cell = Data.define(:header, :align, :blocks, :style) do
41
+ def initialize(header:, align:, blocks:, style: {}) = super
42
+ end
43
+ Image = Data.define(:src, :alt, :width, :height, :style) do
44
+ def initialize(src:, alt:, width:, height:, style: {}) = super
45
+ end
46
+ # A styled container (a `div` with a background, padding, margins or a
47
+ # page-break rule) holding blocks; an unstyled one flattens into its parent.
48
+ Container = Data.define(:blocks, :style)
49
+ PageBreak = Data.define
24
50
 
25
51
  # Operations on inline sequences shared by the parsers.
26
52
  module Inlines
@@ -42,12 +68,12 @@ module Stationery
42
68
  end
43
69
 
44
70
  # Groups inlines into paragraphs, with each image standing as a block between them.
45
- def paragraphs(items)
71
+ def paragraphs(items, style: {})
46
72
  items.chunk_while { |a, b| !a.is_a?(Image) && !b.is_a?(Image) }.filter_map do |run|
47
73
  next run.first if run.first.is_a?(Image)
48
74
 
49
75
  inlines = merge(block_given? ? yield(run) : run)
50
- Paragraph.new(inlines:) unless inlines.empty?
76
+ Paragraph.new(inlines:, style:) unless inlines.empty?
51
77
  end
52
78
  end
53
79
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Rich
5
+ class Renderer
6
+ # What a block's CSS style becomes around the block: margins as spacers,
7
+ # a background and padding as a box, page-break rules as page breaks and
8
+ # kept-together groups, borders and widths as table options.
9
+ module Boxes
10
+ PADDING = { padding_top: 0, padding_right: 1, padding_bottom: 2, padding_left: 3 }.freeze
11
+
12
+ private
13
+
14
+ # Draws the block inside its margins and its box. A block styled with
15
+ # nothing but an alignment draws as it is.
16
+ def styled(style, &)
17
+ return yield if style.empty?
18
+
19
+ spaced(style) { boxed(style, &) }
20
+ end
21
+
22
+ # Page breaks and margins around the block, and the rule that keeps it
23
+ # on one page.
24
+ def spaced(style)
25
+ return yield unless spacing?(style)
26
+
27
+ top, bottom = margins(style)
28
+ @component.page_break if style[:break_before]
29
+ @component.group(keep_together: style[:keep_together] == true) do
30
+ @component.spacer(top) if top.positive?
31
+ yield
32
+ @component.spacer(bottom) if bottom.positive?
33
+ end
34
+ @component.page_break if style[:break_after]
35
+ end
36
+
37
+ def spacing?(style)
38
+ style[:break_before] || style[:break_after] || style[:keep_together] || margins(style).any?(&:positive?)
39
+ end
40
+
41
+ def margins(style)
42
+ all = style[:margin]
43
+ [style[:margin_top] || all&.[](0) || 0, style[:margin_bottom] || all&.[](2) || 0]
44
+ end
45
+
46
+ def boxed(style, &)
47
+ options = box_options(style)
48
+ options.empty? ? yield : @component.box(**options, &)
49
+ end
50
+
51
+ # The background and padding a style gives a box, to merge over a
52
+ # block's own (`styles[:blockquote]`, `styles[:pre]`).
53
+ def box_options(style)
54
+ options = {}
55
+ options[:background] = style[:background] if style[:background]
56
+ padding = padding_of(style)
57
+ options[:padding] = padding if padding
58
+ options
59
+ end
60
+
61
+ def padding_of(style)
62
+ sides = PADDING.select { |key, _| style[key] }
63
+ return style[:padding] if sides.empty?
64
+
65
+ padding = (style[:padding] || [0, 0, 0, 0]).dup
66
+ sides.each { |key, index| padding[index] = style[key] }
67
+ padding
68
+ end
69
+
70
+ def aligned(style) = style[:align] ? { align: style[:align] } : {}
71
+
72
+ def border_options(border)
73
+ return {} unless border
74
+ return { borders: [] } if border[:width].zero?
75
+
76
+ { border_width: border[:width], border_color: border[:color] }
77
+ end
78
+
79
+ def cell_options(style)
80
+ options = border_options(style[:border]).merge(box_options(style))
81
+ options[:borders] = %i[top right bottom left] if options[:border_width]
82
+ options
83
+ end
84
+
85
+ # Column widths from the first row, when every cell of it has one. A
86
+ # percentage is a share of the table, which then takes the full width
87
+ # unless it has a width of its own.
88
+ def column_widths(table)
89
+ widths = table.rows.first.map { |cell| cell.style[:width] }
90
+ return {} unless widths.all?
91
+
92
+ widths.any? { |width| fraction?(width) } && !table.style[:width] ? { widths:, width: :full } : { widths: }
93
+ end
94
+
95
+ # A table's own width: points, the full width for 100%, or a share of
96
+ # the space through a box of that width.
97
+ def sized(width)
98
+ return yield({}) unless width
99
+ return yield({ width: }) unless fraction?(width)
100
+ return yield({ width: :full }) if width >= 1
101
+
102
+ @component.box(width:) { yield({ width: :full }) }
103
+ end
104
+
105
+ def fraction?(value) = value.is_a?(Float) && value <= 1
106
+ end
107
+ end
108
+ end
109
+ end
@@ -30,6 +30,7 @@ module Stationery
30
30
  blocks.map do |block|
31
31
  case block
32
32
  when Blockquote then 1 + indents_of(block.blocks)
33
+ when Container then indents_of(block.blocks)
33
34
  when List then 1 + (block.items.map { |item| indents_of(item) }.max || 0)
34
35
  when Table then block.rows.flatten.map { |cell| indents_of(cell.blocks) }.max || 0
35
36
  else 0
@@ -15,8 +15,12 @@ module Stationery
15
15
  font: ->(value) { [:font, value] if value }
16
16
  }.freeze
17
17
 
18
+ # A true mark switches the style on, a false one (from CSS) switches it off again.
18
19
  MARK_STEPS = {
19
- bold: [[:b]], italic: [[:i]], underline: [[:u]], strike: [[:strikethrough]]
20
+ bold: { true => [[:b]], false => [[:with, { weight: :regular }]] },
21
+ italic: { true => [[:i]], false => [[:with, { style: :normal }]] },
22
+ underline: { true => [[:u]], false => [[:with, { underline: false }]] },
23
+ strike: { true => [[:strikethrough]], false => [[:with, { strikethrough: false }]] }
20
24
  }.freeze
21
25
 
22
26
  def initialize(runs, styles, links = Links.new(:all, nil))
@@ -50,7 +54,10 @@ module Stationery
50
54
  when :link then [[:link, value], *style_steps(@styles[:a])]
51
55
  when :code then style_steps(@styles[:code])
52
56
  when :script then [[value]]
53
- else MARK_STEPS.fetch(mark, [])
57
+ when :color then [[:color, value]]
58
+ when :size then [[:size, value]]
59
+ when :scale then [[:scale, value]]
60
+ else MARK_STEPS.dig(mark, value) || []
54
61
  end
55
62
  end
56
63
 
@@ -5,6 +5,7 @@ require_relative "styles"
5
5
  require_relative "renderer/indents"
6
6
  require_relative "renderer/inlines"
7
7
  require_relative "renderer/links"
8
+ require_relative "renderer/boxes"
8
9
 
9
10
  module Stationery
10
11
  module Rich
@@ -12,9 +13,12 @@ module Stationery
12
13
  # `images:` (a callable given the src, returning a path, an IO or nil) or
13
14
  # from files under `base_path:`; remote URLs are never fetched. Block
14
15
  # quotes and lists nested deeper than Indents::LIMIT stop indenting, which
15
- # is reported as a NestingLimit warning.
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.
16
19
  class Renderer
17
20
  include Indents
21
+ include Boxes
18
22
 
19
23
  REMOTE = /\A[a-z][a-z0-9+.-]*:/i
20
24
  LINK_SCHEMES = %w[http https mailto tel].freeze
@@ -43,14 +47,16 @@ module Stationery
43
47
 
44
48
  def block(block)
45
49
  case block
46
- when Paragraph then paragraph(block.inlines, **@styles[:p])
47
- when Heading then heading(block)
48
- when List then indented(block) { list(block) }
49
- when Blockquote then indented(block) { blockquote(block) }
50
- 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) }
51
55
  when Rule then @component.rule(**@styles[:hr])
52
- when Table then table(block)
56
+ when Table then styled(block.style.except(:border, :width)) { table(block) }
53
57
  when Image then image(block)
58
+ when Container then styled(block.style) { group(block.blocks) }
59
+ when PageBreak then @component.page_break
54
60
  end
55
61
  end
56
62
 
@@ -62,7 +68,8 @@ module Stationery
62
68
  style = @styles[:"h#{heading.level}"]
63
69
  size = style[:size] || (@builder.style.size * style.fetch(:scale, 1))
64
70
  bookmark = heading_bookmark(heading)
65
- 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))
66
73
  end
67
74
 
68
75
  def heading_bookmark(heading)
@@ -72,7 +79,7 @@ module Stationery
72
79
  end
73
80
 
74
81
  def blockquote(quote)
75
- @component.box(role: :blockquote, **@styles[:blockquote]) { group(quote.blocks) }
82
+ @component.box(role: :blockquote, **@styles[:blockquote], **box_options(quote.style)) { group(quote.blocks) }
76
83
  end
77
84
 
78
85
  def list(list)
@@ -86,18 +93,23 @@ module Stationery
86
93
 
87
94
  def code(block)
88
95
  font = @styles[:code][:font]
89
- @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
90
99
  end
91
100
 
92
101
  def table(table)
93
102
  header = table.rows.first&.all?(&:header) ? 1 : 0
94
103
  rows = table.rows.map { |row| row.map { |cell| cell_content(cell) } }
95
- @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)) }
96
106
  end
97
107
 
98
108
  def cell_content(cell)
99
109
  style = cell.header ? @styles[:table][:header] : {}
100
- -> { @component.text_style(**style, align: cell.align) { group(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 }
101
113
  end
102
114
 
103
115
  def image(node)
@@ -105,7 +117,7 @@ module Stationery
105
117
  return unless source
106
118
 
107
119
  loaded = Images.load(source)
108
- @component.image(loaded, alt: node.alt, **dimensions(node, loaded))
120
+ @component.image(loaded, alt: node.alt, **aligned(node.style), **dimensions(node, loaded))
109
121
  rescue UnsupportedImage => e
110
122
  skip(node.src, e.message)
111
123
  end
@@ -121,9 +133,14 @@ module Stationery
121
133
  File.file?(path) ? path : skip(src, "not found")
122
134
  end
123
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.
124
138
  def dimensions(node, loaded)
125
- width = node.width
126
- 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
127
144
  limit = @styles[:img][:max_width]
128
145
  natural = width || (height ? loaded.width * height.fdiv(loaded.height) : loaded.width)
129
146
  return { width:, height: } unless limit && natural > limit
@@ -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
 
@@ -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
@@ -134,10 +136,13 @@ module Stationery
134
136
  end
135
137
 
136
138
  # 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
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
140
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.
141
146
  def signatures
142
147
  form = catalog[:AcroForm]
143
148
  return [] unless form
@@ -207,13 +212,51 @@ module Stationery
207
212
  # parsed dictionary: it is the one string encryption leaves alone.
208
213
  def verdict(range)
209
214
  contents = pdf.byteslice(range[1], range[2] - range[1]).to_s
210
- cms = OpenSSL::PKCS7.new(PDF::Signature.der([contents[1..-2]].pack("H*")))
215
+ der = PDF::Signature.der([contents[1..-2]].pack("H*"))
216
+ cms = OpenSSL::PKCS7.new(der)
211
217
  signed = pdf.byteslice(range[0], range[1]) + pdf.byteslice(range[2], range[3])
212
218
  flags = OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::BINARY
213
219
  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) }
220
+ { signer: signer(cms), valid: whole && cms.verify([], OpenSSL::X509::Store.new, signed, flags),
221
+ timestamp: timestamp(der) }
215
222
  rescue OpenSSL::OpenSSLError, ArgumentError, TypeError
216
- { signer: nil, valid: false }
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
217
260
  end
218
261
 
219
262
  def signer(cms)
@@ -16,6 +16,8 @@ module Stationery
16
16
  set_character_spacing set_word_spacing set_text_rise set_horizontal_text_scaling
17
17
  set_text_rendering_mode].freeze
18
18
 
19
+ UTF16_BOM = "\xFE\xFF".b
20
+
19
21
  def_delegators :@state, *STATE
20
22
 
21
23
  # { MCID => text } and the strings shown outside marked content.
@@ -31,20 +33,30 @@ module Stationery
31
33
  @unmarked = []
32
34
  end
33
35
 
36
+ # A sequence with ActualText reads as that text, whatever it shows.
34
37
  def begin_marked_content_with_pl(_tag, properties)
35
- @open << (properties[:MCID] if properties.is_a?(Hash))
38
+ properties = {} unless properties.is_a?(Hash)
39
+ @open << properties[:MCID]
40
+ return unless properties[:ActualText]
41
+
42
+ @actual = decode(properties[:ActualText])
43
+ @actual_depth = @open.size
36
44
  end
37
45
 
38
46
  def begin_marked_content(_tag) = @open << nil
39
- def end_marked_content = @open.pop
47
+
48
+ def end_marked_content
49
+ @actual = nil if @actual_depth && @open.size <= @actual_depth
50
+ @open.pop
51
+ end
52
+
40
53
  def show_text(string) = append(string)
41
54
  def show_text_with_positioning(params) = params.grep(String).each { |string| append(string) }
42
55
 
43
56
  private
44
57
 
45
58
  def append(string)
46
- font = @state.current_font
47
- text = font.unpack(string).map { |code| font.to_utf8(code) }.join
59
+ text = shown(string)
48
60
  return @unmarked << text if @open.empty?
49
61
 
50
62
  mcid = @open.compact.last
@@ -55,6 +67,24 @@ module Stationery
55
67
  @texts[mcid] = "#{@texts[mcid]}#{separator}#{text}"
56
68
  @baselines[mcid] = baseline
57
69
  end
70
+
71
+ # The characters a string shows: its ActualText once, when it has one.
72
+ def shown(string)
73
+ if @actual
74
+ text = @actual
75
+ @actual = ""
76
+ return text
77
+ end
78
+
79
+ font = @state.current_font
80
+ font.unpack(string).map { |code| font.to_utf8(code) }.join
81
+ end
82
+
83
+ def decode(text)
84
+ return text.dup.force_encoding(Encoding::UTF_8) unless text.b.start_with?(UTF16_BOM)
85
+
86
+ text.b.byteslice(2..).force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8)
87
+ end
58
88
  end
59
89
  end
60
90
  end