stationery 0.9.0 → 0.10.1
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 +17 -0
- data/README.md +146 -35
- data/lib/stationery/{svg → css}/selector.rb +1 -1
- data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
- data/lib/stationery/css.rb +20 -0
- data/lib/stationery/document.rb +21 -9
- data/lib/stationery/elements/rich.rb +10 -2
- data/lib/stationery/elements.rb +9 -4
- data/lib/stationery/errors.rb +4 -0
- data/lib/stationery/fonts/fallback.rb +1 -1
- data/lib/stationery/fonts/font.rb +5 -2
- data/lib/stationery/fonts/glyph_run.rb +35 -5
- data/lib/stationery/html/css.rb +276 -0
- data/lib/stationery/html/document.rb +5 -3
- data/lib/stationery/html/tokenizer.rb +15 -4
- data/lib/stationery/html/tree_builder/blocks.rb +90 -40
- data/lib/stationery/html/tree_builder/collector.rb +9 -3
- data/lib/stationery/html/tree_builder.rb +11 -1
- data/lib/stationery/layout/flow.rb +29 -0
- data/lib/stationery/layout/image.rb +12 -6
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +4 -0
- data/lib/stationery/layout/text.rb +47 -10
- data/lib/stationery/pdf/assembler.rb +14 -4
- data/lib/stationery/pdf/signature/cms.rb +21 -13
- data/lib/stationery/pdf/signature/timestamp.rb +120 -0
- data/lib/stationery/pdf/signature.rb +16 -9
- data/lib/stationery/pdf/writer.rb +73 -19
- data/lib/stationery/rich/nodes.rb +37 -11
- data/lib/stationery/rich/renderer/boxes.rb +109 -0
- data/lib/stationery/rich/renderer/indents.rb +1 -0
- data/lib/stationery/rich/renderer/inlines.rb +9 -2
- data/lib/stationery/rich/renderer.rb +32 -15
- data/lib/stationery/svg/style.rb +4 -6
- data/lib/stationery/testing/inspector.rb +49 -6
- data/lib/stationery/testing/marked_text.rb +34 -4
- data/lib/stationery/text/breaks.rb +123 -0
- data/lib/stationery/text/paragraph.rb +8 -0
- data/lib/stationery/text/runs_builder.rb +4 -0
- data/lib/stationery/text/wrapper.rb +57 -8
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +9 -0
- data/lib/stationery.rb +3 -2
- metadata +8 -3
|
@@ -5,15 +5,26 @@ require "digest/md5"
|
|
|
5
5
|
module Stationery
|
|
6
6
|
module PDF
|
|
7
7
|
# Collects numbered objects and writes them as a PDF file with a classic
|
|
8
|
-
# cross-reference table.
|
|
8
|
+
# cross-reference table. `render` returns the file as a String with the
|
|
9
|
+
# objects in numbered order. With a `sink:` (anything answering
|
|
10
|
+
# `call(bytes)`) the file goes there in pieces instead: `flush` writes every
|
|
11
|
+
# object set so far and drops it, so objects leave in the order they were
|
|
12
|
+
# flushed and the first bytes leave before the last page is assembled.
|
|
9
13
|
class Writer
|
|
10
14
|
HEADER = "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".b
|
|
15
|
+
WRITTEN = Object.new.freeze
|
|
11
16
|
|
|
12
17
|
# `encryption` (an Encryption::StandardSecurity) encrypts every string
|
|
13
18
|
# and stream except those of its own /Encrypt dictionary.
|
|
14
|
-
def initialize(encryption: nil)
|
|
19
|
+
def initialize(encryption: nil, sink: nil)
|
|
15
20
|
@objects = []
|
|
21
|
+
@offsets = []
|
|
16
22
|
@encryption = encryption
|
|
23
|
+
@sink = sink
|
|
24
|
+
@buffer = HEADER.dup unless sink
|
|
25
|
+
@size = HEADER.bytesize
|
|
26
|
+
@digest = Digest::MD5.new if sink && !encryption
|
|
27
|
+
announce(HEADER) if sink
|
|
17
28
|
end
|
|
18
29
|
|
|
19
30
|
# Hands out a reference now and lets the object be set later, so a page
|
|
@@ -24,6 +35,8 @@ module Stationery
|
|
|
24
35
|
end
|
|
25
36
|
|
|
26
37
|
def set(ref, value)
|
|
38
|
+
raise Error, "object #{ref.id} is already written" if @objects[ref.id - 1].equal?(WRITTEN)
|
|
39
|
+
|
|
27
40
|
@objects[ref.id - 1] = value
|
|
28
41
|
ref
|
|
29
42
|
end
|
|
@@ -32,43 +45,84 @@ module Stationery
|
|
|
32
45
|
set(reserve, value)
|
|
33
46
|
end
|
|
34
47
|
|
|
48
|
+
# With a sink: writes every object set and not yet written, lowest number
|
|
49
|
+
# first, and drops it. References to objects still unset stay valid, as
|
|
50
|
+
# the cross-reference table is written last. Without a sink it does
|
|
51
|
+
# nothing: the String keeps its objects in numbered order.
|
|
52
|
+
def flush
|
|
53
|
+
return unless @sink
|
|
54
|
+
|
|
55
|
+
@encrypt ||= reserve if @encryption
|
|
56
|
+
write_pending
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Finishes the file: the remaining objects, the cross-reference table
|
|
60
|
+
# and the trailer. Returns the whole file as a String when there is no
|
|
61
|
+
# sink, else the number of bytes handed to it.
|
|
35
62
|
def render(root:, info:)
|
|
63
|
+
set(@encrypt ||= reserve, @encryption.dictionary) if @encryption
|
|
36
64
|
if (missing = @objects.index(nil))
|
|
37
65
|
raise Error, "object #{missing + 1} reserved but never set"
|
|
38
66
|
end
|
|
39
67
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
xref = out.bytesize
|
|
44
|
-
id = HexString.new(@encryption ? @encryption.file_id : Digest::MD5.digest(out))
|
|
68
|
+
write_pending
|
|
69
|
+
xref = @size
|
|
70
|
+
id = HexString.new(file_id)
|
|
45
71
|
|
|
46
|
-
|
|
47
|
-
offsets.each { |offset|
|
|
48
|
-
|
|
49
|
-
|
|
72
|
+
emit("xref\n0 #{@objects.size + 1}\n0000000000 65535 f \n")
|
|
73
|
+
@offsets.each { |offset| emit(format("%010d 00000 n \n", offset)) }
|
|
74
|
+
emit("trailer\n#{Serializer.dump(trailer(root, info, id))}")
|
|
75
|
+
emit("\nstartxref\n#{xref}\n%%EOF\n")
|
|
76
|
+
@buffer || @size
|
|
50
77
|
end
|
|
51
78
|
|
|
52
79
|
private
|
|
53
80
|
|
|
81
|
+
def write_pending
|
|
82
|
+
@objects.each_with_index do |object, index|
|
|
83
|
+
next if object.nil? || object.equal?(WRITTEN)
|
|
84
|
+
|
|
85
|
+
write_object(index + 1, object)
|
|
86
|
+
@objects[index] = WRITTEN
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
54
90
|
def trailer(root, info, id)
|
|
55
91
|
trailer = { Size: @objects.size + 1, Root: root, Info: info, ID: [id, id] }
|
|
56
92
|
@encrypt ? trailer.merge(Encrypt: @encrypt) : trailer
|
|
57
93
|
end
|
|
58
94
|
|
|
59
|
-
def write_object(
|
|
60
|
-
|
|
95
|
+
def write_object(number, object)
|
|
96
|
+
@offsets[number - 1] = @size
|
|
61
97
|
crypt = crypt_for(number)
|
|
62
|
-
|
|
98
|
+
emit("#{number} 0 obj\n")
|
|
63
99
|
if object.is_a?(Stream)
|
|
64
100
|
data = crypt ? crypt.call(object.data) : object.data
|
|
65
|
-
|
|
66
|
-
|
|
101
|
+
emit(Serializer.dump(object.dictionary.merge(Length: data.bytesize), crypt))
|
|
102
|
+
emit("\nstream\n")
|
|
103
|
+
emit(data)
|
|
104
|
+
emit("\nendstream")
|
|
67
105
|
else
|
|
68
|
-
|
|
106
|
+
emit(Serializer.dump(object, crypt))
|
|
69
107
|
end
|
|
70
|
-
|
|
71
|
-
|
|
108
|
+
emit("\nendobj\n")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def emit(bytes)
|
|
112
|
+
@size += bytes.bytesize
|
|
113
|
+
@sink ? announce(bytes) : @buffer << bytes
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def announce(bytes)
|
|
117
|
+
@digest&.update(bytes)
|
|
118
|
+
@sink.call(bytes)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The encryption's own identifier, else a digest of the file so far.
|
|
122
|
+
def file_id
|
|
123
|
+
return @encryption.file_id if @encryption
|
|
124
|
+
|
|
125
|
+
@digest ? @digest.digest : Digest::MD5.digest(@buffer)
|
|
72
126
|
end
|
|
73
127
|
|
|
74
128
|
# The Serializer hook: encrypts a string or stream with this object's key.
|
|
@@ -4,7 +4,9 @@ module Stationery
|
|
|
4
4
|
# The block model rich-text parsers (HTML, Markdown) produce and elements render.
|
|
5
5
|
module Rich
|
|
6
6
|
# A run of text with its marks: any of bold:, italic:, underline:, strike:, code:, link: (href),
|
|
7
|
-
# script: (:sub/:sup)
|
|
7
|
+
# script: (:sub/:sup), color: (a colour), size: (points), scale: (a factor of the text size)
|
|
8
|
+
# and break: true, a hard line break whose text is "". A false bold:, italic:, underline: or
|
|
9
|
+
# strike: switches the mark off inside an element that has it.
|
|
8
10
|
Inline = Data.define(:text, :marks) do
|
|
9
11
|
def self.break = new(text: "", marks: { break: true })
|
|
10
12
|
|
|
@@ -12,15 +14,39 @@ module Stationery
|
|
|
12
14
|
def break? = marks[:break] == true
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
# Every block carries a `style` Hash from the source's CSS (see HTML::Css):
|
|
18
|
+
# align:, background:, padding:, margin:/margin_top:/margin_bottom:,
|
|
19
|
+
# border:, width:, break_before:, break_after:, keep_together:. Empty
|
|
20
|
+
# for Markdown and for HTML without styles.
|
|
21
|
+
Paragraph = Data.define(:inlines, :style) do
|
|
22
|
+
def initialize(inlines:, style: {}) = super
|
|
23
|
+
end
|
|
24
|
+
Heading = Data.define(:level, :inlines, :style) do
|
|
25
|
+
def initialize(level:, inlines:, style: {}) = super
|
|
26
|
+
end
|
|
27
|
+
List = Data.define(:ordered, :start, :items, :style) do
|
|
28
|
+
def initialize(ordered:, start:, items:, style: {}) = super
|
|
29
|
+
end
|
|
30
|
+
Blockquote = Data.define(:blocks, :style) do
|
|
31
|
+
def initialize(blocks:, style: {}) = super
|
|
32
|
+
end
|
|
33
|
+
CodeBlock = Data.define(:text, :language, :style) do
|
|
34
|
+
def initialize(text:, language:, style: {}) = super
|
|
35
|
+
end
|
|
20
36
|
Rule = Data.define
|
|
21
|
-
Table = Data.define(:rows)
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
Table = Data.define(:rows, :style) do
|
|
38
|
+
def initialize(rows:, style: {}) = super
|
|
39
|
+
end
|
|
40
|
+
Cell = Data.define(:header, :align, :blocks, :style) do
|
|
41
|
+
def initialize(header:, align:, blocks:, style: {}) = super
|
|
42
|
+
end
|
|
43
|
+
Image = Data.define(:src, :alt, :width, :height, :style) do
|
|
44
|
+
def initialize(src:, alt:, width:, height:, style: {}) = super
|
|
45
|
+
end
|
|
46
|
+
# A styled container (a `div` with a background, padding, margins or a
|
|
47
|
+
# page-break rule) holding blocks; an unstyled one flattens into its parent.
|
|
48
|
+
Container = Data.define(:blocks, :style)
|
|
49
|
+
PageBreak = Data.define
|
|
24
50
|
|
|
25
51
|
# Operations on inline sequences shared by the parsers.
|
|
26
52
|
module Inlines
|
|
@@ -42,12 +68,12 @@ module Stationery
|
|
|
42
68
|
end
|
|
43
69
|
|
|
44
70
|
# Groups inlines into paragraphs, with each image standing as a block between them.
|
|
45
|
-
def paragraphs(items)
|
|
71
|
+
def paragraphs(items, style: {})
|
|
46
72
|
items.chunk_while { |a, b| !a.is_a?(Image) && !b.is_a?(Image) }.filter_map do |run|
|
|
47
73
|
next run.first if run.first.is_a?(Image)
|
|
48
74
|
|
|
49
75
|
inlines = merge(block_given? ? yield(run) : run)
|
|
50
|
-
Paragraph.new(inlines:) unless inlines.empty?
|
|
76
|
+
Paragraph.new(inlines:, style:) unless inlines.empty?
|
|
51
77
|
end
|
|
52
78
|
end
|
|
53
79
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Rich
|
|
5
|
+
class Renderer
|
|
6
|
+
# What a block's CSS style becomes around the block: margins as spacers,
|
|
7
|
+
# a background and padding as a box, page-break rules as page breaks and
|
|
8
|
+
# kept-together groups, borders and widths as table options.
|
|
9
|
+
module Boxes
|
|
10
|
+
PADDING = { padding_top: 0, padding_right: 1, padding_bottom: 2, padding_left: 3 }.freeze
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# Draws the block inside its margins and its box. A block styled with
|
|
15
|
+
# nothing but an alignment draws as it is.
|
|
16
|
+
def styled(style, &)
|
|
17
|
+
return yield if style.empty?
|
|
18
|
+
|
|
19
|
+
spaced(style) { boxed(style, &) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Page breaks and margins around the block, and the rule that keeps it
|
|
23
|
+
# on one page.
|
|
24
|
+
def spaced(style)
|
|
25
|
+
return yield unless spacing?(style)
|
|
26
|
+
|
|
27
|
+
top, bottom = margins(style)
|
|
28
|
+
@component.page_break if style[:break_before]
|
|
29
|
+
@component.group(keep_together: style[:keep_together] == true) do
|
|
30
|
+
@component.spacer(top) if top.positive?
|
|
31
|
+
yield
|
|
32
|
+
@component.spacer(bottom) if bottom.positive?
|
|
33
|
+
end
|
|
34
|
+
@component.page_break if style[:break_after]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def spacing?(style)
|
|
38
|
+
style[:break_before] || style[:break_after] || style[:keep_together] || margins(style).any?(&:positive?)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def margins(style)
|
|
42
|
+
all = style[:margin]
|
|
43
|
+
[style[:margin_top] || all&.[](0) || 0, style[:margin_bottom] || all&.[](2) || 0]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def boxed(style, &)
|
|
47
|
+
options = box_options(style)
|
|
48
|
+
options.empty? ? yield : @component.box(**options, &)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The background and padding a style gives a box, to merge over a
|
|
52
|
+
# block's own (`styles[:blockquote]`, `styles[:pre]`).
|
|
53
|
+
def box_options(style)
|
|
54
|
+
options = {}
|
|
55
|
+
options[:background] = style[:background] if style[:background]
|
|
56
|
+
padding = padding_of(style)
|
|
57
|
+
options[:padding] = padding if padding
|
|
58
|
+
options
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def padding_of(style)
|
|
62
|
+
sides = PADDING.select { |key, _| style[key] }
|
|
63
|
+
return style[:padding] if sides.empty?
|
|
64
|
+
|
|
65
|
+
padding = (style[:padding] || [0, 0, 0, 0]).dup
|
|
66
|
+
sides.each { |key, index| padding[index] = style[key] }
|
|
67
|
+
padding
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def aligned(style) = style[:align] ? { align: style[:align] } : {}
|
|
71
|
+
|
|
72
|
+
def border_options(border)
|
|
73
|
+
return {} unless border
|
|
74
|
+
return { borders: [] } if border[:width].zero?
|
|
75
|
+
|
|
76
|
+
{ border_width: border[:width], border_color: border[:color] }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cell_options(style)
|
|
80
|
+
options = border_options(style[:border]).merge(box_options(style))
|
|
81
|
+
options[:borders] = %i[top right bottom left] if options[:border_width]
|
|
82
|
+
options
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Column widths from the first row, when every cell of it has one. A
|
|
86
|
+
# percentage is a share of the table, which then takes the full width
|
|
87
|
+
# unless it has a width of its own.
|
|
88
|
+
def column_widths(table)
|
|
89
|
+
widths = table.rows.first.map { |cell| cell.style[:width] }
|
|
90
|
+
return {} unless widths.all?
|
|
91
|
+
|
|
92
|
+
widths.any? { |width| fraction?(width) } && !table.style[:width] ? { widths:, width: :full } : { widths: }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A table's own width: points, the full width for 100%, or a share of
|
|
96
|
+
# the space through a box of that width.
|
|
97
|
+
def sized(width)
|
|
98
|
+
return yield({}) unless width
|
|
99
|
+
return yield({ width: }) unless fraction?(width)
|
|
100
|
+
return yield({ width: :full }) if width >= 1
|
|
101
|
+
|
|
102
|
+
@component.box(width:) { yield({ width: :full }) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fraction?(value) = value.is_a?(Float) && value <= 1
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -30,6 +30,7 @@ module Stationery
|
|
|
30
30
|
blocks.map do |block|
|
|
31
31
|
case block
|
|
32
32
|
when Blockquote then 1 + indents_of(block.blocks)
|
|
33
|
+
when Container then indents_of(block.blocks)
|
|
33
34
|
when List then 1 + (block.items.map { |item| indents_of(item) }.max || 0)
|
|
34
35
|
when Table then block.rows.flatten.map { |cell| indents_of(cell.blocks) }.max || 0
|
|
35
36
|
else 0
|
|
@@ -15,8 +15,12 @@ module Stationery
|
|
|
15
15
|
font: ->(value) { [:font, value] if value }
|
|
16
16
|
}.freeze
|
|
17
17
|
|
|
18
|
+
# A true mark switches the style on, a false one (from CSS) switches it off again.
|
|
18
19
|
MARK_STEPS = {
|
|
19
|
-
bold: [[:b]],
|
|
20
|
+
bold: { true => [[:b]], false => [[:with, { weight: :regular }]] },
|
|
21
|
+
italic: { true => [[:i]], false => [[:with, { style: :normal }]] },
|
|
22
|
+
underline: { true => [[:u]], false => [[:with, { underline: false }]] },
|
|
23
|
+
strike: { true => [[:strikethrough]], false => [[:with, { strikethrough: false }]] }
|
|
20
24
|
}.freeze
|
|
21
25
|
|
|
22
26
|
def initialize(runs, styles, links = Links.new(:all, nil))
|
|
@@ -50,7 +54,10 @@ module Stationery
|
|
|
50
54
|
when :link then [[:link, value], *style_steps(@styles[:a])]
|
|
51
55
|
when :code then style_steps(@styles[:code])
|
|
52
56
|
when :script then [[value]]
|
|
53
|
-
|
|
57
|
+
when :color then [[:color, value]]
|
|
58
|
+
when :size then [[:size, value]]
|
|
59
|
+
when :scale then [[:scale, value]]
|
|
60
|
+
else MARK_STEPS.dig(mark, value) || []
|
|
54
61
|
end
|
|
55
62
|
end
|
|
56
63
|
|
|
@@ -5,6 +5,7 @@ require_relative "styles"
|
|
|
5
5
|
require_relative "renderer/indents"
|
|
6
6
|
require_relative "renderer/inlines"
|
|
7
7
|
require_relative "renderer/links"
|
|
8
|
+
require_relative "renderer/boxes"
|
|
8
9
|
|
|
9
10
|
module Stationery
|
|
10
11
|
module Rich
|
|
@@ -12,9 +13,12 @@ module Stationery
|
|
|
12
13
|
# `images:` (a callable given the src, returning a path, an IO or nil) or
|
|
13
14
|
# from files under `base_path:`; remote URLs are never fetched. Block
|
|
14
15
|
# quotes and lists nested deeper than Indents::LIMIT stop indenting, which
|
|
15
|
-
# is reported as a NestingLimit warning.
|
|
16
|
+
# is reported as a NestingLimit warning. A block's `style` (from the
|
|
17
|
+
# source's CSS) adds margins, a box, an alignment or a page break around
|
|
18
|
+
# what `styles:` draws; a block without one draws exactly as before.
|
|
16
19
|
class Renderer
|
|
17
20
|
include Indents
|
|
21
|
+
include Boxes
|
|
18
22
|
|
|
19
23
|
REMOTE = /\A[a-z][a-z0-9+.-]*:/i
|
|
20
24
|
LINK_SCHEMES = %w[http https mailto tel].freeze
|
|
@@ -43,14 +47,16 @@ module Stationery
|
|
|
43
47
|
|
|
44
48
|
def block(block)
|
|
45
49
|
case block
|
|
46
|
-
when Paragraph then paragraph(block.inlines, **@styles[:p])
|
|
47
|
-
when Heading then heading(block)
|
|
48
|
-
when List then indented(block) { list(block) }
|
|
49
|
-
when Blockquote then indented(block) { blockquote(block) }
|
|
50
|
-
when CodeBlock then code(block)
|
|
50
|
+
when Paragraph then styled(block.style) { paragraph(block.inlines, **@styles[:p], **aligned(block.style)) }
|
|
51
|
+
when Heading then styled(block.style) { heading(block) }
|
|
52
|
+
when List then styled(block.style) { indented(block) { list(block) } }
|
|
53
|
+
when Blockquote then spaced(block.style) { indented(block) { blockquote(block) } }
|
|
54
|
+
when CodeBlock then spaced(block.style) { code(block) }
|
|
51
55
|
when Rule then @component.rule(**@styles[:hr])
|
|
52
|
-
when Table then table(block)
|
|
56
|
+
when Table then styled(block.style.except(:border, :width)) { table(block) }
|
|
53
57
|
when Image then image(block)
|
|
58
|
+
when Container then styled(block.style) { group(block.blocks) }
|
|
59
|
+
when PageBreak then @component.page_break
|
|
54
60
|
end
|
|
55
61
|
end
|
|
56
62
|
|
|
@@ -62,7 +68,8 @@ module Stationery
|
|
|
62
68
|
style = @styles[:"h#{heading.level}"]
|
|
63
69
|
size = style[:size] || (@builder.style.size * style.fetch(:scale, 1))
|
|
64
70
|
bookmark = heading_bookmark(heading)
|
|
65
|
-
paragraph(heading.inlines, **style.except(:scale), size:, heading: heading.level,
|
|
71
|
+
paragraph(heading.inlines, **style.except(:scale), size:, heading: heading.level,
|
|
72
|
+
**aligned(heading.style), **({ bookmark: } if bookmark))
|
|
66
73
|
end
|
|
67
74
|
|
|
68
75
|
def heading_bookmark(heading)
|
|
@@ -72,7 +79,7 @@ module Stationery
|
|
|
72
79
|
end
|
|
73
80
|
|
|
74
81
|
def blockquote(quote)
|
|
75
|
-
@component.box(role: :blockquote, **@styles[:blockquote]) { group(quote.blocks) }
|
|
82
|
+
@component.box(role: :blockquote, **@styles[:blockquote], **box_options(quote.style)) { group(quote.blocks) }
|
|
76
83
|
end
|
|
77
84
|
|
|
78
85
|
def list(list)
|
|
@@ -86,18 +93,23 @@ module Stationery
|
|
|
86
93
|
|
|
87
94
|
def code(block)
|
|
88
95
|
font = @styles[:code][:font]
|
|
89
|
-
@component.box(**@styles[:pre]
|
|
96
|
+
@component.box(**@styles[:pre], **box_options(block.style)) do
|
|
97
|
+
@component.text(block.text, **({ font: } if font))
|
|
98
|
+
end
|
|
90
99
|
end
|
|
91
100
|
|
|
92
101
|
def table(table)
|
|
93
102
|
header = table.rows.first&.all?(&:header) ? 1 : 0
|
|
94
103
|
rows = table.rows.map { |row| row.map { |cell| cell_content(cell) } }
|
|
95
|
-
|
|
104
|
+
cell = @styles[:table][:cell].merge(border_options(table.style[:border]))
|
|
105
|
+
sized(table.style[:width]) { |width| @component.table(rows, header:, cell:, **width, **column_widths(table)) }
|
|
96
106
|
end
|
|
97
107
|
|
|
98
108
|
def cell_content(cell)
|
|
99
109
|
style = cell.header ? @styles[:table][:header] : {}
|
|
100
|
-
-> { @component.text_style(**style, align: cell.align) { group(cell.blocks) } }
|
|
110
|
+
content = -> { @component.text_style(**style, align: cell.align) { group(cell.blocks) } }
|
|
111
|
+
options = cell_options(cell.style)
|
|
112
|
+
options.empty? ? content : { content:, **options }
|
|
101
113
|
end
|
|
102
114
|
|
|
103
115
|
def image(node)
|
|
@@ -105,7 +117,7 @@ module Stationery
|
|
|
105
117
|
return unless source
|
|
106
118
|
|
|
107
119
|
loaded = Images.load(source)
|
|
108
|
-
@component.image(loaded, alt: node.alt, **dimensions(node, loaded))
|
|
120
|
+
@component.image(loaded, alt: node.alt, **aligned(node.style), **dimensions(node, loaded))
|
|
109
121
|
rescue UnsupportedImage => e
|
|
110
122
|
skip(node.src, e.message)
|
|
111
123
|
end
|
|
@@ -121,9 +133,14 @@ module Stationery
|
|
|
121
133
|
File.file?(path) ? path : skip(src, "not found")
|
|
122
134
|
end
|
|
123
135
|
|
|
136
|
+
# A CSS width wins over the width and height attributes (the height
|
|
137
|
+
# then follows the aspect ratio); a percentage is a share of the space.
|
|
124
138
|
def dimensions(node, loaded)
|
|
125
|
-
|
|
126
|
-
|
|
139
|
+
styled = node.style[:width]
|
|
140
|
+
return { width: styled } if fraction?(styled)
|
|
141
|
+
|
|
142
|
+
width = styled || node.width
|
|
143
|
+
height = styled ? nil : node.height
|
|
127
144
|
limit = @styles[:img][:max_width]
|
|
128
145
|
natural = width || (height ? loaded.width * height.fdiv(loaded.height) : loaded.width)
|
|
129
146
|
return { width:, height: } unless limit && natural > limit
|
data/lib/stationery/svg/style.rb
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module SVG
|
|
5
|
+
Selector = CSS::Selector
|
|
6
|
+
Stylesheet = CSS::Stylesheet
|
|
7
|
+
|
|
5
8
|
# Presentation attributes an element inherits from its ancestors, and the
|
|
6
9
|
# transform it draws with. An element's own values cascade: presentation
|
|
7
10
|
# attributes, then stylesheet rules, then its inline `style`. `color` sets
|
|
@@ -27,12 +30,7 @@ module Stationery
|
|
|
27
30
|
NAMED.fetch(value.downcase, value)
|
|
28
31
|
end
|
|
29
32
|
|
|
30
|
-
def self.declarations(style)
|
|
31
|
-
style.to_s.split(";").filter_map do |declaration|
|
|
32
|
-
key, value = declaration.split(":", 2).map(&:strip)
|
|
33
|
-
[key, value] if key && value
|
|
34
|
-
end.to_h
|
|
35
|
-
end
|
|
33
|
+
def self.declarations(style) = CSS.declarations(style)
|
|
36
34
|
|
|
37
35
|
attr_reader :values, :matrix, :color
|
|
38
36
|
|
|
@@ -12,6 +12,8 @@ module Stationery
|
|
|
12
12
|
# Needs the pdf-reader gem, loaded on first use.
|
|
13
13
|
class Inspector
|
|
14
14
|
UTF16_BOM = "\xFE\xFF".b
|
|
15
|
+
# id-aa-signatureTimeStampToken: a signature's RFC 3161 timestamp.
|
|
16
|
+
TIMESTAMP_TOKEN = "1.2.840.113549.1.9.16.2.14"
|
|
15
17
|
XMP_PROPERTY = %r{^\s*<((?!rdf:)[\w.-]+:[\w.-]+)>(.*?)</\1>$}
|
|
16
18
|
XMP_ITEM = %r{<rdf:li[^>]*>(.*?)</rdf:li>}
|
|
17
19
|
XMP_ENTITIES = { "&" => "&", "<" => "<", ">" => ">", """ => '"' }.freeze
|
|
@@ -134,10 +136,13 @@ module Stationery
|
|
|
134
136
|
end
|
|
135
137
|
|
|
136
138
|
# Every signed signature field: `{ field:, name:, reason:, location:,
|
|
137
|
-
# signed_at:, subfilter:, byte_range:, signer:, valid: }`.
|
|
138
|
-
# the signing certificate's subject ("CN=…,O=…"). `valid` is
|
|
139
|
-
# the signature covers the whole file and verifies against the
|
|
139
|
+
# signed_at:, subfilter:, byte_range:, signer:, valid:, timestamp: }`.
|
|
140
|
+
# `signer` is the signing certificate's subject ("CN=…,O=…"). `valid` is
|
|
141
|
+
# whether the signature covers the whole file and verifies against the
|
|
140
142
|
# certificate it carries; the certificate's own trust is not judged.
|
|
143
|
+
# `timestamp` is nil, or `{ time:, tsa:, valid: }` for the RFC 3161 token
|
|
144
|
+
# a signature carries: `valid` when the token is over this signature
|
|
145
|
+
# and verifies against the TSA certificate it carries.
|
|
141
146
|
def signatures
|
|
142
147
|
form = catalog[:AcroForm]
|
|
143
148
|
return [] unless form
|
|
@@ -207,13 +212,51 @@ module Stationery
|
|
|
207
212
|
# parsed dictionary: it is the one string encryption leaves alone.
|
|
208
213
|
def verdict(range)
|
|
209
214
|
contents = pdf.byteslice(range[1], range[2] - range[1]).to_s
|
|
210
|
-
|
|
215
|
+
der = PDF::Signature.der([contents[1..-2]].pack("H*"))
|
|
216
|
+
cms = OpenSSL::PKCS7.new(der)
|
|
211
217
|
signed = pdf.byteslice(range[0], range[1]) + pdf.byteslice(range[2], range[3])
|
|
212
218
|
flags = OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::BINARY
|
|
213
219
|
whole = range[0].zero? && range[2] + range[3] == pdf.bytesize && contents.start_with?("<")
|
|
214
|
-
{ signer: signer(cms), valid: whole && cms.verify([], OpenSSL::X509::Store.new, signed, flags)
|
|
220
|
+
{ signer: signer(cms), valid: whole && cms.verify([], OpenSSL::X509::Store.new, signed, flags),
|
|
221
|
+
timestamp: timestamp(der) }
|
|
215
222
|
rescue OpenSSL::OpenSSLError, ArgumentError, TypeError
|
|
216
|
-
{ signer: nil, valid: false }
|
|
223
|
+
{ signer: nil, valid: false, timestamp: nil }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The timestamp token among the first SignerInfo's unsigned attributes,
|
|
227
|
+
# judged against that SignerInfo's signature value; nil without one.
|
|
228
|
+
def timestamp(der)
|
|
229
|
+
info = OpenSSL::ASN1.decode(der).value[1].value[0].value[4].value[0].value
|
|
230
|
+
unsigned = info[6..].to_a.find { |item| item.tag == 1 && item.tag_class == :CONTEXT_SPECIFIC }
|
|
231
|
+
attribute = unsigned&.value&.find { |one| one.value[0].oid == TIMESTAMP_TOKEN }
|
|
232
|
+
attribute && timestamp_verdict(attribute.value[1].value.first, info[5].value)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# The token verified as a TimeStampResp would be, against the signature
|
|
236
|
+
# value it should cover. The certificates it carries are the trust
|
|
237
|
+
# anchors (a TSA sends its chain, often without the root), so this
|
|
238
|
+
# says the token is sound, not that the TSA is one to trust.
|
|
239
|
+
def timestamp_verdict(token, signature)
|
|
240
|
+
granted = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(0)])
|
|
241
|
+
response = OpenSSL::Timestamp::Response.new(OpenSSL::ASN1::Sequence.new([granted, token]).to_der)
|
|
242
|
+
info = response.token_info
|
|
243
|
+
covers = info.message_imprint == OpenSSL::Digest.digest(info.algorithm, signature)
|
|
244
|
+
{ time: info.gen_time, tsa: signer(response.token), valid: covers && timestamp_verifies?(response) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def timestamp_verifies?(response)
|
|
248
|
+
info = response.token_info
|
|
249
|
+
request = OpenSSL::Timestamp::Request.new
|
|
250
|
+
request.algorithm = info.algorithm
|
|
251
|
+
request.message_imprint = info.message_imprint
|
|
252
|
+
request.nonce = info.nonce if info.nonce
|
|
253
|
+
store = OpenSSL::X509::Store.new
|
|
254
|
+
store.flags = OpenSSL::X509::V_FLAG_PARTIAL_CHAIN
|
|
255
|
+
response.token.certificates.to_a.each { |certificate| store.add_cert(certificate) }
|
|
256
|
+
response.verify(request, store)
|
|
257
|
+
true
|
|
258
|
+
rescue OpenSSL::OpenSSLError
|
|
259
|
+
false
|
|
217
260
|
end
|
|
218
261
|
|
|
219
262
|
def signer(cms)
|
|
@@ -16,6 +16,8 @@ module Stationery
|
|
|
16
16
|
set_character_spacing set_word_spacing set_text_rise set_horizontal_text_scaling
|
|
17
17
|
set_text_rendering_mode].freeze
|
|
18
18
|
|
|
19
|
+
UTF16_BOM = "\xFE\xFF".b
|
|
20
|
+
|
|
19
21
|
def_delegators :@state, *STATE
|
|
20
22
|
|
|
21
23
|
# { MCID => text } and the strings shown outside marked content.
|
|
@@ -31,20 +33,30 @@ module Stationery
|
|
|
31
33
|
@unmarked = []
|
|
32
34
|
end
|
|
33
35
|
|
|
36
|
+
# A sequence with ActualText reads as that text, whatever it shows.
|
|
34
37
|
def begin_marked_content_with_pl(_tag, properties)
|
|
35
|
-
|
|
38
|
+
properties = {} unless properties.is_a?(Hash)
|
|
39
|
+
@open << properties[:MCID]
|
|
40
|
+
return unless properties[:ActualText]
|
|
41
|
+
|
|
42
|
+
@actual = decode(properties[:ActualText])
|
|
43
|
+
@actual_depth = @open.size
|
|
36
44
|
end
|
|
37
45
|
|
|
38
46
|
def begin_marked_content(_tag) = @open << nil
|
|
39
|
-
|
|
47
|
+
|
|
48
|
+
def end_marked_content
|
|
49
|
+
@actual = nil if @actual_depth && @open.size <= @actual_depth
|
|
50
|
+
@open.pop
|
|
51
|
+
end
|
|
52
|
+
|
|
40
53
|
def show_text(string) = append(string)
|
|
41
54
|
def show_text_with_positioning(params) = params.grep(String).each { |string| append(string) }
|
|
42
55
|
|
|
43
56
|
private
|
|
44
57
|
|
|
45
58
|
def append(string)
|
|
46
|
-
|
|
47
|
-
text = font.unpack(string).map { |code| font.to_utf8(code) }.join
|
|
59
|
+
text = shown(string)
|
|
48
60
|
return @unmarked << text if @open.empty?
|
|
49
61
|
|
|
50
62
|
mcid = @open.compact.last
|
|
@@ -55,6 +67,24 @@ module Stationery
|
|
|
55
67
|
@texts[mcid] = "#{@texts[mcid]}#{separator}#{text}"
|
|
56
68
|
@baselines[mcid] = baseline
|
|
57
69
|
end
|
|
70
|
+
|
|
71
|
+
# The characters a string shows: its ActualText once, when it has one.
|
|
72
|
+
def shown(string)
|
|
73
|
+
if @actual
|
|
74
|
+
text = @actual
|
|
75
|
+
@actual = ""
|
|
76
|
+
return text
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
font = @state.current_font
|
|
80
|
+
font.unpack(string).map { |code| font.to_utf8(code) }.join
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def decode(text)
|
|
84
|
+
return text.dup.force_encoding(Encoding::UTF_8) unless text.b.start_with?(UTF16_BOM)
|
|
85
|
+
|
|
86
|
+
text.b.byteslice(2..).force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8)
|
|
87
|
+
end
|
|
58
88
|
end
|
|
59
89
|
end
|
|
60
90
|
end
|