stationery 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +251 -23
  4. data/lib/stationery/canvas.rb +22 -1
  5. data/lib/stationery/cli/render.rb +19 -4
  6. data/lib/stationery/document.rb +69 -9
  7. data/lib/stationery/elements/forms.rb +11 -6
  8. data/lib/stationery/elements/rich.rb +13 -6
  9. data/lib/stationery/elements.rb +11 -3
  10. data/lib/stationery/errors.rb +13 -0
  11. data/lib/stationery/fonts/font.rb +28 -18
  12. data/lib/stationery/forms/acro_form.rb +95 -25
  13. data/lib/stationery/forms/appearance.rb +92 -30
  14. data/lib/stationery/forms/field.rb +20 -11
  15. data/lib/stationery/forms/metrics.rb +3 -17
  16. data/lib/stationery/forms/typeface.rb +113 -0
  17. data/lib/stationery/html/document.rb +8 -2
  18. data/lib/stationery/html/tree_builder.rb +34 -4
  19. data/lib/stationery/images/jpeg.rb +2 -1
  20. data/lib/stationery/layout/image.rb +9 -1
  21. data/lib/stationery/layout/table/cell.rb +14 -4
  22. data/lib/stationery/layout/table.rb +72 -14
  23. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  24. data/lib/stationery/markdown/block_parser.rb +9 -3
  25. data/lib/stationery/markdown/document.rb +8 -2
  26. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  27. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  28. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  29. data/lib/stationery/markdown/inline_parser.rb +9 -3
  30. data/lib/stationery/minitest.rb +16 -0
  31. data/lib/stationery/pdf/assembler.rb +51 -11
  32. data/lib/stationery/pdf/attachments.rb +76 -0
  33. data/lib/stationery/pdf/conformance.rb +141 -0
  34. data/lib/stationery/pdf/factur_x.rb +117 -0
  35. data/lib/stationery/pdf/icc/LICENSE.md +14 -0
  36. data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
  37. data/lib/stationery/pdf/serializer.rb +3 -1
  38. data/lib/stationery/pdf/signature/cms.rb +87 -0
  39. data/lib/stationery/pdf/signature.rb +167 -0
  40. data/lib/stationery/pdf/stream.rb +5 -3
  41. data/lib/stationery/pdf/types.rb +4 -0
  42. data/lib/stationery/pdf/xmp.rb +149 -0
  43. data/lib/stationery/resources.rb +3 -0
  44. data/lib/stationery/rich/nesting.rb +30 -0
  45. data/lib/stationery/rich/renderer/indents.rb +42 -0
  46. data/lib/stationery/rich/renderer.rb +22 -6
  47. data/lib/stationery/svg/clip_path.rb +69 -0
  48. data/lib/stationery/svg/document.rb +34 -26
  49. data/lib/stationery/svg/painter.rb +11 -0
  50. data/lib/stationery/svg/parser.rb +51 -11
  51. data/lib/stationery/svg/region.rb +25 -0
  52. data/lib/stationery/svg/shapes.rb +2 -0
  53. data/lib/stationery/svg/style.rb +25 -14
  54. data/lib/stationery/svg/viewport.rb +78 -0
  55. data/lib/stationery/svg/walker.rb +144 -0
  56. data/lib/stationery/tagging/element.rb +1 -1
  57. data/lib/stationery/testing/inspector.rb +123 -0
  58. data/lib/stationery/testing/matchers.rb +92 -0
  59. data/lib/stationery/text/wrapper.rb +12 -3
  60. data/lib/stationery/version.rb +1 -1
  61. data/lib/stationery/warnings.rb +8 -0
  62. data/lib/stationery.rb +11 -0
  63. metadata +16 -1
@@ -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,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module SVG
5
+ # Walks a document in paint order and yields every drawn shape and text
6
+ # with its resolved style. A `use` draws what it refers to at its x and
7
+ # y, a symbol (through `use` only) and a nested svg draw into their
8
+ # viewport, and what has a `clip-path` or overflows a viewport is walked
9
+ # inside `clipper`: a callable given the Region and a block, left out
10
+ # when the document is only read. What cannot be followed is in `issues`.
11
+ class Walker
12
+ RENDERED = (Shapes::NAMES + %w[text g use svg]).freeze
13
+ VIEWPORTS = %w[symbol svg].freeze
14
+ MAX_USES = 32 # a use of a use of a use …
15
+
16
+ attr_reader :issues
17
+
18
+ # `ids` are the document's elements by id, `viewport` the [width,
19
+ # height] percentages resolve against at the root.
20
+ def initialize(ids, viewport, clipper: nil)
21
+ @ids = ids
22
+ @viewports = [viewport]
23
+ @clipper = clipper
24
+ @clip_paths = ClipPath.new(ids, self)
25
+ @issues = []
26
+ @measuring = false
27
+ end
28
+
29
+ # Yields what the children of `parent` draw; `style` is the parent's.
30
+ # `chain` holds the ids of the `use` targets being drawn.
31
+ def children(parent, style, chain = [], &)
32
+ parent.children.grep(Parser::Element).each { |element| node(element, style.child(element), chain, &) }
33
+ end
34
+
35
+ # The box of what `element` draws, in its own user space, clipping
36
+ # and text aside: [x, y, width, height], or nil when it has no area.
37
+ def box(element, style)
38
+ bounds = Bounds.new
39
+ measuring do
40
+ node(element, style.at(Style::IDENTITY), []) do |shape, own|
41
+ corners(shape, own.matrix).each { |x, y| bounds.line_to(x, y) } unless shape.name == "text"
42
+ end
43
+ end
44
+ bounds.box
45
+ end
46
+
47
+ def issue(message)
48
+ @issues << message unless @measuring
49
+ nil
50
+ end
51
+
52
+ private
53
+
54
+ # Anything else (defs, symbol, clipPath, a gradient, …) draws nothing
55
+ # where it stands.
56
+ def node(element, style, chain, &)
57
+ return unless RENDERED.include?(element.name) && style.displayed?
58
+
59
+ clipped(element, style) do
60
+ case element.name
61
+ when "g" then children(element, style, chain, &)
62
+ when "use" then use(element, style, chain, &)
63
+ when "svg" then viewport(element, element.attributes, style, chain, &)
64
+ else yield element, style if style.visible?
65
+ end
66
+ end
67
+ end
68
+
69
+ def use(element, style, chain, &)
70
+ target, id = target_of(element)
71
+ return unless target
72
+ return issue("use: circular reference ##{id}") if chain.include?(id)
73
+ return issue("use: nested deeper than #{MAX_USES}") if chain.size >= MAX_USES
74
+
75
+ attributes = element.attributes
76
+ placed = style.transformed([1, 0, 0, 1, Shapes.f(attributes, "x"), Shapes.f(attributes, "y")])
77
+ instance(target, placed.child(target), attributes.slice("width", "height"), chain + [id], &)
78
+ end
79
+
80
+ # A symbol or an svg drawn by a use takes the use's width and height.
81
+ def instance(target, style, size, chain, &)
82
+ return node(target, style, chain, &) unless VIEWPORTS.include?(target.name)
83
+ return unless style.displayed?
84
+
85
+ clipped(target, style) { viewport(target, target.attributes.merge(size), style, chain, &) }
86
+ end
87
+
88
+ def target_of(element)
89
+ href = (element.attributes["href"] || element.attributes["xlink:href"]).to_s.strip
90
+ return issue("use: no href") if href.empty?
91
+
92
+ id = href[/\A#(.+)/, 1]
93
+ target = @ids[id]
94
+ target ? [target, id] : issue("use: #{href} not found")
95
+ end
96
+
97
+ def viewport(element, attributes, style, chain, &)
98
+ port = Viewport.new(attributes, @viewports.last, origin: element.name == "svg")
99
+ return unless port.drawable?
100
+
101
+ region = Region.new([Region::Part.new(style.matrix, port)], false, nil) if style.clips?
102
+ clip(region) { within(port) { children(element, style.transformed(port.matrix), chain, &) } }
103
+ end
104
+
105
+ def within(port)
106
+ @viewports.push(port.size)
107
+ yield
108
+ ensure
109
+ @viewports.pop
110
+ end
111
+
112
+ def clipped(element, style, &)
113
+ id = style.clip_path
114
+ return yield if id.nil? || @measuring
115
+
116
+ region = @clip_paths.region(id, element, style)
117
+ region ? clip(region, &) : yield
118
+ end
119
+
120
+ def clip(region, &)
121
+ return yield if region.nil? || @measuring
122
+ return if region.empty?
123
+
124
+ @clipper ? @clipper.call(region, &) : yield
125
+ end
126
+
127
+ def measuring
128
+ previous = @measuring
129
+ @measuring = true
130
+ yield
131
+ ensure
132
+ @measuring = previous
133
+ end
134
+
135
+ def corners(shape, matrix)
136
+ x, y, width, height = Bounds.new.tap { |bounds| Shapes.trace(bounds, shape) }.box
137
+ return [] unless x
138
+
139
+ [[x, y], [x + width, y], [x, y + height], [x + width, y + height]]
140
+ .map { |corner_x, corner_y| Transform.apply(matrix, corner_x, corner_y) }
141
+ end
142
+ end
143
+ end
144
+ end
@@ -39,7 +39,7 @@ module Stationery
39
39
  @type = type
40
40
  @alt = alt
41
41
  @kind = kind
42
- @attributes = attributes.transform_values(&:dup)
42
+ @attributes = attributes.empty? ? {} : attributes.transform_values(&:dup)
43
43
  @kids = []
44
44
  end
45
45
 
@@ -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,29 @@ module Stationery
81
123
  PDF::PageLabels.labels(nums, page_count)
82
124
  end
83
125
 
126
+ # Every embedded file from the catalog's /EmbeddedFiles name tree:
127
+ # `{ name:, mime:, bytes:, description:, relationship: }` (bytes inflated).
128
+ def attachments
129
+ names = catalog.dig(:Names, :EmbeddedFiles)
130
+ return [] unless names
131
+
132
+ entries = objects.deref_array(objects.deref_hash(names)[:Names])
133
+ entries.each_slice(2).map { |_name, ref| attachment(objects.deref_hash(ref)) }
134
+ end
135
+
136
+ # Every signed signature field: `{ field:, name:, reason:, location:,
137
+ # signed_at:, subfilter:, byte_range:, signer:, valid: }`. `signer` is
138
+ # the signing certificate's subject ("CN=…,O=…"). `valid` is whether
139
+ # the signature covers the whole file and verifies against the
140
+ # certificate it carries; the certificate's own trust is not judged.
141
+ def signatures
142
+ form = catalog[:AcroForm]
143
+ return [] unless form
144
+
145
+ require "openssl"
146
+ signature_fields(objects.deref_hash(form)[:Fields], nil)
147
+ end
148
+
84
149
  # The structure tree of a tagged PDF as nested arrays; see StructureReader.
85
150
  def structure = @structure ||= StructureReader.new(reader).tree
86
151
 
@@ -108,6 +173,64 @@ module Stationery
108
173
  end
109
174
  end
110
175
 
176
+ def attachment(spec)
177
+ stream = objects.deref(objects.deref_hash(spec[:EF])[:F])
178
+ relationship = PDF::Attachments::RELATIONSHIPS.key(spec[:AFRelationship]) || spec[:AFRelationship]
179
+ { name: decode(spec[:UF] || spec[:F]), mime: stream.hash[:Subtype].to_s, bytes: stream.unfiltered_data,
180
+ description: spec[:Desc] && decode(spec[:Desc]), relationship: }
181
+ end
182
+
183
+ def signature_fields(refs, prefix)
184
+ Array(objects.deref_array(refs)).flat_map do |ref|
185
+ field = objects.deref_hash(ref)
186
+ name = [prefix, field[:T] && decode(field[:T])].compact.join(".")
187
+ kids = Array(objects.deref_array(field[:Kids])).select { |kid| objects.deref_hash(kid).key?(:T) }
188
+ next signature_fields(kids, name) if kids.any?
189
+
190
+ field[:FT] == :Sig && field[:V] ? [signature(name, objects.deref_hash(field[:V]))] : []
191
+ end
192
+ end
193
+
194
+ def signature(field, value)
195
+ range = objects.deref_array(value[:ByteRange])
196
+ text = value.slice(:Name, :Reason, :Location).transform_values { |string| decode(string) }
197
+ { field:, name: text[:Name], reason: text[:Reason], location: text[:Location],
198
+ signed_at: signing_time(value[:M]), subfilter: value[:SubFilter], byte_range: range, **verdict(range) }
199
+ end
200
+
201
+ def signing_time(date)
202
+ digits = date.to_s[/\AD:(\d{14})/, 1]
203
+ digits && Time.utc(*digits.unpack("a4a2a2a2a2a2").map(&:to_i))
204
+ end
205
+
206
+ # The signature is read from the file by its byte range, not from the
207
+ # parsed dictionary: it is the one string encryption leaves alone.
208
+ def verdict(range)
209
+ contents = pdf.byteslice(range[1], range[2] - range[1]).to_s
210
+ cms = OpenSSL::PKCS7.new(PDF::Signature.der([contents[1..-2]].pack("H*")))
211
+ signed = pdf.byteslice(range[0], range[1]) + pdf.byteslice(range[2], range[3])
212
+ flags = OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::BINARY
213
+ whole = range[0].zero? && range[2] + range[3] == pdf.bytesize && contents.start_with?("<")
214
+ { signer: signer(cms), valid: whole && cms.verify([], OpenSSL::X509::Store.new, signed, flags) }
215
+ rescue OpenSSL::OpenSSLError, ArgumentError, TypeError
216
+ { signer: nil, valid: false }
217
+ end
218
+
219
+ def signer(cms)
220
+ info = cms.signers.first
221
+ certificate = cms.certificates.find { |one| one.serial == info.serial && one.issuer == info.issuer }
222
+ certificate&.subject&.to_utf8
223
+ end
224
+
225
+ def xmp_value(body)
226
+ items = body.scan(XMP_ITEM).flatten.map { |item| xmp_text(item) }
227
+ return xmp_text(body) if items.empty?
228
+
229
+ body.start_with?("<rdf:Alt>") ? items.first : items
230
+ end
231
+
232
+ def xmp_text(value) = value.gsub(/&(amp|lt|gt|quot);/, XMP_ENTITIES)
233
+
111
234
  def destination(dest)
112
235
  return dest.to_s unless dest.is_a?(Array)
113
236
 
@@ -119,6 +119,94 @@ module Stationery
119
119
  def actual = @inspector.page_labels.empty? ? "got none" : "got #{@inspector.page_labels.inspect}"
120
120
  end
121
121
 
122
+ class HaveConformance < Base
123
+ def initialize(*levels) = super(levels.flatten.map(&:to_sym))
124
+
125
+ def description = "conform to #{labels(@expected)}"
126
+
127
+ private
128
+
129
+ def labels(levels) = levels.map { |level| PDF::Conformance.label(level) }.join(" and ")
130
+ def match?(pdf) = (@expected - pdf.conformance).empty?
131
+ def actual = @inspector.conformance.empty? ? "got no claim" : "got #{labels(@inspector.conformance)}"
132
+ end
133
+
134
+ class HaveFacturX < Base
135
+ def initialize(profile: nil) = super(profile&.to_sym)
136
+
137
+ def description
138
+ @expected ? "be a Factur-X invoice of profile #{@expected.inspect}" : "be a Factur-X invoice"
139
+ end
140
+
141
+ private
142
+
143
+ def match?(pdf)
144
+ invoice = pdf.factur_x
145
+ !invoice.nil? && !invoice[:xml].nil? && (@expected.nil? || invoice[:profile] == @expected)
146
+ end
147
+
148
+ def actual
149
+ invoice = @inspector.factur_x
150
+ return "got no invoice" unless invoice
151
+ return "got #{invoice[:filename]} named in XMP but not embedded" unless invoice[:xml]
152
+
153
+ "got profile #{invoice[:profile].inspect}"
154
+ end
155
+ end
156
+
157
+ class HaveAttachment < Base
158
+ def initialize(name, mime: nil, relationship: nil)
159
+ super(name)
160
+ @mime = mime
161
+ @relationship = relationship
162
+ end
163
+
164
+ def description
165
+ extra = { mime: @mime, relationship: @relationship }.compact.map { |k, v| " #{k} #{v.inspect}" }.join
166
+ "have an attachment #{show(@expected)}#{extra}"
167
+ end
168
+
169
+ private
170
+
171
+ def match?(pdf)
172
+ pdf.attachments.any? do |file|
173
+ file[:name] == @expected && (@mime.nil? || file[:mime] == @mime) &&
174
+ (@relationship.nil? || file[:relationship] == @relationship)
175
+ end
176
+ end
177
+
178
+ def actual
179
+ files = @inspector.attachments
180
+ return "got none" if files.empty?
181
+
182
+ "got #{files.map { |file| "#{file[:name]} (#{file[:mime]}, #{file[:relationship]})" }.inspect}"
183
+ end
184
+ end
185
+
186
+ class HaveSignature < Base
187
+ def initialize(name: nil, valid: true)
188
+ super(name)
189
+ @valid = valid
190
+ end
191
+
192
+ def description
193
+ "have #{@valid ? "a valid" : "an invalid"} signature#{" by #{show(@expected)}" if @expected}"
194
+ end
195
+
196
+ private
197
+
198
+ def match?(pdf)
199
+ pdf.signatures.any? { |one| (@expected.nil? || one[:name] == @expected) && one[:valid] == @valid }
200
+ end
201
+
202
+ def actual
203
+ signatures = @inspector.signatures
204
+ return "got none" if signatures.empty?
205
+
206
+ "got #{signatures.map { |one| "#{one[:name].inspect} (#{one[:valid] ? "valid" : "invalid"})" }.join(", ")}"
207
+ end
208
+ end
209
+
122
210
  class HaveNoWarnings < Base
123
211
  def description = "have no warnings"
124
212
 
@@ -160,6 +248,10 @@ module Stationery
160
248
  def have_bookmark(title) = HaveBookmark.new(title)
161
249
  def have_pdf_language(lang) = HaveLanguage.new(lang)
162
250
  def have_page_labels(labels) = HavePageLabels.new(labels)
251
+ def have_attachment(name, **) = HaveAttachment.new(name, **)
252
+ def have_conformance(*levels) = HaveConformance.new(*levels)
253
+ def have_factur_x(profile: nil) = HaveFacturX.new(profile:)
254
+ def have_signature(name: nil, valid: true) = HaveSignature.new(name:, valid:)
163
255
  def have_no_warnings = HaveNoWarnings.new
164
256
  def have_structure(expected) = HaveStructure.new(expected)
165
257
  def have_tagged_content = HaveTaggedContent.new
@@ -26,6 +26,7 @@ module Stationery
26
26
  @current = []
27
27
  @pending_space = []
28
28
  @fallback = fallback_style
29
+ @widths = {}.compare_by_identity
29
30
  items(runs).each { |item| place(item) }
30
31
  finish unless @current.empty? && @lines.any? && !@ended_with_newline
31
32
  @lines
@@ -97,6 +98,8 @@ module Stationery
97
98
  # its hyphen) fits `available`; nil when the word offers no break that
98
99
  # fits. Soft hyphens name the breaks and suppress the patterns.
99
100
  def hyphenated(word, available)
101
+ return unless @explicit || word.first&.style&.hyphenate
102
+
100
103
  chars = word.flat_map { |segment| segment.text.chars.map { |char| Segment.new(char, segment.style) } }
101
104
  break_points(chars).reverse_each do |index|
102
105
  head = chars.first(index).reject { |segment| segment.text == SOFT_HYPHEN }
@@ -164,7 +167,7 @@ module Stationery
164
167
  def fragments(segments)
165
168
  x = 0
166
169
  segments.chunk_while { |a, b| a.style == b.style }.filter_map do |group|
167
- text = group.map(&:text).join.delete(SOFT_HYPHEN)
170
+ text = plain(group.map(&:text).join)
168
171
  next if text.empty?
169
172
 
170
173
  font, face = @book.resolve(group.first.style)
@@ -178,12 +181,18 @@ module Stationery
178
181
  end
179
182
 
180
183
  def line_width = width(@current)
181
- def characters(segments) = segments.sum { |segment| segment.text.delete(SOFT_HYPHEN).length }
184
+ def characters(segments) = segments.sum { |segment| plain(segment.text).length }
182
185
 
186
+ # A line is summed again for every word that joins it; its segments
187
+ # are the same objects each time, so each is measured once per wrap.
183
188
  def width(segments)
184
- segments.sum { |segment| measure(segment.text.delete(SOFT_HYPHEN), segment.style) }
189
+ segments.sum { |segment| @widths[segment] ||= measure(plain(segment.text), segment.style) }
185
190
  end
186
191
 
192
+ # The text without its soft hyphens; the same String when it has none,
193
+ # which is nearly always, so measuring a word allocates nothing.
194
+ def plain(text) = text.include?(SOFT_HYPHEN) ? text.delete(SOFT_HYPHEN) : text
195
+
187
196
  def measure(text, style)
188
197
  font = @book.resolve(style).first
189
198
  font.width_of(text, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -43,6 +43,10 @@ module Stationery
43
43
  def message = %(link "#{href}" dropped: scheme not allowed)
44
44
  end
45
45
 
46
+ NestingLimit = Data.define(:depth, :limit) do
47
+ def message = "content nested #{depth} levels deep was flattened below level #{limit}"
48
+ end
49
+
46
50
  DuplicateAnchor = Data.define(:name, :page) do
47
51
  def message = %(anchor "#{name}" on page #{page} is already defined)
48
52
  end
@@ -55,6 +59,10 @@ module Stationery
55
59
  def message = "tagged PDF has no language: set metadata lang:"
56
60
  end
57
61
 
62
+ ConformanceIssue = Data.define(:level, :subject) do
63
+ def message = "#{PDF::Conformance.label(level)}: #{subject} is not covered by the sRGB output intent"
64
+ end
65
+
58
66
  def initialize
59
67
  @items = []
60
68
  @glyphs = Hash.new(0)
data/lib/stationery.rb CHANGED
@@ -62,7 +62,14 @@ require_relative "stationery/canvas/marking"
62
62
  require_relative "stationery/canvas"
63
63
  require_relative "stationery/pdf/outline_writer"
64
64
  require_relative "stationery/pdf/page_labels"
65
+ require_relative "stationery/pdf/attachments"
66
+ require_relative "stationery/pdf/xmp"
67
+ require_relative "stationery/pdf/conformance"
68
+ require_relative "stationery/pdf/factur_x"
69
+ require_relative "stationery/pdf/signature"
70
+ require_relative "stationery/pdf/signature/cms"
65
71
  require_relative "stationery/forms/metrics"
72
+ require_relative "stationery/forms/typeface"
66
73
  require_relative "stationery/forms/field"
67
74
  require_relative "stationery/forms/appearance"
68
75
  require_relative "stationery/forms/acro_form"
@@ -108,6 +115,10 @@ require_relative "stationery/svg/gradient"
108
115
  require_relative "stationery/svg/shading"
109
116
  require_relative "stationery/svg/painter"
110
117
  require_relative "stationery/svg/text"
118
+ require_relative "stationery/svg/viewport"
119
+ require_relative "stationery/svg/region"
120
+ require_relative "stationery/svg/clip_path"
121
+ require_relative "stationery/svg/walker"
111
122
  require_relative "stationery/svg/document"
112
123
  require_relative "stationery/layout/svg"
113
124
  require_relative "stationery/layout/wrap"