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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +198 -18
  4. data/lib/stationery/builder.rb +5 -3
  5. data/lib/stationery/canvas/text.rb +4 -3
  6. data/lib/stationery/canvas.rb +24 -2
  7. data/lib/stationery/cli/render.rb +19 -4
  8. data/lib/stationery/document.rb +72 -11
  9. data/lib/stationery/elements.rb +1 -1
  10. data/lib/stationery/errors.rb +13 -0
  11. data/lib/stationery/fonts/fallback.rb +3 -3
  12. data/lib/stationery/fonts/font.rb +39 -21
  13. data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
  14. data/lib/stationery/fonts/gsub.rb +65 -20
  15. data/lib/stationery/fonts/ligatures.rb +6 -4
  16. data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
  17. data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
  18. data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
  19. data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
  20. data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
  21. data/lib/stationery/hyphenation.rb +132 -0
  22. data/lib/stationery/images/jpeg.rb +2 -1
  23. data/lib/stationery/images/png.rb +66 -0
  24. data/lib/stationery/images/resampled.rb +80 -0
  25. data/lib/stationery/layout/image.rb +28 -2
  26. data/lib/stationery/layout/paginator.rb +1 -1
  27. data/lib/stationery/layout/text.rb +2 -2
  28. data/lib/stationery/minitest.rb +17 -0
  29. data/lib/stationery/page_templates.rb +2 -1
  30. data/lib/stationery/pdf/assembler.rb +40 -6
  31. data/lib/stationery/pdf/attachments.rb +76 -0
  32. data/lib/stationery/pdf/conformance.rb +139 -0
  33. data/lib/stationery/pdf/factur_x.rb +117 -0
  34. data/lib/stationery/pdf/icc/LICENSE.md +14 -0
  35. data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
  36. data/lib/stationery/pdf/page_labels.rb +71 -0
  37. data/lib/stationery/pdf/stream.rb +5 -3
  38. data/lib/stationery/pdf/xmp.rb +149 -0
  39. data/lib/stationery/resources.rb +3 -0
  40. data/lib/stationery/svg/clip_path.rb +69 -0
  41. data/lib/stationery/svg/document.rb +26 -22
  42. data/lib/stationery/svg/painter.rb +11 -0
  43. data/lib/stationery/svg/region.rb +25 -0
  44. data/lib/stationery/svg/shapes.rb +2 -0
  45. data/lib/stationery/svg/style.rb +25 -14
  46. data/lib/stationery/svg/viewport.rb +78 -0
  47. data/lib/stationery/svg/walker.rb +142 -0
  48. data/lib/stationery/testing/inspector.rb +81 -0
  49. data/lib/stationery/testing/matchers.rb +77 -0
  50. data/lib/stationery/text/entities.rb +14 -4
  51. data/lib/stationery/text/paragraph.rb +1 -1
  52. data/lib/stationery/text/style.rb +14 -3
  53. data/lib/stationery/text/wrapper.rb +73 -12
  54. data/lib/stationery/version.rb +1 -1
  55. data/lib/stationery/warnings.rb +10 -0
  56. data/lib/stationery.rb +12 -0
  57. 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, polyline, polygon
7
- # and g, with fill, stroke, stroke width/caps/joins, fill-rule, opacity,
8
- # inline styles, <style> stylesheets, transforms, linear/radial gradients
9
- # and text/tspan. `currentColor` takes the colour you pass.
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 = %w[path rect circle ellipse line polyline polygon].freeze
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
- shapes(@root, style) do |element, own|
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, skipping undisplayed subtrees and hidden elements.
75
- def shapes(parent, parent_style, &)
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
- shapes(@root, Style.new(sheet: @sheet).child(@root)) do |_element, style|
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
@@ -4,6 +4,8 @@ module Stationery
4
4
  module SVG
5
5
  # Traces one SVG shape element onto a canvas path.
6
6
  module Shapes
7
+ NAMES = %w[path rect circle ellipse line polyline polygon].freeze
8
+
7
9
  module_function
8
10
 
9
11
  def trace(path, element)
@@ -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 = [1, 0, 0, 1, 0, 0], color: "#000000", sheet: Stylesheet::EMPTY,
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
- @display = display
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
- values = @values.merge(own.slice(*INHERITED))
48
- matrix = if attributes["transform"]
49
- Transform.multiply(@matrix,
50
- Transform.parse(attributes["transform"]))
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
- def displayed? = @display != "none"
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 = { "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"' }.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