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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +114 -1
- data/README.md +395 -25
- data/exe/stationery +6 -0
- data/lib/generators/stationery/fonts_generator.rb +24 -0
- data/lib/stationery/builder.rb +26 -3
- data/lib/stationery/canvas/debug.rb +26 -0
- data/lib/stationery/canvas/path.rb +22 -12
- data/lib/stationery/canvas/text.rb +10 -6
- data/lib/stationery/canvas.rb +38 -8
- data/lib/stationery/cli/fonts.rb +85 -0
- data/lib/stationery/cli/render.rb +137 -0
- data/lib/stationery/cli.rb +74 -0
- data/lib/stationery/document.rb +60 -9
- data/lib/stationery/elements/lists.rb +58 -0
- data/lib/stationery/elements/rich.rb +27 -0
- data/lib/stationery/elements.rb +88 -12
- data/lib/stationery/errors.rb +13 -1
- data/lib/stationery/fonts/bundled.rb +25 -0
- data/lib/stationery/fonts/catalog.rb +101 -0
- data/lib/stationery/fonts/cff.rb +140 -0
- data/lib/stationery/fonts/cff_subset.rb +145 -0
- data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
- data/lib/stationery/fonts/data/OFL.txt +92 -0
- data/lib/stationery/fonts/data/README.md +14 -0
- data/lib/stationery/fonts/embedding/base.rb +56 -0
- data/lib/stationery/fonts/embedding/cff.rb +35 -0
- data/lib/stationery/fonts/embedding/true_type.rb +35 -0
- data/lib/stationery/fonts/fallback.rb +58 -0
- data/lib/stationery/fonts/family.rb +11 -0
- data/lib/stationery/fonts/font.rb +68 -88
- data/lib/stationery/fonts/font_book.rb +49 -8
- data/lib/stationery/fonts/glyph_run.rb +39 -0
- data/lib/stationery/fonts/gpos/class_def.rb +25 -0
- data/lib/stationery/fonts/gpos/coverage.rb +20 -0
- data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
- data/lib/stationery/fonts/gpos.rb +64 -0
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -0
- data/lib/stationery/fonts/installer.rb +121 -0
- data/lib/stationery/fonts/kern_table.rb +52 -0
- data/lib/stationery/fonts/kerning.rb +17 -0
- data/lib/stationery/fonts/ligatures.rb +16 -0
- data/lib/stationery/fonts/packs.rb +45 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
- data/lib/stationery/fonts/to_unicode.rb +36 -0
- data/lib/stationery/fonts/true_type.rb +64 -57
- data/lib/stationery/fonts/woff.rb +52 -0
- data/lib/stationery/html/document.rb +11 -0
- data/lib/stationery/html/tokenizer.rb +74 -0
- data/lib/stationery/html/tree_builder/blocks.rb +137 -0
- data/lib/stationery/html/tree_builder/collector.rb +50 -0
- data/lib/stationery/html/tree_builder.rb +72 -0
- data/lib/stationery/html/whitespace.rb +43 -0
- data/lib/stationery/layout/box.rb +102 -16
- data/lib/stationery/layout/flow.rb +48 -15
- data/lib/stationery/layout/image.rb +1 -0
- data/lib/stationery/layout/list_item.rb +89 -0
- data/lib/stationery/layout/mark.rb +58 -0
- data/lib/stationery/layout/node.rb +26 -2
- data/lib/stationery/layout/paginator.rb +35 -6
- data/lib/stationery/layout/positioned.rb +3 -1
- data/lib/stationery/layout/row.rb +34 -4
- data/lib/stationery/layout/svg.rb +48 -0
- data/lib/stationery/layout/table/cell.rb +33 -6
- data/lib/stationery/layout/table/grid.rb +93 -0
- data/lib/stationery/layout/table/row_splitter.rb +36 -0
- data/lib/stationery/layout/table/selection.rb +3 -2
- data/lib/stationery/layout/table.rb +69 -23
- data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
- data/lib/stationery/layout/table_of_contents.rb +43 -0
- data/lib/stationery/layout/text.rb +6 -4
- data/lib/stationery/layout/wrap.rb +78 -0
- data/lib/stationery/list_markers.rb +47 -0
- data/lib/stationery/markdown/block_parser/containers.rb +94 -0
- data/lib/stationery/markdown/block_parser/tables.rb +54 -0
- data/lib/stationery/markdown/block_parser.rb +141 -0
- data/lib/stationery/markdown/document.rb +50 -0
- data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
- data/lib/stationery/markdown/inline_parser/links.rb +70 -0
- data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
- data/lib/stationery/markdown/inline_parser.rb +89 -0
- data/lib/stationery/minitest.rb +33 -0
- data/lib/stationery/outline.rb +37 -0
- data/lib/stationery/page.rb +18 -4
- data/lib/stationery/page_templates.rb +33 -10
- data/lib/stationery/pdf/assembler.rb +20 -8
- 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/outline_writer.rb +72 -0
- data/lib/stationery/pdf/serializer.rb +22 -10
- data/lib/stationery/pdf/writer.rb +28 -5
- data/lib/stationery/preview.rb +64 -0
- data/lib/stationery/rails/previews_controller.rb +61 -0
- data/lib/stationery/rails.rb +2 -0
- data/lib/stationery/railtie.rb +60 -0
- data/lib/stationery/regions.rb +67 -0
- data/lib/stationery/resources.rb +8 -2
- data/lib/stationery/rich/nodes.rb +55 -0
- data/lib/stationery/rich/renderer/inlines.rb +53 -0
- data/lib/stationery/rich/renderer.rb +121 -0
- data/lib/stationery/rich/styles.rb +34 -0
- data/lib/stationery/rspec.rb +6 -0
- data/lib/stationery/structure.rb +78 -0
- data/lib/stationery/svg/arc.rb +91 -0
- data/lib/stationery/svg/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +103 -0
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +58 -0
- data/lib/stationery/svg/path_data.rb +137 -0
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/shapes.rb +54 -0
- data/lib/stationery/svg/style.rb +85 -0
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +54 -0
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +123 -0
- data/lib/stationery/text/entities/html4.rb +49 -0
- data/lib/stationery/text/entities.rb +6 -1
- data/lib/stationery/text/line.rb +8 -2
- data/lib/stationery/text/paragraph.rb +33 -11
- data/lib/stationery/text/runs_builder.rb +9 -1
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +4 -2
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +65 -0
- data/lib/stationery.rb +67 -0
- 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 = { "&" => "&", "<" => "<", ">" => ">" }.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
|