stationery 0.9.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +143 -34
  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/html/css.rb +276 -0
  13. data/lib/stationery/html/document.rb +5 -3
  14. data/lib/stationery/html/tokenizer.rb +15 -4
  15. data/lib/stationery/html/tree_builder/blocks.rb +90 -40
  16. data/lib/stationery/html/tree_builder/collector.rb +9 -3
  17. data/lib/stationery/html/tree_builder.rb +11 -1
  18. data/lib/stationery/layout/flow.rb +29 -0
  19. data/lib/stationery/layout/image.rb +12 -6
  20. data/lib/stationery/layout/mark.rb +2 -0
  21. data/lib/stationery/layout/node.rb +4 -0
  22. data/lib/stationery/layout/text.rb +47 -10
  23. data/lib/stationery/pdf/assembler.rb +14 -4
  24. data/lib/stationery/pdf/signature/cms.rb +21 -13
  25. data/lib/stationery/pdf/signature/timestamp.rb +120 -0
  26. data/lib/stationery/pdf/signature.rb +16 -9
  27. data/lib/stationery/pdf/writer.rb +73 -19
  28. data/lib/stationery/rich/nodes.rb +37 -11
  29. data/lib/stationery/rich/renderer/boxes.rb +109 -0
  30. data/lib/stationery/rich/renderer/indents.rb +1 -0
  31. data/lib/stationery/rich/renderer/inlines.rb +9 -2
  32. data/lib/stationery/rich/renderer.rb +32 -15
  33. data/lib/stationery/svg/style.rb +4 -6
  34. data/lib/stationery/testing/inspector.rb +49 -6
  35. data/lib/stationery/text/breaks.rb +123 -0
  36. data/lib/stationery/text/paragraph.rb +8 -0
  37. data/lib/stationery/text/runs_builder.rb +4 -0
  38. data/lib/stationery/text/wrapper.rb +57 -8
  39. data/lib/stationery/version.rb +1 -1
  40. data/lib/stationery/warnings.rb +9 -0
  41. data/lib/stationery.rb +3 -2
  42. metadata +8 -3
@@ -4,7 +4,8 @@ module Stationery
4
4
  module Layout
5
5
  # An image sized by width, height, both, or a box to fit into (aspect
6
6
  # preserved), never wider than the space it is given. One pixel is one
7
- # point when no size is given. `fit: :cover` fills `width:` × `height:`
7
+ # point when no size is given; a Float width up to 1 is a fraction of the
8
+ # space (`width: 0.5`), as for boxes and columns. `fit: :cover` fills `width:` × `height:`
8
9
  # instead, scaling up and clipping the excess around the centre.
9
10
  # `radius:` clips to rounded corners and `rotate:` (degrees, clockwise)
10
11
  # turns the painted image around the centre of its rectangle; neither
@@ -37,14 +38,16 @@ module Stationery
37
38
  end
38
39
 
39
40
  def size(available)
40
- width, height = requested
41
+ width, height = requested(available)
41
42
  return [width, height] if width <= available
42
43
 
43
44
  [available, height * available / width]
44
45
  end
45
46
 
46
47
  def measure(width) = size(width)[1]
47
- def fixed_width(available) = size(available)[0]
48
+ # A fractional width is a share of the space offered here; measuring and
49
+ # painting are then given the width that came out of it.
50
+ def fixed_width(available) = fraction? ? tidy(available * @width) : size(available)[0]
48
51
  def natural_width = requested[0]
49
52
  def min_width = 0
50
53
 
@@ -100,19 +103,22 @@ module Stationery
100
103
  @image
101
104
  end
102
105
 
103
- def requested
106
+ def requested(available = nil)
104
107
  iw = @image.width.to_f
105
108
  ih = @image.height.to_f
109
+ width = fraction? ? (available || tidy(iw * @width)) : @width
106
110
  if @fit.is_a?(Array)
107
111
  scale = [@fit[0] / iw, @fit[1] / ih].min
108
112
  [iw * scale, ih * scale].map { |v| tidy(v) }
109
- elsif @width && @height then [@width, @height]
110
- elsif @width then [@width, tidy(ih * @width / iw)]
113
+ elsif width && @height then [width, @height]
114
+ elsif width then [width, tidy(ih * width / iw)]
111
115
  elsif @height then [tidy(iw * @height / ih), @height]
112
116
  else [@image.width, @image.height]
113
117
  end
114
118
  end
115
119
 
120
+ def fraction? = @width.is_a?(Float) && @width <= 1
121
+
116
122
  def tidy(value) = value.round(6)
117
123
  end
118
124
  end
@@ -31,6 +31,8 @@ module Stationery
31
31
  def fixed_width(available) = @child.fixed_width(available)
32
32
  def splittable? = @child.splittable?
33
33
  def page_break? = @child.page_break?
34
+ def breaks? = @child.breaks?
35
+ def leading_break? = @child.leading_break?
34
36
  def avoid_break? = @child.avoid_break?
35
37
  def keep_with_next = @child.keep_with_next
36
38
 
@@ -42,6 +42,10 @@ module Stationery
42
42
  # The width this node is laid out at inside a parent of `available`.
43
43
  def width_in(available) = fixed_width(available) || available
44
44
  def page_break? = false
45
+ # Whether a page break lies somewhere inside (a flow holding one).
46
+ def breaks? = false
47
+ # Whether it starts with a page break (a flow whose first node is one).
48
+ def leading_break? = false
45
49
 
46
50
  def split(width, height, **)
47
51
  measure(width) <= height + EPSILON ? [self, nil] : [nil, self]
@@ -2,21 +2,34 @@
2
2
 
3
3
  module Stationery
4
4
  module Layout
5
- # A paragraph of styled runs. Splits between lines.
5
+ # A paragraph of styled runs. Splits between lines; `orphans:` is the
6
+ # fewest lines a split leaves at the foot of a page and `widows:` the
7
+ # fewest it carries to the next (1 and 1: any line). A paragraph that
8
+ # cannot meet them moves to the next page whole, unless it is already
9
+ # first on a fresh page, where it splits as best it can.
6
10
  class Text < Node
7
11
  attr_reader :runs
8
12
 
9
- def initialize(runs, context:, align: :left, leading: 0, paragraph: nil, tag: Tagging::Element.new(:P))
13
+ def initialize(runs, context:, align: :left, leading: 0, orphans: 1, widows: 1, paragraph: nil,
14
+ tag: Tagging::Element.new(:P))
10
15
  super()
11
16
  @tag = tag
12
17
  @runs = context.book.fallback(runs)
13
18
  @context = context
14
19
  @align = align
15
20
  @leading = leading
21
+ @orphans = self.class.lines_option(:orphans, orphans)
22
+ @widows = self.class.lines_option(:widows, widows)
16
23
  @paragraph = paragraph
17
24
  @paragraphs = {}
18
25
  end
19
26
 
27
+ def self.lines_option(name, value)
28
+ return value if value.is_a?(Integer) && value >= 1
29
+
30
+ raise ArgumentError, "#{name}: must be an Integer of at least 1, got #{value.inspect}"
31
+ end
32
+
20
33
  def splittable? = true
21
34
 
22
35
  def measure(width) = paragraph(width).height
@@ -25,9 +38,11 @@ module Stationery
25
38
  paragraph(width).draw(canvas, x, y, tag: @tag)
26
39
  end
27
40
 
28
- def split(width, height, **)
41
+ def split(width, height, fresh: false, **)
29
42
  head, tail = paragraph(width).split(height)
30
- [head && from(head), tail && from(tail)]
43
+ return [head && from(head), tail && from(tail)] if (@orphans == 1 && @widows == 1) || !(head && tail)
44
+
45
+ keep_lines(paragraph(width), head.lines.size, fresh:)
31
46
  end
32
47
 
33
48
  def fit(width, height, overflow:)
@@ -38,19 +53,40 @@ module Stationery
38
53
  @natural_width ||= paragraph(Float::INFINITY).lines.map(&:width).max || 0
39
54
  end
40
55
 
56
+ # The widest piece that cannot be broken: a word, or one break unit of a
57
+ # word with CJK characters.
41
58
  def min_width
42
59
  @min_width ||= @runs.flat_map do |run|
43
- style = run.style
44
- font = @context.book.resolve(style).first
45
- run.text.delete(::Stationery::Text::Wrapper::SOFT_HYPHEN).split(/[ \t\n]+/).map do |word|
46
- font.width_of(word, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
47
- ligatures: style.ligatures, features: style.features)
60
+ font = @context.book.resolve(run.style).first
61
+ run.text.delete(::Stationery::Text::Wrapper::SOFT_HYPHEN).split(/[ \t\n\u200B]+/).map do |word|
62
+ next piece_width(font, run.style, word) unless ::Stationery::Text::Breaks.cjk?(word)
63
+
64
+ ::Stationery::Text::Breaks.units(word).map { |unit, _| piece_width(font, run.style, unit) }.max
48
65
  end
49
66
  end.max || 0
50
67
  end
51
68
 
52
69
  private
53
70
 
71
+ # Applies orphans and widows to a split after `fitting` lines: carries
72
+ # more lines over for the widows, then moves the paragraph whole when
73
+ # too few would stay. First on a fresh page nothing can move, so the
74
+ # widows are honoured if a line can still stay and the orphans are not.
75
+ def keep_lines(paragraph, fitting, fresh:)
76
+ total = paragraph.lines.size
77
+ kept = [fitting, total - @widows].min
78
+ kept = fitting if fresh && kept < 1
79
+ return [nil, self] if !fresh && (kept < @orphans || total < @orphans + @widows)
80
+
81
+ head, tail = paragraph.split_at(kept)
82
+ [head && from(head), tail && from(tail)]
83
+ end
84
+
85
+ def piece_width(font, style, text)
86
+ font.width_of(text, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
87
+ ligatures: style.ligatures, features: style.features)
88
+ end
89
+
54
90
  def paragraph(width)
55
91
  @paragraph || (@paragraphs[width] ||= ::Stationery::Text::Paragraph.new(
56
92
  @runs, book: @context.book, width:, align: @align, leading: @leading, fallback_style: @context.style
@@ -58,7 +94,8 @@ module Stationery
58
94
  end
59
95
 
60
96
  def from(paragraph)
61
- self.class.new(@runs, context: @context, align: @align, leading: @leading, paragraph:, tag: @tag)
97
+ self.class.new(@runs, context: @context, align: @align, leading: @leading, orphans: @orphans, widows: @widows,
98
+ paragraph:, tag: @tag)
62
99
  end
63
100
  end
64
101
  end
@@ -11,10 +11,16 @@ module Stationery
11
11
  # with `xmp_extensions:` as further schemas and `xmp_schemas:` describing
12
12
  # them to PDF/A (see XMP); `conformance:` (a PDF::Conformance) adds what
13
13
  # PDF/A and PDF/UA ask of the file; `signature:` (a PDF::Signature)
14
- # signs it.
14
+ # signs it. `sink:` (anything answering `call(bytes)`) receives the file
15
+ # in pieces as each page is written, in the order the objects are
16
+ # flushed, instead of one String at the end; a signature needs the
17
+ # finished file, so it cannot be streamed.
15
18
  def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil,
16
19
  page_labels: nil, attachments: [], xmp: true, xmp_extensions: {}, xmp_schemas: [],
17
- conformance: nil, signature: nil)
20
+ conformance: nil, signature: nil, sink: nil)
21
+ raise ArgumentError, "a signed document cannot be streamed: sign needs the whole file" if signature && sink
22
+
23
+ @sink = sink
18
24
  @xmp_schemas = xmp_schemas
19
25
  @conformance = conformance
20
26
  @signature = signature
@@ -31,15 +37,19 @@ module Stationery
31
37
  @encryption = encryption
32
38
  end
33
39
 
40
+ # The file as a String, or the number of bytes streamed to the sink.
34
41
  def render
35
- writer = Writer.new(encryption: @encryption)
42
+ writer = Writer.new(encryption: @encryption, sink: @sink)
36
43
  tree = writer.reserve
37
44
  refs = @resources.build(writer)
38
45
  @form = Forms::AcroForm.new(writer, fonts: refs.fetch(:Font), signature: @signature,
39
46
  need_appearances: @conformance.nil? && @signature.nil?)
40
47
  kids = @kids = @pages.map { writer.reserve }
41
48
  @structure = @tagging && Tagging::Writer.new(@tagging, pages: @pages, refs: kids)
42
- @pages.each_with_index { |page, index| write_page(writer, page, kids[index], tree, refs) }
49
+ @pages.each_with_index do |page, index|
50
+ write_page(writer, page, kids[index], tree, refs)
51
+ writer.flush
52
+ end
43
53
  writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
44
54
  outlines = OutlineWriter.new(writer, @outline, kids).write
45
55
  now = Time.now
@@ -10,6 +10,9 @@ module Stationery
10
10
  # signer's certificate; there is no signing-time attribute, a PDF keeps
11
11
  # that in the signature dictionary's /M.
12
12
  #
13
+ # A timestamp token over the signature value (see Timestamp) goes into
14
+ # the unsigned attributes, which makes the signature PAdES baseline B-T.
15
+ #
13
16
  # Ruby's OpenSSL::PKCS7 cannot add signed attributes, so the structure
14
17
  # is built with OpenSSL::ASN1. RSA keys sign with PKCS #1 v1.5, EC keys
15
18
  # with ECDSA, both over SHA-256.
@@ -18,6 +21,7 @@ module Stationery
18
21
  data: "1.2.840.113549.1.7.1", signed_data: "1.2.840.113549.1.7.2",
19
22
  content_type: "1.2.840.113549.1.9.3", message_digest: "1.2.840.113549.1.9.4",
20
23
  signing_certificate_v2: "1.2.840.113549.1.9.16.2.47", sha256: "2.16.840.1.101.3.4.2.1",
24
+ signature_timestamp_token: "1.2.840.113549.1.9.16.2.14",
21
25
  rsa: "1.2.840.113549.1.1.1", ecdsa_sha256: "1.2.840.10045.4.3.2"
22
26
  }.freeze
23
27
 
@@ -28,16 +32,18 @@ module Stationery
28
32
  @chain = chain
29
33
  end
30
34
 
31
- # The DER of the SignedData signing `data`.
32
- def sign(data)
35
+ # The DER of the SignedData signing `data`. `timestamp:` is a callable
36
+ # given the signature value and answering a timestamp token (DER).
37
+ def sign(data, timestamp: nil)
33
38
  attributes = attributes(OpenSSL::Digest::SHA256.digest(data))
34
39
  signature = @key.sign("SHA256", asn1::Set.new(attributes).to_der)
40
+ unsigned = timestamp && [attribute(:signature_timestamp_token, asn1.decode(timestamp.call(signature)))]
35
41
  content = asn1::Sequence.new([
36
42
  asn1::Integer.new(1),
37
43
  asn1::Set.new([algorithm(:sha256)]),
38
44
  asn1::Sequence.new([oid(:data)]),
39
45
  implicit(certificates),
40
- asn1::Set.new([signer(attributes, signature)])
46
+ asn1::Set.new([signer(attributes, signature, unsigned)])
41
47
  ])
42
48
  asn1::Sequence.new([oid(:signed_data), asn1::ASN1Data.new([content], 0, :CONTEXT_SPECIFIC)]).to_der
43
49
  end
@@ -46,7 +52,7 @@ module Stationery
46
52
 
47
53
  def asn1 = OpenSSL::ASN1
48
54
  def oid(name) = asn1::ObjectId.new(OIDS.fetch(name))
49
- def implicit(items) = asn1::Set.new(items, 0, :IMPLICIT, :CONTEXT_SPECIFIC)
55
+ def implicit(items, tag = 0) = asn1::Set.new(items, tag, :IMPLICIT, :CONTEXT_SPECIFIC)
50
56
  def certificates = [@certificate, *@chain].map { |certificate| asn1.decode(certificate.to_der) }
51
57
 
52
58
  # RSA and the digests carry an explicit NULL parameter, ECDSA none.
@@ -70,16 +76,18 @@ module Stationery
70
76
  asn1::Sequence.new([asn1::Sequence.new([asn1::Sequence.new([hash])])])
71
77
  end
72
78
 
73
- def signer(attributes, signature)
79
+ def signer(attributes, signature, unsigned)
74
80
  issuer = asn1.decode(@certificate.issuer.to_der)
75
- asn1::Sequence.new([
76
- asn1::Integer.new(1),
77
- asn1::Sequence.new([issuer, asn1::Integer.new(@certificate.serial)]),
78
- algorithm(:sha256),
79
- implicit(attributes),
80
- @key.is_a?(OpenSSL::PKey::EC) ? algorithm(:ecdsa_sha256, null: false) : algorithm(:rsa),
81
- asn1::OctetString.new(signature)
82
- ])
81
+ info = [
82
+ asn1::Integer.new(1),
83
+ asn1::Sequence.new([issuer, asn1::Integer.new(@certificate.serial)]),
84
+ algorithm(:sha256),
85
+ implicit(attributes),
86
+ @key.is_a?(OpenSSL::PKey::EC) ? algorithm(:ecdsa_sha256, null: false) : algorithm(:rsa),
87
+ asn1::OctetString.new(signature)
88
+ ]
89
+ info << implicit(unsigned, 1) if unsigned
90
+ asn1::Sequence.new(info)
83
91
  end
84
92
  end
85
93
  end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module PDF
5
+ class Signature
6
+ # A signature timestamp from an RFC 3161 time-stamping authority (TSA):
7
+ # the TSA signs the digest of the signature value and the time it saw
8
+ # it, and the token goes into the signature's unsigned attributes, which
9
+ # makes it PAdES baseline B-T. A reader can then trust the signing time
10
+ # even after the signer's certificate expires.
11
+ #
12
+ # `sign timestamp: "https://tsa.example/tsr"`, or a Hash with `url:`,
13
+ # `username:`/`password:` for a TSA that wants HTTP basic auth, `hash:`
14
+ # (:sha256, the default, :sha384 or :sha512) and `client:`, a callable
15
+ # given the request DER and answering the response DER, in place of
16
+ # the built-in HTTP client. Anything short of a token over exactly what
17
+ # was asked raises SignatureError: a document is never quietly written
18
+ # without the timestamp it was asked for.
19
+ class Timestamp
20
+ OPTIONS = %i[url username password hash client].freeze
21
+ DIGESTS = { sha256: "SHA256", sha384: "SHA384", sha512: "SHA512" }.freeze
22
+ # Status values a TSA answers with when it did grant the token
23
+ # (granted, grantedWithMods).
24
+ GRANTED = [0, 1].freeze
25
+ REQUEST_TYPE = "application/timestamp-query"
26
+ REPLY_TYPE = "application/timestamp-reply"
27
+
28
+ attr_reader :url, :hash
29
+
30
+ # The timestamp for `spec`: a URL String, a Hash of OPTIONS, or nil.
31
+ def self.for(spec)
32
+ return unless spec
33
+
34
+ spec = { url: spec.to_s } unless spec.is_a?(Hash)
35
+ spec = spec.transform_keys(&:to_sym)
36
+ unknown = spec.keys - OPTIONS
37
+ unless unknown.empty?
38
+ raise ArgumentError, "unknown timestamp option: #{unknown.join(", ")} (use #{OPTIONS.join(", ")})"
39
+ end
40
+
41
+ new(**spec)
42
+ end
43
+
44
+ def initialize(url: nil, username: nil, password: nil, hash: :sha256, client: nil)
45
+ raise ArgumentError, "sign timestamp: needs the TSA's url:" if url.to_s.empty? && client.nil?
46
+
47
+ @url = url&.to_s
48
+ @username = username
49
+ @password = password
50
+ @hash = hash.to_sym
51
+ @client = client
52
+ return if DIGESTS[@hash]
53
+
54
+ raise ArgumentError,
55
+ "timestamp hash: is #{DIGESTS.keys.join(", ")}, got #{hash.inspect}"
56
+ end
57
+
58
+ # The timestamp token (a ContentInfo, DER) the TSA issued over
59
+ # `signature`, checked against what was asked.
60
+ def token(signature)
61
+ request = request_for(OpenSSL::Digest.digest(DIGESTS.fetch(@hash), signature))
62
+ response = parse((@client || method(:post)).call(request.to_der))
63
+ check!(response, request)
64
+ response.token.to_der
65
+ end
66
+
67
+ private
68
+
69
+ def request_for(digest)
70
+ request = OpenSSL::Timestamp::Request.new
71
+ request.algorithm = DIGESTS.fetch(@hash)
72
+ request.message_imprint = digest
73
+ request.nonce = OpenSSL::BN.rand(64)
74
+ request.cert_requested = true
75
+ request
76
+ end
77
+
78
+ def parse(der)
79
+ OpenSSL::Timestamp::Response.new(der.to_s)
80
+ rescue OpenSSL::Timestamp::TimestampError, TypeError => e
81
+ raise SignatureError, "#{tsa} did not answer with a timestamp response: #{e.message}"
82
+ end
83
+
84
+ def check!(response, request)
85
+ status = response.status.to_i
86
+ unless GRANTED.include?(status) && response.token
87
+ text = [*response.status_text, response.failure_info].compact.map(&:to_s).reject(&:empty?)
88
+ raise SignatureError,
89
+ "#{tsa} refused the timestamp (status #{status}#{": #{text.join(", ")}" unless text.empty?})"
90
+ end
91
+
92
+ info = response.token_info
93
+ raise SignatureError, "#{tsa} answered another request (nonce differs)" unless info.nonce == request.nonce
94
+ return if info.message_imprint == request.message_imprint
95
+
96
+ raise SignatureError, "#{tsa} timestamped something else (message imprint differs)"
97
+ end
98
+
99
+ def tsa = "timestamp: #{@url || "the TSA"}"
100
+
101
+ # POSTs the request as RFC 3161 over HTTP asks for it.
102
+ def post(body)
103
+ require "net/http"
104
+ uri = URI.parse(@url)
105
+ request = Net::HTTP::Post.new(uri)
106
+ request["Content-Type"] = REQUEST_TYPE
107
+ request["Accept"] = REPLY_TYPE
108
+ request.basic_auth(@username, @password.to_s) if @username
109
+ request.body = body
110
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
111
+ raise SignatureError, "#{tsa} answered HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
112
+
113
+ response.body
114
+ rescue SystemCallError, IOError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError => e
115
+ raise SignatureError, "#{tsa} could not be reached: #{e.message}"
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -8,22 +8,26 @@ module Stationery
8
8
  #
9
9
  # The file is written once with room for the signature (`contents_size:`
10
10
  # bytes) and a placeholder /ByteRange; #apply then fills both in place, so
11
- # no offset moves. Timestamps (PAdES-T), long-term validation data and a
12
- # second signature need incremental updates, which Stationery does not
13
- # write.
11
+ # no offset moves. `timestamp:` adds a time-stamping authority's token to
12
+ # the signature (PAdES baseline B-T, see Timestamp). Long-term validation
13
+ # data and a second signature need incremental updates, which Stationery
14
+ # does not write.
14
15
  #
15
16
  # OpenSSL is loaded when the first signature is made, never before.
16
17
  class Signature
17
18
  SUBFILTER = :"ETSI.CAdES.detached"
18
19
  CONTENTS_SIZE = 8192
20
+ # A timestamp token brings the TSA's certificate chain along.
21
+ TIMESTAMPED_CONTENTS_SIZE = 16_384
19
22
  BYTE_RANGE = "[0 0000000000 0000000000 0000000000]"
20
- OPTIONS = %i[certificate key chain passphrase reason location contact name field at contents_size].freeze
23
+ OPTIONS = %i[certificate key chain passphrase reason location contact name field at contents_size
24
+ timestamp].freeze
21
25
  # Options a Symbol names a document method for; elsewhere it is a value.
22
26
  SECRETS = %i[certificate key chain passphrase].freeze
23
27
  # Print and Locked: the widget of a signature that fills no field.
24
28
  INVISIBLE = 132
25
29
 
26
- attr_reader :certificate, :key, :chain, :field, :at, :contents_size
30
+ attr_reader :certificate, :key, :chain, :field, :at, :contents_size, :timestamp
27
31
 
28
32
  class << self
29
33
  # The signature for `spec` (the options of `sign` and `to_pdf(sign:)`,
@@ -73,14 +77,15 @@ module Stationery
73
77
  end
74
78
 
75
79
  def initialize(certificate: nil, key: nil, chain: [], passphrase: nil, field: nil, at: Time.now,
76
- contents_size: CONTENTS_SIZE, **details)
80
+ contents_size: nil, timestamp: nil, **details)
77
81
  require "openssl"
78
82
  @certificate = certificate_for(certificate, "certificate:")
79
83
  @key = key_for(key, passphrase)
80
84
  @chain = Array(chain).map { |link| certificate_for(link, "chain:") }
81
85
  @field = field&.to_s
82
86
  @at = at
83
- @contents_size = contents_size
87
+ @timestamp = Timestamp.for(timestamp)
88
+ @contents_size = contents_size || (@timestamp ? TIMESTAMPED_CONTENTS_SIZE : CONTENTS_SIZE)
84
89
  @details = self.class.check(details)
85
90
  match!
86
91
  end
@@ -110,7 +115,8 @@ module Stationery
110
115
  range, first, last = gap(pdf)
111
116
  pdf.bytesplice(range, BYTE_RANGE.bytesize,
112
117
  "[0 #{first} #{last} #{pdf.bytesize - last}]".ljust(BYTE_RANGE.bytesize))
113
- signature = CMS.new(@certificate, @key, @chain).sign(pdf.byteslice(0, first) + pdf.byteslice(last..))
118
+ signed = pdf.byteslice(0, first) + pdf.byteslice(last..)
119
+ signature = CMS.new(@certificate, @key, @chain).sign(signed, timestamp: @timestamp&.method(:token))
114
120
  raise ArgumentError, too_large(signature) if signature.bytesize > @contents_size
115
121
 
116
122
  pdf.bytesplice(first + 1, @contents_size * 2, signature.unpack1("H*").upcase.ljust(@contents_size * 2, "0"))
@@ -133,7 +139,8 @@ module Stationery
133
139
  end
134
140
 
135
141
  def too_large(signature)
136
- "the signature takes #{signature.bytesize} bytes, more than contents_size: #{@contents_size} leaves for it; " \
142
+ what = @timestamp ? "the signature and its timestamp take" : "the signature takes"
143
+ "#{what} #{signature.bytesize} bytes, more than contents_size: #{@contents_size} leaves for it; " \
137
144
  "pass a larger contents_size:"
138
145
  end
139
146
 
@@ -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.