stationery 0.1.0 → 0.3.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +114 -1
  3. data/README.md +395 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +26 -3
  7. data/lib/stationery/canvas/debug.rb +26 -0
  8. data/lib/stationery/canvas/path.rb +22 -12
  9. data/lib/stationery/canvas/text.rb +10 -6
  10. data/lib/stationery/canvas.rb +38 -8
  11. data/lib/stationery/cli/fonts.rb +85 -0
  12. data/lib/stationery/cli/render.rb +137 -0
  13. data/lib/stationery/cli.rb +74 -0
  14. data/lib/stationery/document.rb +60 -9
  15. data/lib/stationery/elements/lists.rb +58 -0
  16. data/lib/stationery/elements/rich.rb +27 -0
  17. data/lib/stationery/elements.rb +88 -12
  18. data/lib/stationery/errors.rb +13 -1
  19. data/lib/stationery/fonts/bundled.rb +25 -0
  20. data/lib/stationery/fonts/catalog.rb +101 -0
  21. data/lib/stationery/fonts/cff.rb +140 -0
  22. data/lib/stationery/fonts/cff_subset.rb +145 -0
  23. data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
  24. data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
  25. data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
  26. data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
  27. data/lib/stationery/fonts/data/OFL.txt +92 -0
  28. data/lib/stationery/fonts/data/README.md +14 -0
  29. data/lib/stationery/fonts/embedding/base.rb +56 -0
  30. data/lib/stationery/fonts/embedding/cff.rb +35 -0
  31. data/lib/stationery/fonts/embedding/true_type.rb +35 -0
  32. data/lib/stationery/fonts/fallback.rb +58 -0
  33. data/lib/stationery/fonts/family.rb +11 -0
  34. data/lib/stationery/fonts/font.rb +68 -88
  35. data/lib/stationery/fonts/font_book.rb +49 -8
  36. data/lib/stationery/fonts/glyph_run.rb +39 -0
  37. data/lib/stationery/fonts/gpos/class_def.rb +25 -0
  38. data/lib/stationery/fonts/gpos/coverage.rb +20 -0
  39. data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
  40. data/lib/stationery/fonts/gpos.rb +64 -0
  41. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  42. data/lib/stationery/fonts/gsub.rb +65 -0
  43. data/lib/stationery/fonts/installer.rb +121 -0
  44. data/lib/stationery/fonts/kern_table.rb +52 -0
  45. data/lib/stationery/fonts/kerning.rb +17 -0
  46. data/lib/stationery/fonts/ligatures.rb +16 -0
  47. data/lib/stationery/fonts/packs.rb +45 -0
  48. data/lib/stationery/fonts/registry.rb +18 -9
  49. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  50. data/lib/stationery/fonts/to_unicode.rb +36 -0
  51. data/lib/stationery/fonts/true_type.rb +64 -57
  52. data/lib/stationery/fonts/woff.rb +52 -0
  53. data/lib/stationery/html/document.rb +11 -0
  54. data/lib/stationery/html/tokenizer.rb +74 -0
  55. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  56. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  57. data/lib/stationery/html/tree_builder.rb +72 -0
  58. data/lib/stationery/html/whitespace.rb +43 -0
  59. data/lib/stationery/layout/box.rb +102 -16
  60. data/lib/stationery/layout/flow.rb +48 -15
  61. data/lib/stationery/layout/image.rb +1 -0
  62. data/lib/stationery/layout/list_item.rb +89 -0
  63. data/lib/stationery/layout/mark.rb +58 -0
  64. data/lib/stationery/layout/node.rb +26 -2
  65. data/lib/stationery/layout/paginator.rb +35 -6
  66. data/lib/stationery/layout/positioned.rb +3 -1
  67. data/lib/stationery/layout/row.rb +34 -4
  68. data/lib/stationery/layout/svg.rb +48 -0
  69. data/lib/stationery/layout/table/cell.rb +33 -6
  70. data/lib/stationery/layout/table/grid.rb +93 -0
  71. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  72. data/lib/stationery/layout/table/selection.rb +3 -2
  73. data/lib/stationery/layout/table.rb +69 -23
  74. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  75. data/lib/stationery/layout/table_of_contents.rb +43 -0
  76. data/lib/stationery/layout/text.rb +6 -4
  77. data/lib/stationery/layout/wrap.rb +78 -0
  78. data/lib/stationery/list_markers.rb +47 -0
  79. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  80. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  81. data/lib/stationery/markdown/block_parser.rb +141 -0
  82. data/lib/stationery/markdown/document.rb +50 -0
  83. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  84. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  85. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  86. data/lib/stationery/markdown/inline_parser.rb +89 -0
  87. data/lib/stationery/minitest.rb +33 -0
  88. data/lib/stationery/outline.rb +37 -0
  89. data/lib/stationery/page.rb +18 -4
  90. data/lib/stationery/page_templates.rb +33 -10
  91. data/lib/stationery/pdf/assembler.rb +20 -8
  92. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  93. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  94. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  95. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  96. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  97. data/lib/stationery/pdf/outline_writer.rb +72 -0
  98. data/lib/stationery/pdf/serializer.rb +22 -10
  99. data/lib/stationery/pdf/writer.rb +28 -5
  100. data/lib/stationery/preview.rb +64 -0
  101. data/lib/stationery/rails/previews_controller.rb +61 -0
  102. data/lib/stationery/rails.rb +2 -0
  103. data/lib/stationery/railtie.rb +60 -0
  104. data/lib/stationery/regions.rb +67 -0
  105. data/lib/stationery/resources.rb +8 -2
  106. data/lib/stationery/rich/nodes.rb +55 -0
  107. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  108. data/lib/stationery/rich/renderer.rb +121 -0
  109. data/lib/stationery/rich/styles.rb +34 -0
  110. data/lib/stationery/rspec.rb +6 -0
  111. data/lib/stationery/structure.rb +78 -0
  112. data/lib/stationery/svg/arc.rb +91 -0
  113. data/lib/stationery/svg/bounds.rb +76 -0
  114. data/lib/stationery/svg/document.rb +103 -0
  115. data/lib/stationery/svg/gradient.rb +115 -0
  116. data/lib/stationery/svg/painter.rb +70 -0
  117. data/lib/stationery/svg/parser.rb +58 -0
  118. data/lib/stationery/svg/path_data.rb +137 -0
  119. data/lib/stationery/svg/selector.rb +37 -0
  120. data/lib/stationery/svg/shading.rb +34 -0
  121. data/lib/stationery/svg/shapes.rb +54 -0
  122. data/lib/stationery/svg/style.rb +85 -0
  123. data/lib/stationery/svg/stylesheet.rb +47 -0
  124. data/lib/stationery/svg/text.rb +122 -0
  125. data/lib/stationery/svg/transform.rb +54 -0
  126. data/lib/stationery/testing/inspector.rb +98 -0
  127. data/lib/stationery/testing/matchers.rb +123 -0
  128. data/lib/stationery/text/entities/html4.rb +49 -0
  129. data/lib/stationery/text/entities.rb +6 -1
  130. data/lib/stationery/text/line.rb +8 -2
  131. data/lib/stationery/text/paragraph.rb +33 -11
  132. data/lib/stationery/text/runs_builder.rb +9 -1
  133. data/lib/stationery/text/style.rb +4 -2
  134. data/lib/stationery/text/wrapper.rb +4 -2
  135. data/lib/stationery/version.rb +1 -1
  136. data/lib/stationery/warnings.rb +65 -0
  137. data/lib/stationery.rb +67 -0
  138. metadata +101 -4
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module SVG
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.
10
+ class Document
11
+ SHAPES = %w[path rect circle ellipse line polyline polygon].freeze
12
+ QUIET = (SHAPES + %w[svg g title desc metadata style defs linearGradient radialGradient stop text
13
+ tspan]).freeze
14
+
15
+ def self.parse(source)
16
+ root = Parser.parse(source)
17
+ raise Error, "not an svg document" unless root&.name == "svg"
18
+
19
+ new(root)
20
+ end
21
+
22
+ attr_reader :view_box, :unsupported
23
+
24
+ def initialize(root)
25
+ @root = root
26
+ @view_box = read_view_box
27
+ @sheet = Stylesheet.parse(style_text)
28
+ @gradients = Gradient.collect(root, @sheet)
29
+ @unsupported = (element_names(root).uniq - QUIET).sort + approximations
30
+ end
31
+
32
+ def aspect = @view_box[2].to_f / @view_box[3]
33
+
34
+ # Scales the viewBox into the box (aspect ratio kept, centred). Text is
35
+ # drawn from `book` (bundled fonts when nil), unknown families in `family`.
36
+ def draw(canvas, x:, y:, width:, height:, color: "#000000", book: nil, family: nil)
37
+ vx, vy, vw, vh = @view_box
38
+ scale = [width.to_f / vw, height.to_f / vh].min
39
+ e = x + ((width - (vw * scale)) / 2.0) - (vx * scale)
40
+ f = y + ((height - (vh * scale)) / 2.0) - (vy * scale)
41
+ style = Style.new(Style::DEFAULTS, [scale, 0, 0, scale, e, f], color:, sheet: @sheet).child(@root)
42
+ painter = Painter.new(canvas, @gradients, @view_box.last(2))
43
+ text = Text.new(canvas, painter, book || Fonts::FontBook.new, family || Fonts::Bundled::DEFAULT)
44
+ shapes(@root, style) do |element, own|
45
+ element.name == "text" ? text.draw(element, own) : painter.paint(element, own)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def read_view_box
52
+ values = @root.attributes["viewBox"]&.split(/[\s,]+/)&.map { |v| number(v) }
53
+ return values if values&.size == 4
54
+
55
+ [0, 0, number(@root.attributes.fetch("width", "100")), number(@root.attributes.fetch("height", "100"))]
56
+ end
57
+
58
+ def style_text
59
+ styles = []
60
+ Parser.walk(@root) { |element| styles << element if element.name == "style" }
61
+ styles.flat_map(&:children).join(" ")
62
+ end
63
+
64
+ def element_names(element)
65
+ element.children.grep(Parser::Element).flat_map { |child| [child.name, *element_names(child)] }
66
+ end
67
+
68
+ def number(value)
69
+ float = value.to_s.to_f
70
+ float == float.round ? float.round : float
71
+ end
72
+
73
+ # 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
85
+
86
+ # Paint references to missing gradients, gradient spreads drawn as pad
87
+ # and stylesheet selectors ignored.
88
+ def approximations
89
+ missing = []
90
+ shapes(@root, Style.new(sheet: @sheet).child(@root)) do |_element, style|
91
+ [style.fill, style.stroke].grep(Style::Reference).each do |reference|
92
+ missing << "url(##{reference.id})" unless @gradients.key?(reference.id)
93
+ end
94
+ end
95
+ spreads = @gradients.values.filter_map do |gradient|
96
+ "#{gradient.kind}Gradient spreadMethod=#{gradient.approximated_spread}" if gradient.approximated_spread
97
+ end
98
+ selectors = @sheet.unsupported.empty? ? [] : ["style selectors: #{@sheet.unsupported.join(", ")}"]
99
+ (missing + spreads).uniq.sort + selectors
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module SVG
5
+ # A linearGradient or radialGradient paint server, with the stops and
6
+ # attributes it inherits through `href`. Coordinates are in gradient
7
+ # space; `matrix` maps them into the painted element's user space.
8
+ class Gradient
9
+ KINDS = %w[linearGradient radialGradient].freeze
10
+ INHERITED = %w[x1 y1 x2 y2 cx cy r fx fy gradientUnits gradientTransform spreadMethod].freeze
11
+ SPREADS = %w[reflect repeat].freeze
12
+
13
+ Stop = Data.define(:offset, :color, :opacity)
14
+
15
+ # Every gradient in the document, by id; stops take `sheet` rules.
16
+ def self.collect(root, sheet = Stylesheet::EMPTY)
17
+ elements = {}
18
+ Parser.walk(root) { |element| elements[element.attributes["id"]] = element if KINDS.include?(element.name) }
19
+ elements.delete(nil)
20
+ elements.transform_values { |element| new(element, elements, sheet) }
21
+ end
22
+
23
+ attr_reader :kind, :attributes, :stops
24
+
25
+ def initialize(element, elements, sheet = Stylesheet::EMPTY)
26
+ @sheet = sheet
27
+ @kind = element.name == "radialGradient" ? :radial : :linear
28
+ chain = lineage(element, elements)
29
+ @attributes = chain.reverse.map { |link| link.attributes.slice(*INHERITED) }.reduce({}, :merge)
30
+ @stops = read_stops(chain.find { |link| stop_elements(link).any? })
31
+ end
32
+
33
+ def bounding_box? = @attributes["gradientUnits"] != "userSpaceOnUse"
34
+
35
+ def approximated_spread
36
+ spread = @attributes["spreadMethod"]
37
+ spread if SPREADS.include?(spread)
38
+ end
39
+
40
+ # Gradient space to user space, for a shape whose box is [x, y, w, h].
41
+ def matrix(box)
42
+ own = Transform.parse(@attributes["gradientTransform"].to_s)
43
+ return own unless bounding_box?
44
+
45
+ x, y, w, h = box
46
+ Transform.multiply([w, 0, 0, h, x, y], own)
47
+ end
48
+
49
+ # Axial [x1 y1 x2 y2] or radial [fx fy 0 cx cy r] coordinates; user
50
+ # space percentages resolve against the viewport [width, height].
51
+ def coords(viewport)
52
+ width, height = viewport
53
+ if @kind == :linear
54
+ return [length("x1", 0, width), length("y1", 0, height), length("x2", "100%", width), length("y2", 0, height)]
55
+ end
56
+
57
+ cx = length("cx", "50%", width)
58
+ cy = length("cy", "50%", height)
59
+ [length("fx", cx, width), length("fy", cy, height), 0, cx, cy,
60
+ length("r", "50%", Math.sqrt(((width**2) + (height**2)) / 2.0))]
61
+ end
62
+
63
+ # The interpolated colour at `offset` as "#RRGGBB".
64
+ def color_at(offset, current)
65
+ before = @stops.reverse.find { |stop| stop.offset <= offset } || @stops.first
66
+ after = @stops.find { |stop| stop.offset >= offset } || @stops.last
67
+ span = after.offset - before.offset
68
+ t = span.positive? ? (offset - before.offset) / span : 0
69
+ from, to = [before, after].map { |stop| rgb(stop, current) }
70
+ "##{from.zip(to).map { |a, b| format("%02X", ((a + ((b - a) * t)) * 255).round) }.join}"
71
+ end
72
+
73
+ def rgb(stop, current) = Color.parse(Style.color(stop.color, current)).components
74
+
75
+ private
76
+
77
+ def lineage(element, elements)
78
+ chain = [element]
79
+ while (id = reference(chain.last)) && (parent = elements[id]) && !chain.include?(parent)
80
+ chain << parent
81
+ end
82
+ chain
83
+ end
84
+
85
+ def reference(element)
86
+ (element.attributes["href"] || element.attributes["xlink:href"]).to_s[/\A#(.+)/, 1]
87
+ end
88
+
89
+ def stop_elements(element)
90
+ element.children.select { |child| child.is_a?(Parser::Element) && child.name == "stop" }
91
+ end
92
+
93
+ def read_stops(element)
94
+ return [] unless element
95
+
96
+ floor = 0.0
97
+ stop_elements(element).map do |stop|
98
+ values = stop.attributes.merge(@sheet.declarations(stop), Style.declarations(stop.attributes["style"]))
99
+ floor = [floor, fraction(values.fetch("offset", "0")).clamp(0.0, 1.0)].max
100
+ Stop.new(floor, values.fetch("stop-color", "black"), values.fetch("stop-opacity", "1").to_f)
101
+ end
102
+ end
103
+
104
+ def length(key, default, reference)
105
+ value = @attributes.fetch(key, default)
106
+ return value.to_f if value.is_a?(Numeric)
107
+ return value.to_f unless value.strip.end_with?("%")
108
+
109
+ bounding_box? ? value.to_f / 100 : value.to_f * reference / 100
110
+ end
111
+
112
+ def fraction(value) = value.strip.end_with?("%") ? value.to_f / 100 : value.to_f
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module SVG
5
+ # Paints one shape element. Solid fills and strokes are one path; a
6
+ # gradient fill is a shading clipped to the shape, drawn under the
7
+ # stroke. A gradient stroke is drawn in the gradient's middle colour.
8
+ class Painter
9
+ def self.translucent(opacity) = opacity < 1 ? opacity : nil
10
+
11
+ def initialize(canvas, gradients, viewport)
12
+ @canvas = canvas
13
+ @gradients = gradients
14
+ @viewport = viewport
15
+ end
16
+
17
+ def paint(element, style)
18
+ fill = resolve(style.fill)
19
+ stroke = color(style.stroke, style)
20
+ if fill.is_a?(Gradient)
21
+ shade(element, style, fill)
22
+ fill = nil
23
+ end
24
+ return if fill.nil? && stroke.nil?
25
+
26
+ @canvas.path(fill:, stroke:, line_width: style.line_width, cap: style.cap || :butt, join: style.join || :miter,
27
+ even_odd: style.even_odd?, opacity: Painter.translucent(style.opacity),
28
+ transform: style.matrix) { |path| Shapes.trace(path, element) }
29
+ end
30
+
31
+ # A paint as one colour: a gradient's middle colour, nil for none.
32
+ def color(paint, style)
33
+ paint = resolve(paint)
34
+ paint.is_a?(Gradient) ? paint.color_at(0.5, style.color) : paint
35
+ end
36
+
37
+ private
38
+
39
+ def resolve(paint)
40
+ return paint unless paint.is_a?(Style::Reference)
41
+
42
+ gradient = @gradients[paint.id]
43
+ return paint.fallback unless gradient
44
+ return nil if gradient.stops.empty?
45
+
46
+ gradient
47
+ end
48
+
49
+ # Stops with differing opacities are approximated by the first one's.
50
+ def shade(element, style, gradient)
51
+ opacity = Painter.translucent(style.opacity * gradient.stops.first.opacity)
52
+ return single_stop(element, style, gradient, opacity) if gradient.stops.one?
53
+
54
+ box = Bounds.new.tap { |bounds| Shapes.trace(bounds, element) }.box
55
+ return if gradient.bounding_box? && box.nil?
56
+
57
+ shading = Shading.dictionary(gradient, gradient.coords(@viewport), style.color)
58
+ matrix = Transform.multiply(style.matrix, gradient.matrix(box))
59
+ @canvas.shade(shading, matrix:, transform: style.matrix, even_odd: style.even_odd?, opacity:) do |path|
60
+ Shapes.trace(path, element)
61
+ end
62
+ end
63
+
64
+ def single_stop(element, style, gradient, opacity)
65
+ @canvas.path(fill: gradient.color_at(0, style.color), even_odd: style.even_odd?, opacity:,
66
+ transform: style.matrix) { |path| Shapes.trace(path, element) }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Stationery
6
+ module SVG
7
+ # A minimal XML reader for SVG documents: elements, attributes and nesting.
8
+ # Comments, processing instructions and doctypes are skipped; text content
9
+ # (CDATA included) is kept only inside text, tspan and style, as String
10
+ # children with entities decoded and each whitespace run one space.
11
+ module Parser
12
+ Element = Data.define(:name, :attributes, :children)
13
+
14
+ TEXT = %w[text tspan style].freeze
15
+
16
+ IGNORED = /<!--.*?-->|<\?.*?\?>|<!DOCTYPE[^>]*>/m
17
+ CDATA = /<!\[CDATA\[(.*?)\]\]>/m
18
+ ESCAPES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;" }.freeze
19
+ TAG = %r{<(/?)([a-zA-Z][\w:.-]*)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(/?)>}m
20
+ ATTRIBUTE = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/
21
+
22
+ module_function
23
+
24
+ def parse(source)
25
+ source = source.to_s.gsub(IGNORED, "").gsub(CDATA) { Regexp.last_match(1).gsub(/[&<>]/, ESCAPES) }
26
+ scanner = StringScanner.new(source)
27
+ root = Element.new("document", {}, [])
28
+ stack = [root]
29
+ while (content = scanner.scan_until(/(?=<)/))
30
+ keep(stack.last, content)
31
+ tag = scanner.scan(TAG) or (scanner.getch && next)
32
+ handle(stack, *tag.match(TAG).captures)
33
+ end
34
+ root.children.first
35
+ end
36
+
37
+ # Yields the element and every element below it, depth first.
38
+ def walk(element, &)
39
+ yield element
40
+ element.children.each { |child| walk(child, &) if child.is_a?(Element) }
41
+ end
42
+
43
+ def keep(element, content)
44
+ return if content.empty? || !TEXT.include?(element.name)
45
+
46
+ element.children << Stationery::Text::Entities.decode(content).gsub(/\s+/, " ")
47
+ end
48
+
49
+ def handle(stack, closing, name, attributes, self_closing)
50
+ return stack.pop if closing == "/" && stack.size > 1
51
+
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?
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Stationery
6
+ module SVG
7
+ class Error < Stationery::Error; end
8
+
9
+ # Parses SVG path data into absolute segments:
10
+ #
11
+ # [:move, x, y] [:line, x, y] [:curve, x1, y1, x2, y2, x, y] [:close]
12
+ #
13
+ # Quadratic curves become cubics and elliptical arcs become cubic segments
14
+ # of at most 90 degrees, so a drawing backend needs only move, line, curve
15
+ # and close.
16
+ class PathData
17
+ ARITY = { "M" => 2, "L" => 2, "T" => 2, "H" => 1, "V" => 1, "C" => 6, "S" => 4, "Q" => 4, "A" => 7,
18
+ "Z" => 0 }.freeze
19
+ NUMBER = /[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/
20
+ FLAG = /[01]/
21
+ SEPARATORS = /[\s,]*/
22
+
23
+ def self.parse(data) = new(data).segments
24
+
25
+ def initialize(data)
26
+ @scanner = StringScanner.new(data.to_s)
27
+ @segments = []
28
+ @x = @y = @start_x = @start_y = 0
29
+ @control = nil
30
+ @command = nil
31
+ end
32
+
33
+ def segments
34
+ loop do
35
+ @scanner.skip(SEPARATORS)
36
+ break if @scanner.eos?
37
+
38
+ if (letter = @scanner.scan(/[MmLlHhVvCcSsQqTtAaZz]/))
39
+ @command = letter
40
+ next close_path if letter.upcase == "Z"
41
+ end
42
+ raise Error, "unreadable path data near #{@scanner.rest[0, 12].inspect}" unless @command
43
+
44
+ apply(@command, arguments(@command))
45
+ end
46
+ @segments
47
+ end
48
+
49
+ private
50
+
51
+ def arguments(command)
52
+ Array.new(ARITY.fetch(command.upcase)) do |index|
53
+ @scanner.skip(SEPARATORS)
54
+ token = @scanner.scan(command.upcase == "A" && [3, 4].include?(index) ? FLAG : NUMBER)
55
+ raise Error, "unreadable path data near #{@scanner.rest[0, 12].inspect}" unless token
56
+
57
+ number(token)
58
+ end
59
+ end
60
+
61
+ def number(token)
62
+ token.match?(/[.eE]/) ? token.to_f : token.to_i
63
+ end
64
+
65
+ def apply(command, args)
66
+ relative = command == command.downcase
67
+ case command.upcase
68
+ when "M" then move(*absolute(args, relative))
69
+ when "L" then line(*absolute(args, relative))
70
+ when "H" then line(relative ? @x + args[0] : args[0], @y)
71
+ when "V" then line(@x, relative ? @y + args[0] : args[0])
72
+ when "C" then curve(*absolute(args, relative))
73
+ when "S" then curve(*reflected_control, *absolute(args, relative))
74
+ when "Q" then quadratic(*absolute(args, relative))
75
+ when "T" then quadratic(*reflected_control, *absolute(args, relative))
76
+ else arc(*args.first(5), *absolute(args.last(2), relative))
77
+ end
78
+ end
79
+
80
+ def absolute(args, relative)
81
+ return args unless relative
82
+
83
+ args.each_with_index.map { |value, index| value + (index.even? ? @x : @y) }
84
+ end
85
+
86
+ def move(x, y)
87
+ @segments << [:move, x, y]
88
+ @x = @start_x = x
89
+ @y = @start_y = y
90
+ @control = nil
91
+ # Coordinates after a move are implicit lines.
92
+ @command = @command == "m" ? "l" : "L"
93
+ end
94
+
95
+ def line(x, y)
96
+ @segments << [:line, x, y]
97
+ @x = x
98
+ @y = y
99
+ @control = nil
100
+ end
101
+
102
+ def curve(x1, y1, x2, y2, x, y)
103
+ @segments << [:curve, x1, y1, x2, y2, x, y]
104
+ @x = x
105
+ @y = y
106
+ @control = [x2, y2]
107
+ end
108
+
109
+ def quadratic(qx, qy, x, y)
110
+ curve(@x + ((2 * (qx - @x)) / 3.0), @y + ((2 * (qy - @y)) / 3.0),
111
+ x + ((2 * (qx - x)) / 3.0), y + ((2 * (qy - y)) / 3.0), x, y)
112
+ @control = [qx, qy]
113
+ end
114
+
115
+ def reflected_control
116
+ return [@x, @y] unless @control
117
+
118
+ [(2 * @x) - @control[0], (2 * @y) - @control[1]]
119
+ end
120
+
121
+ def close_path
122
+ @segments << [:close]
123
+ @x = @start_x
124
+ @y = @start_y
125
+ @control = nil
126
+ end
127
+
128
+ def arc(rx, ry, rotation, large, sweep, x, y)
129
+ return line(x, y) if rx.zero? || ry.zero?
130
+
131
+ Arc.new(@x, @y, rx.abs, ry.abs, rotation, large, sweep, x, y).curves.each { |c| curve(*c) }
132
+ @x = x
133
+ @y = y
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module SVG
5
+ # A compound CSS selector: an element name or `*`, then any number of
6
+ # `.class` and `#id` parts. Combinators, pseudo-classes and attribute
7
+ # selectors are not read.
8
+ class Selector
9
+ PATTERN = /\A(\*|[a-zA-Z][\w-]*)?((?:[.#][\w-]+)*)\z/
10
+
11
+ def self.parse(text)
12
+ match = text.match(PATTERN)
13
+ return nil if match.nil? || text.empty?
14
+
15
+ parts = match[2].scan(/[.#][\w-]+/)
16
+ new(match[1], parts.grep(/\A#/).map { |id| id[1..] }, parts.grep(/\A\./).map { |name| name[1..] })
17
+ end
18
+
19
+ def initialize(name, ids, classes)
20
+ @name = name == "*" ? nil : name
21
+ @ids = ids
22
+ @classes = classes
23
+ end
24
+
25
+ # [ids, classes, element names], compared in that order.
26
+ def specificity = [@ids.size, @classes.size, @name ? 1 : 0]
27
+
28
+ def match?(element)
29
+ attributes = element.attributes
30
+ return false if @name && @name != element.name
31
+ return false unless @ids.all?(attributes["id"])
32
+
33
+ (@classes - attributes["class"].to_s.split).empty?
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module SVG
5
+ # A gradient as a PDF shading dictionary: axial (type 2) or radial
6
+ # (type 3) in DeviceRGB, its stops as one exponential function or a
7
+ # stitching of one per pair. The ends always extend (spread `pad`).
8
+ module Shading
9
+ module_function
10
+
11
+ def dictionary(gradient, coords, current)
12
+ {
13
+ ShadingType: gradient.kind == :radial ? 3 : 2, ColorSpace: :DeviceRGB, Coords: coords,
14
+ Function: function(padded(gradient.stops), gradient, current), Extend: [true, true]
15
+ }
16
+ end
17
+
18
+ def function(stops, gradient, current)
19
+ colors = stops.map { |stop| gradient.rgb(stop, current).map { |v| v.round(4) } }
20
+ segments = colors.each_cons(2).map { |c0, c1| { FunctionType: 2, Domain: [0, 1], C0: c0, C1: c1, N: 1 } }
21
+ return segments.first if segments.one?
22
+
23
+ { FunctionType: 3, Domain: [0, 1], Functions: segments, Bounds: stops[1..-2].map(&:offset),
24
+ Encode: [0, 1] * segments.size }
25
+ end
26
+
27
+ def padded(stops)
28
+ stops = [stops.first.with(offset: 0.0), *stops] if stops.first.offset.positive?
29
+ stops = [*stops, stops.last.with(offset: 1.0)] if stops.last.offset < 1
30
+ stops
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module SVG
5
+ # Traces one SVG shape element onto a canvas path.
6
+ module Shapes
7
+ module_function
8
+
9
+ def trace(path, element)
10
+ a = element.attributes
11
+ case element.name
12
+ when "path" then trace_data(path, PathData.parse(a["d"]))
13
+ when "rect" then path.rounded_rect(f(a, "x"), f(a, "y"), f(a, "width"), f(a, "height"), f(a, "rx", f(a, "ry")))
14
+ when "circle" then path.ellipse(f(a, "cx"), f(a, "cy"), f(a, "r"), f(a, "r"))
15
+ when "ellipse" then path.ellipse(f(a, "cx"), f(a, "cy"), f(a, "rx"), f(a, "ry"))
16
+ when "line" then trace_points(path, [[f(a, "x1"), f(a, "y1")], [f(a, "x2"), f(a, "y2")]], close: false)
17
+ else trace_points(path, points(a["points"]), close: element.name == "polygon")
18
+ end
19
+ end
20
+
21
+ def trace_data(path, segments)
22
+ segments.each do |kind, *args|
23
+ case kind
24
+ when :move then path.move_to(*args)
25
+ when :line then path.line_to(*args)
26
+ when :curve then path.curve_to(*args)
27
+ else path.close
28
+ end
29
+ end
30
+ end
31
+
32
+ def trace_points(path, points, close:)
33
+ return if points.empty?
34
+
35
+ path.move_to(*points.first)
36
+ points.drop(1).each { |point| path.line_to(*point) }
37
+ path.close if close
38
+ end
39
+
40
+ def points(source)
41
+ source.to_s.scan(PathData::NUMBER).map { |v| v.match?(/[.eE]/) ? v.to_f : v.to_i }.each_slice(2)
42
+ .select { |pair| pair.size == 2 }
43
+ end
44
+
45
+ def f(attributes, key, default = 0)
46
+ value = attributes[key]
47
+ return default if value.nil? || value.empty?
48
+
49
+ number = value.to_f
50
+ number == number.round ? number.round : number
51
+ end
52
+ end
53
+ end
54
+ end