stationery 0.1.0 → 0.2.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 +94 -1
- data/README.md +358 -25
- data/exe/stationery +6 -0
- data/lib/generators/stationery/fonts_generator.rb +24 -0
- data/lib/stationery/builder.rb +25 -3
- data/lib/stationery/canvas/debug.rb +26 -0
- data/lib/stationery/canvas/path.rb +22 -12
- data/lib/stationery/canvas/text.rb +8 -5
- data/lib/stationery/canvas.rb +25 -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 +53 -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 +52 -88
- data/lib/stationery/fonts/font_book.rb +46 -8
- data/lib/stationery/fonts/glyph_run.rb +38 -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 +62 -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/packs.rb +45 -0
- 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 +32 -49
- 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 +85 -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 +45 -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 +5 -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 +18 -7
- data/lib/stationery/pdf/outline_writer.rb +72 -0
- data/lib/stationery/pdf/serializer.rb +5 -1
- data/lib/stationery/pdf/writer.rb +4 -0
- 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/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/document.rb +73 -0
- data/lib/stationery/svg/parser.rb +38 -0
- data/lib/stationery/svg/path_data.rb +137 -0
- data/lib/stationery/svg/shapes.rb +54 -0
- data/lib/stationery/svg/style.rb +68 -0
- data/lib/stationery/svg/transform.rb +43 -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 +32 -11
- data/lib/stationery/text/runs_builder.rb +9 -1
- data/lib/stationery/text/style.rb +2 -2
- data/lib/stationery/text/wrapper.rb +3 -2
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +65 -0
- data/lib/stationery.rb +51 -0
- metadata +85 -4
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# Resolves internal links (`#name`) against the anchors painted on the
|
|
5
|
+
# finished pages and fills page-number slots with the pages their anchors
|
|
6
|
+
# landed on. Anchors drawn by page templates repeat on every page, so they
|
|
7
|
+
# resolve to their first page and never count as duplicates.
|
|
8
|
+
class Structure
|
|
9
|
+
# Where a named anchor sits: a zero-based page index and a PDF-space top.
|
|
10
|
+
Destination = Data.define(:page, :top)
|
|
11
|
+
|
|
12
|
+
# Returns the destinations by name; unresolved links are dropped.
|
|
13
|
+
# Runs before resources are written, so slot digits join the font subset.
|
|
14
|
+
def self.resolve(pages, warnings:, resources: nil, book: nil) = new(pages, warnings, resources, book).resolve
|
|
15
|
+
|
|
16
|
+
def initialize(pages, warnings, resources, book)
|
|
17
|
+
@pages = pages
|
|
18
|
+
@warnings = warnings
|
|
19
|
+
@resources = resources
|
|
20
|
+
@book = book
|
|
21
|
+
@destinations = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def resolve
|
|
25
|
+
collect
|
|
26
|
+
collect_templates
|
|
27
|
+
@pages.each_with_index do |page, index|
|
|
28
|
+
page.slots.each { |slot| fill(page, slot) }
|
|
29
|
+
page.annotations.replace(page.annotations.filter_map { |link| link_to(link, index) })
|
|
30
|
+
end
|
|
31
|
+
@destinations
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def collect
|
|
37
|
+
@pages.each_with_index do |page, index|
|
|
38
|
+
page.anchors.each do |name, top|
|
|
39
|
+
if @destinations.key?(name)
|
|
40
|
+
@warnings << Warnings::DuplicateAnchor.new(name:, page: index + 1)
|
|
41
|
+
else
|
|
42
|
+
@destinations[name] = Destination.new(index, top)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def collect_templates
|
|
49
|
+
@pages.each_with_index do |page, index|
|
|
50
|
+
page.template_anchors.each { |name, top| @destinations[name] ||= Destination.new(index, top) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fill(page, slot)
|
|
55
|
+
destination = @destinations[slot.anchor] or return
|
|
56
|
+
label = (destination.page + 1).to_s
|
|
57
|
+
font, face = @book.resolve(slot.style)
|
|
58
|
+
style = slot.style
|
|
59
|
+
width = font.width_of(label, style.render_size, letter_spacing: style.letter_spacing)
|
|
60
|
+
canvas = Canvas.new(page, @resources)
|
|
61
|
+
canvas.link(*slot.link, "##{slot.anchor}") if slot.link
|
|
62
|
+
canvas.text(label, x: slot.x + slot.width - width, y: slot.baseline, font:,
|
|
63
|
+
size: style.render_size, color: style.color,
|
|
64
|
+
letter_spacing: style.letter_spacing, opacity: style.opacity,
|
|
65
|
+
synthetic_bold: face.synthetic_bold, synthetic_oblique: face.synthetic_oblique)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def link_to(link, index)
|
|
69
|
+
return link unless link.key?(:dest)
|
|
70
|
+
|
|
71
|
+
destination = @destinations[link[:dest]]
|
|
72
|
+
return { rect: link[:rect], dest: destination } if destination
|
|
73
|
+
|
|
74
|
+
@warnings << Warnings::UnresolvedLink.new(name: link[:dest], page: index + 1)
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module SVG
|
|
5
|
+
# An SVG elliptical arc as cubic Bezier segments (SVG 1.1 appendix F.6.5:
|
|
6
|
+
# endpoint to centre parameterisation, then at most 90 degrees a segment).
|
|
7
|
+
class Arc
|
|
8
|
+
def initialize(x1, y1, rx, ry, rotation, large, sweep, x2, y2)
|
|
9
|
+
@x1 = x1.to_f
|
|
10
|
+
@y1 = y1.to_f
|
|
11
|
+
@x2 = x2
|
|
12
|
+
@y2 = y2
|
|
13
|
+
@rx = rx.to_f
|
|
14
|
+
@ry = ry.to_f
|
|
15
|
+
@phi = rotation * Math::PI / 180
|
|
16
|
+
@large = large == 1
|
|
17
|
+
@sweep = sweep == 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def curves
|
|
21
|
+
centre
|
|
22
|
+
count = [(@delta.abs / (Math::PI / 2)).ceil, 1].max
|
|
23
|
+
step = @delta / count
|
|
24
|
+
curves = Array.new(count) { |i| segment(@theta + (i * step), step) }
|
|
25
|
+
curves.last[4] = @x2
|
|
26
|
+
curves.last[5] = @y2
|
|
27
|
+
curves
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def centre
|
|
33
|
+
cos = Math.cos(@phi)
|
|
34
|
+
sin = Math.sin(@phi)
|
|
35
|
+
dx = (@x1 - @x2) / 2.0
|
|
36
|
+
dy = (@y1 - @y2) / 2.0
|
|
37
|
+
x1p = (cos * dx) + (sin * dy)
|
|
38
|
+
y1p = (-sin * dx) + (cos * dy)
|
|
39
|
+
scale_radii(x1p, y1p)
|
|
40
|
+
cxp, cyp = centre_prime(x1p, y1p)
|
|
41
|
+
@cx = (cos * cxp) - (sin * cyp) + ((@x1 + @x2) / 2.0)
|
|
42
|
+
@cy = (sin * cxp) + (cos * cyp) + ((@y1 + @y2) / 2.0)
|
|
43
|
+
angles(x1p, y1p, cxp, cyp)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def scale_radii(x1p, y1p)
|
|
47
|
+
lambda = ((x1p**2) / (@rx**2)) + ((y1p**2) / (@ry**2))
|
|
48
|
+
return unless lambda > 1
|
|
49
|
+
|
|
50
|
+
@rx *= Math.sqrt(lambda)
|
|
51
|
+
@ry *= Math.sqrt(lambda)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def centre_prime(x1p, y1p)
|
|
55
|
+
rx2 = @rx**2
|
|
56
|
+
ry2 = @ry**2
|
|
57
|
+
numerator = (rx2 * ry2) - (rx2 * (y1p**2)) - (ry2 * (x1p**2))
|
|
58
|
+
denominator = (rx2 * (y1p**2)) + (ry2 * (x1p**2))
|
|
59
|
+
coefficient = Math.sqrt([numerator, 0].max / denominator) * (@large == @sweep ? -1 : 1)
|
|
60
|
+
[coefficient * @rx * y1p / @ry, -coefficient * @ry * x1p / @rx]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def angles(x1p, y1p, cxp, cyp)
|
|
64
|
+
ux = (x1p - cxp) / @rx
|
|
65
|
+
uy = (y1p - cyp) / @ry
|
|
66
|
+
vx = (-x1p - cxp) / @rx
|
|
67
|
+
vy = (-y1p - cyp) / @ry
|
|
68
|
+
@theta = Math.atan2(uy, ux)
|
|
69
|
+
@delta = Math.atan2((ux * vy) - (uy * vx), (ux * vx) + (uy * vy))
|
|
70
|
+
@delta -= 2 * Math::PI if !@sweep && @delta.positive?
|
|
71
|
+
@delta += 2 * Math::PI if @sweep && @delta.negative?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def segment(start, step)
|
|
75
|
+
t = 4.0 / 3 * Math.tan(step / 4)
|
|
76
|
+
a1 = start
|
|
77
|
+
a2 = start + step
|
|
78
|
+
p1 = point(Math.cos(a1) - (t * Math.sin(a1)), Math.sin(a1) + (t * Math.cos(a1)))
|
|
79
|
+
p2 = point(Math.cos(a2) + (t * Math.sin(a2)), Math.sin(a2) - (t * Math.cos(a2)))
|
|
80
|
+
p3 = point(Math.cos(a2), Math.sin(a2))
|
|
81
|
+
[*p1, *p2, *p3]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def point(ux, uy)
|
|
85
|
+
x = @rx * ux
|
|
86
|
+
y = @ry * uy
|
|
87
|
+
[(Math.cos(@phi) * x) - (Math.sin(@phi) * y) + @cx, (Math.sin(@phi) * x) + (Math.cos(@phi) * y) + @cy]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
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 and transforms. `currentColor` takes the colour you pass.
|
|
9
|
+
class Document
|
|
10
|
+
SHAPES = %w[path rect circle ellipse line polyline polygon].freeze
|
|
11
|
+
QUIET = (SHAPES + %w[svg g title desc metadata]).freeze
|
|
12
|
+
|
|
13
|
+
def self.parse(source)
|
|
14
|
+
root = Parser.parse(source)
|
|
15
|
+
raise Error, "not an svg document" unless root&.name == "svg"
|
|
16
|
+
|
|
17
|
+
new(root)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :view_box, :unsupported
|
|
21
|
+
|
|
22
|
+
def initialize(root)
|
|
23
|
+
@root = root
|
|
24
|
+
@view_box = read_view_box
|
|
25
|
+
@unsupported = element_names(root).uniq.sort - QUIET
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def aspect = @view_box[2].to_f / @view_box[3]
|
|
29
|
+
|
|
30
|
+
# Scales the viewBox into the box (aspect ratio kept, centred).
|
|
31
|
+
def draw(canvas, x:, y:, width:, height:, color: "#000000")
|
|
32
|
+
vx, vy, vw, vh = @view_box
|
|
33
|
+
scale = [width.to_f / vw, height.to_f / vh].min
|
|
34
|
+
e = x + ((width - (vw * scale)) / 2.0) - (vx * scale)
|
|
35
|
+
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) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def read_view_box
|
|
43
|
+
values = @root.attributes["viewBox"]&.split(/[\s,]+/)&.map { |v| number(v) }
|
|
44
|
+
return values if values&.size == 4
|
|
45
|
+
|
|
46
|
+
[0, 0, number(@root.attributes.fetch("width", "100")), number(@root.attributes.fetch("height", "100"))]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def element_names(element)
|
|
50
|
+
element.children.flat_map { |child| [child.name, *element_names(child)] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def number(value)
|
|
54
|
+
float = value.to_s.to_f
|
|
55
|
+
float == float.round ? float.round : float
|
|
56
|
+
end
|
|
57
|
+
|
|
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)
|
|
62
|
+
|
|
63
|
+
fill = style.fill
|
|
64
|
+
stroke = style.stroke
|
|
65
|
+
return if fill.nil? && stroke.nil?
|
|
66
|
+
|
|
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) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
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, doctypes and text content are skipped.
|
|
9
|
+
module Parser
|
|
10
|
+
Element = Data.define(:name, :attributes, :children)
|
|
11
|
+
|
|
12
|
+
IGNORED = /<!--.*?-->|<\?.*?\?>|<!DOCTYPE[^>]*>|<!\[CDATA\[.*?\]\]>/m
|
|
13
|
+
TAG = %r{<(/?)([a-zA-Z][\w:.-]*)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(/?)>}m
|
|
14
|
+
ATTRIBUTE = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def parse(source)
|
|
19
|
+
scanner = StringScanner.new(source.to_s.gsub(IGNORED, ""))
|
|
20
|
+
root = Element.new("document", {}, [])
|
|
21
|
+
stack = [root]
|
|
22
|
+
while scanner.skip_until(/(?=<)/)
|
|
23
|
+
tag = scanner.scan(TAG) or (scanner.getch && next)
|
|
24
|
+
handle(stack, *tag.match(TAG).captures)
|
|
25
|
+
end
|
|
26
|
+
root.children.first
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def handle(stack, closing, name, attributes, self_closing)
|
|
30
|
+
return stack.pop if closing == "/" && stack.size > 1
|
|
31
|
+
|
|
32
|
+
element = Element.new(name, attributes.scan(ATTRIBUTE).to_h { |k, *v| [k, v.compact.first] }, [])
|
|
33
|
+
stack.last.children << element
|
|
34
|
+
stack << element if self_closing.empty?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
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,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
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module SVG
|
|
5
|
+
# Presentation attributes an element inherits from its ancestors, and the
|
|
6
|
+
# transform it draws with.
|
|
7
|
+
class Style
|
|
8
|
+
INHERITED = %w[fill stroke stroke-width stroke-linecap stroke-linejoin fill-rule opacity fill-opacity
|
|
9
|
+
stroke-opacity].freeze
|
|
10
|
+
NAMED = { "black" => "#000000", "white" => "#FFFFFF", "red" => "#FF0000", "green" => "#008000",
|
|
11
|
+
"blue" => "#0000FF", "gray" => "#808080", "grey" => "#808080" }.freeze
|
|
12
|
+
DEFAULTS = { "fill" => "black", "stroke" => "none", "stroke-width" => "1" }.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :values, :matrix
|
|
15
|
+
|
|
16
|
+
def initialize(values = DEFAULTS, matrix = [1, 0, 0, 1, 0, 0], color: "#000000")
|
|
17
|
+
@values = values
|
|
18
|
+
@matrix = matrix
|
|
19
|
+
@color = color
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def child(attributes)
|
|
23
|
+
own = attributes.merge(declarations(attributes["style"]))
|
|
24
|
+
values = @values.merge(own.slice(*INHERITED))
|
|
25
|
+
matrix = if attributes["transform"]
|
|
26
|
+
Transform.multiply(@matrix,
|
|
27
|
+
Transform.parse(attributes["transform"]))
|
|
28
|
+
else
|
|
29
|
+
@matrix
|
|
30
|
+
end
|
|
31
|
+
Style.new(values, matrix, color: @color)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fill = paint("fill")
|
|
35
|
+
def stroke = paint("stroke")
|
|
36
|
+
def even_odd? = @values["fill-rule"] == "evenodd"
|
|
37
|
+
def cap = @values["stroke-linecap"]&.to_sym
|
|
38
|
+
def join = @values["stroke-linejoin"]&.to_sym
|
|
39
|
+
|
|
40
|
+
# 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
|
|
45
|
+
|
|
46
|
+
def opacity
|
|
47
|
+
%w[opacity fill-opacity stroke-opacity].filter_map { |key| @values[key]&.to_f }.reduce(1.0, :*)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def paint(key)
|
|
53
|
+
value = @values[key].to_s.strip
|
|
54
|
+
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
|
|
59
|
+
|
|
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
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module SVG
|
|
5
|
+
# SVG `transform` attributes as affine matrices [a, b, c, d, e, f].
|
|
6
|
+
module Transform
|
|
7
|
+
FUNCTION = /(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def parse(source)
|
|
12
|
+
source.scan(FUNCTION).reduce([1, 0, 0, 1, 0, 0]) do |matrix, (name, args)|
|
|
13
|
+
multiply(matrix, function(name, args.split(/[\s,]+/).reject(&:empty?).map(&:to_f)))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def function(name, args)
|
|
18
|
+
case name
|
|
19
|
+
when "matrix" then args
|
|
20
|
+
when "translate" then [1, 0, 0, 1, args[0], args[1] || 0]
|
|
21
|
+
when "scale" then [args[0], 0, 0, args[1] || args[0], 0, 0]
|
|
22
|
+
when "rotate" then rotate(*args)
|
|
23
|
+
when "skewX" then [1, 0, Math.tan(args[0] * Math::PI / 180), 1, 0, 0]
|
|
24
|
+
else [1, Math.tan(args[0] * Math::PI / 180), 0, 1, 0, 0]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def rotate(degrees, cx = 0, cy = 0)
|
|
29
|
+
r = degrees * Math::PI / 180
|
|
30
|
+
rotation = [Math.cos(r), Math.sin(r), -Math.sin(r), Math.cos(r), 0, 0]
|
|
31
|
+
multiply(multiply([1, 0, 0, 1, cx, cy], rotation), [1, 0, 0, 1, -cx, -cy])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The transform that applies `inner` first, then `outer`.
|
|
35
|
+
def multiply(outer, inner)
|
|
36
|
+
a, b, c, d, e, f = outer
|
|
37
|
+
ia, ib, ic, id, ie, if_ = inner
|
|
38
|
+
[(a * ia) + (c * ib), (b * ia) + (d * ib), (a * ic) + (c * id), (b * ic) + (d * id),
|
|
39
|
+
(a * ie) + (c * if_) + e, (b * ie) + (d * if_) + f]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "stationery"
|
|
6
|
+
|
|
7
|
+
module Stationery
|
|
8
|
+
module Testing
|
|
9
|
+
# Reads a rendered PDF back for assertions. The subject is a
|
|
10
|
+
# Stationery::Document, PDF bytes, a path (String or Pathname) or an IO.
|
|
11
|
+
# Needs the pdf-reader gem, loaded on first use.
|
|
12
|
+
class Inspector
|
|
13
|
+
UTF16_BOM = "\xFE\xFF".b
|
|
14
|
+
|
|
15
|
+
def initialize(subject)
|
|
16
|
+
@subject = subject
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pdf
|
|
20
|
+
@pdf ||= case @subject
|
|
21
|
+
when Document then @subject.to_pdf
|
|
22
|
+
when String then @subject.start_with?("%PDF") ? @subject : File.binread(@subject)
|
|
23
|
+
when Pathname then File.binread(@subject)
|
|
24
|
+
else @subject.read
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reader
|
|
29
|
+
@reader ||= begin
|
|
30
|
+
require "pdf/reader"
|
|
31
|
+
::PDF::Reader.new(StringIO.new(pdf))
|
|
32
|
+
rescue LoadError
|
|
33
|
+
raise Stationery::Error, 'Stationery::Testing needs pdf-reader: add gem "pdf-reader" to your test group'
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def page_count = reader.page_count
|
|
38
|
+
def page_texts = @page_texts ||= reader.pages.map { |page| page.text.squeeze(" ").strip }
|
|
39
|
+
def text = page_texts.join("\n")
|
|
40
|
+
def metadata = reader.info
|
|
41
|
+
def image_count = pdf.scan(%r{/Subtype\s*/Image\b}).size
|
|
42
|
+
|
|
43
|
+
def warnings
|
|
44
|
+
return [] unless @subject.is_a?(Document)
|
|
45
|
+
|
|
46
|
+
pdf
|
|
47
|
+
@subject.warnings.to_a
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def links
|
|
51
|
+
annotations.filter_map { |annot| annot.dig(:A, :URI) if annot.dig(:A, :S) == :URI }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def internal_links
|
|
55
|
+
annotations.filter_map do |annot|
|
|
56
|
+
dest = annot[:Dest] || (annot.dig(:A, :D) if annot.dig(:A, :S) == :GoTo)
|
|
57
|
+
destination(dest) if dest
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def bookmarks
|
|
62
|
+
outlines = objects.deref!(objects.trailer[:Root])[:Outlines]
|
|
63
|
+
outlines ? titles(outlines[:First]) : []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def objects = reader.objects
|
|
69
|
+
|
|
70
|
+
def annotations
|
|
71
|
+
reader.pages.flat_map do |page|
|
|
72
|
+
Array(objects.deref_array(page.attributes[:Annots])).map do |ref|
|
|
73
|
+
annot = objects.deref_hash(ref)
|
|
74
|
+
annot.merge(A: objects.deref_hash(annot[:A]))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def destination(dest)
|
|
80
|
+
return dest.to_s unless dest.is_a?(Array)
|
|
81
|
+
|
|
82
|
+
objects.page_references.index(dest.first) + 1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def titles(item)
|
|
86
|
+
return [] unless item
|
|
87
|
+
|
|
88
|
+
[decode(item[:Title]), *titles(item[:First]), *titles(item[:Next])]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def decode(title)
|
|
92
|
+
return title.dup.force_encoding(Encoding::UTF_8) unless title.b.start_with?(UTF16_BOM)
|
|
93
|
+
|
|
94
|
+
title.b.byteslice(2..).force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|