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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -1
- data/README.md +143 -12
- data/lib/stationery/builder.rb +2 -1
- data/lib/stationery/canvas/debug.rb +2 -1
- data/lib/stationery/canvas/marking.rb +124 -0
- data/lib/stationery/canvas/text.rb +5 -4
- data/lib/stationery/canvas.rb +37 -8
- data/lib/stationery/document.rb +35 -10
- data/lib/stationery/elements/forms.rb +55 -0
- data/lib/stationery/elements/lists.rb +15 -5
- data/lib/stationery/elements.rb +10 -7
- 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/forms/acro_form.rb +115 -0
- data/lib/stationery/forms/appearance.rb +119 -0
- data/lib/stationery/forms/field.rb +122 -0
- data/lib/stationery/forms/metrics.rb +49 -0
- data/lib/stationery/layout/box.rb +34 -11
- data/lib/stationery/layout/field.rb +51 -0
- data/lib/stationery/layout/flow.rb +17 -9
- data/lib/stationery/layout/image.rb +4 -2
- data/lib/stationery/layout/list_item.rb +15 -6
- data/lib/stationery/layout/node.rb +2 -0
- data/lib/stationery/layout/paginator.rb +3 -2
- data/lib/stationery/layout/row.rb +1 -1
- data/lib/stationery/layout/svg.rb +9 -2
- data/lib/stationery/layout/table/cell.rb +10 -2
- data/lib/stationery/layout/table.rb +42 -10
- data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
- data/lib/stationery/layout/table_of_contents.rb +1 -1
- data/lib/stationery/layout/text.rb +6 -4
- data/lib/stationery/minitest.rb +2 -0
- data/lib/stationery/page.rb +5 -2
- data/lib/stationery/page_templates.rb +7 -4
- data/lib/stationery/pdf/assembler.rb +38 -5
- 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/rich/renderer.rb +3 -3
- data/lib/stationery/structure.rb +15 -9
- 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/tagging/element.rb +74 -0
- data/lib/stationery/tagging/tree.rb +43 -0
- data/lib/stationery/tagging/writer.rb +81 -0
- data/lib/stationery/testing/inspector.rb +15 -0
- data/lib/stationery/testing/marked_text.rb +60 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/testing/structure_reader.rb +71 -0
- data/lib/stationery/text/paragraph.rb +28 -12
- 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/warnings.rb +8 -0
- data/lib/stationery.rb +26 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/stationery/resources.rb
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
|
-
# Document-wide names for fonts, images
|
|
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
|
data/lib/stationery/structure.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
62
|
-
canvas.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|