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,85 @@
|
|
|
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. An element's own values cascade: presentation
|
|
7
|
+
# attributes, then stylesheet rules, then its inline `style`.
|
|
8
|
+
class Style
|
|
9
|
+
INHERITED = %w[fill stroke stroke-width stroke-linecap stroke-linejoin fill-rule opacity fill-opacity
|
|
10
|
+
stroke-opacity font-family font-size font-weight font-style text-anchor visibility].freeze
|
|
11
|
+
NAMED = { "black" => "#000000", "white" => "#FFFFFF", "red" => "#FF0000", "green" => "#008000",
|
|
12
|
+
"blue" => "#0000FF", "gray" => "#808080", "grey" => "#808080" }.freeze
|
|
13
|
+
DEFAULTS = { "fill" => "black", "stroke" => "none", "stroke-width" => "1" }.freeze
|
|
14
|
+
URL = /\Aurl\(\s*['"]?#([^'")\s]+)['"]?\s*\)\s*(.*)\z/
|
|
15
|
+
|
|
16
|
+
# A paint server reference, `url(#id)`, with its fallback colour (or nil).
|
|
17
|
+
Reference = Data.define(:id, :fallback)
|
|
18
|
+
|
|
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)
|
|
37
|
+
@values = values
|
|
38
|
+
@matrix = matrix
|
|
39
|
+
@color = color
|
|
40
|
+
@sheet = sheet
|
|
41
|
+
@display = display
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def child(element)
|
|
45
|
+
attributes = element.attributes
|
|
46
|
+
own = attributes.merge(@sheet.declarations(element), Style.declarations(attributes["style"]))
|
|
47
|
+
values = @values.merge(own.slice(*INHERITED))
|
|
48
|
+
matrix = if attributes["transform"]
|
|
49
|
+
Transform.multiply(@matrix,
|
|
50
|
+
Transform.parse(attributes["transform"]))
|
|
51
|
+
else
|
|
52
|
+
@matrix
|
|
53
|
+
end
|
|
54
|
+
Style.new(values, matrix, color: @color, sheet: @sheet, display: own["display"])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def displayed? = @display != "none"
|
|
58
|
+
def visible? = !%w[hidden collapse].include?(@values["visibility"])
|
|
59
|
+
|
|
60
|
+
def fill = paint("fill")
|
|
61
|
+
def stroke = paint("stroke")
|
|
62
|
+
def even_odd? = @values["fill-rule"] == "evenodd"
|
|
63
|
+
def cap = @values["stroke-linecap"]&.to_sym
|
|
64
|
+
def join = @values["stroke-linejoin"]&.to_sym
|
|
65
|
+
|
|
66
|
+
# Stroke width scales with the drawing, by the transform's area factor.
|
|
67
|
+
def line_width = @values["stroke-width"].to_f * Transform.scale(@matrix)
|
|
68
|
+
|
|
69
|
+
def opacity
|
|
70
|
+
%w[opacity fill-opacity stroke-opacity].filter_map { |key| @values[key]&.to_f }.reduce(1.0, :*)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def paint(key)
|
|
76
|
+
value = @values[key].to_s.strip
|
|
77
|
+
return nil if value.empty? || %w[none transparent].include?(value)
|
|
78
|
+
return Style.color(value, @color) unless (url = value.match(URL))
|
|
79
|
+
|
|
80
|
+
fallback = url[2]
|
|
81
|
+
Reference.new(url[1], fallback.empty? || fallback == "none" ? nil : Style.color(fallback, @color))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
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
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module SVG
|
|
5
|
+
# Lays out a <text> element: its strings and tspans become pieces along
|
|
6
|
+
# the baseline, a new chunk starting wherever x or y is set, and each
|
|
7
|
+
# chunk is shifted by its text-anchor. Glyphs are drawn upright at the
|
|
8
|
+
# transformed origin, sized by the transform's uniform scale, so rotated,
|
|
9
|
+
# skewed or unevenly scaled text is approximated.
|
|
10
|
+
class Text
|
|
11
|
+
ANCHORS = { "middle" => 0.5, "end" => 1.0 }.freeze
|
|
12
|
+
DEFAULT_SIZE = 16
|
|
13
|
+
|
|
14
|
+
Piece = Data.define(:runs, :style, :x, :y, :chunk, :advance)
|
|
15
|
+
|
|
16
|
+
def initialize(canvas, painter, book, family)
|
|
17
|
+
@canvas = canvas
|
|
18
|
+
@painter = painter
|
|
19
|
+
@book = book
|
|
20
|
+
@family = family
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def draw(element, style)
|
|
24
|
+
@pieces = []
|
|
25
|
+
@x = @y = @chunk = 0
|
|
26
|
+
@space = true
|
|
27
|
+
place(element, style)
|
|
28
|
+
trim
|
|
29
|
+
@pieces.group_by(&:chunk).each_value { |chunk| draw_chunk(chunk) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def place(element, style)
|
|
35
|
+
move(element.attributes)
|
|
36
|
+
element.children.each do |child|
|
|
37
|
+
if child.is_a?(String) then add(child, style)
|
|
38
|
+
elsif child.name == "tspan" then place(child, style.child(child))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def move(attributes)
|
|
44
|
+
x, y, dx, dy = attributes.values_at("x", "y", "dx", "dy").map { |value| first(value) }
|
|
45
|
+
@chunk += 1 if x || y
|
|
46
|
+
@x = (x || @x) + (dx || 0)
|
|
47
|
+
@y = (y || @y) + (dy || 0)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def first(value) = value.to_s.split(/[\s,]+/).find { |part| !part.empty? }&.to_f
|
|
51
|
+
|
|
52
|
+
# Collapses the space between pieces and at the start of the text.
|
|
53
|
+
def add(string, style)
|
|
54
|
+
string = string.lstrip if @space
|
|
55
|
+
return if string.empty?
|
|
56
|
+
|
|
57
|
+
@space = string.end_with?(" ")
|
|
58
|
+
@pieces << piece(string, style, @x, @y)
|
|
59
|
+
@x += @pieces.last.advance
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def piece(string, style, x, y, chunk = @chunk)
|
|
63
|
+
italic = style.values["font-style"].to_s.match?(/italic|oblique/)
|
|
64
|
+
text_style = Stationery::Text::Style.new(family: family(style), size: size(style), weight: weight(style),
|
|
65
|
+
style: italic ? :italic : :normal)
|
|
66
|
+
runs = @book.fallback([Stationery::Text::Run.new(string, text_style)])
|
|
67
|
+
advance = runs.sum { |run| font(run).width_of(run.text, text_style.size) }
|
|
68
|
+
Piece.new(runs, style, x, y, chunk, advance)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def trim
|
|
72
|
+
last = @pieces.last or return
|
|
73
|
+
text = last.runs.map(&:text).join
|
|
74
|
+
return unless text.end_with?(" ")
|
|
75
|
+
|
|
76
|
+
@pieces[-1] = piece(text.rstrip, last.style, last.x, last.y, last.chunk)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def draw_chunk(pieces)
|
|
80
|
+
width = pieces.last.x + pieces.last.advance - pieces.first.x
|
|
81
|
+
shift = -width * ANCHORS.fetch(pieces.first.style.values["text-anchor"].to_s, 0)
|
|
82
|
+
pieces.each { |piece| draw_piece(piece, shift) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def draw_piece(piece, shift)
|
|
86
|
+
color = @painter.color(piece.style.fill, piece.style)
|
|
87
|
+
return unless color
|
|
88
|
+
|
|
89
|
+
x = piece.x + shift
|
|
90
|
+
scale = Transform.scale(piece.style.matrix)
|
|
91
|
+
piece.runs.each do |run|
|
|
92
|
+
font, face = @book.resolve(run.style)
|
|
93
|
+
page_x, page_y = Transform.apply(piece.style.matrix, x, piece.y)
|
|
94
|
+
@canvas.text(run.text, x: page_x, y: page_y, font:, size: run.style.size * scale, color:,
|
|
95
|
+
opacity: Painter.translucent(piece.style.opacity),
|
|
96
|
+
synthetic_bold: face.synthetic_bold, synthetic_oblique: face.synthetic_oblique)
|
|
97
|
+
x += font.width_of(run.text, run.style.size)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def font(run) = @book.resolve(run.style).first
|
|
102
|
+
|
|
103
|
+
# The first family of the list the book knows, else the document's.
|
|
104
|
+
def family(style)
|
|
105
|
+
names = style.values["font-family"].to_s.split(",").map { |name| name.strip.delete("'\"") }
|
|
106
|
+
names.find { |name| !name.empty? && @book.known?(name) } || @family
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def size(style)
|
|
110
|
+
size = style.values["font-size"].to_f
|
|
111
|
+
size.positive? ? size : DEFAULT_SIZE
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def weight(style)
|
|
115
|
+
value = style.values["font-weight"].to_s.strip
|
|
116
|
+
return value.to_i if value.match?(/\A\d+\z/)
|
|
117
|
+
|
|
118
|
+
%w[bold bolder].include?(value) ? :bold : :regular
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
def apply(matrix, x, y)
|
|
35
|
+
a, b, c, d, e, f = matrix
|
|
36
|
+
[(a * x) + (c * y) + e, (b * x) + (d * y) + f]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The uniform scale a matrix applies to areas, as a length factor.
|
|
40
|
+
def scale(matrix)
|
|
41
|
+
a, b, c, d, = matrix
|
|
42
|
+
Math.sqrt(((a * d) - (b * c)).abs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The transform that applies `inner` first, then `outer`.
|
|
46
|
+
def multiply(outer, inner)
|
|
47
|
+
a, b, c, d, e, f = outer
|
|
48
|
+
ia, ib, ic, id, ie, if_ = inner
|
|
49
|
+
[(a * ia) + (c * ib), (b * ia) + (d * ib), (a * ic) + (c * id), (b * ic) + (d * id),
|
|
50
|
+
(a * ie) + (c * if_) + e, (b * ie) + (d * if_) + f]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
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
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "inspector"
|
|
4
|
+
|
|
5
|
+
module Stationery
|
|
6
|
+
module Testing
|
|
7
|
+
# Framework-independent PDF matchers: each answers matches?(subject),
|
|
8
|
+
# failure_message, failure_message_when_negated and description, so RSpec
|
|
9
|
+
# uses them directly and Minitest assertions wrap them.
|
|
10
|
+
module Matchers
|
|
11
|
+
EXCERPT = 500
|
|
12
|
+
|
|
13
|
+
class Base
|
|
14
|
+
def initialize(expected = nil)
|
|
15
|
+
@expected = expected
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def matches?(subject)
|
|
19
|
+
@inspector = subject.is_a?(Inspector) ? subject : Inspector.new(subject)
|
|
20
|
+
match?(@inspector)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def failure_message = "expected PDF to #{description}, #{actual}"
|
|
24
|
+
def failure_message_when_negated = "expected PDF not to #{description}, #{actual}"
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def show(expected) = expected.is_a?(Regexp) ? expected.inspect : expected.to_s.inspect
|
|
29
|
+
|
|
30
|
+
def contains?(haystack, expected)
|
|
31
|
+
expected.is_a?(Regexp) ? expected.match?(haystack) : haystack.include?(expected.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def excerpt(text)
|
|
35
|
+
text.length > EXCERPT ? "#{text[0, EXCERPT]}…" : text
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class HaveText < Base
|
|
40
|
+
def description = "have text #{show(@expected)}"
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def match?(pdf) = contains?(pdf.text, @expected)
|
|
45
|
+
def actual = "got text:\n#{excerpt(@inspector.text)}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class HaveTextOnPage < Base
|
|
49
|
+
def initialize(page, expected)
|
|
50
|
+
super(expected)
|
|
51
|
+
@page = page
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def description = "have text #{show(@expected)} on page #{@page}"
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def page_text = @inspector.page_texts.fetch(@page - 1, nil)
|
|
59
|
+
def match?(_pdf) = !page_text.nil? && contains?(page_text, @expected)
|
|
60
|
+
|
|
61
|
+
def actual
|
|
62
|
+
return "but it has #{@inspector.page_count} page(s)" if page_text.nil?
|
|
63
|
+
|
|
64
|
+
"got text on page #{@page}:\n#{excerpt(page_text)}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class HavePageCount < Base
|
|
69
|
+
def description = "have #{@expected} page(s)"
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def match?(pdf) = pdf.page_count == @expected
|
|
74
|
+
def actual = "got #{@inspector.page_count}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class HaveLink < Base
|
|
78
|
+
def description = "have a link to #{show(@expected)}"
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def match?(pdf) = pdf.links.any? { |url| @expected.is_a?(Regexp) ? @expected.match?(url) : url == @expected }
|
|
83
|
+
def actual = "got links #{@inspector.links.inspect}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
class HaveImageCount < Base
|
|
87
|
+
def description = "have #{@expected} image(s)"
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def match?(pdf) = pdf.image_count == @expected
|
|
92
|
+
def actual = "got #{@inspector.image_count}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class HaveBookmark < Base
|
|
96
|
+
def description = "have a bookmark #{show(@expected)}"
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def match?(pdf) = pdf.bookmarks.include?(@expected)
|
|
101
|
+
def actual = "got bookmarks #{@inspector.bookmarks.inspect}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class HaveNoWarnings < Base
|
|
105
|
+
def description = "have no warnings"
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def match?(pdf) = pdf.warnings.empty?
|
|
110
|
+
def messages = @inspector.warnings.map(&:message)
|
|
111
|
+
def actual = messages.empty? ? "got none" : "got:\n#{messages.map { |m| " - #{m}" }.join("\n")}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def have_pdf_text(expected) = HaveText.new(expected)
|
|
115
|
+
def have_pdf_text_on_page(page, expected) = HaveTextOnPage.new(page, expected)
|
|
116
|
+
def have_page_count(expected) = HavePageCount.new(expected)
|
|
117
|
+
def have_pdf_link(expected) = HaveLink.new(expected)
|
|
118
|
+
def have_image_count(expected) = HaveImageCount.new(expected)
|
|
119
|
+
def have_bookmark(title) = HaveBookmark.new(title)
|
|
120
|
+
def have_no_warnings = HaveNoWarnings.new
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Text
|
|
5
|
+
module Entities
|
|
6
|
+
# The 252 named character references of HTML 4.
|
|
7
|
+
HTML4 = { # rubocop:disable Metrics/CollectionLiteralLength
|
|
8
|
+
"AElig" => "Æ", "Aacute" => "Á", "Acirc" => "Â", "Agrave" => "À", "Alpha" => "Α", "Aring" => "Å",
|
|
9
|
+
"Atilde" => "Ã", "Auml" => "Ä", "Beta" => "Β", "Ccedil" => "Ç", "Chi" => "Χ", "Dagger" => "‡", "Delta" => "Δ",
|
|
10
|
+
"ETH" => "Ð", "Eacute" => "É", "Ecirc" => "Ê", "Egrave" => "È", "Epsilon" => "Ε", "Eta" => "Η", "Euml" => "Ë",
|
|
11
|
+
"Gamma" => "Γ", "Iacute" => "Í", "Icirc" => "Î", "Igrave" => "Ì", "Iota" => "Ι", "Iuml" => "Ï", "Kappa" => "Κ",
|
|
12
|
+
"Lambda" => "Λ", "Mu" => "Μ", "Ntilde" => "Ñ", "Nu" => "Ν", "OElig" => "Œ", "Oacute" => "Ó", "Ocirc" => "Ô",
|
|
13
|
+
"Ograve" => "Ò", "Omega" => "Ω", "Omicron" => "Ο", "Oslash" => "Ø", "Otilde" => "Õ", "Ouml" => "Ö",
|
|
14
|
+
"Phi" => "Φ", "Pi" => "Π", "Prime" => "″", "Psi" => "Ψ", "Rho" => "Ρ", "Scaron" => "Š", "Sigma" => "Σ",
|
|
15
|
+
"THORN" => "Þ", "Tau" => "Τ", "Theta" => "Θ", "Uacute" => "Ú", "Ucirc" => "Û", "Ugrave" => "Ù",
|
|
16
|
+
"Upsilon" => "Υ", "Uuml" => "Ü", "Xi" => "Ξ", "Yacute" => "Ý", "Yuml" => "Ÿ", "Zeta" => "Ζ", "aacute" => "á",
|
|
17
|
+
"acirc" => "â", "acute" => "´", "aelig" => "æ", "agrave" => "à", "alefsym" => "ℵ", "alpha" => "α", "amp" => "&",
|
|
18
|
+
"and" => "∧", "ang" => "∠", "aring" => "å", "asymp" => "≈", "atilde" => "ã", "auml" => "ä", "bdquo" => "„",
|
|
19
|
+
"beta" => "β", "brvbar" => "¦", "bull" => "•", "cap" => "∩", "ccedil" => "ç", "cedil" => "¸", "cent" => "¢",
|
|
20
|
+
"chi" => "χ", "circ" => "ˆ", "clubs" => "♣", "cong" => "≅", "copy" => "©", "crarr" => "↵", "cup" => "∪",
|
|
21
|
+
"curren" => "¤", "dArr" => "⇓", "dagger" => "†", "darr" => "↓", "deg" => "°", "delta" => "δ", "diams" => "♦",
|
|
22
|
+
"divide" => "÷", "eacute" => "é", "ecirc" => "ê", "egrave" => "è", "empty" => "∅", "emsp" => "\u2003",
|
|
23
|
+
"ensp" => "\u2002", "epsilon" => "ε", "equiv" => "≡", "eta" => "η", "eth" => "ð", "euml" => "ë", "euro" => "€",
|
|
24
|
+
"exist" => "∃", "fnof" => "ƒ", "forall" => "∀", "frac12" => "½", "frac14" => "¼", "frac34" => "¾",
|
|
25
|
+
"frasl" => "⁄", "gamma" => "γ", "ge" => "≥", "gt" => ">", "hArr" => "⇔", "harr" => "↔", "hearts" => "♥",
|
|
26
|
+
"hellip" => "…", "iacute" => "í", "icirc" => "î", "iexcl" => "¡", "igrave" => "ì", "image" => "ℑ",
|
|
27
|
+
"infin" => "∞", "int" => "∫", "iota" => "ι", "iquest" => "¿", "isin" => "∈", "iuml" => "ï", "kappa" => "κ",
|
|
28
|
+
"lArr" => "⇐", "lambda" => "λ", "lang" => "〈", "laquo" => "«", "larr" => "←", "lceil" => "⌈", "ldquo" => "“",
|
|
29
|
+
"le" => "≤", "lfloor" => "⌊", "lowast" => "∗", "loz" => "◊", "lrm" => "\u200E", "lsaquo" => "‹", "lsquo" => "‘",
|
|
30
|
+
"lt" => "<", "macr" => "¯", "mdash" => "—", "micro" => "µ", "middot" => "·", "minus" => "−", "mu" => "μ",
|
|
31
|
+
"nabla" => "∇", "nbsp" => "\u00A0", "ndash" => "–", "ne" => "≠", "ni" => "∋", "not" => "¬", "notin" => "∉",
|
|
32
|
+
"nsub" => "⊄", "ntilde" => "ñ", "nu" => "ν", "oacute" => "ó", "ocirc" => "ô", "oelig" => "œ", "ograve" => "ò",
|
|
33
|
+
"oline" => "‾", "omega" => "ω", "omicron" => "ο", "oplus" => "⊕", "or" => "∨", "ordf" => "ª", "ordm" => "º",
|
|
34
|
+
"oslash" => "ø", "otilde" => "õ", "otimes" => "⊗", "ouml" => "ö", "para" => "¶", "part" => "∂", "permil" => "‰",
|
|
35
|
+
"perp" => "⊥", "phi" => "φ", "pi" => "π", "piv" => "ϖ", "plusmn" => "±", "pound" => "£", "prime" => "′",
|
|
36
|
+
"prod" => "∏", "prop" => "∝", "psi" => "ψ", "quot" => "\"", "rArr" => "⇒", "radic" => "√", "rang" => "〉",
|
|
37
|
+
"raquo" => "»", "rarr" => "→", "rceil" => "⌉", "rdquo" => "”", "real" => "ℜ", "reg" => "®", "rfloor" => "⌋",
|
|
38
|
+
"rho" => "ρ", "rlm" => "\u200F", "rsaquo" => "›", "rsquo" => "’", "sbquo" => "‚", "scaron" => "š",
|
|
39
|
+
"sdot" => "⋅", "sect" => "§", "shy" => "\u00AD", "sigma" => "σ", "sigmaf" => "ς", "sim" => "∼", "spades" => "♠",
|
|
40
|
+
"sub" => "⊂", "sube" => "⊆", "sum" => "∑", "sup" => "⊃", "sup1" => "¹", "sup2" => "²", "sup3" => "³",
|
|
41
|
+
"supe" => "⊇", "szlig" => "ß", "tau" => "τ", "there4" => "∴", "theta" => "θ", "thetasym" => "ϑ",
|
|
42
|
+
"thinsp" => "\u2009", "thorn" => "þ", "tilde" => "˜", "times" => "×", "trade" => "™", "uArr" => "⇑",
|
|
43
|
+
"uacute" => "ú", "uarr" => "↑", "ucirc" => "û", "ugrave" => "ù", "uml" => "¨", "upsih" => "ϒ", "upsilon" => "υ",
|
|
44
|
+
"uuml" => "ü", "weierp" => "℘", "xi" => "ξ", "yacute" => "ý", "yen" => "¥", "yuml" => "ÿ", "zeta" => "ζ",
|
|
45
|
+
"zwj" => "\u200D", "zwnj" => "\u200C"
|
|
46
|
+
}.freeze
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -14,10 +14,15 @@ module Stationery
|
|
|
14
14
|
name = Regexp.last_match(1)
|
|
15
15
|
if name.start_with?("#x") then [name[2..].to_i(16)].pack("U")
|
|
16
16
|
elsif name.start_with?("#") then [name[1..].to_i].pack("U")
|
|
17
|
-
else NAMED.fetch(name, entity)
|
|
17
|
+
else NAMED.fetch(name) { html4.fetch(name, entity) }
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
|
+
|
|
22
|
+
def html4
|
|
23
|
+
require_relative "entities/html4" unless defined?(HTML4)
|
|
24
|
+
HTML4
|
|
25
|
+
end
|
|
21
26
|
end
|
|
22
27
|
end
|
|
23
28
|
end
|
data/lib/stationery/text/line.rb
CHANGED
|
@@ -7,12 +7,14 @@ module Stationery
|
|
|
7
7
|
Fragment = Data.define(:text, :style, :font, :face, :width, :x)
|
|
8
8
|
|
|
9
9
|
# One laid-out line. Vertical metrics come from its tallest fragment (or
|
|
10
|
-
# from the paragraph's base style when the line is empty).
|
|
10
|
+
# from the paragraph's base style when the line is empty). A soft-wrapped
|
|
11
|
+
# line is justifiable; the last line and one ended by a newline are not.
|
|
11
12
|
class Line
|
|
12
13
|
attr_reader :fragments, :width, :ascent, :descent, :height
|
|
13
14
|
|
|
14
|
-
def initialize(fragments, fallback)
|
|
15
|
+
def initialize(fragments, fallback, justifiable: false)
|
|
15
16
|
@fragments = fragments
|
|
17
|
+
@justifiable = justifiable
|
|
16
18
|
@width = fragments.sum(&:width)
|
|
17
19
|
metrics = fragments.empty? ? [fallback] : fragments.map { |f| [f.font, f.style.render_size] }
|
|
18
20
|
@ascent = metrics.map { |font, size| font.ascender(size) }.max
|
|
@@ -21,6 +23,10 @@ module Stationery
|
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def text = fragments.map(&:text).join
|
|
26
|
+
def justifiable? = @justifiable
|
|
27
|
+
|
|
28
|
+
# U+0020 spaces only: justification stretches those, never tabs.
|
|
29
|
+
def space_count = fragments.sum { |fragment| fragment.text.count(" ") }
|
|
24
30
|
end
|
|
25
31
|
end
|
|
26
32
|
end
|