stationery 0.7.0 → 0.8.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 +11 -0
- data/README.md +133 -10
- data/lib/stationery/canvas.rb +19 -0
- data/lib/stationery/cli/render.rb +19 -4
- data/lib/stationery/document.rb +53 -9
- data/lib/stationery/errors.rb +13 -0
- data/lib/stationery/images/jpeg.rb +2 -1
- data/lib/stationery/minitest.rb +12 -0
- data/lib/stationery/pdf/assembler.rb +36 -6
- data/lib/stationery/pdf/attachments.rb +76 -0
- data/lib/stationery/pdf/conformance.rb +139 -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/stream.rb +5 -3
- data/lib/stationery/pdf/xmp.rb +149 -0
- data/lib/stationery/resources.rb +3 -0
- data/lib/stationery/svg/clip_path.rb +69 -0
- data/lib/stationery/svg/document.rb +26 -22
- data/lib/stationery/svg/painter.rb +11 -0
- 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 +142 -0
- data/lib/stationery/testing/inspector.rb +68 -0
- data/lib/stationery/testing/matchers.rb +67 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +4 -0
- data/lib/stationery.rb +8 -0
- metadata +11 -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,142 @@
|
|
|
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
|
+
|
|
15
|
+
attr_reader :issues
|
|
16
|
+
|
|
17
|
+
# `ids` are the document's elements by id, `viewport` the [width,
|
|
18
|
+
# height] percentages resolve against at the root.
|
|
19
|
+
def initialize(ids, viewport, clipper: nil)
|
|
20
|
+
@ids = ids
|
|
21
|
+
@viewports = [viewport]
|
|
22
|
+
@clipper = clipper
|
|
23
|
+
@clip_paths = ClipPath.new(ids, self)
|
|
24
|
+
@issues = []
|
|
25
|
+
@measuring = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Yields what the children of `parent` draw; `style` is the parent's.
|
|
29
|
+
# `chain` holds the ids of the `use` targets being drawn.
|
|
30
|
+
def children(parent, style, chain = [], &)
|
|
31
|
+
parent.children.grep(Parser::Element).each { |element| node(element, style.child(element), chain, &) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The box of what `element` draws, in its own user space, clipping
|
|
35
|
+
# and text aside: [x, y, width, height], or nil when it has no area.
|
|
36
|
+
def box(element, style)
|
|
37
|
+
bounds = Bounds.new
|
|
38
|
+
measuring do
|
|
39
|
+
node(element, style.at(Style::IDENTITY), []) do |shape, own|
|
|
40
|
+
corners(shape, own.matrix).each { |x, y| bounds.line_to(x, y) } unless shape.name == "text"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
bounds.box
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def issue(message)
|
|
47
|
+
@issues << message unless @measuring
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Anything else (defs, symbol, clipPath, a gradient, …) draws nothing
|
|
54
|
+
# where it stands.
|
|
55
|
+
def node(element, style, chain, &)
|
|
56
|
+
return unless RENDERED.include?(element.name) && style.displayed?
|
|
57
|
+
|
|
58
|
+
clipped(element, style) do
|
|
59
|
+
case element.name
|
|
60
|
+
when "g" then children(element, style, chain, &)
|
|
61
|
+
when "use" then use(element, style, chain, &)
|
|
62
|
+
when "svg" then viewport(element, element.attributes, style, chain, &)
|
|
63
|
+
else yield element, style if style.visible?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def use(element, style, chain, &)
|
|
69
|
+
target, id = target_of(element)
|
|
70
|
+
return unless target
|
|
71
|
+
return issue("use: circular reference ##{id}") if chain.include?(id)
|
|
72
|
+
|
|
73
|
+
attributes = element.attributes
|
|
74
|
+
placed = style.transformed([1, 0, 0, 1, Shapes.f(attributes, "x"), Shapes.f(attributes, "y")])
|
|
75
|
+
instance(target, placed.child(target), attributes.slice("width", "height"), chain + [id], &)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A symbol or an svg drawn by a use takes the use's width and height.
|
|
79
|
+
def instance(target, style, size, chain, &)
|
|
80
|
+
return node(target, style, chain, &) unless VIEWPORTS.include?(target.name)
|
|
81
|
+
return unless style.displayed?
|
|
82
|
+
|
|
83
|
+
clipped(target, style) { viewport(target, target.attributes.merge(size), style, chain, &) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def target_of(element)
|
|
87
|
+
href = (element.attributes["href"] || element.attributes["xlink:href"]).to_s.strip
|
|
88
|
+
return issue("use: no href") if href.empty?
|
|
89
|
+
|
|
90
|
+
id = href[/\A#(.+)/, 1]
|
|
91
|
+
target = @ids[id]
|
|
92
|
+
target ? [target, id] : issue("use: #{href} not found")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def viewport(element, attributes, style, chain, &)
|
|
96
|
+
port = Viewport.new(attributes, @viewports.last, origin: element.name == "svg")
|
|
97
|
+
return unless port.drawable?
|
|
98
|
+
|
|
99
|
+
region = Region.new([Region::Part.new(style.matrix, port)], false, nil) if style.clips?
|
|
100
|
+
clip(region) { within(port) { children(element, style.transformed(port.matrix), chain, &) } }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def within(port)
|
|
104
|
+
@viewports.push(port.size)
|
|
105
|
+
yield
|
|
106
|
+
ensure
|
|
107
|
+
@viewports.pop
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def clipped(element, style, &)
|
|
111
|
+
id = style.clip_path
|
|
112
|
+
return yield if id.nil? || @measuring
|
|
113
|
+
|
|
114
|
+
region = @clip_paths.region(id, element, style)
|
|
115
|
+
region ? clip(region, &) : yield
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def clip(region, &)
|
|
119
|
+
return yield if region.nil? || @measuring
|
|
120
|
+
return if region.empty?
|
|
121
|
+
|
|
122
|
+
@clipper ? @clipper.call(region, &) : yield
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def measuring
|
|
126
|
+
previous = @measuring
|
|
127
|
+
@measuring = true
|
|
128
|
+
yield
|
|
129
|
+
ensure
|
|
130
|
+
@measuring = previous
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def corners(shape, matrix)
|
|
134
|
+
x, y, width, height = Bounds.new.tap { |bounds| Shapes.trace(bounds, shape) }.box
|
|
135
|
+
return [] unless x
|
|
136
|
+
|
|
137
|
+
[[x, y], [x + width, y], [x, y + height], [x + width, y + height]]
|
|
138
|
+
.map { |corner_x, corner_y| Transform.apply(matrix, corner_x, corner_y) }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
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,16 @@ 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
|
+
|
|
84
136
|
# The structure tree of a tagged PDF as nested arrays; see StructureReader.
|
|
85
137
|
def structure = @structure ||= StructureReader.new(reader).tree
|
|
86
138
|
|
|
@@ -108,6 +160,22 @@ module Stationery
|
|
|
108
160
|
end
|
|
109
161
|
end
|
|
110
162
|
|
|
163
|
+
def attachment(spec)
|
|
164
|
+
stream = objects.deref(objects.deref_hash(spec[:EF])[:F])
|
|
165
|
+
relationship = PDF::Attachments::RELATIONSHIPS.key(spec[:AFRelationship]) || spec[:AFRelationship]
|
|
166
|
+
{ name: decode(spec[:UF] || spec[:F]), mime: stream.hash[:Subtype].to_s, bytes: stream.unfiltered_data,
|
|
167
|
+
description: spec[:Desc] && decode(spec[:Desc]), relationship: }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def xmp_value(body)
|
|
171
|
+
items = body.scan(XMP_ITEM).flatten.map { |item| xmp_text(item) }
|
|
172
|
+
return xmp_text(body) if items.empty?
|
|
173
|
+
|
|
174
|
+
body.start_with?("<rdf:Alt>") ? items.first : items
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def xmp_text(value) = value.gsub(/&(amp|lt|gt|quot);/, XMP_ENTITIES)
|
|
178
|
+
|
|
111
179
|
def destination(dest)
|
|
112
180
|
return dest.to_s unless dest.is_a?(Array)
|
|
113
181
|
|
|
@@ -119,6 +119,70 @@ 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
|
+
|
|
122
186
|
class HaveNoWarnings < Base
|
|
123
187
|
def description = "have no warnings"
|
|
124
188
|
|
|
@@ -160,6 +224,9 @@ module Stationery
|
|
|
160
224
|
def have_bookmark(title) = HaveBookmark.new(title)
|
|
161
225
|
def have_pdf_language(lang) = HaveLanguage.new(lang)
|
|
162
226
|
def have_page_labels(labels) = HavePageLabels.new(labels)
|
|
227
|
+
def have_attachment(name, **) = HaveAttachment.new(name, **)
|
|
228
|
+
def have_conformance(*levels) = HaveConformance.new(*levels)
|
|
229
|
+
def have_factur_x(profile: nil) = HaveFacturX.new(profile:)
|
|
163
230
|
def have_no_warnings = HaveNoWarnings.new
|
|
164
231
|
def have_structure(expected) = HaveStructure.new(expected)
|
|
165
232
|
def have_tagged_content = HaveTaggedContent.new
|
data/lib/stationery/version.rb
CHANGED
data/lib/stationery/warnings.rb
CHANGED
|
@@ -55,6 +55,10 @@ module Stationery
|
|
|
55
55
|
def message = "tagged PDF has no language: set metadata lang:"
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
ConformanceIssue = Data.define(:level, :subject) do
|
|
59
|
+
def message = "#{PDF::Conformance.label(level)}: #{subject} is not covered by the sRGB output intent"
|
|
60
|
+
end
|
|
61
|
+
|
|
58
62
|
def initialize
|
|
59
63
|
@items = []
|
|
60
64
|
@glyphs = Hash.new(0)
|
data/lib/stationery.rb
CHANGED
|
@@ -62,6 +62,10 @@ 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"
|
|
65
69
|
require_relative "stationery/forms/metrics"
|
|
66
70
|
require_relative "stationery/forms/field"
|
|
67
71
|
require_relative "stationery/forms/appearance"
|
|
@@ -108,6 +112,10 @@ require_relative "stationery/svg/gradient"
|
|
|
108
112
|
require_relative "stationery/svg/shading"
|
|
109
113
|
require_relative "stationery/svg/painter"
|
|
110
114
|
require_relative "stationery/svg/text"
|
|
115
|
+
require_relative "stationery/svg/viewport"
|
|
116
|
+
require_relative "stationery/svg/region"
|
|
117
|
+
require_relative "stationery/svg/clip_path"
|
|
118
|
+
require_relative "stationery/svg/walker"
|
|
111
119
|
require_relative "stationery/svg/document"
|
|
112
120
|
require_relative "stationery/layout/svg"
|
|
113
121
|
require_relative "stationery/layout/wrap"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: stationery
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -145,17 +145,23 @@ files:
|
|
|
145
145
|
- lib/stationery/page.rb
|
|
146
146
|
- lib/stationery/page_templates.rb
|
|
147
147
|
- lib/stationery/pdf/assembler.rb
|
|
148
|
+
- lib/stationery/pdf/attachments.rb
|
|
149
|
+
- lib/stationery/pdf/conformance.rb
|
|
148
150
|
- lib/stationery/pdf/encryption/aes.rb
|
|
149
151
|
- lib/stationery/pdf/encryption/rc4.rb
|
|
150
152
|
- lib/stationery/pdf/encryption/revision4.rb
|
|
151
153
|
- lib/stationery/pdf/encryption/revision6.rb
|
|
152
154
|
- lib/stationery/pdf/encryption/standard_security.rb
|
|
155
|
+
- lib/stationery/pdf/factur_x.rb
|
|
156
|
+
- lib/stationery/pdf/icc/LICENSE.md
|
|
157
|
+
- lib/stationery/pdf/icc/sRGB2014.icc
|
|
153
158
|
- lib/stationery/pdf/outline_writer.rb
|
|
154
159
|
- lib/stationery/pdf/page_labels.rb
|
|
155
160
|
- lib/stationery/pdf/serializer.rb
|
|
156
161
|
- lib/stationery/pdf/stream.rb
|
|
157
162
|
- lib/stationery/pdf/types.rb
|
|
158
163
|
- lib/stationery/pdf/writer.rb
|
|
164
|
+
- lib/stationery/pdf/xmp.rb
|
|
159
165
|
- lib/stationery/preview.rb
|
|
160
166
|
- lib/stationery/rails.rb
|
|
161
167
|
- lib/stationery/rails/previews_controller.rb
|
|
@@ -171,11 +177,13 @@ files:
|
|
|
171
177
|
- lib/stationery/structure.rb
|
|
172
178
|
- lib/stationery/svg/arc.rb
|
|
173
179
|
- lib/stationery/svg/bounds.rb
|
|
180
|
+
- lib/stationery/svg/clip_path.rb
|
|
174
181
|
- lib/stationery/svg/document.rb
|
|
175
182
|
- lib/stationery/svg/gradient.rb
|
|
176
183
|
- lib/stationery/svg/painter.rb
|
|
177
184
|
- lib/stationery/svg/parser.rb
|
|
178
185
|
- lib/stationery/svg/path_data.rb
|
|
186
|
+
- lib/stationery/svg/region.rb
|
|
179
187
|
- lib/stationery/svg/selector.rb
|
|
180
188
|
- lib/stationery/svg/shading.rb
|
|
181
189
|
- lib/stationery/svg/shapes.rb
|
|
@@ -183,6 +191,8 @@ files:
|
|
|
183
191
|
- lib/stationery/svg/stylesheet.rb
|
|
184
192
|
- lib/stationery/svg/text.rb
|
|
185
193
|
- lib/stationery/svg/transform.rb
|
|
194
|
+
- lib/stationery/svg/viewport.rb
|
|
195
|
+
- lib/stationery/svg/walker.rb
|
|
186
196
|
- lib/stationery/tagging/element.rb
|
|
187
197
|
- lib/stationery/tagging/tree.rb
|
|
188
198
|
- lib/stationery/tagging/writer.rb
|