stationery 0.9.0 → 0.10.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 +13 -0
- data/README.md +143 -34
- 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/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/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
|
@@ -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)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Text
|
|
5
|
+
# Where a line may break inside a word: between two ideographic
|
|
6
|
+
# characters (UAX #14 class ID: CJK ideographs, kana, Hangul, fullwidth
|
|
7
|
+
# forms), never before a closing mark or a character that must not start
|
|
8
|
+
# a line (、。」ー and the small kana: classes CL, CP, EX and NS), never
|
|
9
|
+
# after an opening bracket (「(: class OP). A word is cut into units at
|
|
10
|
+
# those points; Latin text stays one unit, so its breaking is unchanged.
|
|
11
|
+
module Breaks
|
|
12
|
+
ZERO_WIDTH_SPACE = ""
|
|
13
|
+
|
|
14
|
+
IDEOGRAPHIC = [
|
|
15
|
+
0x1100..0x11FF, # Hangul Jamo
|
|
16
|
+
0x2E80..0x2FDF, # CJK radicals
|
|
17
|
+
0x3005..0x3007, # 々 〆 〇
|
|
18
|
+
0x3021..0x3029, # Hangzhou numerals
|
|
19
|
+
0x3031..0x3035, # kana repeat marks
|
|
20
|
+
0x3038..0x303C,
|
|
21
|
+
0x3041..0x309A, # Hiragana (the small kana are taken out below)
|
|
22
|
+
0x30A1..0x30FA, # Katakana
|
|
23
|
+
0x3100..0x312F, # Bopomofo
|
|
24
|
+
0x3130..0x318F, # Hangul compatibility Jamo
|
|
25
|
+
0x3190..0x31FF, # Kanbun, Bopomofo extended, CJK strokes, Katakana phonetic extensions
|
|
26
|
+
0x3200..0x33FF, # enclosed CJK, CJK compatibility
|
|
27
|
+
0x3400..0x4DBF, # CJK extension A
|
|
28
|
+
0x4E00..0x9FFF, # CJK unified ideographs
|
|
29
|
+
0xA000..0xA4CF, # Yi
|
|
30
|
+
0xA960..0xA97F, # Hangul Jamo extended A
|
|
31
|
+
0xAC00..0xD7AF, # Hangul syllables
|
|
32
|
+
0xF900..0xFAFF, # CJK compatibility ideographs
|
|
33
|
+
0xFE30..0xFE4F, # CJK compatibility forms
|
|
34
|
+
0xFF10..0xFF19, # fullwidth digits
|
|
35
|
+
0xFF21..0xFF3A, 0xFF41..0xFF5A, # fullwidth Latin
|
|
36
|
+
0xFF66..0xFF6F, 0xFF71..0xFF9D, # halfwidth Katakana (ー and the small ones are NS)
|
|
37
|
+
0x1B000..0x1B16F, # Kana supplement and extended
|
|
38
|
+
0x20000..0x3134F # CJK extensions B–G
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# Never at the start of a line: closing brackets and quotes, sentence
|
|
42
|
+
# and clause punctuation, prolonged sound and iteration marks, small kana.
|
|
43
|
+
CLOSING = [
|
|
44
|
+
0x3001..0x3003, 0x3009, 0x300B, 0x300D, 0x300F, 0x3011, 0x3015, 0x3017, 0x3019, 0x301B, 0x301C,
|
|
45
|
+
0x301F, 0x303B, 0x309B..0x309E, 0x30A0, 0x30FB..0x30FE,
|
|
46
|
+
0x3041, 0x3043, 0x3045, 0x3047, 0x3049, 0x3063, 0x3083, 0x3085, 0x3087, 0x308E, 0x3095, 0x3096,
|
|
47
|
+
0x30A1, 0x30A3, 0x30A5, 0x30A7, 0x30A9, 0x30C3, 0x30E3, 0x30E5, 0x30E7, 0x30EE, 0x30F5, 0x30F6,
|
|
48
|
+
0x31F0..0x31FF,
|
|
49
|
+
0xFE50..0xFE52, 0xFE54..0xFE57, 0xFE5A, 0xFE5C, 0xFE5E,
|
|
50
|
+
0xFF01, 0xFF09, 0xFF0C, 0xFF0E, 0xFF1A, 0xFF1B, 0xFF1F, 0xFF3D, 0xFF5D, 0xFF60, 0xFF61, 0xFF63,
|
|
51
|
+
0xFF64, 0xFF65, 0xFF67..0xFF6F, 0xFF70, 0xFF9E, 0xFF9F,
|
|
52
|
+
0x2025, 0x2026
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
# Never at the end of a line: opening brackets and quotes.
|
|
56
|
+
OPENING = [
|
|
57
|
+
0x3008, 0x300A, 0x300C, 0x300E, 0x3010, 0x3014, 0x3016, 0x3018, 0x301A, 0x301D,
|
|
58
|
+
0xFE59, 0xFE5B, 0xFE5D,
|
|
59
|
+
0xFF08, 0xFF3B, 0xFF5B, 0xFF5F, 0xFF62
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
CLOSING_CODES = CLOSING.flat_map { |entry| Array(entry) }.to_h { |code| [code, true] }.freeze
|
|
63
|
+
OPENING_CODES = OPENING.flat_map { |entry| Array(entry) }.to_h { |code| [code, true] }.freeze
|
|
64
|
+
|
|
65
|
+
module_function
|
|
66
|
+
|
|
67
|
+
def ideographic?(char)
|
|
68
|
+
code = char.ord
|
|
69
|
+
!CLOSING_CODES.key?(code) && IDEOGRAPHIC.any? { |range| range.cover?(code) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def closing?(char) = CLOSING_CODES.key?(char.ord)
|
|
73
|
+
def opening?(char) = OPENING_CODES.key?(char.ord)
|
|
74
|
+
|
|
75
|
+
# Whether `text` holds any character these rules know about, the cheap
|
|
76
|
+
# test that keeps Latin text on the fast path.
|
|
77
|
+
def cjk?(text) = text.match?(CJK)
|
|
78
|
+
|
|
79
|
+
CJK = /[ᄀ-ᇿ⺀-、-ヿ-鿿ꀀ-ꥠ-가-豈-︰-﹞!-゚\u{1B000}-\u{1B16F}\u{20000}-\u{3134F}‥…]/
|
|
80
|
+
|
|
81
|
+
# The word's break units in order, each `[text, glued]`: `glued` is true
|
|
82
|
+
# for a unit that must stay with the unit before it (it starts with a
|
|
83
|
+
# closing mark), so the caller joins it to whatever came before, even
|
|
84
|
+
# across styled runs. A word without CJK characters is one unit.
|
|
85
|
+
def units(word)
|
|
86
|
+
return [[word, false]] unless cjk?(word)
|
|
87
|
+
|
|
88
|
+
units = []
|
|
89
|
+
current = +""
|
|
90
|
+
glued = false
|
|
91
|
+
open = false # current holds opening brackets only, waiting for what they open
|
|
92
|
+
word.each_char do |char|
|
|
93
|
+
if closing?(char)
|
|
94
|
+
glued = true if current.empty?
|
|
95
|
+
elsif starts_unit?(char, current, open)
|
|
96
|
+
units << [current.freeze, glued] unless current.empty?
|
|
97
|
+
current = +""
|
|
98
|
+
glued = false
|
|
99
|
+
end
|
|
100
|
+
current << char
|
|
101
|
+
open = opening?(char) && (open || current.length == 1)
|
|
102
|
+
end
|
|
103
|
+
units << [current.freeze, glued] unless current.empty?
|
|
104
|
+
units
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Whether `char` opens a new unit after `current`: an opening bracket or
|
|
108
|
+
# an ideographic character does unless `current` is only opening
|
|
109
|
+
# brackets; Latin text does after a unit that ends in a CJK character
|
|
110
|
+
# (an opening bracket or Latin followed by a closing mark stay glued).
|
|
111
|
+
def starts_unit?(char, current, open)
|
|
112
|
+
return false if current.empty? || open
|
|
113
|
+
return true if opening?(char) || ideographic?(char)
|
|
114
|
+
|
|
115
|
+
cjk?(current[-1]) && !(closing?(current[-1]) && !ideographic_unit?(current))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A unit that carries an ideographic character (as opposed to Latin text
|
|
119
|
+
# followed by a closing mark, such as "abc。").
|
|
120
|
+
def ideographic_unit?(text) = text.each_char.any? { |char| ideographic?(char) }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -34,6 +34,14 @@ module Stationery
|
|
|
34
34
|
[with_lines(@lines.first(count)), with_lines(@lines.drop(count))]
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# [first `count` lines, the rest]; nil for an empty side.
|
|
38
|
+
def split_at(count)
|
|
39
|
+
return [nil, self] if count <= 0
|
|
40
|
+
return [self, nil] if count >= @lines.size
|
|
41
|
+
|
|
42
|
+
[with_lines(@lines.first(count)), with_lines(@lines.drop(count))]
|
|
43
|
+
end
|
|
44
|
+
|
|
37
45
|
# A paragraph that fits `max_height`: truncated to whole lines, shrunk
|
|
38
46
|
# (never below 4pt) or left as is for :visible.
|
|
39
47
|
def fit(max_height, overflow:)
|
|
@@ -46,6 +46,10 @@ module Stationery
|
|
|
46
46
|
def size(value, text = nil, &) = styled({ size: value }, text, &)
|
|
47
47
|
def font(family, text = nil, &) = styled({ family: family.to_s }, text, &)
|
|
48
48
|
def link(url, text = nil, &) = styled({ link: url.to_s }, text, &)
|
|
49
|
+
# The current size multiplied: `scale(1.5) { … }` is one and a half times the text around it.
|
|
50
|
+
def scale(factor, text = nil, &) = styled({ size: @styles.last.size * factor }, text, &)
|
|
51
|
+
# Any style fields at once, also to switch a mark off: `with({ weight: :regular }) { … }`.
|
|
52
|
+
def with(overrides, text = nil, &) = styled(overrides, text, &)
|
|
49
53
|
|
|
50
54
|
private
|
|
51
55
|
|