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
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module PDF
5
+ # The XMP metadata packet (ISO 16684-1) mirroring the document info:
6
+ # Dublin Core title, creator, description, subject and language, the
7
+ # `xmp:` dates and creator tool, and `pdf:` producer and keywords. Later
8
+ # identification schemas (PDF/A, PDF/UA, Factur-X) go in `extensions:`.
9
+ #
10
+ # The packet is deterministic for the same info and time: no instance
11
+ # ids, so two renders of one document are byte-identical.
12
+ module XMP
13
+ PACKET_ID = "W5M0MpCehiHzreSzNTczkc9d"
14
+ PADDING = "#{" " * 63}\n" * 32 # 2 KB of whitespace, as the spec suggests
15
+ DC = "http://purl.org/dc/elements/1.1/"
16
+ XMP_NS = "http://ns.adobe.com/xap/1.0/"
17
+ PDF_NS = "http://ns.adobe.com/pdf/1.3/"
18
+ RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19
+ PDFA_EXTENSION = { "pdfaExtension" => "http://www.aiim.org/pdfa/ns/extension/",
20
+ "pdfaSchema" => "http://www.aiim.org/pdfa/ns/schema#",
21
+ "pdfaProperty" => "http://www.aiim.org/pdfa/ns/property#" }.freeze
22
+
23
+ module_function
24
+
25
+ # `info` is the Info dictionary's keys (`:Title`, `:Author`, `:Subject`,
26
+ # `:Keywords`, `:Creator`, `:Producer`), `lang` the catalog language,
27
+ # `time` the creation instant, and `extensions` further schemas as
28
+ # `{ namespace_uri => { prefix: "pdfaid", "part" => 3, "conformance" => "B" } }`:
29
+ # each becomes one `rdf:Description`; Array values become an `rdf:Bag`.
30
+ # `schemas` describes extension schemas PDF/A does not know by itself:
31
+ # `[{ name:, uri:, prefix:, properties: [{ name:, type:, category:, description: }] }]`.
32
+ def packet(info: {}, lang: nil, time: Time.now, extensions: {}, schemas: [])
33
+ descriptions = [dublin_core(info, lang), xmp_schema(info, time), pdf_schema(info)]
34
+ descriptions << extension_schemas(schemas)
35
+ descriptions.concat(extensions.map { |uri, values| extension(uri, values) })
36
+ body = <<~XML.gsub(/^ *\n/, "")
37
+ <?xpacket begin="" id="#{PACKET_ID}"?>
38
+ <x:xmpmeta xmlns:x="adobe:ns:meta/">
39
+ <rdf:RDF xmlns:rdf="#{RDF}">
40
+ #{descriptions.compact.join("\n")}
41
+ </rdf:RDF>
42
+ </x:xmpmeta>
43
+ XML
44
+ "#{body}#{PADDING}<?xpacket end=\"w\"?>\n"
45
+ end
46
+
47
+ def escape(value)
48
+ value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
49
+ end
50
+
51
+ def dublin_core(info, lang)
52
+ keywords = info[:Keywords].to_s.split(/,\s*/).reject(&:empty?)
53
+ elements = [
54
+ alt("dc:title", info[:Title]),
55
+ seq("dc:creator", info[:Author]),
56
+ alt("dc:description", info[:Subject]),
57
+ bag("dc:subject", keywords),
58
+ bag("dc:language", lang && [lang])
59
+ ]
60
+ description(DC, "dc", elements)
61
+ end
62
+
63
+ def xmp_schema(info, time)
64
+ stamp = time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
65
+ elements = [
66
+ element("xmp:CreateDate", stamp), element("xmp:ModifyDate", stamp), element("xmp:MetadataDate", stamp),
67
+ element("xmp:CreatorTool", info[:Creator])
68
+ ]
69
+ description(XMP_NS, "xmp", elements)
70
+ end
71
+
72
+ def pdf_schema(info)
73
+ description(PDF_NS, "pdf", [element("pdf:Producer", info[:Producer]), element("pdf:Keywords", info[:Keywords])])
74
+ end
75
+
76
+ def extension(uri, values)
77
+ prefix = values.fetch(:prefix) { raise ArgumentError, "XMP extension #{uri} needs a :prefix" }
78
+ elements = values.except(:prefix).map do |name, value|
79
+ value.is_a?(Array) ? bag("#{prefix}:#{name}", value) : element("#{prefix}:#{name}", value)
80
+ end
81
+ description(uri, prefix, elements)
82
+ end
83
+
84
+ # The PDF/A extension schema container describing each of `schemas`.
85
+ def extension_schemas(schemas)
86
+ return if schemas.empty?
87
+
88
+ namespaces = PDFA_EXTENSION.map { |prefix, uri| %(xmlns:#{prefix}="#{uri}") }.join(" ")
89
+ items = schemas.map { |schema| " #{schema_item(schema)}\n" }.join
90
+ <<~XML.chomp.gsub(/^/, " ")
91
+ <rdf:Description rdf:about="" #{namespaces}>
92
+ <pdfaExtension:schemas><rdf:Bag>
93
+ #{items.chomp}
94
+ </rdf:Bag></pdfaExtension:schemas>
95
+ </rdf:Description>
96
+ XML
97
+ end
98
+
99
+ def schema_item(schema)
100
+ properties = schema.fetch(:properties).map do |property|
101
+ resource("pdfaProperty", name: property.fetch(:name), valueType: property.fetch(:type),
102
+ category: property.fetch(:category), description: property.fetch(:description))
103
+ end
104
+ head = { schema: schema.fetch(:name), namespaceURI: schema.fetch(:uri), prefix: schema.fetch(:prefix) }
105
+ resource("pdfaSchema", **head) do
106
+ "<pdfaSchema:property><rdf:Seq>#{properties.join}</rdf:Seq></pdfaSchema:property>"
107
+ end
108
+ end
109
+
110
+ def resource(prefix, **values)
111
+ elements = values.map { |name, value| element("#{prefix}:#{name}", value) }.join
112
+ %(<rdf:li rdf:parseType="Resource">#{elements}#{yield if block_given?}</rdf:li>)
113
+ end
114
+
115
+ def description(uri, prefix, elements)
116
+ elements = elements.compact
117
+ return if elements.empty?
118
+
119
+ " <rdf:Description rdf:about=\"\" xmlns:#{prefix}=\"#{escape(uri)}\">\n" \
120
+ "#{elements.map { |line| " #{line}\n" }.join} </rdf:Description>"
121
+ end
122
+
123
+ def element(name, value)
124
+ return if blank?(value)
125
+
126
+ "<#{name}>#{escape(value)}</#{name}>"
127
+ end
128
+
129
+ def alt(name, value)
130
+ return if blank?(value)
131
+
132
+ "<#{name}><rdf:Alt><rdf:li xml:lang=\"x-default\">#{escape(value)}</rdf:li></rdf:Alt></#{name}>"
133
+ end
134
+
135
+ def seq(name, value) = list(name, "rdf:Seq", value)
136
+ def bag(name, value) = list(name, "rdf:Bag", value)
137
+
138
+ def list(name, container, values)
139
+ values = Array(values).reject { |value| blank?(value) }
140
+ return if values.empty?
141
+
142
+ items = values.map { |value| "<rdf:li>#{escape(value)}</rdf:li>" }.join
143
+ "<#{name}><#{container}>#{items}</#{container}></#{name}>"
144
+ end
145
+
146
+ def blank?(value) = value.nil? || value.to_s.empty?
147
+ end
148
+ end
149
+ end
@@ -11,6 +11,9 @@ module Stationery
11
11
  @shadings = {}
12
12
  end
13
13
 
14
+ # Every image drawn so far.
15
+ def images = @images.keys
16
+
14
17
  def font(font)
15
18
  @fonts[font] ||= :"F#{@fonts.size + 1}"
16
19
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Rich
5
+ # How deep one html or markdown source may nest, and how deep it went.
6
+ # Parsing, layout and painting all recurse over the nesting, so a parser
7
+ # flattens what lies deeper than `limit` into the deepest element it kept
8
+ # (text stays, structure goes) and records the depth it saw here.
9
+ class Nesting
10
+ DEFAULT = 64
11
+
12
+ attr_reader :limit, :deepest
13
+
14
+ def initialize(limit = DEFAULT)
15
+ unless limit.is_a?(Integer) && limit.positive?
16
+ raise ArgumentError, "max_depth must be a positive Integer (got #{limit.inspect})"
17
+ end
18
+
19
+ @limit = limit
20
+ @deepest = 0
21
+ end
22
+
23
+ def record(depth)
24
+ @deepest = depth if depth > @deepest
25
+ end
26
+
27
+ def exceeded? = @deepest > @limit
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Rich
5
+ class Renderer
6
+ # Block quotes and lists each indent what they hold, so nested deep
7
+ # enough they leave their text no width and it is lost. Past LIMIT
8
+ # levels the content is drawn where the last indented level put it:
9
+ # a block quote without its bar, list items without their markers.
10
+ module Indents
11
+ LIMIT = 12
12
+
13
+ private
14
+
15
+ def indented(block)
16
+ return flat(block) if @indents >= LIMIT
17
+
18
+ @indents += 1
19
+ begin
20
+ yield
21
+ ensure
22
+ @indents -= 1
23
+ end
24
+ end
25
+
26
+ def flat(block) = group(block.is_a?(List) ? block.items.flatten(1) : block.blocks)
27
+
28
+ # How many block quotes and lists deep the blocks go.
29
+ def indents_of(blocks)
30
+ blocks.map do |block|
31
+ case block
32
+ when Blockquote then 1 + indents_of(block.blocks)
33
+ when List then 1 + (block.items.map { |item| indents_of(item) }.max || 0)
34
+ when Table then block.rows.flatten.map { |cell| indents_of(cell.blocks) }.max || 0
35
+ else 0
36
+ end
37
+ end.max || 0
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "nodes"
4
4
  require_relative "styles"
5
+ require_relative "renderer/indents"
5
6
  require_relative "renderer/inlines"
6
7
  require_relative "renderer/links"
7
8
 
@@ -9,8 +10,12 @@ module Stationery
9
10
  module Rich
10
11
  # Draws rich-text blocks with a component's element DSL. Images come from
11
12
  # `images:` (a callable given the src, returning a path, an IO or nil) or
12
- # from files under `base_path:`; remote URLs are never fetched.
13
+ # from files under `base_path:`; remote URLs are never fetched. Block
14
+ # quotes and lists nested deeper than Indents::LIMIT stop indenting, which
15
+ # is reported as a NestingLimit warning.
13
16
  class Renderer
17
+ include Indents
18
+
14
19
  REMOTE = /\A[a-z][a-z0-9+.-]*:/i
15
20
  LINK_SCHEMES = %w[http https mailto tel].freeze
16
21
 
@@ -23,18 +28,25 @@ module Stationery
23
28
  @base_path = base_path && File.expand_path(base_path.to_s)
24
29
  @bookmarks = bookmarks
25
30
  @links = Links.new(links || LINK_SCHEMES, builder.warnings)
31
+ @indents = 0
26
32
  end
27
33
 
28
- def render(blocks) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
34
+ def render(blocks)
35
+ depth = indents_of(blocks)
36
+ @builder.warnings << Warnings::NestingLimit.new(depth:, limit: Indents::LIMIT) if depth > Indents::LIMIT
37
+ group(blocks)
38
+ end
29
39
 
30
40
  private
31
41
 
42
+ def group(blocks) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
43
+
32
44
  def block(block)
33
45
  case block
34
46
  when Paragraph then paragraph(block.inlines, **@styles[:p])
35
47
  when Heading then heading(block)
36
- when List then list(block)
37
- when Blockquote then @component.box(role: :blockquote, **@styles[:blockquote]) { render(block.blocks) }
48
+ when List then indented(block) { list(block) }
49
+ when Blockquote then indented(block) { blockquote(block) }
38
50
  when CodeBlock then code(block)
39
51
  when Rule then @component.rule(**@styles[:hr])
40
52
  when Table then table(block)
@@ -59,11 +71,15 @@ module Stationery
59
71
  { title: heading.inlines.grep(Inline).map(&:text).join, level: heading.level }
60
72
  end
61
73
 
74
+ def blockquote(quote)
75
+ @component.box(role: :blockquote, **@styles[:blockquote]) { group(quote.blocks) }
76
+ end
77
+
62
78
  def list(list)
63
79
  options = list.ordered ? { **@styles[:ol], start: list.start || 1 } : @styles[:ul]
64
80
  @component.public_send(list.ordered ? :ol : :ul, **options) do
65
81
  list.items.each do |blocks|
66
- @component.li { @component.text_style(**@styles[:li]) { render(blocks) } }
82
+ @component.li { @component.text_style(**@styles[:li]) { group(blocks) } }
67
83
  end
68
84
  end
69
85
  end
@@ -81,7 +97,7 @@ module Stationery
81
97
 
82
98
  def cell_content(cell)
83
99
  style = cell.header ? @styles[:table][:header] : {}
84
- -> { @component.text_style(**style, align: cell.align) { render(cell.blocks) } }
100
+ -> { @component.text_style(**style, align: cell.align) { group(cell.blocks) } }
85
101
  end
86
102
 
87
103
  def image(node)
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module SVG
5
+ # Turns `clip-path: url(#id)` into the Region an element is painted in.
6
+ # The shapes of the clipPath (and the shapes its `use` children refer to)
7
+ # are outlined in the user space of the clipped element, under the clip
8
+ # path's own transform and theirs; `clipPathUnits="objectBoundingBox"`
9
+ # scales them to the element's box. They join into one clipping path, so
10
+ # overlapping shapes wound in opposite directions cancel where they
11
+ # overlap. A clip path's own clip-path is the region around it. Text in
12
+ # a clip path is skipped and reported.
13
+ class ClipPath
14
+ OUTLINED = (Shapes::NAMES + %w[text]).freeze
15
+
16
+ def initialize(ids, walker)
17
+ @ids = ids
18
+ @walker = walker
19
+ end
20
+
21
+ # The Region for `element`, drawn with `style`; nil without such a clip
22
+ # path, when the element is painted unclipped.
23
+ def region(id, element, style, seen = [])
24
+ clip = @ids[id]
25
+ return @walker.issue("clip-path: ##{id} not found") unless clip&.name == "clipPath"
26
+ return @walker.issue("clip-path: circular reference ##{id}") if seen.include?(id)
27
+
28
+ base = user_space(clip, element, style) or return Region::EMPTY
29
+ own = base.child(clip)
30
+ outlines = clip.children.grep(Parser::Element).filter_map { |child| outline(child, own.child(child), id) }
31
+ outer = own.clip_path && region(own.clip_path, element, style, seen + [id])
32
+ Region.new(outlines.map(&:first), outlines.any?(&:last), outer)
33
+ end
34
+
35
+ private
36
+
37
+ # The style the clip path's content starts from; nil when the units
38
+ # are the element's box and it has none.
39
+ def user_space(clip, element, style)
40
+ space = style.at(style.matrix, Style::DEFAULTS)
41
+ return space unless clip.attributes["clipPathUnits"] == "objectBoundingBox"
42
+
43
+ x, y, width, height = @walker.box(element, style)
44
+ space.transformed([width, 0, 0, height, x, y]) if x
45
+ end
46
+
47
+ # [part, even-odd?] for a child that outlines something.
48
+ def outline(child, style, id)
49
+ return unless style.displayed? && style.visible?
50
+
51
+ case child.name
52
+ when "use" then used(child, style, id)
53
+ when "text" then @walker.issue("clipPath ##{id}: text")
54
+ when *Shapes::NAMES then [Region::Part.new(style.matrix, Region::Shape.new(child)), style.clip_even_odd?]
55
+ end
56
+ end
57
+
58
+ # A use in a clip path refers to a shape or a text, not to a group.
59
+ def used(child, style, id)
60
+ attributes = child.attributes
61
+ target = @ids[(attributes["href"] || attributes["xlink:href"]).to_s[/\A#(.+)/, 1]]
62
+ return unless target && OUTLINED.include?(target.name)
63
+
64
+ placed = style.transformed([1, 0, 0, 1, Shapes.f(attributes, "x"), Shapes.f(attributes, "y")])
65
+ outline(target, placed.child(target), id)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -3,29 +3,37 @@
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
- root = Parser.parse(source)
19
+ nesting = nil
20
+ root = Parser.parse(source) { |depth| nesting = depth }
17
21
  raise Error, "not an svg document" unless root&.name == "svg"
18
22
 
19
- new(root)
23
+ new(root, nesting:)
20
24
  end
21
25
 
22
- attr_reader :view_box, :unsupported
26
+ # `nesting` is how deep the source nested when that was deeper than
27
+ # Parser::MAX_DEPTH (and was flattened there), else nil.
28
+ attr_reader :view_box, :unsupported, :nesting
23
29
 
24
- def initialize(root)
30
+ def initialize(root, nesting: nil)
25
31
  @root = root
32
+ @nesting = nesting
26
33
  @view_box = read_view_box
27
34
  @sheet = Stylesheet.parse(style_text)
28
35
  @gradients = Gradient.collect(root, @sheet)
36
+ @ids = index
29
37
  @unsupported = (element_names(root).uniq - QUIET).sort + approximations
30
38
  end
31
39
 
@@ -41,7 +49,7 @@ module Stationery
41
49
  style = Style.new(Style::DEFAULTS, [scale, 0, 0, scale, e, f], color:, sheet: @sheet).child(@root)
42
50
  painter = Painter.new(canvas, @gradients, @view_box.last(2))
43
51
  text = Text.new(canvas, painter, book || Fonts::FontBook.new, family || Fonts::Bundled::DEFAULT)
44
- shapes(@root, style) do |element, own|
52
+ walker(painter.method(:clip)).children(@root, style) do |element, own|
45
53
  element.name == "text" ? text.draw(element, own) : painter.paint(element, own)
46
54
  end
47
55
  end
@@ -61,6 +69,14 @@ module Stationery
61
69
  styles.flat_map(&:children).join(" ")
62
70
  end
63
71
 
72
+ # Every element with an id, the first of a repeated id.
73
+ def index
74
+ ids = {}
75
+ Parser.walk(@root) { |element| ids[element.attributes["id"]] ||= element }
76
+ ids.delete(nil)
77
+ ids
78
+ end
79
+
64
80
  def element_names(element)
65
81
  element.children.grep(Parser::Element).flat_map { |child| [child.name, *element_names(child)] }
66
82
  end
@@ -71,23 +87,15 @@ module Stationery
71
87
  end
72
88
 
73
89
  # 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
90
+ # order; `clipper` paints what is clipped (see Walker).
91
+ def walker(clipper = nil) = Walker.new(@ids, @view_box.last(2), clipper:)
85
92
 
86
- # Paint references to missing gradients, gradient spreads drawn as pad
87
- # and stylesheet selectors ignored.
93
+ # Paint references to missing gradients, gradient spreads drawn as pad,
94
+ # uses and clip paths that lead nowhere, and stylesheet selectors ignored.
88
95
  def approximations
96
+ walker = self.walker
89
97
  missing = []
90
- shapes(@root, Style.new(sheet: @sheet).child(@root)) do |_element, style|
98
+ walker.children(@root, Style.new(sheet: @sheet).child(@root)) do |_element, style|
91
99
  [style.fill, style.stroke].grep(Style::Reference).each do |reference|
92
100
  missing << "url(##{reference.id})" unless @gradients.key?(reference.id)
93
101
  end
@@ -96,7 +104,7 @@ module Stationery
96
104
  "#{gradient.kind}Gradient spreadMethod=#{gradient.approximated_spread}" if gradient.approximated_spread
97
105
  end
98
106
  selectors = @sheet.unsupported.empty? ? [] : ["style selectors: #{@sheet.unsupported.join(", ")}"]
99
- (missing + spreads).uniq.sort + selectors
107
+ (missing + spreads + walker.issues).uniq.sort + selectors
100
108
  end
101
109
  end
102
110
  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)
@@ -8,10 +8,15 @@ module Stationery
8
8
  # Comments, processing instructions and doctypes are skipped; text content
9
9
  # (CDATA included) is kept only inside text, tspan and style, as String
10
10
  # children with entities decoded and each whitespace run one space.
11
+ #
12
+ # An element nested deeper than MAX_DEPTH is not descended into: it takes
13
+ # its place in the deepest element kept and so does what it holds, so
14
+ # nothing that walks the tree recurses deeper than that.
11
15
  module Parser
12
16
  Element = Data.define(:name, :attributes, :children)
13
17
 
14
18
  TEXT = %w[text tspan style].freeze
19
+ MAX_DEPTH = 128
15
20
 
16
21
  IGNORED = /<!--.*?-->|<\?.*?\?>|<!DOCTYPE[^>]*>/m
17
22
  CDATA = /<!\[CDATA\[(.*?)\]\]>/m
@@ -21,17 +26,19 @@ module Stationery
21
26
 
22
27
  module_function
23
28
 
24
- def parse(source)
29
+ # The root element. The block is given how deep the source nested when
30
+ # that was deeper than `max_depth`.
31
+ def parse(source, max_depth: MAX_DEPTH)
25
32
  source = source.to_s.gsub(IGNORED, "").gsub(CDATA) { Regexp.last_match(1).gsub(/[&<>]/, ESCAPES) }
26
33
  scanner = StringScanner.new(source)
27
- root = Element.new("document", {}, [])
28
- stack = [root]
34
+ tree = Tree.new(max_depth)
29
35
  while (content = scanner.scan_until(/(?=<)/))
30
- keep(stack.last, content)
36
+ keep(tree.open, content)
31
37
  tag = scanner.scan(TAG) or (scanner.getch && next)
32
- handle(stack, *tag.match(TAG).captures)
38
+ tree.tag(*tag.match(TAG).captures)
33
39
  end
34
- root.children.first
40
+ yield tree.deepest if block_given? && tree.deepest > max_depth
41
+ tree.root
35
42
  end
36
43
 
37
44
  # Yields the element and every element below it, depth first.
@@ -46,12 +53,45 @@ module Stationery
46
53
  element.children << Stationery::Text::Entities.decode(content).gsub(/\s+/, " ")
47
54
  end
48
55
 
49
- def handle(stack, closing, name, attributes, self_closing)
50
- return stack.pop if closing == "/" && stack.size > 1
56
+ # The elements open while the source is read. `hoisted` counts the open
57
+ # ones too deep to descend into; their end tags are counted off again.
58
+ class Tree
59
+ attr_reader :deepest
51
60
 
52
- element = Element.new(name, attributes.scan(ATTRIBUTE).to_h { |k, *v| [k, v.compact.first] }, [])
53
- stack.last.children << element
54
- stack << element if self_closing.empty?
61
+ def initialize(max_depth)
62
+ @document = Element.new("document", {}, [])
63
+ @stack = [@document]
64
+ @max_depth = max_depth
65
+ @hoisted = 0
66
+ @deepest = 0
67
+ end
68
+
69
+ def root = @document.children.first
70
+ def open = @stack.last
71
+
72
+ def tag(closing, name, attributes, self_closing)
73
+ return close if closing == "/"
74
+
75
+ element = Element.new(name, attributes.scan(ATTRIBUTE).to_h { |k, *v| [k, v.compact.first] }, [])
76
+ open.children << element
77
+ descend(element) if self_closing.empty?
78
+ end
79
+
80
+ private
81
+
82
+ def close
83
+ return @hoisted -= 1 if @hoisted.positive?
84
+
85
+ @stack.pop if @stack.size > 1
86
+ end
87
+
88
+ def descend(element)
89
+ depth = @stack.size + @hoisted
90
+ @deepest = depth if depth > @deepest
91
+ return @stack << element if @stack.size <= @max_depth
92
+
93
+ @hoisted += 1
94
+ end
55
95
  end
56
96
  end
57
97
  end
@@ -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)