stationery 0.1.0 → 0.3.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +114 -1
  3. data/README.md +395 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +26 -3
  7. data/lib/stationery/canvas/debug.rb +26 -0
  8. data/lib/stationery/canvas/path.rb +22 -12
  9. data/lib/stationery/canvas/text.rb +10 -6
  10. data/lib/stationery/canvas.rb +38 -8
  11. data/lib/stationery/cli/fonts.rb +85 -0
  12. data/lib/stationery/cli/render.rb +137 -0
  13. data/lib/stationery/cli.rb +74 -0
  14. data/lib/stationery/document.rb +60 -9
  15. data/lib/stationery/elements/lists.rb +58 -0
  16. data/lib/stationery/elements/rich.rb +27 -0
  17. data/lib/stationery/elements.rb +88 -12
  18. data/lib/stationery/errors.rb +13 -1
  19. data/lib/stationery/fonts/bundled.rb +25 -0
  20. data/lib/stationery/fonts/catalog.rb +101 -0
  21. data/lib/stationery/fonts/cff.rb +140 -0
  22. data/lib/stationery/fonts/cff_subset.rb +145 -0
  23. data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
  24. data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
  25. data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
  26. data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
  27. data/lib/stationery/fonts/data/OFL.txt +92 -0
  28. data/lib/stationery/fonts/data/README.md +14 -0
  29. data/lib/stationery/fonts/embedding/base.rb +56 -0
  30. data/lib/stationery/fonts/embedding/cff.rb +35 -0
  31. data/lib/stationery/fonts/embedding/true_type.rb +35 -0
  32. data/lib/stationery/fonts/fallback.rb +58 -0
  33. data/lib/stationery/fonts/family.rb +11 -0
  34. data/lib/stationery/fonts/font.rb +68 -88
  35. data/lib/stationery/fonts/font_book.rb +49 -8
  36. data/lib/stationery/fonts/glyph_run.rb +39 -0
  37. data/lib/stationery/fonts/gpos/class_def.rb +25 -0
  38. data/lib/stationery/fonts/gpos/coverage.rb +20 -0
  39. data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
  40. data/lib/stationery/fonts/gpos.rb +64 -0
  41. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  42. data/lib/stationery/fonts/gsub.rb +65 -0
  43. data/lib/stationery/fonts/installer.rb +121 -0
  44. data/lib/stationery/fonts/kern_table.rb +52 -0
  45. data/lib/stationery/fonts/kerning.rb +17 -0
  46. data/lib/stationery/fonts/ligatures.rb +16 -0
  47. data/lib/stationery/fonts/packs.rb +45 -0
  48. data/lib/stationery/fonts/registry.rb +18 -9
  49. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  50. data/lib/stationery/fonts/to_unicode.rb +36 -0
  51. data/lib/stationery/fonts/true_type.rb +64 -57
  52. data/lib/stationery/fonts/woff.rb +52 -0
  53. data/lib/stationery/html/document.rb +11 -0
  54. data/lib/stationery/html/tokenizer.rb +74 -0
  55. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  56. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  57. data/lib/stationery/html/tree_builder.rb +72 -0
  58. data/lib/stationery/html/whitespace.rb +43 -0
  59. data/lib/stationery/layout/box.rb +102 -16
  60. data/lib/stationery/layout/flow.rb +48 -15
  61. data/lib/stationery/layout/image.rb +1 -0
  62. data/lib/stationery/layout/list_item.rb +89 -0
  63. data/lib/stationery/layout/mark.rb +58 -0
  64. data/lib/stationery/layout/node.rb +26 -2
  65. data/lib/stationery/layout/paginator.rb +35 -6
  66. data/lib/stationery/layout/positioned.rb +3 -1
  67. data/lib/stationery/layout/row.rb +34 -4
  68. data/lib/stationery/layout/svg.rb +48 -0
  69. data/lib/stationery/layout/table/cell.rb +33 -6
  70. data/lib/stationery/layout/table/grid.rb +93 -0
  71. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  72. data/lib/stationery/layout/table/selection.rb +3 -2
  73. data/lib/stationery/layout/table.rb +69 -23
  74. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  75. data/lib/stationery/layout/table_of_contents.rb +43 -0
  76. data/lib/stationery/layout/text.rb +6 -4
  77. data/lib/stationery/layout/wrap.rb +78 -0
  78. data/lib/stationery/list_markers.rb +47 -0
  79. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  80. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  81. data/lib/stationery/markdown/block_parser.rb +141 -0
  82. data/lib/stationery/markdown/document.rb +50 -0
  83. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  84. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  85. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  86. data/lib/stationery/markdown/inline_parser.rb +89 -0
  87. data/lib/stationery/minitest.rb +33 -0
  88. data/lib/stationery/outline.rb +37 -0
  89. data/lib/stationery/page.rb +18 -4
  90. data/lib/stationery/page_templates.rb +33 -10
  91. data/lib/stationery/pdf/assembler.rb +20 -8
  92. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  93. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  94. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  95. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  96. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  97. data/lib/stationery/pdf/outline_writer.rb +72 -0
  98. data/lib/stationery/pdf/serializer.rb +22 -10
  99. data/lib/stationery/pdf/writer.rb +28 -5
  100. data/lib/stationery/preview.rb +64 -0
  101. data/lib/stationery/rails/previews_controller.rb +61 -0
  102. data/lib/stationery/rails.rb +2 -0
  103. data/lib/stationery/railtie.rb +60 -0
  104. data/lib/stationery/regions.rb +67 -0
  105. data/lib/stationery/resources.rb +8 -2
  106. data/lib/stationery/rich/nodes.rb +55 -0
  107. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  108. data/lib/stationery/rich/renderer.rb +121 -0
  109. data/lib/stationery/rich/styles.rb +34 -0
  110. data/lib/stationery/rspec.rb +6 -0
  111. data/lib/stationery/structure.rb +78 -0
  112. data/lib/stationery/svg/arc.rb +91 -0
  113. data/lib/stationery/svg/bounds.rb +76 -0
  114. data/lib/stationery/svg/document.rb +103 -0
  115. data/lib/stationery/svg/gradient.rb +115 -0
  116. data/lib/stationery/svg/painter.rb +70 -0
  117. data/lib/stationery/svg/parser.rb +58 -0
  118. data/lib/stationery/svg/path_data.rb +137 -0
  119. data/lib/stationery/svg/selector.rb +37 -0
  120. data/lib/stationery/svg/shading.rb +34 -0
  121. data/lib/stationery/svg/shapes.rb +54 -0
  122. data/lib/stationery/svg/style.rb +85 -0
  123. data/lib/stationery/svg/stylesheet.rb +47 -0
  124. data/lib/stationery/svg/text.rb +122 -0
  125. data/lib/stationery/svg/transform.rb +54 -0
  126. data/lib/stationery/testing/inspector.rb +98 -0
  127. data/lib/stationery/testing/matchers.rb +123 -0
  128. data/lib/stationery/text/entities/html4.rb +49 -0
  129. data/lib/stationery/text/entities.rb +6 -1
  130. data/lib/stationery/text/line.rb +8 -2
  131. data/lib/stationery/text/paragraph.rb +33 -11
  132. data/lib/stationery/text/runs_builder.rb +9 -1
  133. data/lib/stationery/text/style.rb +4 -2
  134. data/lib/stationery/text/wrapper.rb +4 -2
  135. data/lib/stationery/version.rb +1 -1
  136. data/lib/stationery/warnings.rb +65 -0
  137. data/lib/stationery.rb +67 -0
  138. metadata +101 -4
@@ -4,31 +4,39 @@ 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: {})
7
+ def initialize(pages:, resources:, info: {}, outline: [], encryption: nil)
8
8
  @pages = pages
9
9
  @resources = resources
10
10
  @info = info
11
+ @outline = outline
12
+ @encryption = encryption
11
13
  end
12
14
 
13
15
  def render
14
- writer = Writer.new
16
+ writer = Writer.new(encryption: @encryption)
15
17
  tree = writer.reserve
16
18
  refs = @resources.build(writer)
17
- kids = @pages.map { |page| write_page(writer, page, tree, refs) }
19
+ kids = @kids = @pages.map { writer.reserve }
20
+ @pages.each_with_index { |page, index| write_page(writer, page, kids[index], tree, refs) }
18
21
  writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
19
- root = writer.add({ Type: :Catalog, Pages: tree })
22
+ root = writer.add(catalog(tree, OutlineWriter.new(writer, @outline, kids).write))
20
23
  writer.render(root:, info: writer.add(info_dictionary))
21
24
  end
22
25
 
23
26
  private
24
27
 
25
- def write_page(writer, page, tree, refs)
28
+ def catalog(tree, outlines)
29
+ catalog = { Type: :Catalog, Pages: tree }
30
+ outlines ? catalog.merge(Outlines: outlines, PageMode: :UseOutlines) : catalog
31
+ end
32
+
33
+ def write_page(writer, page, ref, tree, refs)
26
34
  dictionary = {
27
35
  Type: :Page, Parent: tree, MediaBox: [0, 0, *page.size],
28
36
  Contents: writer.add(Stream.new(page.content)), Resources: page_resources(page, refs)
29
37
  }
30
38
  dictionary[:Annots] = page.annotations.map { |link| writer.add(annotation(link)) } if page.annotations.any?
31
- writer.add(dictionary)
39
+ writer.set(ref, dictionary)
32
40
  end
33
41
 
34
42
  def page_resources(page, refs)
@@ -38,8 +46,12 @@ module Stationery
38
46
  end
39
47
 
40
48
  def annotation(link)
41
- { Type: :Annot, Subtype: :Link, Rect: link[:rect], Border: [0, 0, 0],
42
- A: { Type: :Action, S: :URI, URI: link[:url].b } }
49
+ target = if (dest = link[:dest])
50
+ { Dest: [@kids.fetch(dest.page), :XYZ, nil, dest.top, nil] }
51
+ else
52
+ { A: { Type: :Action, S: :URI, URI: link[:url].b } }
53
+ end
54
+ { Type: :Annot, Subtype: :Link, Rect: link[:rect], Border: [0, 0, 0], **target }
43
55
  end
44
56
 
45
57
  def info_dictionary
@@ -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
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module PDF
5
+ # Writes outline items as the /Outlines tree. Each item nests under the
6
+ # nearest earlier item of a shallower level, so a level jump (1 → 3) or a
7
+ # leading level-2 item still lands somewhere sensible.
8
+ class OutlineWriter
9
+ Node = Data.define(:item, :ref, :children) do
10
+ def open? = item.nil? || item.open
11
+ end
12
+
13
+ def initialize(writer, items, page_refs)
14
+ @writer = writer
15
+ @items = items
16
+ @page_refs = page_refs
17
+ end
18
+
19
+ # The /Outlines root reference, or nil when there is nothing to write.
20
+ def write
21
+ return if @items.empty?
22
+
23
+ root = tree
24
+ write_children(root)
25
+ @writer.set(root.ref, { Type: :Outlines, First: root.children.first.ref, Last: root.children.last.ref,
26
+ Count: visible(root) })
27
+ end
28
+
29
+ private
30
+
31
+ def tree
32
+ root = Node.new(nil, @writer.reserve, [])
33
+ stack = []
34
+ @items.each do |item|
35
+ stack.pop while stack.any? && stack.last.item.level >= item.level
36
+ node = Node.new(item, @writer.reserve, [])
37
+ (stack.last || root).children << node
38
+ stack << node
39
+ end
40
+ root
41
+ end
42
+
43
+ def write_children(parent)
44
+ parent.children.each_with_index do |node, index|
45
+ write_children(node)
46
+ siblings = { Prev: index.positive? ? parent.children[index - 1].ref : nil,
47
+ Next: parent.children[index + 1]&.ref }
48
+ @writer.set(node.ref, dictionary(node, parent).merge(siblings).compact)
49
+ end
50
+ end
51
+
52
+ def dictionary(node, parent)
53
+ dest = node.item.dest
54
+ {
55
+ Title: TextString.new(node.item.title), Parent: parent.ref,
56
+ First: node.children.first&.ref, Last: node.children.last&.ref, Count: count(node),
57
+ Dest: [@page_refs.fetch(dest.page), :XYZ, nil, dest.top, nil]
58
+ }
59
+ end
60
+
61
+ # Items shown when `node` is expanded: its children plus the visible
62
+ # descendants of each open child.
63
+ def visible(node) = node.children.sum { |child| 1 + (child.open? ? visible(child) : 0) }
64
+
65
+ def count(node)
66
+ return if node.children.empty?
67
+
68
+ node.open? ? visible(node) : -visible(node)
69
+ end
70
+ end
71
+ end
72
+ 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,18 +41,27 @@ 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
 
44
- str = format("%.4f", value).sub(/\.?0+\z/, "")
52
+ str = format("%.4f", value)
53
+ last = str.bytesize
54
+ last -= 1 while str.getbyte(last - 1) == 48 # "0"
55
+ last -= 1 if str.getbyte(last - 1) == 46 # "."
56
+ str = str.byteslice(0, last)
45
57
  str == "-0" ? "0" : str
46
58
  end
47
59
 
48
- def text(value)
60
+ def text(value, crypt = nil)
49
61
  value = value.to_s
50
- return literal(value) if value.ascii_only?
62
+ return dump(value, crypt) if value.ascii_only?
51
63
 
52
- dump(HexString.new(UTF16_BOM + value.encode(Encoding::UTF_16BE).b))
64
+ hex(UTF16_BOM + value.encode(Encoding::UTF_16BE).b, crypt)
53
65
  end
54
66
  end
55
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
@@ -30,30 +33,50 @@ module Stationery
30
33
  end
31
34
 
32
35
  def render(root:, info:)
36
+ if (missing = @objects.index(nil))
37
+ raise Error, "object #{missing + 1} reserved but never set"
38
+ end
39
+
40
+ @encrypt = @encryption && add(@encryption.dictionary)
33
41
  out = HEADER.dup
34
42
  offsets = @objects.each_with_index.map { |object, index| write_object(out, index + 1, object) }
35
43
  xref = out.bytesize
36
- id = HexString.new(Digest::MD5.digest(out))
44
+ id = HexString.new(@encryption ? @encryption.file_id : Digest::MD5.digest(out))
37
45
 
38
46
  out << "xref\n0 #{@objects.size + 1}\n0000000000 65535 f \n"
39
47
  offsets.each { |offset| out << format("%010d 00000 n \n", offset) }
40
- 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))
41
49
  out << "\nstartxref\n#{xref}\n%%EOF\n"
42
50
  end
43
51
 
44
52
  private
45
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
+
46
59
  def write_object(out, number, object)
47
60
  offset = out.bytesize
61
+ crypt = crypt_for(number)
48
62
  out << "#{number} 0 obj\n"
49
63
  if object.is_a?(Stream)
50
- 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"
51
67
  else
52
- out << Serializer.dump(object)
68
+ out << Serializer.dump(object, crypt)
53
69
  end
54
70
  out << "\nendobj\n"
55
71
  offset
56
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
57
80
  end
58
81
  end
59
82
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stationery"
4
+
5
+ module Stationery
6
+ # Browsable sample documents, like ActionMailer previews. Each public method
7
+ # returns one document:
8
+ #
9
+ # # spec/pdfs/previews/invoice_pdf_preview.rb
10
+ # class InvoicePdfPreview < Stationery::Preview
11
+ # def paid = InvoicePdf.new(Invoice.first)
12
+ # def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id")))
13
+ # end
14
+ class Preview
15
+ REGISTRY_LOCK = Mutex.new
16
+
17
+ class << self
18
+ def inherited(subclass)
19
+ super
20
+ REGISTRY_LOCK.synchronize { Preview.registry << subclass }
21
+ end
22
+
23
+ # Named subclasses with at least one preview; a name defined again (a
24
+ # reloaded file) keeps its latest class.
25
+ def all
26
+ classes = REGISTRY_LOCK.synchronize { Preview.registry.dup }
27
+ classes.select { it.name && it.pdfs.any? }.reverse.uniq(&:name).sort_by(&:preview_name)
28
+ end
29
+
30
+ # `load`, not `require`: edited previews show up without a restart.
31
+ def load(paths)
32
+ paths.each { |path| Dir["#{path}/**/*_preview.rb"].each { |file| Kernel.load(file) } }
33
+ end
34
+
35
+ # "invoice_pdf/paid" => [InvoicePdfPreview, "paid"]
36
+ def find(path)
37
+ name, _, pdf = path.to_s.rpartition("/")
38
+ klass = all.find { it.preview_name == name }
39
+ [klass, pdf] if klass&.pdfs&.include?(pdf)
40
+ end
41
+
42
+ def preview_name = underscore(name.delete_suffix("Preview"))
43
+ def pdfs = public_instance_methods(false).sort.map(&:to_s)
44
+
45
+ protected
46
+
47
+ def registry = (@registry ||= []) # rubocop:disable ThreadSafety/ClassInstanceVariable
48
+
49
+ private
50
+
51
+ def underscore(name)
52
+ name.gsub("::", "/").gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
53
+ end
54
+ end
55
+
56
+ def render(name, params = {})
57
+ method = public_method(name)
58
+ document = method.arity.zero? ? method.call : method.call(params)
59
+ return document if document.respond_to?(:to_pdf)
60
+
61
+ raise Error, "#{self.class}##{name} must return a Stationery::Document (got #{document.class})"
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "stationery/preview"
5
+
6
+ module Stationery
7
+ # Serves Stationery::Preview classes at /rails/stationery/previews. Loaded
8
+ # by the Railtie when config.stationery.show_previews is on.
9
+ class PreviewsController < ActionController::Base
10
+ before_action :require_previews
11
+ before_action :load_previews
12
+
13
+ def index
14
+ paths = Preview.all.flat_map { |klass| klass.pdfs.map { "#{klass.preview_name}/#{it}" } }
15
+ render html: page(paths).html_safe
16
+ end
17
+
18
+ def show
19
+ klass, pdf = Preview.find(params[:path])
20
+ return head(:not_found) unless klass
21
+
22
+ document = klass.new.render(pdf, request.query_parameters.except("debug"))
23
+ send_data document.to_pdf(**debug_option(document)), type: "application/pdf", disposition: "inline"
24
+ end
25
+
26
+ private
27
+
28
+ def config = ::Rails.application.config.stationery
29
+
30
+ def require_previews
31
+ head :not_found unless config.show_previews
32
+ end
33
+
34
+ def load_previews
35
+ Preview.load(Railtie.preview_paths(::Rails.root, config.preview_paths))
36
+ end
37
+
38
+ def debug_option(document)
39
+ debug = params[:debug].present? && document.method(:to_pdf).parameters.include?(%i[key debug])
40
+ debug ? { debug: true } : {}
41
+ end
42
+
43
+ def page(paths)
44
+ items = paths.map do |path|
45
+ href = ERB::Util.html_escape("#{request.path.chomp("/")}/#{path}")
46
+ %(<li><a href="#{href}">#{ERB::Util.html_escape(path)}</a> <a href="#{href}?debug=1">debug</a></li>)
47
+ end
48
+ <<~HTML
49
+ <!doctype html>
50
+ <html>
51
+ <head><meta charset="utf-8"><title>PDF previews</title></head>
52
+ <body style="font-family: system-ui, sans-serif; margin: 2rem">
53
+ <h1>PDF previews</h1>
54
+ <ul>#{items.join("\n")}</ul>
55
+ #{"<p>No previews found in #{ERB::Util.html_escape(config.preview_paths.join(", "))}.</p>" if items.empty?}
56
+ </body>
57
+ </html>
58
+ HTML
59
+ end
60
+ end
61
+ end
@@ -18,3 +18,5 @@ end
18
18
  if defined?(ActiveSupport) && ActiveSupport.respond_to?(:on_load)
19
19
  ActiveSupport.on_load(:action_controller) { include Stationery::Rails }
20
20
  end
21
+
22
+ require_relative "railtie" if defined?(Rails::Railtie)