stationery 0.2.0 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -1
- data/README.md +143 -12
- data/lib/stationery/builder.rb +2 -1
- data/lib/stationery/canvas/debug.rb +2 -1
- data/lib/stationery/canvas/marking.rb +124 -0
- data/lib/stationery/canvas/text.rb +5 -4
- data/lib/stationery/canvas.rb +37 -8
- data/lib/stationery/document.rb +35 -10
- data/lib/stationery/elements/forms.rb +55 -0
- data/lib/stationery/elements/lists.rb +15 -5
- data/lib/stationery/elements.rb +10 -7
- data/lib/stationery/fonts/font.rb +36 -20
- data/lib/stationery/fonts/font_book.rb +3 -0
- data/lib/stationery/fonts/glyph_run.rb +4 -3
- data/lib/stationery/fonts/gpos.rb +11 -9
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -0
- data/lib/stationery/fonts/ligatures.rb +16 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- data/lib/stationery/fonts/to_unicode.rb +1 -1
- data/lib/stationery/fonts/true_type.rb +36 -12
- data/lib/stationery/fonts/woff.rb +52 -0
- data/lib/stationery/forms/acro_form.rb +115 -0
- data/lib/stationery/forms/appearance.rb +119 -0
- data/lib/stationery/forms/field.rb +122 -0
- data/lib/stationery/forms/metrics.rb +49 -0
- data/lib/stationery/layout/box.rb +34 -11
- data/lib/stationery/layout/field.rb +51 -0
- data/lib/stationery/layout/flow.rb +17 -9
- data/lib/stationery/layout/image.rb +4 -2
- data/lib/stationery/layout/list_item.rb +15 -6
- data/lib/stationery/layout/node.rb +2 -0
- data/lib/stationery/layout/paginator.rb +3 -2
- data/lib/stationery/layout/row.rb +1 -1
- data/lib/stationery/layout/svg.rb +9 -2
- data/lib/stationery/layout/table/cell.rb +10 -2
- data/lib/stationery/layout/table.rb +42 -10
- data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
- data/lib/stationery/layout/table_of_contents.rb +1 -1
- data/lib/stationery/layout/text.rb +6 -4
- data/lib/stationery/minitest.rb +2 -0
- data/lib/stationery/page.rb +5 -2
- data/lib/stationery/page_templates.rb +7 -4
- data/lib/stationery/pdf/assembler.rb +38 -5
- data/lib/stationery/pdf/encryption/aes.rb +37 -0
- data/lib/stationery/pdf/encryption/rc4.rb +33 -0
- data/lib/stationery/pdf/encryption/revision4.rb +48 -0
- data/lib/stationery/pdf/encryption/revision6.rb +56 -0
- data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
- data/lib/stationery/pdf/serializer.rb +17 -9
- data/lib/stationery/pdf/writer.rb +24 -5
- data/lib/stationery/resources.rb +8 -2
- data/lib/stationery/rich/renderer.rb +3 -3
- data/lib/stationery/structure.rb +15 -9
- data/lib/stationery/svg/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +48 -18
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +24 -4
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/style.rb +37 -20
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +11 -0
- data/lib/stationery/tagging/element.rb +74 -0
- data/lib/stationery/tagging/tree.rb +43 -0
- data/lib/stationery/tagging/writer.rb +81 -0
- data/lib/stationery/testing/inspector.rb +15 -0
- data/lib/stationery/testing/marked_text.rb +60 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/testing/structure_reader.rb +71 -0
- data/lib/stationery/text/paragraph.rb +28 -12
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +2 -1
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +8 -0
- data/lib/stationery.rb +26 -0
- metadata +29 -1
|
@@ -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
|
|
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
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
|
37
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
14
|
+
TEXT = %w[text tspan style].freeze
|
|
15
|
+
|
|
16
|
+
IGNORED = /<!--.*?-->|<\?.*?\?>|<!DOCTYPE[^>]*>/m
|
|
17
|
+
CDATA = /<!\[CDATA\[(.*?)\]\]>/m
|
|
18
|
+
ESCAPES = { "&" => "&", "<" => "<", ">" => ">" }.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
|
-
|
|
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.
|
|
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
|
data/lib/stationery/svg/style.rb
CHANGED
|
@@ -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
|
-
|
|
16
|
+
# A paint server reference, `url(#id)`, with its fallback colour (or nil).
|
|
17
|
+
Reference = Data.define(:id, :fallback)
|
|
15
18
|
|
|
16
|
-
def
|
|
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(
|
|
23
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
NAMED.fetch(value.downcase, value)
|
|
58
|
-
end
|
|
78
|
+
return Style.color(value, @color) unless (url = value.match(URL))
|
|
59
79
|
|
|
60
|
-
|
|
61
|
-
|
|
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
|