stationery 0.8.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 +24 -0
- data/README.md +251 -37
- data/lib/stationery/canvas.rb +3 -1
- 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 +35 -7
- data/lib/stationery/elements/forms.rb +11 -6
- data/lib/stationery/elements/rich.rb +21 -6
- data/lib/stationery/elements.rb +20 -7
- data/lib/stationery/errors.rb +4 -0
- data/lib/stationery/fonts/fallback.rb +1 -1
- data/lib/stationery/fonts/font.rb +28 -18
- data/lib/stationery/forms/acro_form.rb +95 -25
- data/lib/stationery/forms/appearance.rb +92 -30
- data/lib/stationery/forms/field.rb +20 -11
- data/lib/stationery/forms/metrics.rb +3 -17
- data/lib/stationery/forms/typeface.rb +113 -0
- data/lib/stationery/html/css.rb +276 -0
- data/lib/stationery/html/document.rb +10 -2
- 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 +44 -4
- data/lib/stationery/layout/flow.rb +29 -0
- data/lib/stationery/layout/image.rb +21 -7
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +4 -0
- data/lib/stationery/layout/table/cell.rb +14 -4
- data/lib/stationery/layout/table.rb +72 -14
- data/lib/stationery/layout/text.rb +47 -10
- data/lib/stationery/markdown/block_parser/containers.rb +21 -0
- data/lib/stationery/markdown/block_parser.rb +9 -3
- data/lib/stationery/markdown/document.rb +8 -2
- data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
- data/lib/stationery/markdown/inline_parser/links.rb +17 -12
- data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
- data/lib/stationery/markdown/inline_parser.rb +9 -3
- data/lib/stationery/minitest.rb +4 -0
- data/lib/stationery/pdf/assembler.rb +31 -11
- data/lib/stationery/pdf/conformance.rb +7 -5
- data/lib/stationery/pdf/serializer.rb +3 -1
- data/lib/stationery/pdf/signature/cms.rb +95 -0
- data/lib/stationery/pdf/signature/timestamp.rb +120 -0
- data/lib/stationery/pdf/signature.rb +174 -0
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/pdf/writer.rb +73 -19
- data/lib/stationery/rich/nesting.rb +30 -0
- data/lib/stationery/rich/nodes.rb +37 -11
- data/lib/stationery/rich/renderer/boxes.rb +109 -0
- data/lib/stationery/rich/renderer/indents.rb +43 -0
- data/lib/stationery/rich/renderer/inlines.rb +9 -2
- data/lib/stationery/rich/renderer.rb +49 -16
- data/lib/stationery/svg/document.rb +8 -4
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/style.rb +4 -6
- data/lib/stationery/svg/walker.rb +2 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- 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 +69 -11
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +13 -0
- data/lib/stationery.rb +6 -2
- metadata +13 -3
|
@@ -2,15 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "nodes"
|
|
4
4
|
require_relative "styles"
|
|
5
|
+
require_relative "renderer/indents"
|
|
5
6
|
require_relative "renderer/inlines"
|
|
6
7
|
require_relative "renderer/links"
|
|
8
|
+
require_relative "renderer/boxes"
|
|
7
9
|
|
|
8
10
|
module Stationery
|
|
9
11
|
module Rich
|
|
10
12
|
# Draws rich-text blocks with a component's element DSL. Images come from
|
|
11
13
|
# `images:` (a callable given the src, returning a path, an IO or nil) or
|
|
12
|
-
# from files under `base_path:`; remote URLs are never fetched.
|
|
14
|
+
# from files under `base_path:`; remote URLs are never fetched. Block
|
|
15
|
+
# quotes and lists nested deeper than Indents::LIMIT stop indenting, which
|
|
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.
|
|
13
19
|
class Renderer
|
|
20
|
+
include Indents
|
|
21
|
+
include Boxes
|
|
22
|
+
|
|
14
23
|
REMOTE = /\A[a-z][a-z0-9+.-]*:/i
|
|
15
24
|
LINK_SCHEMES = %w[http https mailto tel].freeze
|
|
16
25
|
|
|
@@ -23,22 +32,31 @@ module Stationery
|
|
|
23
32
|
@base_path = base_path && File.expand_path(base_path.to_s)
|
|
24
33
|
@bookmarks = bookmarks
|
|
25
34
|
@links = Links.new(links || LINK_SCHEMES, builder.warnings)
|
|
35
|
+
@indents = 0
|
|
26
36
|
end
|
|
27
37
|
|
|
28
|
-
def render(blocks)
|
|
38
|
+
def render(blocks)
|
|
39
|
+
depth = indents_of(blocks)
|
|
40
|
+
@builder.warnings << Warnings::NestingLimit.new(depth:, limit: Indents::LIMIT) if depth > Indents::LIMIT
|
|
41
|
+
group(blocks)
|
|
42
|
+
end
|
|
29
43
|
|
|
30
44
|
private
|
|
31
45
|
|
|
46
|
+
def group(blocks) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
|
|
47
|
+
|
|
32
48
|
def block(block)
|
|
33
49
|
case block
|
|
34
|
-
when Paragraph then paragraph(block.inlines, **@styles[:p])
|
|
35
|
-
when Heading then heading(block)
|
|
36
|
-
when List then list(block)
|
|
37
|
-
when Blockquote then
|
|
38
|
-
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) }
|
|
39
55
|
when Rule then @component.rule(**@styles[:hr])
|
|
40
|
-
when Table then table(block)
|
|
56
|
+
when Table then styled(block.style.except(:border, :width)) { table(block) }
|
|
41
57
|
when Image then image(block)
|
|
58
|
+
when Container then styled(block.style) { group(block.blocks) }
|
|
59
|
+
when PageBreak then @component.page_break
|
|
42
60
|
end
|
|
43
61
|
end
|
|
44
62
|
|
|
@@ -50,7 +68,8 @@ module Stationery
|
|
|
50
68
|
style = @styles[:"h#{heading.level}"]
|
|
51
69
|
size = style[:size] || (@builder.style.size * style.fetch(:scale, 1))
|
|
52
70
|
bookmark = heading_bookmark(heading)
|
|
53
|
-
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))
|
|
54
73
|
end
|
|
55
74
|
|
|
56
75
|
def heading_bookmark(heading)
|
|
@@ -59,29 +78,38 @@ module Stationery
|
|
|
59
78
|
{ title: heading.inlines.grep(Inline).map(&:text).join, level: heading.level }
|
|
60
79
|
end
|
|
61
80
|
|
|
81
|
+
def blockquote(quote)
|
|
82
|
+
@component.box(role: :blockquote, **@styles[:blockquote], **box_options(quote.style)) { group(quote.blocks) }
|
|
83
|
+
end
|
|
84
|
+
|
|
62
85
|
def list(list)
|
|
63
86
|
options = list.ordered ? { **@styles[:ol], start: list.start || 1 } : @styles[:ul]
|
|
64
87
|
@component.public_send(list.ordered ? :ol : :ul, **options) do
|
|
65
88
|
list.items.each do |blocks|
|
|
66
|
-
@component.li { @component.text_style(**@styles[:li]) {
|
|
89
|
+
@component.li { @component.text_style(**@styles[:li]) { group(blocks) } }
|
|
67
90
|
end
|
|
68
91
|
end
|
|
69
92
|
end
|
|
70
93
|
|
|
71
94
|
def code(block)
|
|
72
95
|
font = @styles[:code][:font]
|
|
73
|
-
@component.box(**@styles[:pre]
|
|
96
|
+
@component.box(**@styles[:pre], **box_options(block.style)) do
|
|
97
|
+
@component.text(block.text, **({ font: } if font))
|
|
98
|
+
end
|
|
74
99
|
end
|
|
75
100
|
|
|
76
101
|
def table(table)
|
|
77
102
|
header = table.rows.first&.all?(&:header) ? 1 : 0
|
|
78
103
|
rows = table.rows.map { |row| row.map { |cell| cell_content(cell) } }
|
|
79
|
-
|
|
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)) }
|
|
80
106
|
end
|
|
81
107
|
|
|
82
108
|
def cell_content(cell)
|
|
83
109
|
style = cell.header ? @styles[:table][:header] : {}
|
|
84
|
-
-> { @component.text_style(**style, align: cell.align) {
|
|
110
|
+
content = -> { @component.text_style(**style, align: cell.align) { group(cell.blocks) } }
|
|
111
|
+
options = cell_options(cell.style)
|
|
112
|
+
options.empty? ? content : { content:, **options }
|
|
85
113
|
end
|
|
86
114
|
|
|
87
115
|
def image(node)
|
|
@@ -89,7 +117,7 @@ module Stationery
|
|
|
89
117
|
return unless source
|
|
90
118
|
|
|
91
119
|
loaded = Images.load(source)
|
|
92
|
-
@component.image(loaded, alt: node.alt, **dimensions(node, loaded))
|
|
120
|
+
@component.image(loaded, alt: node.alt, **aligned(node.style), **dimensions(node, loaded))
|
|
93
121
|
rescue UnsupportedImage => e
|
|
94
122
|
skip(node.src, e.message)
|
|
95
123
|
end
|
|
@@ -105,9 +133,14 @@ module Stationery
|
|
|
105
133
|
File.file?(path) ? path : skip(src, "not found")
|
|
106
134
|
end
|
|
107
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.
|
|
108
138
|
def dimensions(node, loaded)
|
|
109
|
-
|
|
110
|
-
|
|
139
|
+
styled = node.style[:width]
|
|
140
|
+
return { width: styled } if fraction?(styled)
|
|
141
|
+
|
|
142
|
+
width = styled || node.width
|
|
143
|
+
height = styled ? nil : node.height
|
|
111
144
|
limit = @styles[:img][:max_width]
|
|
112
145
|
natural = width || (height ? loaded.width * height.fdiv(loaded.height) : loaded.width)
|
|
113
146
|
return { width:, height: } unless limit && natural > limit
|
|
@@ -16,16 +16,20 @@ module Stationery
|
|
|
16
16
|
tspan use symbol clipPath]).freeze
|
|
17
17
|
|
|
18
18
|
def self.parse(source)
|
|
19
|
-
|
|
19
|
+
nesting = nil
|
|
20
|
+
root = Parser.parse(source) { |depth| nesting = depth }
|
|
20
21
|
raise Error, "not an svg document" unless root&.name == "svg"
|
|
21
22
|
|
|
22
|
-
new(root)
|
|
23
|
+
new(root, nesting:)
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
# `nesting` is how deep the source nested when that was deeper than
|
|
27
|
+
# Parser::MAX_DEPTH (and was flattened there), else nil.
|
|
28
|
+
attr_reader :view_box, :unsupported, :nesting
|
|
26
29
|
|
|
27
|
-
def initialize(root)
|
|
30
|
+
def initialize(root, nesting: nil)
|
|
28
31
|
@root = root
|
|
32
|
+
@nesting = nesting
|
|
29
33
|
@view_box = read_view_box
|
|
30
34
|
@sheet = Stylesheet.parse(style_text)
|
|
31
35
|
@gradients = Gradient.collect(root, @sheet)
|
|
@@ -8,10 +8,15 @@ module Stationery
|
|
|
8
8
|
# Comments, processing instructions and doctypes are skipped; text content
|
|
9
9
|
# (CDATA included) is kept only inside text, tspan and style, as String
|
|
10
10
|
# children with entities decoded and each whitespace run one space.
|
|
11
|
+
#
|
|
12
|
+
# An element nested deeper than MAX_DEPTH is not descended into: it takes
|
|
13
|
+
# its place in the deepest element kept and so does what it holds, so
|
|
14
|
+
# nothing that walks the tree recurses deeper than that.
|
|
11
15
|
module Parser
|
|
12
16
|
Element = Data.define(:name, :attributes, :children)
|
|
13
17
|
|
|
14
18
|
TEXT = %w[text tspan style].freeze
|
|
19
|
+
MAX_DEPTH = 128
|
|
15
20
|
|
|
16
21
|
IGNORED = /<!--.*?-->|<\?.*?\?>|<!DOCTYPE[^>]*>/m
|
|
17
22
|
CDATA = /<!\[CDATA\[(.*?)\]\]>/m
|
|
@@ -21,17 +26,19 @@ module Stationery
|
|
|
21
26
|
|
|
22
27
|
module_function
|
|
23
28
|
|
|
24
|
-
|
|
29
|
+
# The root element. The block is given how deep the source nested when
|
|
30
|
+
# that was deeper than `max_depth`.
|
|
31
|
+
def parse(source, max_depth: MAX_DEPTH)
|
|
25
32
|
source = source.to_s.gsub(IGNORED, "").gsub(CDATA) { Regexp.last_match(1).gsub(/[&<>]/, ESCAPES) }
|
|
26
33
|
scanner = StringScanner.new(source)
|
|
27
|
-
|
|
28
|
-
stack = [root]
|
|
34
|
+
tree = Tree.new(max_depth)
|
|
29
35
|
while (content = scanner.scan_until(/(?=<)/))
|
|
30
|
-
keep(
|
|
36
|
+
keep(tree.open, content)
|
|
31
37
|
tag = scanner.scan(TAG) or (scanner.getch && next)
|
|
32
|
-
|
|
38
|
+
tree.tag(*tag.match(TAG).captures)
|
|
33
39
|
end
|
|
34
|
-
|
|
40
|
+
yield tree.deepest if block_given? && tree.deepest > max_depth
|
|
41
|
+
tree.root
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
# Yields the element and every element below it, depth first.
|
|
@@ -46,12 +53,45 @@ module Stationery
|
|
|
46
53
|
element.children << Stationery::Text::Entities.decode(content).gsub(/\s+/, " ")
|
|
47
54
|
end
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
# The elements open while the source is read. `hoisted` counts the open
|
|
57
|
+
# ones too deep to descend into; their end tags are counted off again.
|
|
58
|
+
class Tree
|
|
59
|
+
attr_reader :deepest
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
def initialize(max_depth)
|
|
62
|
+
@document = Element.new("document", {}, [])
|
|
63
|
+
@stack = [@document]
|
|
64
|
+
@max_depth = max_depth
|
|
65
|
+
@hoisted = 0
|
|
66
|
+
@deepest = 0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def root = @document.children.first
|
|
70
|
+
def open = @stack.last
|
|
71
|
+
|
|
72
|
+
def tag(closing, name, attributes, self_closing)
|
|
73
|
+
return close if closing == "/"
|
|
74
|
+
|
|
75
|
+
element = Element.new(name, attributes.scan(ATTRIBUTE).to_h { |k, *v| [k, v.compact.first] }, [])
|
|
76
|
+
open.children << element
|
|
77
|
+
descend(element) if self_closing.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def close
|
|
83
|
+
return @hoisted -= 1 if @hoisted.positive?
|
|
84
|
+
|
|
85
|
+
@stack.pop if @stack.size > 1
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def descend(element)
|
|
89
|
+
depth = @stack.size + @hoisted
|
|
90
|
+
@deepest = depth if depth > @deepest
|
|
91
|
+
return @stack << element if @stack.size <= @max_depth
|
|
92
|
+
|
|
93
|
+
@hoisted += 1
|
|
94
|
+
end
|
|
55
95
|
end
|
|
56
96
|
end
|
|
57
97
|
end
|
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
|
|
|
@@ -11,6 +11,7 @@ module Stationery
|
|
|
11
11
|
class Walker
|
|
12
12
|
RENDERED = (Shapes::NAMES + %w[text g use svg]).freeze
|
|
13
13
|
VIEWPORTS = %w[symbol svg].freeze
|
|
14
|
+
MAX_USES = 32 # a use of a use of a use …
|
|
14
15
|
|
|
15
16
|
attr_reader :issues
|
|
16
17
|
|
|
@@ -69,6 +70,7 @@ module Stationery
|
|
|
69
70
|
target, id = target_of(element)
|
|
70
71
|
return unless target
|
|
71
72
|
return issue("use: circular reference ##{id}") if chain.include?(id)
|
|
73
|
+
return issue("use: nested deeper than #{MAX_USES}") if chain.size >= MAX_USES
|
|
72
74
|
|
|
73
75
|
attributes = element.attributes
|
|
74
76
|
placed = style.transformed([1, 0, 0, 1, Shapes.f(attributes, "x"), Shapes.f(attributes, "y")])
|
|
@@ -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
|
|
@@ -133,6 +135,22 @@ module Stationery
|
|
|
133
135
|
entries.each_slice(2).map { |_name, ref| attachment(objects.deref_hash(ref)) }
|
|
134
136
|
end
|
|
135
137
|
|
|
138
|
+
# Every signed signature field: `{ field:, name:, reason:, location:,
|
|
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
|
|
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.
|
|
146
|
+
def signatures
|
|
147
|
+
form = catalog[:AcroForm]
|
|
148
|
+
return [] unless form
|
|
149
|
+
|
|
150
|
+
require "openssl"
|
|
151
|
+
signature_fields(objects.deref_hash(form)[:Fields], nil)
|
|
152
|
+
end
|
|
153
|
+
|
|
136
154
|
# The structure tree of a tagged PDF as nested arrays; see StructureReader.
|
|
137
155
|
def structure = @structure ||= StructureReader.new(reader).tree
|
|
138
156
|
|
|
@@ -167,6 +185,86 @@ module Stationery
|
|
|
167
185
|
description: spec[:Desc] && decode(spec[:Desc]), relationship: }
|
|
168
186
|
end
|
|
169
187
|
|
|
188
|
+
def signature_fields(refs, prefix)
|
|
189
|
+
Array(objects.deref_array(refs)).flat_map do |ref|
|
|
190
|
+
field = objects.deref_hash(ref)
|
|
191
|
+
name = [prefix, field[:T] && decode(field[:T])].compact.join(".")
|
|
192
|
+
kids = Array(objects.deref_array(field[:Kids])).select { |kid| objects.deref_hash(kid).key?(:T) }
|
|
193
|
+
next signature_fields(kids, name) if kids.any?
|
|
194
|
+
|
|
195
|
+
field[:FT] == :Sig && field[:V] ? [signature(name, objects.deref_hash(field[:V]))] : []
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def signature(field, value)
|
|
200
|
+
range = objects.deref_array(value[:ByteRange])
|
|
201
|
+
text = value.slice(:Name, :Reason, :Location).transform_values { |string| decode(string) }
|
|
202
|
+
{ field:, name: text[:Name], reason: text[:Reason], location: text[:Location],
|
|
203
|
+
signed_at: signing_time(value[:M]), subfilter: value[:SubFilter], byte_range: range, **verdict(range) }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def signing_time(date)
|
|
207
|
+
digits = date.to_s[/\AD:(\d{14})/, 1]
|
|
208
|
+
digits && Time.utc(*digits.unpack("a4a2a2a2a2a2").map(&:to_i))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The signature is read from the file by its byte range, not from the
|
|
212
|
+
# parsed dictionary: it is the one string encryption leaves alone.
|
|
213
|
+
def verdict(range)
|
|
214
|
+
contents = pdf.byteslice(range[1], range[2] - range[1]).to_s
|
|
215
|
+
der = PDF::Signature.der([contents[1..-2]].pack("H*"))
|
|
216
|
+
cms = OpenSSL::PKCS7.new(der)
|
|
217
|
+
signed = pdf.byteslice(range[0], range[1]) + pdf.byteslice(range[2], range[3])
|
|
218
|
+
flags = OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::BINARY
|
|
219
|
+
whole = range[0].zero? && range[2] + range[3] == pdf.bytesize && contents.start_with?("<")
|
|
220
|
+
{ signer: signer(cms), valid: whole && cms.verify([], OpenSSL::X509::Store.new, signed, flags),
|
|
221
|
+
timestamp: timestamp(der) }
|
|
222
|
+
rescue OpenSSL::OpenSSLError, ArgumentError, TypeError
|
|
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
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def signer(cms)
|
|
263
|
+
info = cms.signers.first
|
|
264
|
+
certificate = cms.certificates.find { |one| one.serial == info.serial && one.issuer == info.issuer }
|
|
265
|
+
certificate&.subject&.to_utf8
|
|
266
|
+
end
|
|
267
|
+
|
|
170
268
|
def xmp_value(body)
|
|
171
269
|
items = body.scan(XMP_ITEM).flatten.map { |item| xmp_text(item) }
|
|
172
270
|
return xmp_text(body) if items.empty?
|
|
@@ -183,6 +183,30 @@ module Stationery
|
|
|
183
183
|
end
|
|
184
184
|
end
|
|
185
185
|
|
|
186
|
+
class HaveSignature < Base
|
|
187
|
+
def initialize(name: nil, valid: true)
|
|
188
|
+
super(name)
|
|
189
|
+
@valid = valid
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def description
|
|
193
|
+
"have #{@valid ? "a valid" : "an invalid"} signature#{" by #{show(@expected)}" if @expected}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def match?(pdf)
|
|
199
|
+
pdf.signatures.any? { |one| (@expected.nil? || one[:name] == @expected) && one[:valid] == @valid }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def actual
|
|
203
|
+
signatures = @inspector.signatures
|
|
204
|
+
return "got none" if signatures.empty?
|
|
205
|
+
|
|
206
|
+
"got #{signatures.map { |one| "#{one[:name].inspect} (#{one[:valid] ? "valid" : "invalid"})" }.join(", ")}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
186
210
|
class HaveNoWarnings < Base
|
|
187
211
|
def description = "have no warnings"
|
|
188
212
|
|
|
@@ -227,6 +251,7 @@ module Stationery
|
|
|
227
251
|
def have_attachment(name, **) = HaveAttachment.new(name, **)
|
|
228
252
|
def have_conformance(*levels) = HaveConformance.new(*levels)
|
|
229
253
|
def have_factur_x(profile: nil) = HaveFacturX.new(profile:)
|
|
254
|
+
def have_signature(name: nil, valid: true) = HaveSignature.new(name:, valid:)
|
|
230
255
|
def have_no_warnings = HaveNoWarnings.new
|
|
231
256
|
def have_structure(expected) = HaveStructure.new(expected)
|
|
232
257
|
def have_tagged_content = HaveTaggedContent.new
|
|
@@ -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
|
|