stationery 0.2.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/README.md +47 -10
  4. data/lib/stationery/builder.rb +2 -1
  5. data/lib/stationery/canvas/text.rb +5 -4
  6. data/lib/stationery/canvas.rb +13 -0
  7. data/lib/stationery/document.rb +9 -2
  8. data/lib/stationery/elements.rb +1 -1
  9. data/lib/stationery/fonts/font.rb +36 -20
  10. data/lib/stationery/fonts/font_book.rb +3 -0
  11. data/lib/stationery/fonts/glyph_run.rb +4 -3
  12. data/lib/stationery/fonts/gpos.rb +11 -9
  13. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  14. data/lib/stationery/fonts/gsub.rb +65 -0
  15. data/lib/stationery/fonts/ligatures.rb +16 -0
  16. data/lib/stationery/fonts/registry.rb +18 -9
  17. data/lib/stationery/fonts/to_unicode.rb +1 -1
  18. data/lib/stationery/fonts/true_type.rb +36 -12
  19. data/lib/stationery/fonts/woff.rb +52 -0
  20. data/lib/stationery/layout/box.rb +23 -6
  21. data/lib/stationery/layout/row.rb +1 -1
  22. data/lib/stationery/layout/svg.rb +5 -2
  23. data/lib/stationery/layout/text.rb +2 -1
  24. data/lib/stationery/pdf/assembler.rb +3 -2
  25. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  26. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  27. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  28. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  29. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  30. data/lib/stationery/pdf/serializer.rb +17 -9
  31. data/lib/stationery/pdf/writer.rb +24 -5
  32. data/lib/stationery/resources.rb +8 -2
  33. data/lib/stationery/svg/bounds.rb +76 -0
  34. data/lib/stationery/svg/document.rb +48 -18
  35. data/lib/stationery/svg/gradient.rb +115 -0
  36. data/lib/stationery/svg/painter.rb +70 -0
  37. data/lib/stationery/svg/parser.rb +24 -4
  38. data/lib/stationery/svg/selector.rb +37 -0
  39. data/lib/stationery/svg/shading.rb +34 -0
  40. data/lib/stationery/svg/style.rb +37 -20
  41. data/lib/stationery/svg/stylesheet.rb +47 -0
  42. data/lib/stationery/svg/text.rb +122 -0
  43. data/lib/stationery/svg/transform.rb +11 -0
  44. data/lib/stationery/text/paragraph.rb +1 -0
  45. data/lib/stationery/text/style.rb +4 -2
  46. data/lib/stationery/text/wrapper.rb +2 -1
  47. data/lib/stationery/version.rb +1 -1
  48. data/lib/stationery.rb +16 -0
  49. metadata +17 -1
@@ -3,9 +3,9 @@
3
3
  module Stationery
4
4
  module Fonts
5
5
  # Reads the sfnt tables of a static font needed to measure text and to
6
- # embed it: TrueType outlines (.ttf) or CFF outlines (.otf). Variable CFF2
7
- # fonts, font collections and web font wrappers are rejected with a named
8
- # reason.
6
+ # embed it: TrueType outlines (.ttf) or CFF outlines (.otf), alone or as one
7
+ # face of a collection (.ttc), or unwrapped from a WOFF web font. Variable
8
+ # CFF2 fonts and WOFF2 are rejected with a named reason.
9
9
  class TrueType
10
10
  include SfntMetrics
11
11
 
@@ -13,10 +13,9 @@ module Stationery
13
13
  OUTLINE_TABLES = { "OTTO" => ["CFF "] }.freeze
14
14
  GLYF_TABLES = %w[loca glyf].freeze
15
15
  SFNT_VERSIONS = ["\x00\x01\x00\x00".b, "true", "OTTO"].freeze
16
+ COLLECTION = "ttcf"
16
17
  SIGNATURES = {
17
- "ttcf" => "TrueType collections (.ttc) are not supported, use a single TrueType (.ttf) font",
18
- "wOFF" => "WOFF web fonts are not supported, use the TrueType (.ttf) file",
19
- "wOF2" => "WOFF2 web fonts are not supported, use the TrueType (.ttf) file"
18
+ "wOF2" => "WOFF2 needs Brotli; convert to .ttf or .woff"
20
19
  }.freeze
21
20
 
22
21
  attr_reader :data, :tables, :units_per_em, :bbox, :ascender, :descender, :line_gap, :num_glyphs,
@@ -24,9 +23,12 @@ module Stationery
24
23
  :strikeout_position, :strikeout_size, :cap_height, :x_height, :weight, :postscript_name
25
24
 
26
25
  # A subset embedded in a PDF carries no cmap (the PDF maps glyphs itself),
27
- # so reading one back passes `cmap: false`.
28
- def initialize(data, cmap: true)
26
+ # so reading one back passes `cmap: false`. `index:` picks a face of a
27
+ # collection, face 0 by default.
28
+ def initialize(data, cmap: true, index: nil)
29
29
  @data = data.b
30
+ @data = WOFF.unpack(@data) if @data.start_with?(WOFF::SIGNATURE)
31
+ @sfnt = face_offset(index || 0)
30
32
  check_signature
31
33
  read_table_directory(cmap)
32
34
  parse_head
@@ -40,6 +42,12 @@ module Stationery
40
42
  @postscript_name = NameTable.postscript_name(self)
41
43
  end
42
44
 
45
+ def self.collection?(data) = data.byteslice(0, 4) == COLLECTION
46
+
47
+ def self.faces(data)
48
+ collection?(data) ? data.byteslice(8, 4).unpack1("N") : 1
49
+ end
50
+
43
51
  def inspect = "#<#{self.class} #{@postscript_name} glyphs=#{@num_glyphs}>"
44
52
 
45
53
  def glyph_id(codepoint)
@@ -68,6 +76,11 @@ module Stationery
68
76
  @kerning ||= Kerning.for(self)
69
77
  end
70
78
 
79
+ # Standard ligatures, read on first use and shared like #kerning.
80
+ def ligatures
81
+ @ligatures ||= Ligatures.for(self)
82
+ end
83
+
71
84
  def cff?
72
85
  @tables.key?("CFF ")
73
86
  end
@@ -106,8 +119,19 @@ module Stationery
106
119
 
107
120
  private
108
121
 
122
+ # Table offsets in a collection are already absolute, so a face is read
123
+ # from the shared data at its own table directory.
124
+ def face_offset(index)
125
+ count = self.class.faces(@data)
126
+ unless index.between?(0, count - 1)
127
+ raise ArgumentError, "font face #{index} is out of range (numFonts #{count})"
128
+ end
129
+
130
+ self.class.collection?(@data) ? u32(12 + (index * 4)) : 0
131
+ end
132
+
109
133
  def check_signature
110
- signature = @data.byteslice(0, 4)
134
+ signature = @data.byteslice(@sfnt, 4)
111
135
  return if SFNT_VERSIONS.include?(signature)
112
136
 
113
137
  raise UnsupportedFont, SIGNATURES.fetch(signature, "not a TrueType font")
@@ -115,13 +139,13 @@ module Stationery
115
139
 
116
140
  def read_table_directory(cmap)
117
141
  @tables = {}
118
- u16(4).times do |i|
119
- record = 12 + (i * 16)
142
+ u16(@sfnt + 4).times do |i|
143
+ record = @sfnt + 12 + (i * 16)
120
144
  @tables[@data.byteslice(record, 4)] = [u32(record + 8), u32(record + 12)]
121
145
  end
122
146
  raise UnsupportedFont, "variable CFF2 fonts are not supported" if @tables.key?("CFF2")
123
147
 
124
- required = REQUIRED_TABLES + OUTLINE_TABLES.fetch(@data.byteslice(0, 4), GLYF_TABLES)
148
+ required = REQUIRED_TABLES + OUTLINE_TABLES.fetch(@data.byteslice(@sfnt, 4), GLYF_TABLES)
125
149
  required += ["cmap"] if cmap
126
150
  missing = required.reject { |tag| @tables.key?(tag) }
127
151
  raise UnsupportedFont, "font is missing the #{missing.map(&:strip).join(", ")} table" if missing.any?
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Stationery
6
+ module Fonts
7
+ # Unwraps a WOFF 1.0 web font into the sfnt it was made from: each table is
8
+ # inflated (or copied when stored) and laid out behind a fresh table
9
+ # directory, 4-byte aligned in directory order. Metadata and private data
10
+ # blocks are dropped.
11
+ module WOFF
12
+ SIGNATURE = "wOFF"
13
+ HEADER = 44
14
+ ENTRY = 20
15
+
16
+ module_function
17
+
18
+ def unpack(data)
19
+ data = data.b
20
+ flavor = data.byteslice(4, 4)
21
+ tables = Array.new(data.byteslice(12, 2).unpack1("n")) do |i|
22
+ tag, offset, compressed, length, checksum = data.byteslice(HEADER + (i * ENTRY), ENTRY).unpack("a4NNNN")
23
+ [tag, checksum, table(tag, data.byteslice(offset, compressed), length)]
24
+ end
25
+ assemble(flavor, tables)
26
+ end
27
+
28
+ def table(tag, bytes, length)
29
+ return bytes if bytes.bytesize >= length
30
+
31
+ Zlib::Inflate.inflate(bytes)
32
+ rescue Zlib::Error
33
+ raise UnsupportedFont, "corrupt WOFF table #{tag}"
34
+ end
35
+
36
+ def assemble(flavor, tables)
37
+ entry_selector = Math.log2(tables.size).floor
38
+ search_range = (2**entry_selector) * 16
39
+ directory = [flavor, tables.size, search_range, entry_selector, (tables.size * 16) - search_range]
40
+ .pack("a4nnnn")
41
+ body = String.new(encoding: Encoding::BINARY)
42
+ offset = 12 + (tables.size * 16)
43
+
44
+ tables.each do |tag, checksum, bytes|
45
+ directory << [tag, checksum, offset + body.bytesize, bytes.bytesize].pack("a4NNN")
46
+ body << bytes << ("\0".b * (-bytes.bytesize % 4))
47
+ end
48
+ directory << body
49
+ end
50
+ end
51
+ end
52
+ end
@@ -8,16 +8,21 @@ module Stationery
8
8
  # `:avoid` never). A fragment's cut sides are `open`: `decoration: :slice`
9
9
  # drops their padding and border, `:clone` keeps the padding. A
10
10
  # fixed-height box can truncate or shrink text that does not fit
11
- # (`overflow:`) and never splits.
11
+ # (`overflow:`) and never splits. `min_height:` is a floor that does split:
12
+ # the first fragment keeps as much of it as the page holds and the next
13
+ # one carries only what is left of it.
12
14
  class Box < Node
13
15
  BORDER = { width: 1, color: "#000000", sides: %i[top right bottom left] }.freeze
14
16
 
15
17
  attr_reader :content, :width_spec
16
18
 
17
19
  def initialize(content = Flow.new, padding: 0, background: nil, border: nil, radius: 0, width: nil,
18
- height: nil, overflow: :visible, valign: :top, opacity: nil, link: nil, outset: 0,
20
+ height: nil, min_height: nil, overflow: :visible, valign: :top, opacity: nil, link: nil, outset: 0,
19
21
  open: [], decoration: :slice)
22
+ raise ArgumentError, "pass height: or min_height:, not both" if height && min_height
23
+
20
24
  super()
25
+ @min_height = min_height
21
26
  @open = open
22
27
  @decoration = decoration
23
28
  @link = link
@@ -58,17 +63,20 @@ module Stationery
58
63
  available = height - vertical(cut)
59
64
  head, tail = @content.split(inner_width(width), available, fresh:)
60
65
  return [nil, self] unless head
61
- return [with_content(head), nil] unless tail
66
+ return [with_content(head), nil] unless tail || @min_height.to_f > height + EPSILON
62
67
 
63
- [with_content(head, cut).tap { |part| part.keep_with_next = nil }, with_content(tail, @open | [:top])]
68
+ fragments(width, height, head, tail || Flow.new, cut)
64
69
  end
65
70
 
71
+ # An empty continuation: open at the top, without a floor.
72
+ def continued = fragment(Flow.new, @open | [:top], nil)
73
+
66
74
  # A copy holding other content and open sides, every option kept.
67
75
  def with_content(content, open = @open) = dup.reopen(content, open)
68
76
  def with_open(*sides) = with_content(@content, @open | sides)
69
77
 
70
78
  def measure(width)
71
- @height || memoize_by_width(width) { @content.measure(inner_width(width)) + vertical }
79
+ @height || memoize_by_width(width) { [@content.measure(inner_width(width)) + vertical, @min_height || 0].max }
72
80
  end
73
81
 
74
82
  def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, **)
@@ -82,14 +90,23 @@ module Stationery
82
90
 
83
91
  protected
84
92
 
85
- def reopen(content, open)
93
+ def reopen(content, open, min_height = @min_height)
86
94
  @content = content
87
95
  @open = open
96
+ @min_height = min_height
88
97
  self
89
98
  end
90
99
 
91
100
  private
92
101
 
102
+ def fragment(content, open, min_height) = dup.reopen(content, open, min_height)
103
+
104
+ def fragments(width, height, head, tail, cut)
105
+ first = fragment(head, cut, @min_height && [@min_height, height].min).tap { |part| part.keep_with_next = nil }
106
+ rest = @min_height.to_f - first.measure(width)
107
+ [first, fragment(tail, @open | [:top], rest.positive? ? rest : nil)]
108
+ end
109
+
93
110
  def insets(open = @open)
94
111
  border = @border ? @border[:width] : 0
95
112
  sides = @border ? @border[:sides] - open : []
@@ -36,7 +36,7 @@ module Stationery
36
36
  return [nil, self] if parts.any? { |head, _| head.nil? }
37
37
 
38
38
  heads = parts.map { |head, _| head.with_open(:bottom) }
39
- tails = parts.zip(@columns).map { |(_, tail), column| tail || column.with_content(Flow.new).with_open(:top) }
39
+ tails = parts.zip(@columns).map { |(_, tail), column| tail || column.continued }
40
40
  [with_columns(heads).tap { |row| row.keep_with_next = nil }, with_columns(tails)]
41
41
  end
42
42
 
@@ -5,9 +5,11 @@ module Stationery
5
5
  # A vector drawing at a fixed size: `width:` and/or `height:` (the other
6
6
  # follows the viewBox's aspect ratio), never wider than the space given.
7
7
  class Svg < Node
8
- def initialize(document, width: nil, height: nil, color: "#000000")
8
+ # `context` gives SVG text its font book and default family.
9
+ def initialize(document, width: nil, height: nil, color: "#000000", context: nil)
9
10
  super()
10
11
  @document = document
12
+ @context = context
11
13
  @width = width
12
14
  @height = height
13
15
  @color = color
@@ -26,7 +28,8 @@ module Stationery
26
28
 
27
29
  def paint(canvas, x, y, width, _height = nil, **)
28
30
  w, h = size(width)
29
- @document.draw(canvas, x:, y:, width: w, height: h, color: @color)
31
+ @document.draw(canvas, x:, y:, width: w, height: h, color: @color, book: @context&.book,
32
+ family: @context&.style&.family)
30
33
  canvas.debug_rect(x, y, w, h, :image)
31
34
  end
32
35
 
@@ -42,7 +42,8 @@ module Stationery
42
42
  style = run.style
43
43
  font = @context.book.resolve(style).first
44
44
  run.text.split(/[ \t\n]+/).map do |word|
45
- font.width_of(word, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning)
45
+ font.width_of(word, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
46
+ ligatures: style.ligatures)
46
47
  end
47
48
  end.max || 0
48
49
  end
@@ -4,15 +4,16 @@ 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
+ def initialize(pages:, resources:, info: {}, outline: [], encryption: nil)
8
8
  @pages = pages
9
9
  @resources = resources
10
10
  @info = info
11
11
  @outline = outline
12
+ @encryption = encryption
12
13
  end
13
14
 
14
15
  def render
15
- writer = Writer.new
16
+ writer = Writer.new(encryption: @encryption)
16
17
  tree = writer.reserve
17
18
  refs = @resources.build(writer)
18
19
  kids = @kids = @pages.map { writer.reserve }
@@ -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