stationery 0.2.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/README.md +47 -10
  4. data/lib/stationery/builder.rb +2 -1
  5. data/lib/stationery/canvas/text.rb +5 -4
  6. data/lib/stationery/canvas.rb +13 -0
  7. data/lib/stationery/document.rb +9 -2
  8. data/lib/stationery/elements.rb +1 -1
  9. data/lib/stationery/fonts/font.rb +36 -20
  10. data/lib/stationery/fonts/font_book.rb +3 -0
  11. data/lib/stationery/fonts/glyph_run.rb +4 -3
  12. data/lib/stationery/fonts/gpos.rb +11 -9
  13. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  14. data/lib/stationery/fonts/gsub.rb +65 -0
  15. data/lib/stationery/fonts/ligatures.rb +16 -0
  16. data/lib/stationery/fonts/registry.rb +18 -9
  17. data/lib/stationery/fonts/to_unicode.rb +1 -1
  18. data/lib/stationery/fonts/true_type.rb +36 -12
  19. data/lib/stationery/fonts/woff.rb +52 -0
  20. data/lib/stationery/layout/box.rb +23 -6
  21. data/lib/stationery/layout/row.rb +1 -1
  22. data/lib/stationery/layout/svg.rb +5 -2
  23. data/lib/stationery/layout/text.rb +2 -1
  24. data/lib/stationery/pdf/assembler.rb +3 -2
  25. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  26. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  27. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  28. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  29. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  30. data/lib/stationery/pdf/serializer.rb +17 -9
  31. data/lib/stationery/pdf/writer.rb +24 -5
  32. data/lib/stationery/resources.rb +8 -2
  33. data/lib/stationery/svg/bounds.rb +76 -0
  34. data/lib/stationery/svg/document.rb +48 -18
  35. data/lib/stationery/svg/gradient.rb +115 -0
  36. data/lib/stationery/svg/painter.rb +70 -0
  37. data/lib/stationery/svg/parser.rb +24 -4
  38. data/lib/stationery/svg/selector.rb +37 -0
  39. data/lib/stationery/svg/shading.rb +34 -0
  40. data/lib/stationery/svg/style.rb +37 -20
  41. data/lib/stationery/svg/stylesheet.rb +47 -0
  42. data/lib/stationery/svg/text.rb +122 -0
  43. data/lib/stationery/svg/transform.rb +11 -0
  44. data/lib/stationery/text/paragraph.rb +1 -0
  45. data/lib/stationery/text/style.rb +4 -2
  46. data/lib/stationery/text/wrapper.rb +2 -1
  47. data/lib/stationery/version.rb +1 -1
  48. data/lib/stationery.rb +16 -0
  49. metadata +17 -1
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- # Document-wide names for fonts, images and graphics states, so each is
4
+ # Document-wide names for fonts, images, graphics states and shadings, so each is
5
5
  # embedded once however many pages use it.
6
6
  class Resources
7
7
  def initialize
8
8
  @fonts = {}
9
9
  @images = {}
10
10
  @states = {}
11
+ @shadings = {}
11
12
  end
12
13
 
13
14
  def font(font)
@@ -22,12 +23,17 @@ module Stationery
22
23
  @states[value.to_f.round(3)] ||= :"GS#{@states.size + 1}"
23
24
  end
24
25
 
26
+ def shading(dictionary)
27
+ @shadings[dictionary] ||= :"Sh#{@shadings.size + 1}"
28
+ end
29
+
25
30
  # Writes every resource once; returns { category => { name => ref } }.
26
31
  def build(writer)
27
32
  {
28
33
  Font: @fonts.to_h { |font, name| [name, font.build(writer)] },
29
34
  XObject: @images.to_h { |image, name| [name, image.build(writer)] },
30
- ExtGState: @states.to_h { |alpha, name| [name, writer.add({ Type: :ExtGState, ca: alpha, CA: alpha })] }
35
+ ExtGState: @states.to_h { |alpha, name| [name, writer.add({ Type: :ExtGState, ca: alpha, CA: alpha })] },
36
+ Shading: @shadings.to_h { |dictionary, name| [name, writer.add(dictionary)] }
31
37
  }
32
38
  end
33
39
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module SVG
5
+ # A path sink that measures a shape's bounding box in its own user space,
6
+ # curves by their extrema. Takes the calls Shapes.trace makes on a path.
7
+ class Bounds
8
+ def initialize
9
+ @xs = []
10
+ @ys = []
11
+ end
12
+
13
+ def move_to(x, y) = add(x, y)
14
+ def line_to(x, y) = add(x, y)
15
+ def close = self
16
+
17
+ def curve_to(x1, y1, x2, y2, x, y)
18
+ x0 = @xs.last || 0
19
+ y0 = @ys.last || 0
20
+ (extrema(x0, x1, x2, x) + extrema(y0, y1, y2, y)).each do |at|
21
+ add(cubic(at, x0, x1, x2, x), cubic(at, y0, y1, y2, y))
22
+ end
23
+ add(x, y)
24
+ end
25
+
26
+ def rounded_rect(x, y, w, h, _radius)
27
+ add(x, y)
28
+ add(x + w, y + h)
29
+ end
30
+
31
+ def ellipse(cx, cy, rx, ry)
32
+ add(cx - rx, cy - ry)
33
+ add(cx + rx, cy + ry)
34
+ end
35
+
36
+ # [x, y, width, height], or nil when the shape has no area.
37
+ def box
38
+ return nil if @xs.empty?
39
+
40
+ width = @xs.max - @xs.min
41
+ height = @ys.max - @ys.min
42
+ [@xs.min, @ys.min, width, height] if width.positive? && height.positive?
43
+ end
44
+
45
+ private
46
+
47
+ def add(x, y)
48
+ @xs << x
49
+ @ys << y
50
+ self
51
+ end
52
+
53
+ # Parameters in (0, 1) where the cubic's derivative is zero.
54
+ def extrema(from, first, second, to)
55
+ a = -from + (3 * first) - (3 * second) + to
56
+ b = 2 * (from - (2 * first) + second)
57
+ c = first - from
58
+ roots(a, b, c).select { |t| t.positive? && t < 1 }
59
+ end
60
+
61
+ def roots(a, b, c)
62
+ return b.zero? ? [] : [-c.fdiv(b)] if a.zero?
63
+
64
+ discriminant = (b**2) - (4 * a * c)
65
+ return [] if discriminant.negative?
66
+
67
+ [1, -1].map { |sign| (-b + (sign * Math.sqrt(discriminant))) / (2.0 * a) }
68
+ end
69
+
70
+ def cubic(at, from, first, second, to)
71
+ rest = 1 - at
72
+ ((rest**3) * from) + (3 * (rest**2) * at * first) + (3 * rest * (at**2) * second) + ((at**3) * to)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -5,10 +5,12 @@ module Stationery
5
5
  # A parsed SVG drawn as vector paths on a canvas. Supports the subset icon
6
6
  # sets use: path, rect (with rx), circle, ellipse, line, polyline, polygon
7
7
  # and g, with fill, stroke, stroke width/caps/joins, fill-rule, opacity,
8
- # inline styles and transforms. `currentColor` takes the colour you pass.
8
+ # inline styles, <style> stylesheets, transforms, linear/radial gradients
9
+ # and text/tspan. `currentColor` takes the colour you pass.
9
10
  class Document
10
11
  SHAPES = %w[path rect circle ellipse line polyline polygon].freeze
11
- QUIET = (SHAPES + %w[svg g title desc metadata]).freeze
12
+ QUIET = (SHAPES + %w[svg g title desc metadata style defs linearGradient radialGradient stop text
13
+ tspan]).freeze
12
14
 
13
15
  def self.parse(source)
14
16
  root = Parser.parse(source)
@@ -22,19 +24,26 @@ module Stationery
22
24
  def initialize(root)
23
25
  @root = root
24
26
  @view_box = read_view_box
25
- @unsupported = element_names(root).uniq.sort - QUIET
27
+ @sheet = Stylesheet.parse(style_text)
28
+ @gradients = Gradient.collect(root, @sheet)
29
+ @unsupported = (element_names(root).uniq - QUIET).sort + approximations
26
30
  end
27
31
 
28
32
  def aspect = @view_box[2].to_f / @view_box[3]
29
33
 
30
- # Scales the viewBox into the box (aspect ratio kept, centred).
31
- def draw(canvas, x:, y:, width:, height:, color: "#000000")
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)
32
37
  vx, vy, vw, vh = @view_box
33
38
  scale = [width.to_f / vw, height.to_f / vh].min
34
39
  e = x + ((width - (vw * scale)) / 2.0) - (vx * scale)
35
40
  f = y + ((height - (vh * scale)) / 2.0) - (vy * scale)
36
- style = Style.new(Style::DEFAULTS, [scale, 0, 0, scale, e, f], color:).child(@root.attributes)
37
- @root.children.each { |element| draw_element(canvas, element, style) }
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
38
47
  end
39
48
 
40
49
  private
@@ -46,8 +55,14 @@ module Stationery
46
55
  [0, 0, number(@root.attributes.fetch("width", "100")), number(@root.attributes.fetch("height", "100"))]
47
56
  end
48
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
+
49
64
  def element_names(element)
50
- element.children.flat_map { |child| [child.name, *element_names(child)] }
65
+ element.children.grep(Parser::Element).flat_map { |child| [child.name, *element_names(child)] }
51
66
  end
52
67
 
53
68
  def number(value)
@@ -55,18 +70,33 @@ module Stationery
55
70
  float == float.round ? float.round : float
56
71
  end
57
72
 
58
- def draw_element(canvas, element, parent)
59
- style = parent.child(element.attributes)
60
- return element.children.each { |child| draw_element(canvas, child, style) } if element.name == "g"
61
- return unless SHAPES.include?(element.name)
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?
62
79
 
63
- fill = style.fill
64
- stroke = style.stroke
65
- return if fill.nil? && stroke.nil?
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
66
85
 
67
- canvas.path(fill:, stroke:, line_width: style.line_width, cap: style.cap || :butt, join: style.join || :miter,
68
- even_odd: style.even_odd?, opacity: style.opacity < 1 ? style.opacity : nil,
69
- transform: style.matrix) { |path| Shapes.trace(path, element) }
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
70
100
  end
71
101
  end
72
102
  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
@@ -5,27 +5,47 @@ require "strscan"
5
5
  module Stationery
6
6
  module SVG
7
7
  # A minimal XML reader for SVG documents: elements, attributes and nesting.
8
- # Comments, processing instructions, doctypes and text content are skipped.
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.
9
11
  module Parser
10
12
  Element = Data.define(:name, :attributes, :children)
11
13
 
12
- IGNORED = /<!--.*?-->|<\?.*?\?>|<!DOCTYPE[^>]*>|<!\[CDATA\[.*?\]\]>/m
14
+ TEXT = %w[text tspan style].freeze
15
+
16
+ IGNORED = /<!--.*?-->|<\?.*?\?>|<!DOCTYPE[^>]*>/m
17
+ CDATA = /<!\[CDATA\[(.*?)\]\]>/m
18
+ ESCAPES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;" }.freeze
13
19
  TAG = %r{<(/?)([a-zA-Z][\w:.-]*)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(/?)>}m
14
20
  ATTRIBUTE = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/
15
21
 
16
22
  module_function
17
23
 
18
24
  def parse(source)
19
- scanner = StringScanner.new(source.to_s.gsub(IGNORED, ""))
25
+ source = source.to_s.gsub(IGNORED, "").gsub(CDATA) { Regexp.last_match(1).gsub(/[&<>]/, ESCAPES) }
26
+ scanner = StringScanner.new(source)
20
27
  root = Element.new("document", {}, [])
21
28
  stack = [root]
22
- while scanner.skip_until(/(?=<)/)
29
+ while (content = scanner.scan_until(/(?=<)/))
30
+ keep(stack.last, content)
23
31
  tag = scanner.scan(TAG) or (scanner.getch && next)
24
32
  handle(stack, *tag.match(TAG).captures)
25
33
  end
26
34
  root.children.first
27
35
  end
28
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
+
29
49
  def handle(stack, closing, name, attributes, self_closing)
30
50
  return stack.pop if closing == "/" && stack.size > 1
31
51
 
@@ -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
@@ -3,24 +3,47 @@
3
3
  module Stationery
4
4
  module SVG
5
5
  # Presentation attributes an element inherits from its ancestors, and the
6
- # transform it draws with.
6
+ # transform it draws with. An element's own values cascade: presentation
7
+ # attributes, then stylesheet rules, then its inline `style`.
7
8
  class Style
8
9
  INHERITED = %w[fill stroke stroke-width stroke-linecap stroke-linejoin fill-rule opacity fill-opacity
9
- stroke-opacity].freeze
10
+ stroke-opacity font-family font-size font-weight font-style text-anchor visibility].freeze
10
11
  NAMED = { "black" => "#000000", "white" => "#FFFFFF", "red" => "#FF0000", "green" => "#008000",
11
12
  "blue" => "#0000FF", "gray" => "#808080", "grey" => "#808080" }.freeze
12
13
  DEFAULTS = { "fill" => "black", "stroke" => "none", "stroke-width" => "1" }.freeze
14
+ URL = /\Aurl\(\s*['"]?#([^'")\s]+)['"]?\s*\)\s*(.*)\z/
13
15
 
14
- attr_reader :values, :matrix
16
+ # A paint server reference, `url(#id)`, with its fallback colour (or nil).
17
+ Reference = Data.define(:id, :fallback)
15
18
 
16
- def initialize(values = DEFAULTS, matrix = [1, 0, 0, 1, 0, 0], color: "#000000")
19
+ def self.color(value, current)
20
+ value = value.to_s.strip
21
+ return current if value == "currentColor"
22
+
23
+ NAMED.fetch(value.downcase, value)
24
+ end
25
+
26
+ def self.declarations(style)
27
+ style.to_s.split(";").filter_map do |declaration|
28
+ key, value = declaration.split(":", 2).map(&:strip)
29
+ [key, value] if key && value
30
+ end.to_h
31
+ end
32
+
33
+ attr_reader :values, :matrix, :color
34
+
35
+ def initialize(values = DEFAULTS, matrix = [1, 0, 0, 1, 0, 0], color: "#000000", sheet: Stylesheet::EMPTY,
36
+ display: nil)
17
37
  @values = values
18
38
  @matrix = matrix
19
39
  @color = color
40
+ @sheet = sheet
41
+ @display = display
20
42
  end
21
43
 
22
- def child(attributes)
23
- own = attributes.merge(declarations(attributes["style"]))
44
+ def child(element)
45
+ attributes = element.attributes
46
+ own = attributes.merge(@sheet.declarations(element), Style.declarations(attributes["style"]))
24
47
  values = @values.merge(own.slice(*INHERITED))
25
48
  matrix = if attributes["transform"]
26
49
  Transform.multiply(@matrix,
@@ -28,9 +51,12 @@ module Stationery
28
51
  else
29
52
  @matrix
30
53
  end
31
- Style.new(values, matrix, color: @color)
54
+ Style.new(values, matrix, color: @color, sheet: @sheet, display: own["display"])
32
55
  end
33
56
 
57
+ def displayed? = @display != "none"
58
+ def visible? = !%w[hidden collapse].include?(@values["visibility"])
59
+
34
60
  def fill = paint("fill")
35
61
  def stroke = paint("stroke")
36
62
  def even_odd? = @values["fill-rule"] == "evenodd"
@@ -38,10 +64,7 @@ module Stationery
38
64
  def join = @values["stroke-linejoin"]&.to_sym
39
65
 
40
66
  # Stroke width scales with the drawing, by the transform's area factor.
41
- def line_width
42
- a, b, c, d, = @matrix
43
- @values["stroke-width"].to_f * Math.sqrt(((a * d) - (b * c)).abs)
44
- end
67
+ def line_width = @values["stroke-width"].to_f * Transform.scale(@matrix)
45
68
 
46
69
  def opacity
47
70
  %w[opacity fill-opacity stroke-opacity].filter_map { |key| @values[key]&.to_f }.reduce(1.0, :*)
@@ -52,16 +75,10 @@ module Stationery
52
75
  def paint(key)
53
76
  value = @values[key].to_s.strip
54
77
  return nil if value.empty? || %w[none transparent].include?(value)
55
- return @color if value == "currentColor"
56
-
57
- NAMED.fetch(value.downcase, value)
58
- end
78
+ return Style.color(value, @color) unless (url = value.match(URL))
59
79
 
60
- def declarations(style)
61
- style.to_s.split(";").filter_map do |declaration|
62
- key, value = declaration.split(":", 2).map(&:strip)
63
- [key, value] if key && value
64
- end.to_h
80
+ fallback = url[2]
81
+ Reference.new(url[1], fallback.empty? || fallback == "none" ? nil : Style.color(fallback, @color))
65
82
  end
66
83
  end
67
84
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module SVG
5
+ # The rules of a document's <style> elements. An element's declarations
6
+ # are those of every matching rule, the more specific winning and the
7
+ # later winning among equals. At-rules are skipped and `!important` is
8
+ # read as a plain declaration; rules whose selectors use combinators are
9
+ # ignored and listed in #unsupported.
10
+ class Stylesheet
11
+ Rule = Data.define(:selector, :declarations, :index)
12
+
13
+ RULE = /([^{}]+)\{([^{}]*)\}/
14
+ COMMENT = %r{/\*.*?\*/}m
15
+
16
+ def self.parse(css)
17
+ rules = []
18
+ unsupported = []
19
+ css.gsub(COMMENT, "").scan(RULE).each do |selectors, body|
20
+ next if selectors.strip.start_with?("@")
21
+
22
+ declarations = Style.declarations(body).transform_values { |value| value.sub(/\s*!important\z/, "") }
23
+ selectors.split(",").map(&:strip).each do |text|
24
+ selector = Selector.parse(text)
25
+ selector ? rules << Rule.new(selector, declarations, rules.size) : unsupported << text
26
+ end
27
+ end
28
+ new(rules, unsupported)
29
+ end
30
+
31
+ attr_reader :unsupported
32
+
33
+ def initialize(rules, unsupported)
34
+ @rules = rules
35
+ @unsupported = unsupported
36
+ end
37
+
38
+ EMPTY = new([], []).freeze
39
+
40
+ def declarations(element)
41
+ @rules.select { |rule| rule.selector.match?(element) }
42
+ .sort_by { |rule| [rule.selector.specificity, rule.index] }
43
+ .reduce({}) { |merged, rule| merged.merge(rule.declarations) }
44
+ end
45
+ end
46
+ end
47
+ end