stationery 0.7.0 → 0.9.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 +22 -0
- data/README.md +251 -23
- data/lib/stationery/canvas.rb +22 -1
- data/lib/stationery/cli/render.rb +19 -4
- data/lib/stationery/document.rb +69 -9
- data/lib/stationery/elements/forms.rb +11 -6
- data/lib/stationery/elements/rich.rb +13 -6
- data/lib/stationery/elements.rb +11 -3
- data/lib/stationery/errors.rb +13 -0
- 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/document.rb +8 -2
- data/lib/stationery/html/tree_builder.rb +34 -4
- data/lib/stationery/images/jpeg.rb +2 -1
- data/lib/stationery/layout/image.rb +9 -1
- data/lib/stationery/layout/table/cell.rb +14 -4
- data/lib/stationery/layout/table.rb +72 -14
- 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 +16 -0
- data/lib/stationery/pdf/assembler.rb +51 -11
- data/lib/stationery/pdf/attachments.rb +76 -0
- data/lib/stationery/pdf/conformance.rb +141 -0
- data/lib/stationery/pdf/factur_x.rb +117 -0
- data/lib/stationery/pdf/icc/LICENSE.md +14 -0
- data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
- data/lib/stationery/pdf/serializer.rb +3 -1
- data/lib/stationery/pdf/signature/cms.rb +87 -0
- data/lib/stationery/pdf/signature.rb +167 -0
- data/lib/stationery/pdf/stream.rb +5 -3
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/pdf/xmp.rb +149 -0
- data/lib/stationery/resources.rb +3 -0
- data/lib/stationery/rich/nesting.rb +30 -0
- data/lib/stationery/rich/renderer/indents.rb +42 -0
- data/lib/stationery/rich/renderer.rb +22 -6
- data/lib/stationery/svg/clip_path.rb +69 -0
- data/lib/stationery/svg/document.rb +34 -26
- data/lib/stationery/svg/painter.rb +11 -0
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/region.rb +25 -0
- data/lib/stationery/svg/shapes.rb +2 -0
- data/lib/stationery/svg/style.rb +25 -14
- data/lib/stationery/svg/viewport.rb +78 -0
- data/lib/stationery/svg/walker.rb +144 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +123 -0
- data/lib/stationery/testing/matchers.rb +92 -0
- data/lib/stationery/text/wrapper.rb +12 -3
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +8 -0
- data/lib/stationery.rb +11 -0
- metadata +16 -1
data/lib/stationery/svg/style.rb
CHANGED
|
@@ -4,13 +4,17 @@ module Stationery
|
|
|
4
4
|
module SVG
|
|
5
5
|
# Presentation attributes an element inherits from its ancestors, and the
|
|
6
6
|
# transform it draws with. An element's own values cascade: presentation
|
|
7
|
-
# attributes, then stylesheet rules, then its inline `style`.
|
|
7
|
+
# attributes, then stylesheet rules, then its inline `style`. `color` sets
|
|
8
|
+
# what `currentColor` means from the element down.
|
|
8
9
|
class Style
|
|
9
|
-
INHERITED = %w[fill stroke stroke-width stroke-linecap stroke-linejoin fill-rule opacity fill-opacity
|
|
10
|
+
INHERITED = %w[fill stroke stroke-width stroke-linecap stroke-linejoin fill-rule clip-rule opacity fill-opacity
|
|
10
11
|
stroke-opacity font-family font-size font-weight font-style text-anchor visibility].freeze
|
|
12
|
+
# Properties of the element alone, not handed to its children.
|
|
13
|
+
LOCAL = %w[display clip-path overflow].freeze
|
|
11
14
|
NAMED = { "black" => "#000000", "white" => "#FFFFFF", "red" => "#FF0000", "green" => "#008000",
|
|
12
15
|
"blue" => "#0000FF", "gray" => "#808080", "grey" => "#808080" }.freeze
|
|
13
16
|
DEFAULTS = { "fill" => "black", "stroke" => "none", "stroke-width" => "1" }.freeze
|
|
17
|
+
IDENTITY = [1, 0, 0, 1, 0, 0].freeze
|
|
14
18
|
URL = /\Aurl\(\s*['"]?#([^'")\s]+)['"]?\s*\)\s*(.*)\z/
|
|
15
19
|
|
|
16
20
|
# A paint server reference, `url(#id)`, with its fallback colour (or nil).
|
|
@@ -32,34 +36,41 @@ module Stationery
|
|
|
32
36
|
|
|
33
37
|
attr_reader :values, :matrix, :color
|
|
34
38
|
|
|
35
|
-
def initialize(values = DEFAULTS, matrix =
|
|
36
|
-
display: nil)
|
|
39
|
+
def initialize(values = DEFAULTS, matrix = IDENTITY, color: "#000000", sheet: Stylesheet::EMPTY, local: {})
|
|
37
40
|
@values = values
|
|
38
41
|
@matrix = matrix
|
|
39
42
|
@color = color
|
|
40
43
|
@sheet = sheet
|
|
41
|
-
@
|
|
44
|
+
@local = local
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
def child(element)
|
|
45
48
|
attributes = element.attributes
|
|
46
49
|
own = attributes.merge(@sheet.declarations(element), Style.declarations(attributes["style"]))
|
|
47
|
-
|
|
48
|
-
matrix =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
else
|
|
52
|
-
@matrix
|
|
53
|
-
end
|
|
54
|
-
Style.new(values, matrix, color: @color, sheet: @sheet, display: own["display"])
|
|
50
|
+
transform = attributes["transform"]
|
|
51
|
+
matrix = transform ? Transform.multiply(@matrix, Transform.parse(transform)) : @matrix
|
|
52
|
+
color = own["color"] ? Style.color(own["color"], @color) : @color
|
|
53
|
+
Style.new(@values.merge(own.slice(*INHERITED)), matrix, color:, sheet: @sheet, local: own.slice(*LOCAL))
|
|
55
54
|
end
|
|
56
55
|
|
|
57
|
-
|
|
56
|
+
# This style with `matrix` applied inside its transform: what a `use`
|
|
57
|
+
# does with its x and y, a viewport with its viewBox.
|
|
58
|
+
def transformed(matrix) = at(Transform.multiply(@matrix, matrix))
|
|
59
|
+
|
|
60
|
+
# This style drawing under another transform altogether.
|
|
61
|
+
def at(matrix, values = @values) = Style.new(values, matrix, color: @color, sheet: @sheet, local: @local)
|
|
62
|
+
|
|
63
|
+
def displayed? = @local["display"] != "none"
|
|
58
64
|
def visible? = !%w[hidden collapse].include?(@values["visibility"])
|
|
65
|
+
# Whether a viewport clips what it draws: all but `visible` and `auto` do.
|
|
66
|
+
def clips? = !%w[visible auto].include?(@local["overflow"])
|
|
67
|
+
# The id of the element's `clip-path: url(#id)`, or nil.
|
|
68
|
+
def clip_path = @local["clip-path"].to_s.strip[URL, 1]
|
|
59
69
|
|
|
60
70
|
def fill = paint("fill")
|
|
61
71
|
def stroke = paint("stroke")
|
|
62
72
|
def even_odd? = @values["fill-rule"] == "evenodd"
|
|
73
|
+
def clip_even_odd? = @values["clip-rule"] == "evenodd"
|
|
63
74
|
def cap = @values["stroke-linecap"]&.to_sym
|
|
64
75
|
def join = @values["stroke-linejoin"]&.to_sym
|
|
65
76
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module SVG
|
|
5
|
+
# The rectangle a symbol or a nested svg draws into, and how its viewBox
|
|
6
|
+
# is fitted there by `preserveAspectRatio`. Lengths may be percentages of
|
|
7
|
+
# the viewport around it (`parent`, [width, height]); without a size it
|
|
8
|
+
# fills that viewport. A symbol sits at the origin of its `use`
|
|
9
|
+
# (`origin: false`), a nested svg at its own x and y.
|
|
10
|
+
class Viewport
|
|
11
|
+
ALIGNMENT = /\Ax(Min|Mid|Max)Y(Min|Mid|Max)(?:\s+(meet|slice))?\z/
|
|
12
|
+
FRACTIONS = { "Min" => 0.0, "Mid" => 0.5, "Max" => 1.0 }.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :x, :y, :width, :height
|
|
15
|
+
|
|
16
|
+
def initialize(attributes, parent, origin: true)
|
|
17
|
+
@x = origin ? length(attributes["x"], parent[0], 0) : 0
|
|
18
|
+
@y = origin ? length(attributes["y"], parent[1], 0) : 0
|
|
19
|
+
@width = length(attributes["width"], parent[0], parent[0])
|
|
20
|
+
@height = length(attributes["height"], parent[1], parent[1])
|
|
21
|
+
@view_box = read_view_box(attributes["viewBox"])
|
|
22
|
+
@aspect = attributes["preserveAspectRatio"].to_s.strip
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def drawable?
|
|
26
|
+
@width.positive? && @height.positive? && (@view_box.nil? || @view_box.last(2).all?(&:positive?))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The size percentages inside resolve against.
|
|
30
|
+
def size = @view_box ? @view_box.last(2) : [@width, @height]
|
|
31
|
+
|
|
32
|
+
# Content space to the user space the viewport sits in.
|
|
33
|
+
def matrix
|
|
34
|
+
return [1, 0, 0, 1, @x, @y] unless @view_box
|
|
35
|
+
|
|
36
|
+
vx, vy, vw, vh = @view_box
|
|
37
|
+
sx, sy = scales(@width.fdiv(vw), @height.fdiv(vh))
|
|
38
|
+
align_x, align_y = alignment
|
|
39
|
+
[sx, 0, 0, sy, @x + (align_x * (@width - (vw * sx))) - (vx * sx),
|
|
40
|
+
@y + (align_y * (@height - (vh * sy))) - (vy * sy)].map { |value| tidy(value) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Traces the viewport's rectangle, for clipping what overflows it.
|
|
44
|
+
def trace(path) = path.rounded_rect(@x, @y, @width, @height, 0)
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def scales(horizontal, vertical)
|
|
49
|
+
return [horizontal, vertical] if @aspect == "none"
|
|
50
|
+
|
|
51
|
+
scale = slice? ? [horizontal, vertical].max : [horizontal, vertical].min
|
|
52
|
+
[scale, scale]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def slice? = @aspect.match(ALIGNMENT)&.[](3) == "slice"
|
|
56
|
+
|
|
57
|
+
# The share of the spare room put before the content on each axis.
|
|
58
|
+
def alignment
|
|
59
|
+
match = @aspect.match(ALIGNMENT)
|
|
60
|
+
match ? [FRACTIONS.fetch(match[1]), FRACTIONS.fetch(match[2])] : [0.5, 0.5]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def read_view_box(source)
|
|
64
|
+
values = source.to_s.split(/[\s,]+/).reject(&:empty?).map { |value| tidy(value.to_f) }
|
|
65
|
+
values if values.size == 4
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def length(value, reference, default)
|
|
69
|
+
value = value.to_s.strip
|
|
70
|
+
return default if value.empty?
|
|
71
|
+
|
|
72
|
+
tidy(value.end_with?("%") ? value.to_f * reference / 100 : value.to_f)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def tidy(value) = value == value.round ? value.round : value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module SVG
|
|
5
|
+
# Walks a document in paint order and yields every drawn shape and text
|
|
6
|
+
# with its resolved style. A `use` draws what it refers to at its x and
|
|
7
|
+
# y, a symbol (through `use` only) and a nested svg draw into their
|
|
8
|
+
# viewport, and what has a `clip-path` or overflows a viewport is walked
|
|
9
|
+
# inside `clipper`: a callable given the Region and a block, left out
|
|
10
|
+
# when the document is only read. What cannot be followed is in `issues`.
|
|
11
|
+
class Walker
|
|
12
|
+
RENDERED = (Shapes::NAMES + %w[text g use svg]).freeze
|
|
13
|
+
VIEWPORTS = %w[symbol svg].freeze
|
|
14
|
+
MAX_USES = 32 # a use of a use of a use …
|
|
15
|
+
|
|
16
|
+
attr_reader :issues
|
|
17
|
+
|
|
18
|
+
# `ids` are the document's elements by id, `viewport` the [width,
|
|
19
|
+
# height] percentages resolve against at the root.
|
|
20
|
+
def initialize(ids, viewport, clipper: nil)
|
|
21
|
+
@ids = ids
|
|
22
|
+
@viewports = [viewport]
|
|
23
|
+
@clipper = clipper
|
|
24
|
+
@clip_paths = ClipPath.new(ids, self)
|
|
25
|
+
@issues = []
|
|
26
|
+
@measuring = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Yields what the children of `parent` draw; `style` is the parent's.
|
|
30
|
+
# `chain` holds the ids of the `use` targets being drawn.
|
|
31
|
+
def children(parent, style, chain = [], &)
|
|
32
|
+
parent.children.grep(Parser::Element).each { |element| node(element, style.child(element), chain, &) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The box of what `element` draws, in its own user space, clipping
|
|
36
|
+
# and text aside: [x, y, width, height], or nil when it has no area.
|
|
37
|
+
def box(element, style)
|
|
38
|
+
bounds = Bounds.new
|
|
39
|
+
measuring do
|
|
40
|
+
node(element, style.at(Style::IDENTITY), []) do |shape, own|
|
|
41
|
+
corners(shape, own.matrix).each { |x, y| bounds.line_to(x, y) } unless shape.name == "text"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
bounds.box
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def issue(message)
|
|
48
|
+
@issues << message unless @measuring
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Anything else (defs, symbol, clipPath, a gradient, …) draws nothing
|
|
55
|
+
# where it stands.
|
|
56
|
+
def node(element, style, chain, &)
|
|
57
|
+
return unless RENDERED.include?(element.name) && style.displayed?
|
|
58
|
+
|
|
59
|
+
clipped(element, style) do
|
|
60
|
+
case element.name
|
|
61
|
+
when "g" then children(element, style, chain, &)
|
|
62
|
+
when "use" then use(element, style, chain, &)
|
|
63
|
+
when "svg" then viewport(element, element.attributes, style, chain, &)
|
|
64
|
+
else yield element, style if style.visible?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def use(element, style, chain, &)
|
|
70
|
+
target, id = target_of(element)
|
|
71
|
+
return unless target
|
|
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
|
|
74
|
+
|
|
75
|
+
attributes = element.attributes
|
|
76
|
+
placed = style.transformed([1, 0, 0, 1, Shapes.f(attributes, "x"), Shapes.f(attributes, "y")])
|
|
77
|
+
instance(target, placed.child(target), attributes.slice("width", "height"), chain + [id], &)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A symbol or an svg drawn by a use takes the use's width and height.
|
|
81
|
+
def instance(target, style, size, chain, &)
|
|
82
|
+
return node(target, style, chain, &) unless VIEWPORTS.include?(target.name)
|
|
83
|
+
return unless style.displayed?
|
|
84
|
+
|
|
85
|
+
clipped(target, style) { viewport(target, target.attributes.merge(size), style, chain, &) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def target_of(element)
|
|
89
|
+
href = (element.attributes["href"] || element.attributes["xlink:href"]).to_s.strip
|
|
90
|
+
return issue("use: no href") if href.empty?
|
|
91
|
+
|
|
92
|
+
id = href[/\A#(.+)/, 1]
|
|
93
|
+
target = @ids[id]
|
|
94
|
+
target ? [target, id] : issue("use: #{href} not found")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def viewport(element, attributes, style, chain, &)
|
|
98
|
+
port = Viewport.new(attributes, @viewports.last, origin: element.name == "svg")
|
|
99
|
+
return unless port.drawable?
|
|
100
|
+
|
|
101
|
+
region = Region.new([Region::Part.new(style.matrix, port)], false, nil) if style.clips?
|
|
102
|
+
clip(region) { within(port) { children(element, style.transformed(port.matrix), chain, &) } }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def within(port)
|
|
106
|
+
@viewports.push(port.size)
|
|
107
|
+
yield
|
|
108
|
+
ensure
|
|
109
|
+
@viewports.pop
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def clipped(element, style, &)
|
|
113
|
+
id = style.clip_path
|
|
114
|
+
return yield if id.nil? || @measuring
|
|
115
|
+
|
|
116
|
+
region = @clip_paths.region(id, element, style)
|
|
117
|
+
region ? clip(region, &) : yield
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def clip(region, &)
|
|
121
|
+
return yield if region.nil? || @measuring
|
|
122
|
+
return if region.empty?
|
|
123
|
+
|
|
124
|
+
@clipper ? @clipper.call(region, &) : yield
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def measuring
|
|
128
|
+
previous = @measuring
|
|
129
|
+
@measuring = true
|
|
130
|
+
yield
|
|
131
|
+
ensure
|
|
132
|
+
@measuring = previous
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def corners(shape, matrix)
|
|
136
|
+
x, y, width, height = Bounds.new.tap { |bounds| Shapes.trace(bounds, shape) }.box
|
|
137
|
+
return [] unless x
|
|
138
|
+
|
|
139
|
+
[[x, y], [x + width, y], [x, y + height], [x + width, y + height]]
|
|
140
|
+
.map { |corner_x, corner_y| Transform.apply(matrix, corner_x, corner_y) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -12,6 +12,9 @@ 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
|
+
XMP_PROPERTY = %r{^\s*<((?!rdf:)[\w.-]+:[\w.-]+)>(.*?)</\1>$}
|
|
16
|
+
XMP_ITEM = %r{<rdf:li[^>]*>(.*?)</rdf:li>}
|
|
17
|
+
XMP_ENTITIES = { "&" => "&", "<" => "<", ">" => ">", """ => '"' }.freeze
|
|
15
18
|
|
|
16
19
|
def initialize(subject)
|
|
17
20
|
@subject = subject
|
|
@@ -39,6 +42,45 @@ module Stationery
|
|
|
39
42
|
def page_texts = @page_texts ||= reader.pages.map { |page| page.text.squeeze(" ").strip }
|
|
40
43
|
def text = page_texts.join("\n")
|
|
41
44
|
def metadata = reader.info
|
|
45
|
+
|
|
46
|
+
# The XMP packet (the catalog's /Metadata stream) as a String, or nil.
|
|
47
|
+
def xmp
|
|
48
|
+
return unless catalog[:Metadata]
|
|
49
|
+
|
|
50
|
+
objects.deref!(catalog[:Metadata]).unfiltered_data.dup.force_encoding(Encoding::UTF_8)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The packet's properties by prefixed name (`"dc:title"`): a plain value
|
|
54
|
+
# as a String, an rdf:Alt as its default String, an rdf:Seq or rdf:Bag as
|
|
55
|
+
# an Array. Reads the packets Stationery writes (one property per line).
|
|
56
|
+
def xmp_values
|
|
57
|
+
packet = xmp
|
|
58
|
+
return {} unless packet
|
|
59
|
+
|
|
60
|
+
packet.scan(XMP_PROPERTY).to_h { |name, body| [name, xmp_value(body)] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The conformance levels the XMP packet claims, as PDF::Conformance
|
|
64
|
+
# names them: `[:pdf_a3b, :pdf_ua1]`, or [] without a claim.
|
|
65
|
+
def conformance
|
|
66
|
+
values = xmp_values
|
|
67
|
+
part, level, accessible = values.values_at("pdfaid:part", "pdfaid:conformance", "pdfuaid:part")
|
|
68
|
+
[part && :"pdf_a#{part}#{level.to_s.downcase}", accessible && :"pdf_ua#{accessible}"].compact
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The Factur-X / ZUGFeRD invoice the XMP packet names: `{ profile:
|
|
72
|
+
# :en16931, filename:, version:, xml: }` (xml nil when the file it
|
|
73
|
+
# names is not embedded), or nil for any other PDF.
|
|
74
|
+
def factur_x
|
|
75
|
+
values = xmp_values
|
|
76
|
+
filename = values["fx:DocumentFileName"]
|
|
77
|
+
return unless filename
|
|
78
|
+
|
|
79
|
+
file = attachments.find { |attachment| attachment[:name] == filename }
|
|
80
|
+
{ profile: PDF::FacturX::PROFILES.key(values["fx:ConformanceLevel"]), filename:,
|
|
81
|
+
version: values["fx:Version"], xml: file && file[:bytes].dup.force_encoding(Encoding::UTF_8) }
|
|
82
|
+
end
|
|
83
|
+
|
|
42
84
|
def image_count = pdf.scan(%r{/Subtype\s*/Image\b}).size
|
|
43
85
|
|
|
44
86
|
def warnings
|
|
@@ -81,6 +123,29 @@ module Stationery
|
|
|
81
123
|
PDF::PageLabels.labels(nums, page_count)
|
|
82
124
|
end
|
|
83
125
|
|
|
126
|
+
# Every embedded file from the catalog's /EmbeddedFiles name tree:
|
|
127
|
+
# `{ name:, mime:, bytes:, description:, relationship: }` (bytes inflated).
|
|
128
|
+
def attachments
|
|
129
|
+
names = catalog.dig(:Names, :EmbeddedFiles)
|
|
130
|
+
return [] unless names
|
|
131
|
+
|
|
132
|
+
entries = objects.deref_array(objects.deref_hash(names)[:Names])
|
|
133
|
+
entries.each_slice(2).map { |_name, ref| attachment(objects.deref_hash(ref)) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Every signed signature field: `{ field:, name:, reason:, location:,
|
|
137
|
+
# signed_at:, subfilter:, byte_range:, signer:, valid: }`. `signer` is
|
|
138
|
+
# the signing certificate's subject ("CN=…,O=…"). `valid` is whether
|
|
139
|
+
# the signature covers the whole file and verifies against the
|
|
140
|
+
# certificate it carries; the certificate's own trust is not judged.
|
|
141
|
+
def signatures
|
|
142
|
+
form = catalog[:AcroForm]
|
|
143
|
+
return [] unless form
|
|
144
|
+
|
|
145
|
+
require "openssl"
|
|
146
|
+
signature_fields(objects.deref_hash(form)[:Fields], nil)
|
|
147
|
+
end
|
|
148
|
+
|
|
84
149
|
# The structure tree of a tagged PDF as nested arrays; see StructureReader.
|
|
85
150
|
def structure = @structure ||= StructureReader.new(reader).tree
|
|
86
151
|
|
|
@@ -108,6 +173,64 @@ module Stationery
|
|
|
108
173
|
end
|
|
109
174
|
end
|
|
110
175
|
|
|
176
|
+
def attachment(spec)
|
|
177
|
+
stream = objects.deref(objects.deref_hash(spec[:EF])[:F])
|
|
178
|
+
relationship = PDF::Attachments::RELATIONSHIPS.key(spec[:AFRelationship]) || spec[:AFRelationship]
|
|
179
|
+
{ name: decode(spec[:UF] || spec[:F]), mime: stream.hash[:Subtype].to_s, bytes: stream.unfiltered_data,
|
|
180
|
+
description: spec[:Desc] && decode(spec[:Desc]), relationship: }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def signature_fields(refs, prefix)
|
|
184
|
+
Array(objects.deref_array(refs)).flat_map do |ref|
|
|
185
|
+
field = objects.deref_hash(ref)
|
|
186
|
+
name = [prefix, field[:T] && decode(field[:T])].compact.join(".")
|
|
187
|
+
kids = Array(objects.deref_array(field[:Kids])).select { |kid| objects.deref_hash(kid).key?(:T) }
|
|
188
|
+
next signature_fields(kids, name) if kids.any?
|
|
189
|
+
|
|
190
|
+
field[:FT] == :Sig && field[:V] ? [signature(name, objects.deref_hash(field[:V]))] : []
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def signature(field, value)
|
|
195
|
+
range = objects.deref_array(value[:ByteRange])
|
|
196
|
+
text = value.slice(:Name, :Reason, :Location).transform_values { |string| decode(string) }
|
|
197
|
+
{ field:, name: text[:Name], reason: text[:Reason], location: text[:Location],
|
|
198
|
+
signed_at: signing_time(value[:M]), subfilter: value[:SubFilter], byte_range: range, **verdict(range) }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def signing_time(date)
|
|
202
|
+
digits = date.to_s[/\AD:(\d{14})/, 1]
|
|
203
|
+
digits && Time.utc(*digits.unpack("a4a2a2a2a2a2").map(&:to_i))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The signature is read from the file by its byte range, not from the
|
|
207
|
+
# parsed dictionary: it is the one string encryption leaves alone.
|
|
208
|
+
def verdict(range)
|
|
209
|
+
contents = pdf.byteslice(range[1], range[2] - range[1]).to_s
|
|
210
|
+
cms = OpenSSL::PKCS7.new(PDF::Signature.der([contents[1..-2]].pack("H*")))
|
|
211
|
+
signed = pdf.byteslice(range[0], range[1]) + pdf.byteslice(range[2], range[3])
|
|
212
|
+
flags = OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::BINARY
|
|
213
|
+
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) }
|
|
215
|
+
rescue OpenSSL::OpenSSLError, ArgumentError, TypeError
|
|
216
|
+
{ signer: nil, valid: false }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def signer(cms)
|
|
220
|
+
info = cms.signers.first
|
|
221
|
+
certificate = cms.certificates.find { |one| one.serial == info.serial && one.issuer == info.issuer }
|
|
222
|
+
certificate&.subject&.to_utf8
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def xmp_value(body)
|
|
226
|
+
items = body.scan(XMP_ITEM).flatten.map { |item| xmp_text(item) }
|
|
227
|
+
return xmp_text(body) if items.empty?
|
|
228
|
+
|
|
229
|
+
body.start_with?("<rdf:Alt>") ? items.first : items
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def xmp_text(value) = value.gsub(/&(amp|lt|gt|quot);/, XMP_ENTITIES)
|
|
233
|
+
|
|
111
234
|
def destination(dest)
|
|
112
235
|
return dest.to_s unless dest.is_a?(Array)
|
|
113
236
|
|
|
@@ -119,6 +119,94 @@ module Stationery
|
|
|
119
119
|
def actual = @inspector.page_labels.empty? ? "got none" : "got #{@inspector.page_labels.inspect}"
|
|
120
120
|
end
|
|
121
121
|
|
|
122
|
+
class HaveConformance < Base
|
|
123
|
+
def initialize(*levels) = super(levels.flatten.map(&:to_sym))
|
|
124
|
+
|
|
125
|
+
def description = "conform to #{labels(@expected)}"
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def labels(levels) = levels.map { |level| PDF::Conformance.label(level) }.join(" and ")
|
|
130
|
+
def match?(pdf) = (@expected - pdf.conformance).empty?
|
|
131
|
+
def actual = @inspector.conformance.empty? ? "got no claim" : "got #{labels(@inspector.conformance)}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class HaveFacturX < Base
|
|
135
|
+
def initialize(profile: nil) = super(profile&.to_sym)
|
|
136
|
+
|
|
137
|
+
def description
|
|
138
|
+
@expected ? "be a Factur-X invoice of profile #{@expected.inspect}" : "be a Factur-X invoice"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def match?(pdf)
|
|
144
|
+
invoice = pdf.factur_x
|
|
145
|
+
!invoice.nil? && !invoice[:xml].nil? && (@expected.nil? || invoice[:profile] == @expected)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def actual
|
|
149
|
+
invoice = @inspector.factur_x
|
|
150
|
+
return "got no invoice" unless invoice
|
|
151
|
+
return "got #{invoice[:filename]} named in XMP but not embedded" unless invoice[:xml]
|
|
152
|
+
|
|
153
|
+
"got profile #{invoice[:profile].inspect}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
class HaveAttachment < Base
|
|
158
|
+
def initialize(name, mime: nil, relationship: nil)
|
|
159
|
+
super(name)
|
|
160
|
+
@mime = mime
|
|
161
|
+
@relationship = relationship
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def description
|
|
165
|
+
extra = { mime: @mime, relationship: @relationship }.compact.map { |k, v| " #{k} #{v.inspect}" }.join
|
|
166
|
+
"have an attachment #{show(@expected)}#{extra}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def match?(pdf)
|
|
172
|
+
pdf.attachments.any? do |file|
|
|
173
|
+
file[:name] == @expected && (@mime.nil? || file[:mime] == @mime) &&
|
|
174
|
+
(@relationship.nil? || file[:relationship] == @relationship)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def actual
|
|
179
|
+
files = @inspector.attachments
|
|
180
|
+
return "got none" if files.empty?
|
|
181
|
+
|
|
182
|
+
"got #{files.map { |file| "#{file[:name]} (#{file[:mime]}, #{file[:relationship]})" }.inspect}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
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
|
+
|
|
122
210
|
class HaveNoWarnings < Base
|
|
123
211
|
def description = "have no warnings"
|
|
124
212
|
|
|
@@ -160,6 +248,10 @@ module Stationery
|
|
|
160
248
|
def have_bookmark(title) = HaveBookmark.new(title)
|
|
161
249
|
def have_pdf_language(lang) = HaveLanguage.new(lang)
|
|
162
250
|
def have_page_labels(labels) = HavePageLabels.new(labels)
|
|
251
|
+
def have_attachment(name, **) = HaveAttachment.new(name, **)
|
|
252
|
+
def have_conformance(*levels) = HaveConformance.new(*levels)
|
|
253
|
+
def have_factur_x(profile: nil) = HaveFacturX.new(profile:)
|
|
254
|
+
def have_signature(name: nil, valid: true) = HaveSignature.new(name:, valid:)
|
|
163
255
|
def have_no_warnings = HaveNoWarnings.new
|
|
164
256
|
def have_structure(expected) = HaveStructure.new(expected)
|
|
165
257
|
def have_tagged_content = HaveTaggedContent.new
|
|
@@ -26,6 +26,7 @@ module Stationery
|
|
|
26
26
|
@current = []
|
|
27
27
|
@pending_space = []
|
|
28
28
|
@fallback = fallback_style
|
|
29
|
+
@widths = {}.compare_by_identity
|
|
29
30
|
items(runs).each { |item| place(item) }
|
|
30
31
|
finish unless @current.empty? && @lines.any? && !@ended_with_newline
|
|
31
32
|
@lines
|
|
@@ -97,6 +98,8 @@ module Stationery
|
|
|
97
98
|
# its hyphen) fits `available`; nil when the word offers no break that
|
|
98
99
|
# fits. Soft hyphens name the breaks and suppress the patterns.
|
|
99
100
|
def hyphenated(word, available)
|
|
101
|
+
return unless @explicit || word.first&.style&.hyphenate
|
|
102
|
+
|
|
100
103
|
chars = word.flat_map { |segment| segment.text.chars.map { |char| Segment.new(char, segment.style) } }
|
|
101
104
|
break_points(chars).reverse_each do |index|
|
|
102
105
|
head = chars.first(index).reject { |segment| segment.text == SOFT_HYPHEN }
|
|
@@ -164,7 +167,7 @@ module Stationery
|
|
|
164
167
|
def fragments(segments)
|
|
165
168
|
x = 0
|
|
166
169
|
segments.chunk_while { |a, b| a.style == b.style }.filter_map do |group|
|
|
167
|
-
text = group.map(&:text).join
|
|
170
|
+
text = plain(group.map(&:text).join)
|
|
168
171
|
next if text.empty?
|
|
169
172
|
|
|
170
173
|
font, face = @book.resolve(group.first.style)
|
|
@@ -178,12 +181,18 @@ module Stationery
|
|
|
178
181
|
end
|
|
179
182
|
|
|
180
183
|
def line_width = width(@current)
|
|
181
|
-
def characters(segments) = segments.sum { |segment| segment.text
|
|
184
|
+
def characters(segments) = segments.sum { |segment| plain(segment.text).length }
|
|
182
185
|
|
|
186
|
+
# A line is summed again for every word that joins it; its segments
|
|
187
|
+
# are the same objects each time, so each is measured once per wrap.
|
|
183
188
|
def width(segments)
|
|
184
|
-
segments.sum { |segment| measure(segment.text
|
|
189
|
+
segments.sum { |segment| @widths[segment] ||= measure(plain(segment.text), segment.style) }
|
|
185
190
|
end
|
|
186
191
|
|
|
192
|
+
# The text without its soft hyphens; the same String when it has none,
|
|
193
|
+
# which is nearly always, so measuring a word allocates nothing.
|
|
194
|
+
def plain(text) = text.include?(SOFT_HYPHEN) ? text.delete(SOFT_HYPHEN) : text
|
|
195
|
+
|
|
187
196
|
def measure(text, style)
|
|
188
197
|
font = @book.resolve(style).first
|
|
189
198
|
font.width_of(text, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
|
data/lib/stationery/version.rb
CHANGED
data/lib/stationery/warnings.rb
CHANGED
|
@@ -43,6 +43,10 @@ module Stationery
|
|
|
43
43
|
def message = %(link "#{href}" dropped: scheme not allowed)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
NestingLimit = Data.define(:depth, :limit) do
|
|
47
|
+
def message = "content nested #{depth} levels deep was flattened below level #{limit}"
|
|
48
|
+
end
|
|
49
|
+
|
|
46
50
|
DuplicateAnchor = Data.define(:name, :page) do
|
|
47
51
|
def message = %(anchor "#{name}" on page #{page} is already defined)
|
|
48
52
|
end
|
|
@@ -55,6 +59,10 @@ module Stationery
|
|
|
55
59
|
def message = "tagged PDF has no language: set metadata lang:"
|
|
56
60
|
end
|
|
57
61
|
|
|
62
|
+
ConformanceIssue = Data.define(:level, :subject) do
|
|
63
|
+
def message = "#{PDF::Conformance.label(level)}: #{subject} is not covered by the sRGB output intent"
|
|
64
|
+
end
|
|
65
|
+
|
|
58
66
|
def initialize
|
|
59
67
|
@items = []
|
|
60
68
|
@glyphs = Hash.new(0)
|
data/lib/stationery.rb
CHANGED
|
@@ -62,7 +62,14 @@ require_relative "stationery/canvas/marking"
|
|
|
62
62
|
require_relative "stationery/canvas"
|
|
63
63
|
require_relative "stationery/pdf/outline_writer"
|
|
64
64
|
require_relative "stationery/pdf/page_labels"
|
|
65
|
+
require_relative "stationery/pdf/attachments"
|
|
66
|
+
require_relative "stationery/pdf/xmp"
|
|
67
|
+
require_relative "stationery/pdf/conformance"
|
|
68
|
+
require_relative "stationery/pdf/factur_x"
|
|
69
|
+
require_relative "stationery/pdf/signature"
|
|
70
|
+
require_relative "stationery/pdf/signature/cms"
|
|
65
71
|
require_relative "stationery/forms/metrics"
|
|
72
|
+
require_relative "stationery/forms/typeface"
|
|
66
73
|
require_relative "stationery/forms/field"
|
|
67
74
|
require_relative "stationery/forms/appearance"
|
|
68
75
|
require_relative "stationery/forms/acro_form"
|
|
@@ -108,6 +115,10 @@ require_relative "stationery/svg/gradient"
|
|
|
108
115
|
require_relative "stationery/svg/shading"
|
|
109
116
|
require_relative "stationery/svg/painter"
|
|
110
117
|
require_relative "stationery/svg/text"
|
|
118
|
+
require_relative "stationery/svg/viewport"
|
|
119
|
+
require_relative "stationery/svg/region"
|
|
120
|
+
require_relative "stationery/svg/clip_path"
|
|
121
|
+
require_relative "stationery/svg/walker"
|
|
111
122
|
require_relative "stationery/svg/document"
|
|
112
123
|
require_relative "stationery/layout/svg"
|
|
113
124
|
require_relative "stationery/layout/wrap"
|