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.
Files changed (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +114 -1
  3. data/README.md +395 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +26 -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 +10 -6
  10. data/lib/stationery/canvas.rb +38 -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 +60 -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 +68 -88
  35. data/lib/stationery/fonts/font_book.rb +49 -8
  36. data/lib/stationery/fonts/glyph_run.rb +39 -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 +64 -0
  41. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  42. data/lib/stationery/fonts/gsub.rb +65 -0
  43. data/lib/stationery/fonts/installer.rb +121 -0
  44. data/lib/stationery/fonts/kern_table.rb +52 -0
  45. data/lib/stationery/fonts/kerning.rb +17 -0
  46. data/lib/stationery/fonts/ligatures.rb +16 -0
  47. data/lib/stationery/fonts/packs.rb +45 -0
  48. data/lib/stationery/fonts/registry.rb +18 -9
  49. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  50. data/lib/stationery/fonts/to_unicode.rb +36 -0
  51. data/lib/stationery/fonts/true_type.rb +64 -57
  52. data/lib/stationery/fonts/woff.rb +52 -0
  53. data/lib/stationery/html/document.rb +11 -0
  54. data/lib/stationery/html/tokenizer.rb +74 -0
  55. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  56. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  57. data/lib/stationery/html/tree_builder.rb +72 -0
  58. data/lib/stationery/html/whitespace.rb +43 -0
  59. data/lib/stationery/layout/box.rb +102 -16
  60. data/lib/stationery/layout/flow.rb +48 -15
  61. data/lib/stationery/layout/image.rb +1 -0
  62. data/lib/stationery/layout/list_item.rb +89 -0
  63. data/lib/stationery/layout/mark.rb +58 -0
  64. data/lib/stationery/layout/node.rb +26 -2
  65. data/lib/stationery/layout/paginator.rb +35 -6
  66. data/lib/stationery/layout/positioned.rb +3 -1
  67. data/lib/stationery/layout/row.rb +34 -4
  68. data/lib/stationery/layout/svg.rb +48 -0
  69. data/lib/stationery/layout/table/cell.rb +33 -6
  70. data/lib/stationery/layout/table/grid.rb +93 -0
  71. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  72. data/lib/stationery/layout/table/selection.rb +3 -2
  73. data/lib/stationery/layout/table.rb +69 -23
  74. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  75. data/lib/stationery/layout/table_of_contents.rb +43 -0
  76. data/lib/stationery/layout/text.rb +6 -4
  77. data/lib/stationery/layout/wrap.rb +78 -0
  78. data/lib/stationery/list_markers.rb +47 -0
  79. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  80. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  81. data/lib/stationery/markdown/block_parser.rb +141 -0
  82. data/lib/stationery/markdown/document.rb +50 -0
  83. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  84. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  85. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  86. data/lib/stationery/markdown/inline_parser.rb +89 -0
  87. data/lib/stationery/minitest.rb +33 -0
  88. data/lib/stationery/outline.rb +37 -0
  89. data/lib/stationery/page.rb +18 -4
  90. data/lib/stationery/page_templates.rb +33 -10
  91. data/lib/stationery/pdf/assembler.rb +20 -8
  92. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  93. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  94. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  95. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  96. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  97. data/lib/stationery/pdf/outline_writer.rb +72 -0
  98. data/lib/stationery/pdf/serializer.rb +22 -10
  99. data/lib/stationery/pdf/writer.rb +28 -5
  100. data/lib/stationery/preview.rb +64 -0
  101. data/lib/stationery/rails/previews_controller.rb +61 -0
  102. data/lib/stationery/rails.rb +2 -0
  103. data/lib/stationery/railtie.rb +60 -0
  104. data/lib/stationery/regions.rb +67 -0
  105. data/lib/stationery/resources.rb +8 -2
  106. data/lib/stationery/rich/nodes.rb +55 -0
  107. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  108. data/lib/stationery/rich/renderer.rb +121 -0
  109. data/lib/stationery/rich/styles.rb +34 -0
  110. data/lib/stationery/rspec.rb +6 -0
  111. data/lib/stationery/structure.rb +78 -0
  112. data/lib/stationery/svg/arc.rb +91 -0
  113. data/lib/stationery/svg/bounds.rb +76 -0
  114. data/lib/stationery/svg/document.rb +103 -0
  115. data/lib/stationery/svg/gradient.rb +115 -0
  116. data/lib/stationery/svg/painter.rb +70 -0
  117. data/lib/stationery/svg/parser.rb +58 -0
  118. data/lib/stationery/svg/path_data.rb +137 -0
  119. data/lib/stationery/svg/selector.rb +37 -0
  120. data/lib/stationery/svg/shading.rb +34 -0
  121. data/lib/stationery/svg/shapes.rb +54 -0
  122. data/lib/stationery/svg/style.rb +85 -0
  123. data/lib/stationery/svg/stylesheet.rb +47 -0
  124. data/lib/stationery/svg/text.rb +122 -0
  125. data/lib/stationery/svg/transform.rb +54 -0
  126. data/lib/stationery/testing/inspector.rb +98 -0
  127. data/lib/stationery/testing/matchers.rb +123 -0
  128. data/lib/stationery/text/entities/html4.rb +49 -0
  129. data/lib/stationery/text/entities.rb +6 -1
  130. data/lib/stationery/text/line.rb +8 -2
  131. data/lib/stationery/text/paragraph.rb +33 -11
  132. data/lib/stationery/text/runs_builder.rb +9 -1
  133. data/lib/stationery/text/style.rb +4 -2
  134. data/lib/stationery/text/wrapper.rb +4 -2
  135. data/lib/stationery/version.rb +1 -1
  136. data/lib/stationery/warnings.rb +65 -0
  137. data/lib/stationery.rb +67 -0
  138. 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
@@ -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