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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/README.md +358 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +25 -3
  7. data/lib/stationery/canvas/debug.rb +26 -0
  8. data/lib/stationery/canvas/path.rb +22 -12
  9. data/lib/stationery/canvas/text.rb +8 -5
  10. data/lib/stationery/canvas.rb +25 -8
  11. data/lib/stationery/cli/fonts.rb +85 -0
  12. data/lib/stationery/cli/render.rb +137 -0
  13. data/lib/stationery/cli.rb +74 -0
  14. data/lib/stationery/document.rb +53 -9
  15. data/lib/stationery/elements/lists.rb +58 -0
  16. data/lib/stationery/elements/rich.rb +27 -0
  17. data/lib/stationery/elements.rb +88 -12
  18. data/lib/stationery/errors.rb +13 -1
  19. data/lib/stationery/fonts/bundled.rb +25 -0
  20. data/lib/stationery/fonts/catalog.rb +101 -0
  21. data/lib/stationery/fonts/cff.rb +140 -0
  22. data/lib/stationery/fonts/cff_subset.rb +145 -0
  23. data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
  24. data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
  25. data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
  26. data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
  27. data/lib/stationery/fonts/data/OFL.txt +92 -0
  28. data/lib/stationery/fonts/data/README.md +14 -0
  29. data/lib/stationery/fonts/embedding/base.rb +56 -0
  30. data/lib/stationery/fonts/embedding/cff.rb +35 -0
  31. data/lib/stationery/fonts/embedding/true_type.rb +35 -0
  32. data/lib/stationery/fonts/fallback.rb +58 -0
  33. data/lib/stationery/fonts/family.rb +11 -0
  34. data/lib/stationery/fonts/font.rb +52 -88
  35. data/lib/stationery/fonts/font_book.rb +46 -8
  36. data/lib/stationery/fonts/glyph_run.rb +38 -0
  37. data/lib/stationery/fonts/gpos/class_def.rb +25 -0
  38. data/lib/stationery/fonts/gpos/coverage.rb +20 -0
  39. data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
  40. data/lib/stationery/fonts/gpos.rb +62 -0
  41. data/lib/stationery/fonts/installer.rb +121 -0
  42. data/lib/stationery/fonts/kern_table.rb +52 -0
  43. data/lib/stationery/fonts/kerning.rb +17 -0
  44. data/lib/stationery/fonts/packs.rb +45 -0
  45. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  46. data/lib/stationery/fonts/to_unicode.rb +36 -0
  47. data/lib/stationery/fonts/true_type.rb +32 -49
  48. data/lib/stationery/html/document.rb +11 -0
  49. data/lib/stationery/html/tokenizer.rb +74 -0
  50. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  51. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  52. data/lib/stationery/html/tree_builder.rb +72 -0
  53. data/lib/stationery/html/whitespace.rb +43 -0
  54. data/lib/stationery/layout/box.rb +85 -16
  55. data/lib/stationery/layout/flow.rb +48 -15
  56. data/lib/stationery/layout/image.rb +1 -0
  57. data/lib/stationery/layout/list_item.rb +89 -0
  58. data/lib/stationery/layout/mark.rb +58 -0
  59. data/lib/stationery/layout/node.rb +26 -2
  60. data/lib/stationery/layout/paginator.rb +35 -6
  61. data/lib/stationery/layout/positioned.rb +3 -1
  62. data/lib/stationery/layout/row.rb +34 -4
  63. data/lib/stationery/layout/svg.rb +45 -0
  64. data/lib/stationery/layout/table/cell.rb +33 -6
  65. data/lib/stationery/layout/table/grid.rb +93 -0
  66. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  67. data/lib/stationery/layout/table/selection.rb +3 -2
  68. data/lib/stationery/layout/table.rb +69 -23
  69. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  70. data/lib/stationery/layout/table_of_contents.rb +43 -0
  71. data/lib/stationery/layout/text.rb +5 -4
  72. data/lib/stationery/layout/wrap.rb +78 -0
  73. data/lib/stationery/list_markers.rb +47 -0
  74. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  75. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  76. data/lib/stationery/markdown/block_parser.rb +141 -0
  77. data/lib/stationery/markdown/document.rb +50 -0
  78. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  79. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  80. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  81. data/lib/stationery/markdown/inline_parser.rb +89 -0
  82. data/lib/stationery/minitest.rb +33 -0
  83. data/lib/stationery/outline.rb +37 -0
  84. data/lib/stationery/page.rb +18 -4
  85. data/lib/stationery/page_templates.rb +33 -10
  86. data/lib/stationery/pdf/assembler.rb +18 -7
  87. data/lib/stationery/pdf/outline_writer.rb +72 -0
  88. data/lib/stationery/pdf/serializer.rb +5 -1
  89. data/lib/stationery/pdf/writer.rb +4 -0
  90. data/lib/stationery/preview.rb +64 -0
  91. data/lib/stationery/rails/previews_controller.rb +61 -0
  92. data/lib/stationery/rails.rb +2 -0
  93. data/lib/stationery/railtie.rb +60 -0
  94. data/lib/stationery/regions.rb +67 -0
  95. data/lib/stationery/rich/nodes.rb +55 -0
  96. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  97. data/lib/stationery/rich/renderer.rb +121 -0
  98. data/lib/stationery/rich/styles.rb +34 -0
  99. data/lib/stationery/rspec.rb +6 -0
  100. data/lib/stationery/structure.rb +78 -0
  101. data/lib/stationery/svg/arc.rb +91 -0
  102. data/lib/stationery/svg/document.rb +73 -0
  103. data/lib/stationery/svg/parser.rb +38 -0
  104. data/lib/stationery/svg/path_data.rb +137 -0
  105. data/lib/stationery/svg/shapes.rb +54 -0
  106. data/lib/stationery/svg/style.rb +68 -0
  107. data/lib/stationery/svg/transform.rb +43 -0
  108. data/lib/stationery/testing/inspector.rb +98 -0
  109. data/lib/stationery/testing/matchers.rb +123 -0
  110. data/lib/stationery/text/entities/html4.rb +49 -0
  111. data/lib/stationery/text/entities.rb +6 -1
  112. data/lib/stationery/text/line.rb +8 -2
  113. data/lib/stationery/text/paragraph.rb +32 -11
  114. data/lib/stationery/text/runs_builder.rb +9 -1
  115. data/lib/stationery/text/style.rb +2 -2
  116. data/lib/stationery/text/wrapper.rb +3 -2
  117. data/lib/stationery/version.rb +1 -1
  118. data/lib/stationery/warnings.rb +65 -0
  119. data/lib/stationery.rb +51 -0
  120. metadata +85 -4
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stationery"
4
+ require_relative "testing/matchers"
5
+
6
+ RSpec.configure { |config| config.include Stationery::Testing::Matchers }
@@ -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