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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -1
- data/README.md +47 -10
- data/lib/stationery/builder.rb +2 -1
- data/lib/stationery/canvas/text.rb +5 -4
- data/lib/stationery/canvas.rb +13 -0
- data/lib/stationery/document.rb +9 -2
- data/lib/stationery/elements.rb +1 -1
- data/lib/stationery/fonts/font.rb +36 -20
- data/lib/stationery/fonts/font_book.rb +3 -0
- data/lib/stationery/fonts/glyph_run.rb +4 -3
- data/lib/stationery/fonts/gpos.rb +11 -9
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -0
- data/lib/stationery/fonts/ligatures.rb +16 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- data/lib/stationery/fonts/to_unicode.rb +1 -1
- data/lib/stationery/fonts/true_type.rb +36 -12
- data/lib/stationery/fonts/woff.rb +52 -0
- data/lib/stationery/layout/box.rb +23 -6
- data/lib/stationery/layout/row.rb +1 -1
- data/lib/stationery/layout/svg.rb +5 -2
- data/lib/stationery/layout/text.rb +2 -1
- data/lib/stationery/pdf/assembler.rb +3 -2
- data/lib/stationery/pdf/encryption/aes.rb +37 -0
- data/lib/stationery/pdf/encryption/rc4.rb +33 -0
- data/lib/stationery/pdf/encryption/revision4.rb +48 -0
- data/lib/stationery/pdf/encryption/revision6.rb +56 -0
- data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
- data/lib/stationery/pdf/serializer.rb +17 -9
- data/lib/stationery/pdf/writer.rb +24 -5
- data/lib/stationery/resources.rb +8 -2
- data/lib/stationery/svg/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +48 -18
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +24 -4
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/style.rb +37 -20
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +11 -0
- data/lib/stationery/text/paragraph.rb +1 -0
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +2 -1
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery.rb +16 -0
- 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)
|
|
7
|
-
#
|
|
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
|
-
"
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
62
|
+
return dump(value, crypt) if value.ascii_only?
|
|
55
63
|
|
|
56
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|