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.
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -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.7.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