stationery 0.2.0 → 0.4.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/README.md +143 -12
  4. data/lib/stationery/builder.rb +2 -1
  5. data/lib/stationery/canvas/debug.rb +2 -1
  6. data/lib/stationery/canvas/marking.rb +124 -0
  7. data/lib/stationery/canvas/text.rb +5 -4
  8. data/lib/stationery/canvas.rb +37 -8
  9. data/lib/stationery/document.rb +35 -10
  10. data/lib/stationery/elements/forms.rb +55 -0
  11. data/lib/stationery/elements/lists.rb +15 -5
  12. data/lib/stationery/elements.rb +10 -7
  13. data/lib/stationery/fonts/font.rb +36 -20
  14. data/lib/stationery/fonts/font_book.rb +3 -0
  15. data/lib/stationery/fonts/glyph_run.rb +4 -3
  16. data/lib/stationery/fonts/gpos.rb +11 -9
  17. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  18. data/lib/stationery/fonts/gsub.rb +65 -0
  19. data/lib/stationery/fonts/ligatures.rb +16 -0
  20. data/lib/stationery/fonts/registry.rb +18 -9
  21. data/lib/stationery/fonts/to_unicode.rb +1 -1
  22. data/lib/stationery/fonts/true_type.rb +36 -12
  23. data/lib/stationery/fonts/woff.rb +52 -0
  24. data/lib/stationery/forms/acro_form.rb +115 -0
  25. data/lib/stationery/forms/appearance.rb +119 -0
  26. data/lib/stationery/forms/field.rb +122 -0
  27. data/lib/stationery/forms/metrics.rb +49 -0
  28. data/lib/stationery/layout/box.rb +34 -11
  29. data/lib/stationery/layout/field.rb +51 -0
  30. data/lib/stationery/layout/flow.rb +17 -9
  31. data/lib/stationery/layout/image.rb +4 -2
  32. data/lib/stationery/layout/list_item.rb +15 -6
  33. data/lib/stationery/layout/node.rb +2 -0
  34. data/lib/stationery/layout/paginator.rb +3 -2
  35. data/lib/stationery/layout/row.rb +1 -1
  36. data/lib/stationery/layout/svg.rb +9 -2
  37. data/lib/stationery/layout/table/cell.rb +10 -2
  38. data/lib/stationery/layout/table.rb +42 -10
  39. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  40. data/lib/stationery/layout/table_of_contents.rb +1 -1
  41. data/lib/stationery/layout/text.rb +6 -4
  42. data/lib/stationery/minitest.rb +2 -0
  43. data/lib/stationery/page.rb +5 -2
  44. data/lib/stationery/page_templates.rb +7 -4
  45. data/lib/stationery/pdf/assembler.rb +38 -5
  46. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  47. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  48. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  49. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  50. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  51. data/lib/stationery/pdf/serializer.rb +17 -9
  52. data/lib/stationery/pdf/writer.rb +24 -5
  53. data/lib/stationery/resources.rb +8 -2
  54. data/lib/stationery/rich/renderer.rb +3 -3
  55. data/lib/stationery/structure.rb +15 -9
  56. data/lib/stationery/svg/bounds.rb +76 -0
  57. data/lib/stationery/svg/document.rb +48 -18
  58. data/lib/stationery/svg/gradient.rb +115 -0
  59. data/lib/stationery/svg/painter.rb +70 -0
  60. data/lib/stationery/svg/parser.rb +24 -4
  61. data/lib/stationery/svg/selector.rb +37 -0
  62. data/lib/stationery/svg/shading.rb +34 -0
  63. data/lib/stationery/svg/style.rb +37 -20
  64. data/lib/stationery/svg/stylesheet.rb +47 -0
  65. data/lib/stationery/svg/text.rb +122 -0
  66. data/lib/stationery/svg/transform.rb +11 -0
  67. data/lib/stationery/tagging/element.rb +74 -0
  68. data/lib/stationery/tagging/tree.rb +43 -0
  69. data/lib/stationery/tagging/writer.rb +81 -0
  70. data/lib/stationery/testing/inspector.rb +15 -0
  71. data/lib/stationery/testing/marked_text.rb +60 -0
  72. data/lib/stationery/testing/matchers.rb +25 -0
  73. data/lib/stationery/testing/structure_reader.rb +71 -0
  74. data/lib/stationery/text/paragraph.rb +28 -12
  75. data/lib/stationery/text/style.rb +4 -2
  76. data/lib/stationery/text/wrapper.rb +2 -1
  77. data/lib/stationery/version.rb +1 -1
  78. data/lib/stationery/warnings.rb +8 -0
  79. data/lib/stationery.rb +26 -0
  80. metadata +29 -1
@@ -4,37 +4,57 @@ module Stationery
4
4
  module PDF
5
5
  # Writes finished pages, their shared resources and document info as PDF.
6
6
  class Assembler
7
- def initialize(pages:, resources:, info: {}, outline: [])
7
+ # `tagging:` (a Tagging::Tree) writes the structure tree of a tagged PDF;
8
+ # `lang:` is the document's natural language.
9
+ def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil)
10
+ @tagging = tagging
11
+ @lang = lang
8
12
  @pages = pages
9
13
  @resources = resources
10
14
  @info = info
11
15
  @outline = outline
16
+ @encryption = encryption
12
17
  end
13
18
 
14
19
  def render
15
- writer = Writer.new
20
+ writer = Writer.new(encryption: @encryption)
21
+ @form = Forms::AcroForm.new(writer)
16
22
  tree = writer.reserve
17
23
  refs = @resources.build(writer)
18
24
  kids = @kids = @pages.map { writer.reserve }
25
+ @structure = @tagging && Tagging::Writer.new(@tagging, pages: @pages, refs: kids)
19
26
  @pages.each_with_index { |page, index| write_page(writer, page, kids[index], tree, refs) }
20
27
  writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
21
- root = writer.add(catalog(tree, OutlineWriter.new(writer, @outline, kids).write))
28
+ outlines = OutlineWriter.new(writer, @outline, kids).write
29
+ root = writer.add(catalog(tree, outlines, @form.write).merge(accessibility(writer)))
22
30
  writer.render(root:, info: writer.add(info_dictionary))
23
31
  end
24
32
 
25
33
  private
26
34
 
27
- def catalog(tree, outlines)
35
+ def catalog(tree, outlines, form)
28
36
  catalog = { Type: :Catalog, Pages: tree }
37
+ catalog[:AcroForm] = form if form
29
38
  outlines ? catalog.merge(Outlines: outlines, PageMode: :UseOutlines) : catalog
30
39
  end
31
40
 
41
+ def accessibility(writer)
42
+ entries = @lang ? { Lang: PDF::TextString.new(@lang.to_s) } : {}
43
+ return entries unless @structure
44
+
45
+ entries.merge!(@structure.write(writer))
46
+ @info[:Title] ? entries.merge(ViewerPreferences: { DisplayDocTitle: true }) : entries
47
+ end
48
+
32
49
  def write_page(writer, page, ref, tree, refs)
33
50
  dictionary = {
34
51
  Type: :Page, Parent: tree, MediaBox: [0, 0, *page.size],
35
52
  Contents: writer.add(Stream.new(page.content)), Resources: page_resources(page, refs)
36
53
  }
37
- dictionary[:Annots] = page.annotations.map { |link| writer.add(annotation(link)) } if page.annotations.any?
54
+ if page.annotations.any?
55
+ dictionary[:Annots] = page.annotations.map { |annot| annotation_ref(writer, annot, ref) }
56
+ end
57
+ dictionary.merge!(@structure.page_entries(page)) if @structure
38
58
  writer.set(ref, dictionary)
39
59
  end
40
60
 
@@ -44,6 +64,19 @@ module Stationery
44
64
  end
45
65
  end
46
66
 
67
+ def annotation_ref(writer, annotation, page)
68
+ if annotation[:widget]
69
+ return @form.add(annotation[:widget], annotation[:rect], page) do |widget_ref|
70
+ @structure ? @structure.annotation(annotation, widget_ref) : {}
71
+ end
72
+ end
73
+
74
+ ref = writer.reserve
75
+ dictionary = annotation(annotation)
76
+ dictionary = dictionary.merge(@structure.annotation(annotation, ref)) if @structure
77
+ writer.set(ref, dictionary)
78
+ end
79
+
47
80
  def annotation(link)
48
81
  target = if (dest = link[:dest])
49
82
  { Dest: [@kids.fetch(dest.page), :XYZ, nil, dest.top, nil] }
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Stationery
6
+ module PDF
7
+ module Encryption
8
+ # AES as PDF uses it: object data is CBC with a random IV in front and
9
+ # PKCS#5 padding; key material (UE, OE, Perms) is unpadded.
10
+ module AES
11
+ ZERO_IV = ("\0" * 16).b
12
+
13
+ module_function
14
+
15
+ def encrypt(key, data)
16
+ iv = OpenSSL::Random.random_bytes(16)
17
+ iv + run(cipher(key, "CBC", iv), data)
18
+ end
19
+
20
+ def raw(key, data, iv: ZERO_IV, mode: "CBC")
21
+ aes = cipher(key, mode, mode == "ECB" ? nil : iv)
22
+ aes.padding = 0
23
+ run(aes, data)
24
+ end
25
+
26
+ def cipher(key, mode, iv)
27
+ aes = OpenSSL::Cipher.new("AES-#{key.bytesize * 8}-#{mode}").encrypt
28
+ aes.key = key
29
+ aes.iv = iv if iv
30
+ aes
31
+ end
32
+
33
+ def run(aes, data) = aes.update(data.b) + aes.final
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module PDF
5
+ module Encryption
6
+ # RC4 in Ruby: OpenSSL 3 builds drop it from the default provider.
7
+ module RC4
8
+ module_function
9
+
10
+ def crypt(key, data)
11
+ state = schedule(key.b.bytes)
12
+ i = j = 0
13
+ data.b.bytes.map do |byte|
14
+ i = (i + 1) & 0xFF
15
+ j = (j + state[i]) & 0xFF
16
+ state[i], state[j] = state[j], state[i]
17
+ byte ^ state[(state[i] + state[j]) & 0xFF]
18
+ end.pack("C*")
19
+ end
20
+
21
+ def schedule(key)
22
+ state = (0..255).to_a
23
+ j = 0
24
+ 256.times do |i|
25
+ j = (j + state[i] + key[i % key.size]) & 0xFF
26
+ state[i], state[j] = state[j], state[i]
27
+ end
28
+ state
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/md5"
4
+
5
+ module Stationery
6
+ module PDF
7
+ module Encryption
8
+ # The MD5 key derivation of security handler revisions 3 and 4
9
+ # (ISO 32000-1 algorithms 1-5) with a 128-bit key. Each object is
10
+ # encrypted with its own key: RC4 for R3, AES-128 (AESV2) for R4.
11
+ class Revision4
12
+ PADDING = ["28BF4E5E4E758A4164004E56FFFA01082E2E00B6D0683E802F0CA9FE6453697A"].pack("H*").freeze
13
+
14
+ def initialize(user:, owner:, permissions:, file_id:, aes:)
15
+ @aes = aes
16
+ user = pad(user)
17
+ @owner_key = rounds(stretch(Digest::MD5.digest(pad(owner))), user)
18
+ @key = stretch(Digest::MD5.digest(user + @owner_key + [permissions].pack("l<") + file_id))
19
+ @user_key = rounds(@key, Digest::MD5.digest(PADDING + file_id)) + PADDING.byteslice(0, 16)
20
+ end
21
+
22
+ def entries = { O: HexString.new(@owner_key), U: HexString.new(@user_key) }
23
+
24
+ def encrypt(data, number, generation)
25
+ key = object_key(number, generation)
26
+ @aes ? AES.encrypt(key, data) : RC4.crypt(key, data)
27
+ end
28
+
29
+ private
30
+
31
+ def pad(password) = (password.b + PADDING).byteslice(0, 32)
32
+
33
+ def stretch(digest) = 50.times.reduce(digest) { |previous, _| Digest::MD5.digest(previous) }
34
+
35
+ def rounds(key, data)
36
+ 20.times.reduce(data) { |previous, round| RC4.crypt(xor(key, round), previous) }
37
+ end
38
+
39
+ def xor(key, value) = key.bytes.map { |byte| byte ^ value }.pack("C*")
40
+
41
+ def object_key(number, generation)
42
+ seed = @key + [number].pack("V").byteslice(0, 3) + [generation].pack("v")
43
+ Digest::MD5.digest(@aes ? "#{seed}sAlT" : seed)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha2"
4
+ require "openssl"
5
+
6
+ module Stationery
7
+ module PDF
8
+ module Encryption
9
+ # Security handler revision 6 (ISO 32000-2, AES-256): a random file key
10
+ # wrapped once per password, with salted SHA-2 hashes that run through
11
+ # the hash iteration of algorithm 2.B. Every object uses the file key.
12
+ class Revision6
13
+ DIGESTS = [Digest::SHA256, Digest::SHA384, Digest::SHA512].freeze
14
+
15
+ def initialize(user:, owner:, permissions:)
16
+ @key = random(32)
17
+ user = password(user)
18
+ owner = password(owner)
19
+ validation = random(8)
20
+ key_salt = random(8)
21
+ @user_key = hash(user, validation) + validation + key_salt
22
+ @user_wrapped = AES.raw(hash(user, key_salt), @key)
23
+ validation = random(8)
24
+ key_salt = random(8)
25
+ @owner_key = hash(owner, validation, @user_key) + validation + key_salt
26
+ @owner_wrapped = AES.raw(hash(owner, key_salt, @user_key), @key)
27
+ @perms = AES.raw(@key, "#{[permissions, 0xFFFFFFFF].pack("l<V")}Tadb#{random(4)}", mode: "ECB")
28
+ end
29
+
30
+ def entries
31
+ { O: @owner_key, U: @user_key, OE: @owner_wrapped, UE: @user_wrapped, Perms: @perms }
32
+ .transform_values { |bytes| HexString.new(bytes) }
33
+ end
34
+
35
+ def encrypt(data, _number, _generation) = AES.encrypt(@key, data)
36
+
37
+ private
38
+
39
+ def random(size) = OpenSSL::Random.random_bytes(size)
40
+
41
+ def password(value) = value.encode(Encoding::UTF_8).b.byteslice(0, 127)
42
+
43
+ def hash(password, salt, user_key = "".b)
44
+ key = Digest::SHA256.digest(password + salt + user_key)
45
+ round = 0
46
+ loop do
47
+ block = AES.raw(key.byteslice(0, 16), (password + key + user_key) * 64, iv: key.byteslice(16, 16))
48
+ key = DIGESTS[block.byteslice(0, 16).bytes.sum % 3].digest(block)
49
+ round += 1
50
+ return key.byteslice(0, 32) if round >= 64 && block.getbyte(-1) <= round - 32
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Stationery
6
+ module PDF
7
+ module Encryption
8
+ # The standard security handler: passwords, permissions and the
9
+ # /Encrypt dictionary, plus the per-object encryption the writer applies
10
+ # to every string and stream.
11
+ #
12
+ # StandardSecurity.new(owner_password: "s3cret", permissions: %i[print copy])
13
+ class StandardSecurity
14
+ PERMISSIONS = { print: 3, modify: 4, copy: 5, annotate: 6, fill_forms: 9, extract_accessible: 10,
15
+ assemble: 11, print_high: 12 }.freeze
16
+ ALGORITHMS = {
17
+ aes_256: { V: 5, R: 6, Length: 256, CFM: :AESV3, key: 32 },
18
+ aes_128: { V: 4, R: 4, Length: 128, CFM: :AESV2, key: 16 },
19
+ rc4_128: { V: 2, R: 3, Length: 128 }
20
+ }.freeze
21
+
22
+ # Checks and normalizes the options without deriving any keys.
23
+ def self.options(owner_password:, user_password: "", permissions: PERMISSIONS.keys, algorithm: :aes_256)
24
+ raise ArgumentError, "encryption needs an owner_password" if owner_password.to_s.empty?
25
+
26
+ permissions = Array(permissions).map(&:to_sym)
27
+ unknown = permissions - PERMISSIONS.keys
28
+ raise ArgumentError, "unknown permission #{unknown.first.inspect}" if unknown.any?
29
+ raise ArgumentError, "unknown algorithm #{algorithm.inspect}" unless ALGORITHMS.key?(algorithm)
30
+
31
+ { owner_password: owner_password.to_s, user_password: user_password.to_s, permissions:, algorithm: }
32
+ end
33
+
34
+ # The signed 32-bit /P value: granted bits set, bits 1-2 clear, the rest set.
35
+ def self.permission_bits(permissions)
36
+ masks = PERMISSIONS.transform_values { |bit| 1 << (bit - 1) }
37
+ bits = 0xFFFFFFFC & ~masks.values.sum
38
+ bits |= permissions.sum { |name| masks.fetch(name) }
39
+ [bits].pack("L").unpack1("l")
40
+ end
41
+
42
+ attr_reader :file_id
43
+
44
+ def initialize(**)
45
+ options = self.class.options(**)
46
+ @algorithm = ALGORITHMS.fetch(options[:algorithm])
47
+ @permissions = self.class.permission_bits(options[:permissions])
48
+ @file_id = OpenSSL::Random.random_bytes(16)
49
+ @handler = handler(options[:user_password], options[:owner_password], options[:algorithm])
50
+ end
51
+
52
+ def dictionary
53
+ { Filter: :Standard, **@algorithm.slice(:V, :R, :Length), P: @permissions, **@handler.entries,
54
+ **crypt_filters }
55
+ end
56
+
57
+ def encrypt(data, number, generation = 0) = @handler.encrypt(data, number, generation)
58
+
59
+ private
60
+
61
+ def handler(user, owner, algorithm)
62
+ if algorithm == :aes_256
63
+ Revision6.new(user:, owner:, permissions: @permissions)
64
+ else
65
+ Revision4.new(user:, owner:, permissions: @permissions, file_id: @file_id, aes: algorithm == :aes_128)
66
+ end
67
+ end
68
+
69
+ def crypt_filters
70
+ return {} unless @algorithm[:CFM]
71
+
72
+ filter = { CFM: @algorithm[:CFM], AuthEvent: :DocOpen, Length: @algorithm[:key] }
73
+ { CF: { StdCF: filter }, StmF: :StdCF, StrF: :StdCF }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -7,6 +7,9 @@ module Stationery
7
7
  # A plain String is always written as a literal byte string `( … )` — never
8
8
  # re-encoded — so a URI stays the 7-bit ASCII the spec requires. Use
9
9
  # TextString for human-readable text that may need UTF-16.
10
+ #
11
+ # `crypt`, when given, receives every string's bytes (the writer binds it
12
+ # to the object being written) and the result is written as hex.
10
13
  module Serializer
11
14
  NAME_ESCAPE = %r{[^\x21-\x7E]|[#%()/<>\[\]{}]}n
12
15
  LITERAL_ESCAPE = /[\\()\r]/n
@@ -14,15 +17,15 @@ module Stationery
14
17
 
15
18
  module_function
16
19
 
17
- def dump(value)
20
+ def dump(value, crypt = nil)
18
21
  case value
19
22
  when Reference then "#{value.id} 0 R"
20
23
  when Symbol then name(value)
21
- when Hash then "<<#{value.map { |k, v| "#{name(k)} #{dump(v)}" }.join(" ")}>>"
22
- when Array then "[#{value.map { |v| dump(v) }.join(" ")}]"
23
- when HexString then "<#{value.bytes.unpack1("H*").upcase}>"
24
- when TextString then text(value.value)
25
- when String then literal(value)
24
+ when Hash then "<<#{value.map { |k, v| "#{name(k)} #{dump(v, crypt)}" }.join(" ")}>>"
25
+ when Array then "[#{value.map { |v| dump(v, crypt) }.join(" ")}]"
26
+ when HexString then hex(value.bytes, crypt)
27
+ when TextString then text(value.value, crypt)
28
+ when String then crypt ? hex(value, crypt) : literal(value)
26
29
  when Integer, true, false then value.to_s
27
30
  when Float then number(value)
28
31
  when nil then "null"
@@ -38,6 +41,11 @@ module Stationery
38
41
  "(#{value.b.gsub(LITERAL_ESCAPE) { |c| c == "\r" ? "\\r" : "\\#{c}" }})"
39
42
  end
40
43
 
44
+ def hex(bytes, crypt = nil)
45
+ bytes = crypt.call(bytes.b) if crypt
46
+ "<#{bytes.unpack1("H*").upcase}>"
47
+ end
48
+
41
49
  def number(value)
42
50
  return value.to_s if value.is_a?(Integer)
43
51
 
@@ -49,11 +57,11 @@ module Stationery
49
57
  str == "-0" ? "0" : str
50
58
  end
51
59
 
52
- def text(value)
60
+ def text(value, crypt = nil)
53
61
  value = value.to_s
54
- return literal(value) if value.ascii_only?
62
+ return dump(value, crypt) if value.ascii_only?
55
63
 
56
- dump(HexString.new(UTF16_BOM + value.encode(Encoding::UTF_16BE).b))
64
+ hex(UTF16_BOM + value.encode(Encoding::UTF_16BE).b, crypt)
57
65
  end
58
66
  end
59
67
  end
@@ -9,8 +9,11 @@ module Stationery
9
9
  class Writer
10
10
  HEADER = "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".b
11
11
 
12
- def initialize
12
+ # `encryption` (an Encryption::StandardSecurity) encrypts every string
13
+ # and stream except those of its own /Encrypt dictionary.
14
+ def initialize(encryption: nil)
13
15
  @objects = []
16
+ @encryption = encryption
14
17
  end
15
18
 
16
19
  # Hands out a reference now and lets the object be set later, so a page
@@ -34,30 +37,46 @@ module Stationery
34
37
  raise Error, "object #{missing + 1} reserved but never set"
35
38
  end
36
39
 
40
+ @encrypt = @encryption && add(@encryption.dictionary)
37
41
  out = HEADER.dup
38
42
  offsets = @objects.each_with_index.map { |object, index| write_object(out, index + 1, object) }
39
43
  xref = out.bytesize
40
- id = HexString.new(Digest::MD5.digest(out))
44
+ id = HexString.new(@encryption ? @encryption.file_id : Digest::MD5.digest(out))
41
45
 
42
46
  out << "xref\n0 #{@objects.size + 1}\n0000000000 65535 f \n"
43
47
  offsets.each { |offset| out << format("%010d 00000 n \n", offset) }
44
- out << "trailer\n" << Serializer.dump({ Size: @objects.size + 1, Root: root, Info: info, ID: [id, id] })
48
+ out << "trailer\n" << Serializer.dump(trailer(root, info, id))
45
49
  out << "\nstartxref\n#{xref}\n%%EOF\n"
46
50
  end
47
51
 
48
52
  private
49
53
 
54
+ def trailer(root, info, id)
55
+ trailer = { Size: @objects.size + 1, Root: root, Info: info, ID: [id, id] }
56
+ @encrypt ? trailer.merge(Encrypt: @encrypt) : trailer
57
+ end
58
+
50
59
  def write_object(out, number, object)
51
60
  offset = out.bytesize
61
+ crypt = crypt_for(number)
52
62
  out << "#{number} 0 obj\n"
53
63
  if object.is_a?(Stream)
54
- out << Serializer.dump(object.dictionary) << "\nstream\n" << object.data << "\nendstream"
64
+ 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"
55
67
  else
56
- out << Serializer.dump(object)
68
+ out << Serializer.dump(object, crypt)
57
69
  end
58
70
  out << "\nendobj\n"
59
71
  offset
60
72
  end
73
+
74
+ # The Serializer hook: encrypts a string or stream with this object's key.
75
+ def crypt_for(number)
76
+ return unless @encrypt && number != @encrypt.id
77
+
78
+ ->(bytes) { @encryption.encrypt(bytes, number) }
79
+ end
61
80
  end
62
81
  end
63
82
  end
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- # Document-wide names for fonts, images and graphics states, so each is
4
+ # Document-wide names for fonts, images, graphics states and shadings, so each is
5
5
  # embedded once however many pages use it.
6
6
  class Resources
7
7
  def initialize
8
8
  @fonts = {}
9
9
  @images = {}
10
10
  @states = {}
11
+ @shadings = {}
11
12
  end
12
13
 
13
14
  def font(font)
@@ -22,12 +23,17 @@ module Stationery
22
23
  @states[value.to_f.round(3)] ||= :"GS#{@states.size + 1}"
23
24
  end
24
25
 
26
+ def shading(dictionary)
27
+ @shadings[dictionary] ||= :"Sh#{@shadings.size + 1}"
28
+ end
29
+
25
30
  # Writes every resource once; returns { category => { name => ref } }.
26
31
  def build(writer)
27
32
  {
28
33
  Font: @fonts.to_h { |font, name| [name, font.build(writer)] },
29
34
  XObject: @images.to_h { |image, name| [name, image.build(writer)] },
30
- ExtGState: @states.to_h { |alpha, name| [name, writer.add({ Type: :ExtGState, ca: alpha, CA: alpha })] }
35
+ ExtGState: @states.to_h { |alpha, name| [name, writer.add({ Type: :ExtGState, ca: alpha, CA: alpha })] },
36
+ Shading: @shadings.to_h { |dictionary, name| [name, writer.add(dictionary)] }
31
37
  }
32
38
  end
33
39
  end
@@ -31,7 +31,7 @@ module Stationery
31
31
  when Paragraph then paragraph(block.inlines, **@styles[:p])
32
32
  when Heading then heading(block)
33
33
  when List then list(block)
34
- when Blockquote then @component.box(**@styles[:blockquote]) { render(block.blocks) }
34
+ when Blockquote then @component.box(role: :blockquote, **@styles[:blockquote]) { render(block.blocks) }
35
35
  when CodeBlock then code(block)
36
36
  when Rule then @component.rule(**@styles[:hr])
37
37
  when Table then table(block)
@@ -47,7 +47,7 @@ module Stationery
47
47
  style = @styles[:"h#{heading.level}"]
48
48
  size = style[:size] || (@builder.style.size * style.fetch(:scale, 1))
49
49
  bookmark = heading_bookmark(heading)
50
- paragraph(heading.inlines, **style.except(:scale), size:, **({ bookmark: } if bookmark))
50
+ paragraph(heading.inlines, **style.except(:scale), size:, heading: heading.level, **({ bookmark: } if bookmark))
51
51
  end
52
52
 
53
53
  def heading_bookmark(heading)
@@ -86,7 +86,7 @@ module Stationery
86
86
  return unless source
87
87
 
88
88
  loaded = Images.load(source)
89
- @component.image(loaded, **dimensions(node, loaded))
89
+ @component.image(loaded, alt: node.alt, **dimensions(node, loaded))
90
90
  rescue UnsupportedImage => e
91
91
  skip(node.src, e.message)
92
92
  end
@@ -11,10 +11,13 @@ module Stationery
11
11
 
12
12
  # Returns the destinations by name; unresolved links are dropped.
13
13
  # Runs before resources are written, so slot digits join the font subset.
14
- def self.resolve(pages, warnings:, resources: nil, book: nil) = new(pages, warnings, resources, book).resolve
14
+ def self.resolve(pages, warnings:, resources: nil, book: nil, tagging: nil)
15
+ new(pages, warnings, resources, book, tagging).resolve
16
+ end
15
17
 
16
- def initialize(pages, warnings, resources, book)
18
+ def initialize(pages, warnings, resources, book, tagging = nil)
17
19
  @pages = pages
20
+ @tagging = tagging
18
21
  @warnings = warnings
19
22
  @resources = resources
20
23
  @book = book
@@ -57,19 +60,22 @@ module Stationery
57
60
  font, face = @book.resolve(slot.style)
58
61
  style = slot.style
59
62
  width = font.width_of(label, style.render_size, letter_spacing: style.letter_spacing)
60
- canvas = Canvas.new(page, @resources)
61
- canvas.link(*slot.link, "##{slot.anchor}") if slot.link
62
- canvas.text(label, x: slot.x + slot.width - width, y: slot.baseline, font:,
63
- size: style.render_size, color: style.color,
64
- letter_spacing: style.letter_spacing, opacity: style.opacity,
65
- synthetic_bold: face.synthetic_bold, synthetic_oblique: face.synthetic_oblique)
63
+ canvas = Canvas.new(page, @resources, tagging: @tagging)
64
+ link, number = slot.tags
65
+ canvas.link(*slot.link, "##{slot.anchor}", tag: link) if slot.link
66
+ canvas.tag(number) do
67
+ canvas.text(label, x: slot.x + slot.width - width, y: slot.baseline, font:,
68
+ size: style.render_size, color: style.color,
69
+ letter_spacing: style.letter_spacing, opacity: style.opacity,
70
+ synthetic_bold: face.synthetic_bold, synthetic_oblique: face.synthetic_oblique)
71
+ end
66
72
  end
67
73
 
68
74
  def link_to(link, index)
69
75
  return link unless link.key?(:dest)
70
76
 
71
77
  destination = @destinations[link[:dest]]
72
- return { rect: link[:rect], dest: destination } if destination
78
+ return link.merge!(dest: destination) if destination
73
79
 
74
80
  @warnings << Warnings::UnresolvedLink.new(name: link[:dest], page: index + 1)
75
81
  nil
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module SVG
5
+ # A path sink that measures a shape's bounding box in its own user space,
6
+ # curves by their extrema. Takes the calls Shapes.trace makes on a path.
7
+ class Bounds
8
+ def initialize
9
+ @xs = []
10
+ @ys = []
11
+ end
12
+
13
+ def move_to(x, y) = add(x, y)
14
+ def line_to(x, y) = add(x, y)
15
+ def close = self
16
+
17
+ def curve_to(x1, y1, x2, y2, x, y)
18
+ x0 = @xs.last || 0
19
+ y0 = @ys.last || 0
20
+ (extrema(x0, x1, x2, x) + extrema(y0, y1, y2, y)).each do |at|
21
+ add(cubic(at, x0, x1, x2, x), cubic(at, y0, y1, y2, y))
22
+ end
23
+ add(x, y)
24
+ end
25
+
26
+ def rounded_rect(x, y, w, h, _radius)
27
+ add(x, y)
28
+ add(x + w, y + h)
29
+ end
30
+
31
+ def ellipse(cx, cy, rx, ry)
32
+ add(cx - rx, cy - ry)
33
+ add(cx + rx, cy + ry)
34
+ end
35
+
36
+ # [x, y, width, height], or nil when the shape has no area.
37
+ def box
38
+ return nil if @xs.empty?
39
+
40
+ width = @xs.max - @xs.min
41
+ height = @ys.max - @ys.min
42
+ [@xs.min, @ys.min, width, height] if width.positive? && height.positive?
43
+ end
44
+
45
+ private
46
+
47
+ def add(x, y)
48
+ @xs << x
49
+ @ys << y
50
+ self
51
+ end
52
+
53
+ # Parameters in (0, 1) where the cubic's derivative is zero.
54
+ def extrema(from, first, second, to)
55
+ a = -from + (3 * first) - (3 * second) + to
56
+ b = 2 * (from - (2 * first) + second)
57
+ c = first - from
58
+ roots(a, b, c).select { |t| t.positive? && t < 1 }
59
+ end
60
+
61
+ def roots(a, b, c)
62
+ return b.zero? ? [] : [-c.fdiv(b)] if a.zero?
63
+
64
+ discriminant = (b**2) - (4 * a * c)
65
+ return [] if discriminant.negative?
66
+
67
+ [1, -1].map { |sign| (-b + (sign * Math.sqrt(discriminant))) / (2.0 * a) }
68
+ end
69
+
70
+ def cubic(at, from, first, second, to)
71
+ rest = 1 - at
72
+ ((rest**3) * from) + (3 * (rest**2) * at * first) + (3 * rest * (at**2) * second) + ((at**3) * to)
73
+ end
74
+ end
75
+ end
76
+ end