stationery 0.6.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 +21 -0
- data/README.md +198 -18
- data/lib/stationery/builder.rb +5 -3
- data/lib/stationery/canvas/text.rb +4 -3
- data/lib/stationery/canvas.rb +24 -2
- data/lib/stationery/cli/render.rb +19 -4
- data/lib/stationery/document.rb +72 -11
- data/lib/stationery/elements.rb +1 -1
- data/lib/stationery/errors.rb +13 -0
- data/lib/stationery/fonts/fallback.rb +3 -3
- data/lib/stationery/fonts/font.rb +39 -21
- data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
- data/lib/stationery/fonts/gsub.rb +65 -20
- data/lib/stationery/fonts/ligatures.rb +6 -4
- data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
- data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
- data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
- data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
- data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
- data/lib/stationery/hyphenation.rb +132 -0
- data/lib/stationery/images/jpeg.rb +2 -1
- data/lib/stationery/images/png.rb +66 -0
- data/lib/stationery/images/resampled.rb +80 -0
- data/lib/stationery/layout/image.rb +28 -2
- data/lib/stationery/layout/paginator.rb +1 -1
- data/lib/stationery/layout/text.rb +2 -2
- data/lib/stationery/minitest.rb +17 -0
- data/lib/stationery/page_templates.rb +2 -1
- data/lib/stationery/pdf/assembler.rb +40 -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/page_labels.rb +71 -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 +81 -0
- data/lib/stationery/testing/matchers.rb +77 -0
- data/lib/stationery/text/entities.rb +14 -4
- data/lib/stationery/text/paragraph.rb +1 -1
- data/lib/stationery/text/style.rb +14 -3
- data/lib/stationery/text/wrapper.rb +73 -12
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +10 -0
- data/lib/stationery.rb +12 -0
- metadata +20 -1
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module SVG
|
|
5
5
|
# A parsed SVG drawn as vector paths on a canvas. Supports the subset icon
|
|
6
|
-
# sets use: path, rect (with rx), circle, ellipse, line,
|
|
7
|
-
# and g, with fill, stroke, stroke width/caps/joins,
|
|
8
|
-
# inline styles, <style> stylesheets, transforms,
|
|
9
|
-
# and text/tspan
|
|
6
|
+
# sets and exports use: path, rect (with rx), circle, ellipse, line,
|
|
7
|
+
# polyline, polygon and g, with fill, stroke, stroke width/caps/joins,
|
|
8
|
+
# fill-rule, opacity, inline styles, <style> stylesheets, transforms,
|
|
9
|
+
# linear/radial gradients and text/tspan; `use` (of shapes, groups, text,
|
|
10
|
+
# symbols and other uses), `symbol` and nested `svg` viewports with
|
|
11
|
+
# `preserveAspectRatio`, and `clipPath`. `currentColor` takes the colour
|
|
12
|
+
# you pass, or the `color` an element sets.
|
|
10
13
|
class Document
|
|
11
|
-
SHAPES =
|
|
14
|
+
SHAPES = Shapes::NAMES
|
|
12
15
|
QUIET = (SHAPES + %w[svg g title desc metadata style defs linearGradient radialGradient stop text
|
|
13
|
-
tspan]).freeze
|
|
16
|
+
tspan use symbol clipPath]).freeze
|
|
14
17
|
|
|
15
18
|
def self.parse(source)
|
|
16
19
|
root = Parser.parse(source)
|
|
@@ -26,6 +29,7 @@ module Stationery
|
|
|
26
29
|
@view_box = read_view_box
|
|
27
30
|
@sheet = Stylesheet.parse(style_text)
|
|
28
31
|
@gradients = Gradient.collect(root, @sheet)
|
|
32
|
+
@ids = index
|
|
29
33
|
@unsupported = (element_names(root).uniq - QUIET).sort + approximations
|
|
30
34
|
end
|
|
31
35
|
|
|
@@ -41,7 +45,7 @@ module Stationery
|
|
|
41
45
|
style = Style.new(Style::DEFAULTS, [scale, 0, 0, scale, e, f], color:, sheet: @sheet).child(@root)
|
|
42
46
|
painter = Painter.new(canvas, @gradients, @view_box.last(2))
|
|
43
47
|
text = Text.new(canvas, painter, book || Fonts::FontBook.new, family || Fonts::Bundled::DEFAULT)
|
|
44
|
-
|
|
48
|
+
walker(painter.method(:clip)).children(@root, style) do |element, own|
|
|
45
49
|
element.name == "text" ? text.draw(element, own) : painter.paint(element, own)
|
|
46
50
|
end
|
|
47
51
|
end
|
|
@@ -61,6 +65,14 @@ module Stationery
|
|
|
61
65
|
styles.flat_map(&:children).join(" ")
|
|
62
66
|
end
|
|
63
67
|
|
|
68
|
+
# Every element with an id, the first of a repeated id.
|
|
69
|
+
def index
|
|
70
|
+
ids = {}
|
|
71
|
+
Parser.walk(@root) { |element| ids[element.attributes["id"]] ||= element }
|
|
72
|
+
ids.delete(nil)
|
|
73
|
+
ids
|
|
74
|
+
end
|
|
75
|
+
|
|
64
76
|
def element_names(element)
|
|
65
77
|
element.children.grep(Parser::Element).flat_map { |child| [child.name, *element_names(child)] }
|
|
66
78
|
end
|
|
@@ -71,23 +83,15 @@ module Stationery
|
|
|
71
83
|
end
|
|
72
84
|
|
|
73
85
|
# Yields every drawn shape and text with its resolved style, in paint
|
|
74
|
-
# order
|
|
75
|
-
def
|
|
76
|
-
parent.children.each do |element|
|
|
77
|
-
style = parent_style.child(element)
|
|
78
|
-
next unless style.displayed?
|
|
79
|
-
|
|
80
|
-
if element.name == "g" then shapes(element, style, &)
|
|
81
|
-
elsif (SHAPES.include?(element.name) || element.name == "text") && style.visible? then yield element, style
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
86
|
+
# order; `clipper` paints what is clipped (see Walker).
|
|
87
|
+
def walker(clipper = nil) = Walker.new(@ids, @view_box.last(2), clipper:)
|
|
85
88
|
|
|
86
|
-
# Paint references to missing gradients, gradient spreads drawn as pad
|
|
87
|
-
# and stylesheet selectors ignored.
|
|
89
|
+
# Paint references to missing gradients, gradient spreads drawn as pad,
|
|
90
|
+
# uses and clip paths that lead nowhere, and stylesheet selectors ignored.
|
|
88
91
|
def approximations
|
|
92
|
+
walker = self.walker
|
|
89
93
|
missing = []
|
|
90
|
-
|
|
94
|
+
walker.children(@root, Style.new(sheet: @sheet).child(@root)) do |_element, style|
|
|
91
95
|
[style.fill, style.stroke].grep(Style::Reference).each do |reference|
|
|
92
96
|
missing << "url(##{reference.id})" unless @gradients.key?(reference.id)
|
|
93
97
|
end
|
|
@@ -96,7 +100,7 @@ module Stationery
|
|
|
96
100
|
"#{gradient.kind}Gradient spreadMethod=#{gradient.approximated_spread}" if gradient.approximated_spread
|
|
97
101
|
end
|
|
98
102
|
selectors = @sheet.unsupported.empty? ? [] : ["style selectors: #{@sheet.unsupported.join(", ")}"]
|
|
99
|
-
(missing + spreads).uniq.sort + selectors
|
|
103
|
+
(missing + spreads + walker.issues).uniq.sort + selectors
|
|
100
104
|
end
|
|
101
105
|
end
|
|
102
106
|
end
|
|
@@ -28,6 +28,17 @@ module Stationery
|
|
|
28
28
|
transform: style.matrix) { |path| Shapes.trace(path, element) }
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Paints the block inside `region` (a clip path's or a viewport's) and
|
|
32
|
+
# inside the regions around it.
|
|
33
|
+
def clip(region, &)
|
|
34
|
+
return clip(region.outer) { clip(region.with(outer: nil), &) } if region.outer
|
|
35
|
+
|
|
36
|
+
outlines = region.parts.map do |part|
|
|
37
|
+
@canvas.outline(transform: part.matrix) { |path| part.shape.trace(path) }
|
|
38
|
+
end
|
|
39
|
+
@canvas.clip_to(outlines, even_odd: region.even_odd, &)
|
|
40
|
+
end
|
|
41
|
+
|
|
31
42
|
# A paint as one colour: a gradient's middle colour, nil for none.
|
|
32
43
|
def color(paint, style)
|
|
33
44
|
paint = resolve(paint)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module SVG
|
|
5
|
+
# What an element is clipped to: the outlines of `parts`, joined into one
|
|
6
|
+
# clipping path (even-odd when `even_odd`), inside the `outer` region when
|
|
7
|
+
# there is one. A region without parts has nothing inside it.
|
|
8
|
+
Region = Data.define(:parts, :even_odd, :outer) do
|
|
9
|
+
def empty? = parts.empty?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Region
|
|
13
|
+
# One outline: anything that traces itself on a path (`trace(path)`),
|
|
14
|
+
# under the transform it is drawn with.
|
|
15
|
+
Part = Data.define(:matrix, :shape)
|
|
16
|
+
|
|
17
|
+
# A shape element as a part's outline.
|
|
18
|
+
Shape = Data.define(:element) do
|
|
19
|
+
def trace(path) = Shapes.trace(path, element)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
EMPTY = new([], false, nil).freeze
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
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
|
|
@@ -70,6 +112,27 @@ module Stationery
|
|
|
70
112
|
decode(lang) if lang
|
|
71
113
|
end
|
|
72
114
|
|
|
115
|
+
# One label per page from the catalog /PageLabels ("i", "A-1", …), nil
|
|
116
|
+
# for pages before the first labelled range; empty without labels.
|
|
117
|
+
def page_labels
|
|
118
|
+
labels = catalog[:PageLabels]
|
|
119
|
+
return [] unless labels
|
|
120
|
+
|
|
121
|
+
nums = objects.deref_array(objects.deref_hash(labels)[:Nums]).map { |value| objects.deref(value) }
|
|
122
|
+
nums = nums.map { |value| value.is_a?(Hash) ? value.transform_values { |v| decode_value(v) } : value }
|
|
123
|
+
PDF::PageLabels.labels(nums, page_count)
|
|
124
|
+
end
|
|
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
|
+
|
|
73
136
|
# The structure tree of a tagged PDF as nested arrays; see StructureReader.
|
|
74
137
|
def structure = @structure ||= StructureReader.new(reader).tree
|
|
75
138
|
|
|
@@ -97,6 +160,22 @@ module Stationery
|
|
|
97
160
|
end
|
|
98
161
|
end
|
|
99
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
|
+
|
|
100
179
|
def destination(dest)
|
|
101
180
|
return dest.to_s unless dest.is_a?(Array)
|
|
102
181
|
|
|
@@ -109,6 +188,8 @@ module Stationery
|
|
|
109
188
|
[decode(item[:Title]), *titles(item[:First]), *titles(item[:Next])]
|
|
110
189
|
end
|
|
111
190
|
|
|
191
|
+
def decode_value(value) = value.is_a?(String) ? decode(value) : value
|
|
192
|
+
|
|
112
193
|
def decode(title)
|
|
113
194
|
return title.dup.force_encoding(Encoding::UTF_8) unless title.b.start_with?(UTF16_BOM)
|
|
114
195
|
|
|
@@ -110,6 +110,79 @@ module Stationery
|
|
|
110
110
|
def actual = @inspector.lang ? "got #{@inspector.lang.inspect}" : "got none"
|
|
111
111
|
end
|
|
112
112
|
|
|
113
|
+
class HavePageLabels < Base
|
|
114
|
+
def description = "have page labels #{@expected.inspect}"
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def match?(pdf) = pdf.page_labels == @expected
|
|
119
|
+
def actual = @inspector.page_labels.empty? ? "got none" : "got #{@inspector.page_labels.inspect}"
|
|
120
|
+
end
|
|
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
|
+
|
|
113
186
|
class HaveNoWarnings < Base
|
|
114
187
|
def description = "have no warnings"
|
|
115
188
|
|
|
@@ -150,6 +223,10 @@ module Stationery
|
|
|
150
223
|
def have_image_count(expected) = HaveImageCount.new(expected)
|
|
151
224
|
def have_bookmark(title) = HaveBookmark.new(title)
|
|
152
225
|
def have_pdf_language(lang) = HaveLanguage.new(lang)
|
|
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:)
|
|
153
230
|
def have_no_warnings = HaveNoWarnings.new
|
|
154
231
|
def have_structure(expected) = HaveStructure.new(expected)
|
|
155
232
|
def have_tagged_content = HaveTaggedContent.new
|