stationery 0.3.0 → 0.5.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +136 -14
  4. data/lib/stationery/builder.rb +12 -0
  5. data/lib/stationery/canvas/debug.rb +2 -1
  6. data/lib/stationery/canvas/marking.rb +124 -0
  7. data/lib/stationery/canvas.rb +50 -8
  8. data/lib/stationery/document.rb +28 -10
  9. data/lib/stationery/elements/forms.rb +55 -0
  10. data/lib/stationery/elements/lists.rb +15 -5
  11. data/lib/stationery/elements/rich.rb +7 -5
  12. data/lib/stationery/elements.rb +34 -7
  13. data/lib/stationery/fonts/fallback.rb +6 -4
  14. data/lib/stationery/fonts/font.rb +51 -8
  15. data/lib/stationery/forms/acro_form.rb +115 -0
  16. data/lib/stationery/forms/appearance.rb +119 -0
  17. data/lib/stationery/forms/field.rb +122 -0
  18. data/lib/stationery/forms/metrics.rb +49 -0
  19. data/lib/stationery/layout/box.rb +64 -12
  20. data/lib/stationery/layout/field.rb +51 -0
  21. data/lib/stationery/layout/flow.rb +17 -9
  22. data/lib/stationery/layout/image.rb +35 -4
  23. data/lib/stationery/layout/list_item.rb +15 -6
  24. data/lib/stationery/layout/node.rb +2 -0
  25. data/lib/stationery/layout/paginator.rb +3 -2
  26. data/lib/stationery/layout/stack.rb +81 -0
  27. data/lib/stationery/layout/svg.rb +7 -3
  28. data/lib/stationery/layout/table/cell.rb +10 -2
  29. data/lib/stationery/layout/table.rb +42 -10
  30. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  31. data/lib/stationery/layout/table_of_contents.rb +1 -1
  32. data/lib/stationery/layout/text.rb +4 -3
  33. data/lib/stationery/minitest.rb +3 -0
  34. data/lib/stationery/page.rb +5 -2
  35. data/lib/stationery/page_templates.rb +7 -4
  36. data/lib/stationery/pdf/assembler.rb +36 -4
  37. data/lib/stationery/preview.rb +23 -2
  38. data/lib/stationery/rails/previews_controller.rb +2 -7
  39. data/lib/stationery/rich/renderer/inlines.rb +10 -2
  40. data/lib/stationery/rich/renderer/links.rb +28 -0
  41. data/lib/stationery/rich/renderer.rb +9 -6
  42. data/lib/stationery/rich/styles.rb +2 -0
  43. data/lib/stationery/rspec.rb +4 -0
  44. data/lib/stationery/structure.rb +15 -9
  45. data/lib/stationery/tagging/element.rb +74 -0
  46. data/lib/stationery/tagging/tree.rb +43 -0
  47. data/lib/stationery/tagging/writer.rb +81 -0
  48. data/lib/stationery/testing/inspector.rb +22 -1
  49. data/lib/stationery/testing/marked_text.rb +60 -0
  50. data/lib/stationery/testing/matchers.rb +35 -0
  51. data/lib/stationery/testing/structure_reader.rb +71 -0
  52. data/lib/stationery/text/paragraph.rb +27 -12
  53. data/lib/stationery/version.rb +1 -1
  54. data/lib/stationery/warnings.rb +12 -0
  55. data/lib/stationery.rb +11 -0
  56. metadata +15 -1
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # A base flow with layers painted over it. The base sets the height and
6
+ # widths; each Layer is placed relative to the stack's rectangle and takes
7
+ # no space, so it can overhang the edges. A stack never splits: it moves
8
+ # to the next page whole (and is reported as an Overflow when taller than
9
+ # a page).
10
+ class Stack < Node
11
+ attr_reader :base, :layers
12
+
13
+ def initialize(base, layers)
14
+ super()
15
+ @base = base
16
+ @layers = layers
17
+ end
18
+
19
+ def measure(width) = @base.measure(width)
20
+ def natural_width = @base.natural_width
21
+ def min_width = @base.min_width
22
+ def fixed_width(available) = @base.fixed_width(available)
23
+ def prefer_whole? = true
24
+
25
+ def paint(canvas, x, y, width, height = nil, **)
26
+ height ||= measure(width)
27
+ @base.paint(canvas, x, y, width, height)
28
+ @layers.each { |layer| layer.paint(canvas, x, y, width, height) }
29
+ canvas.debug_rect(x, y, width, height, :stack)
30
+ end
31
+ end
32
+
33
+ # A box placed over a Stack by CSS-like insets. `top:`, `right:`,
34
+ # `bottom:`, `left:`, `width:` and `height:` are points, or fractions of
35
+ # the stack's width (horizontal ones) or height (vertical ones) when given
36
+ # as a Rational or a Float between -1 and 1; negative points overhang.
37
+ # Without `left:`/`top:` the layer sits at the stack's left/top edge;
38
+ # without `width:` it takes its content's natural width, at most the
39
+ # stack's, and without `height:` its content's height.
40
+ class Layer
41
+ def initialize(box, top: nil, right: nil, bottom: nil, left: nil, width: nil, height: nil)
42
+ @box = box
43
+ @top = top
44
+ @right = right
45
+ @bottom = bottom
46
+ @left = left
47
+ @width = width
48
+ @height = height
49
+ end
50
+
51
+ def paint(canvas, x, y, width, height)
52
+ own = layer_width(width)
53
+ tall = @height ? resolve(@height, height) : @box.measure(own)
54
+ @box.paint(canvas, x + offset(@left, @right, width, own), y + offset(@top, @bottom, height, tall), own, tall)
55
+ end
56
+
57
+ private
58
+
59
+ # Where the layer's near edge sits along one axis of the stack: `start`
60
+ # points from the start edge, else `finish` points before the end edge.
61
+ def offset(start, finish, extent, own)
62
+ return resolve(start, extent) if start
63
+ return extent - resolve(finish, extent) - own if finish
64
+
65
+ 0
66
+ end
67
+
68
+ def layer_width(width)
69
+ return [@box.natural_width, width].min unless @width
70
+
71
+ resolve(@width, width)
72
+ end
73
+
74
+ def resolve(value, extent)
75
+ fraction?(value) ? extent * value : value
76
+ end
77
+
78
+ def fraction?(value) = value.is_a?(Rational) || (value.is_a?(Float) && value.abs <= 1)
79
+ end
80
+ end
81
+ end
@@ -6,8 +6,10 @@ module Stationery
6
6
  # follows the viewBox's aspect ratio), never wider than the space given.
7
7
  class Svg < Node
8
8
  # `context` gives SVG text its font book and default family.
9
- def initialize(document, width: nil, height: nil, color: "#000000", context: nil)
9
+ # `alt:` describes the drawing in a tagged PDF; `alt: false` marks it decorative.
10
+ def initialize(document, width: nil, height: nil, color: "#000000", context: nil, alt: nil)
10
11
  super()
12
+ @tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :svg)
11
13
  @document = document
12
14
  @context = context
13
15
  @width = width
@@ -28,8 +30,10 @@ module Stationery
28
30
 
29
31
  def paint(canvas, x, y, width, _height = nil, **)
30
32
  w, h = size(width)
31
- @document.draw(canvas, x:, y:, width: w, height: h, color: @color, book: @context&.book,
32
- family: @context&.style&.family)
33
+ canvas.tag(@tag, bbox: [x, y, w, h]) do
34
+ @document.draw(canvas, x:, y:, width: w, height: h, color: @color, book: @context&.book,
35
+ family: @context&.style&.family)
36
+ end
33
37
  canvas.debug_rect(x, y, w, h, :image)
34
38
  end
35
39
 
@@ -9,7 +9,7 @@ module Stationery
9
9
  TEXT_OPTIONS = { size: :size, color: :color, weight: :weight, style: :style, font: :family,
10
10
  letter_spacing: :letter_spacing }.freeze
11
11
 
12
- attr_reader :content, :options, :colspan, :rowspan
12
+ attr_reader :content, :options, :colspan, :rowspan, :tag, :row_tag
13
13
 
14
14
  def initialize(content, options, colspan: 1, rowspan: 1)
15
15
  @content = content
@@ -23,7 +23,15 @@ module Stationery
23
23
  end
24
24
 
25
25
  # The same cell holding another node, as a row split into two parts needs.
26
- def with_content(node) = Cell.new(node, @options, colspan:, rowspan:)
26
+ def with_content(node) = Cell.new(node, @options, colspan:, rowspan:).tagged(@tag, @row_tag)
27
+
28
+ # Its TH or TD and its row's TR in a tagged PDF; set once, by the
29
+ # table the cell is first laid out in.
30
+ def tagged(tag, row_tag)
31
+ @tag ||= tag
32
+ @row_tag ||= row_tag
33
+ self
34
+ end
27
35
 
28
36
  # Changes an option and forgets the node built from the old ones.
29
37
  def []=(name, value)
@@ -10,8 +10,13 @@ module Stationery
10
10
  DEFAULT_CELL = { padding: 5, borders: %i[top right bottom left], border_width: 0.5,
11
11
  border_color: "#000000" }.freeze
12
12
 
13
- def initialize(rows, context:, widths: nil, width: :auto, header: false, split_rows: false, cell: {})
13
+ # `tag` is the Table element its fragments share; a `continued` fragment
14
+ # repeats header rows already read on an earlier page.
15
+ def initialize(rows, context:, widths: nil, width: :auto, header: false, split_rows: false, cell: {},
16
+ tag: Tagging::Element.new(:Table), continued: false)
14
17
  super()
18
+ @tag = tag
19
+ @continued = continued
15
20
  @split_rows = split_rows
16
21
  @context = context
17
22
  @widths = widths
@@ -20,6 +25,7 @@ module Stationery
20
25
  defaults = DEFAULT_CELL.merge(cell)
21
26
  @cells = rows.map { |row| row.map { |content| build_cell(content, defaults) } }
22
27
  check_header
28
+ tag_cells
23
29
  yield self if block_given?
24
30
  end
25
31
 
@@ -74,11 +80,12 @@ module Stationery
74
80
  def paint(canvas, x, y, width, _height = nil, **)
75
81
  widths = column_widths(width)
76
82
  heights = row_heights(width)
77
- grid.placements.each do |p|
78
- rect = Rect.new(x + widths[0...p.column].sum, y + heights[0...p.row].sum,
79
- widths[p.columns].sum, heights[p.rows].sum)
80
- p.cell.paint(canvas, @context, rect, last_column: p.columns.end == column_count,
81
- last_row: p.rows.end == row_count)
83
+ canvas.structure(@tag) do
84
+ grid.placements.each do |p|
85
+ rect = Rect.new(x + widths[0...p.column].sum, y + heights[0...p.row].sum,
86
+ widths[p.columns].sum, heights[p.rows].sum)
87
+ paint_cell(canvas, p, rect)
88
+ end
82
89
  end
83
90
  end
84
91
 
@@ -97,10 +104,34 @@ module Stationery
97
104
 
98
105
  def grid = @grid ||= Grid.new(@cells)
99
106
 
107
+ def paint_cell(canvas, placement, rect)
108
+ cell = placement.cell
109
+ paint = lambda do
110
+ cell.paint(canvas, @context, rect, last_column: placement.columns.end == column_count,
111
+ last_row: placement.rows.end == row_count)
112
+ end
113
+ return canvas.artifact(type: :pagination, &paint) if @continued && placement.row < @header
114
+
115
+ canvas.structure(cell.row_tag) { canvas.structure(cell.tag, &paint) }
116
+ end
117
+
118
+ def tag_cells
119
+ @cells.each_with_index do |row, index|
120
+ row_tag = Tagging::Element.new(:TR)
121
+ row.each { |cell| cell.tagged(cell_tag(cell, index < @header), row_tag) }
122
+ end
123
+ end
124
+
125
+ def cell_tag(cell, header)
126
+ attributes = { Scope: (:Column if header), ColSpan: (cell.colspan if cell.colspan > 1),
127
+ RowSpan: (cell.rowspan if cell.rowspan > 1) }.compact
128
+ Tagging::Element.new(header ? :TH : :TD, attributes: attributes.empty? ? {} : { Table: attributes })
129
+ end
130
+
100
131
  def split_before(count)
101
132
  return [nil, self] if count == @header
102
133
 
103
- [with_rows(@cells.first(count)), with_rows(@cells.first(@header) + @cells.drop(count))]
134
+ [with_rows(@cells.first(count)), with_rows(@cells.first(@header) + @cells.drop(count), continued: true)]
104
135
  end
105
136
 
106
137
  def split_row(row, width, space, fresh:)
@@ -113,7 +144,7 @@ module Stationery
113
144
  return unless heads
114
145
 
115
146
  [with_rows(@cells.first(row) + [heads], widths:),
116
- with_rows(@cells.first(@header) + [tails] + @cells.drop(row + 1), widths:)]
147
+ with_rows(@cells.first(@header) + [tails] + @cells.drop(row + 1), widths:, continued: true)]
117
148
  end
118
149
 
119
150
  def build_cell(content, defaults)
@@ -156,8 +187,9 @@ module Stationery
156
187
  @row_heights[width] ||= grid.row_heights(column_widths(width)) { |p, span| p.cell.measure(@context, span) }
157
188
  end
158
189
 
159
- def with_rows(rows, widths: @widths)
160
- self.class.new(rows, context: @context, widths:, width: @width, header: @header, split_rows: @split_rows)
190
+ def with_rows(rows, widths: @widths, continued: @continued)
191
+ self.class.new(rows, context: @context, widths:, width: @width, header: @header, split_rows: @split_rows,
192
+ tag: @tag, continued:)
161
193
  end
162
194
  end
163
195
  end
@@ -5,7 +5,8 @@ module Stationery
5
5
  class TableOfContents
6
6
  # One contents row. The title wraps within the room the number slot
7
7
  # leaves; the leader and the number sit on its last line, and the whole
8
- # row links to the entry's anchor once that anchor has painted.
8
+ # row links to the entry's anchor once that anchor has painted. Tagged,
9
+ # it is a TOCI holding the title as a Link and the number as a Reference.
9
10
  class Entry < Node
10
11
  LEADERS = { dots: { dash: [0, 3], cap: :round, width: 1 }, line: { width: 0.5 } }.freeze
11
12
 
@@ -16,20 +17,27 @@ module Stationery
16
17
  @options = options
17
18
  @slot = slot
18
19
  @paragraphs = {}
20
+ @tag = Tagging::Element.new(:TOCI)
21
+ @link = Tagging::Element.new(:Link)
22
+ @reference = Tagging::Element.new(:Reference)
19
23
  end
20
24
 
21
25
  def measure(width) = paragraph(width).height
22
26
  def natural_width = paragraph(Float::INFINITY).lines.map(&:width).max.to_f + offset + @slot + @options.gap
23
27
 
24
28
  def paint(canvas, x, y, width, _height = nil, **)
25
- text = paragraph(width)
26
- text.draw(canvas, x + offset, y)
27
- last = text.lines.last
28
- baseline = y + text.height - last.height + last.ascent
29
- slot_x = x + width - @slot
30
- draw_leader(canvas, x + offset + last.width + 2, slot_x, baseline)
31
- canvas.number_slot(@entry.anchor, x: slot_x, baseline:, width: @slot, style: @context.style,
32
- link: [x + offset, y, width - offset, text.height])
29
+ canvas.structure(@tag) do
30
+ text = paragraph(width)
31
+ text.draw(canvas, x + offset, y, tag: @link)
32
+ last = text.lines.last
33
+ baseline = y + text.height - last.height + last.ascent
34
+ slot_x = x + width - @slot
35
+ draw_leader(canvas, x + offset + last.width + 2, slot_x, baseline)
36
+ canvas.structure(@reference) { nil } # its place in reading order; Structure fills it
37
+ canvas.number_slot(@entry.anchor, x: slot_x, baseline:, width: @slot, style: @context.style,
38
+ link: [x + offset, y, width - offset, text.height],
39
+ tags: [@link, @reference])
40
+ end
33
41
  end
34
42
 
35
43
  private
@@ -26,7 +26,7 @@ module Stationery
26
26
 
27
27
  def rows
28
28
  @rows ||= Flow.new(entries.map { |entry| Entry.new(entry, context: @context, options: @options, slot:) },
29
- gap: @options.gap)
29
+ gap: @options.gap, tag: Tagging::Element.new(:TOC))
30
30
  end
31
31
 
32
32
  def entries
@@ -6,8 +6,9 @@ module Stationery
6
6
  class Text < Node
7
7
  attr_reader :runs
8
8
 
9
- def initialize(runs, context:, align: :left, leading: 0, paragraph: nil)
9
+ def initialize(runs, context:, align: :left, leading: 0, paragraph: nil, tag: Tagging::Element.new(:P))
10
10
  super()
11
+ @tag = tag
11
12
  @runs = context.book.fallback(runs)
12
13
  @context = context
13
14
  @align = align
@@ -21,7 +22,7 @@ module Stationery
21
22
  def measure(width) = paragraph(width).height
22
23
 
23
24
  def paint(canvas, x, y, width, _height = nil, **)
24
- paragraph(width).draw(canvas, x, y)
25
+ paragraph(width).draw(canvas, x, y, tag: @tag)
25
26
  end
26
27
 
27
28
  def split(width, height, **)
@@ -57,7 +58,7 @@ module Stationery
57
58
  end
58
59
 
59
60
  def from(paragraph)
60
- self.class.new(@runs, context: @context, align: @align, leading: @leading, paragraph:)
61
+ self.class.new(@runs, context: @context, align: @align, leading: @leading, paragraph:, tag: @tag)
61
62
  end
62
63
  end
63
64
  end
@@ -16,7 +16,10 @@ module Stationery
16
16
  def assert_pdf_link(subject, url, msg = nil) = assert_pdf(Matchers::HaveLink.new(url), subject, msg)
17
17
  def assert_image_count(subject, count, msg = nil) = assert_pdf(Matchers::HaveImageCount.new(count), subject, msg)
18
18
  def assert_bookmark(subject, title, msg = nil) = assert_pdf(Matchers::HaveBookmark.new(title), subject, msg)
19
+ def assert_pdf_language(subject, lang, msg = nil) = assert_pdf(Matchers::HaveLanguage.new(lang), subject, msg)
19
20
  def assert_no_pdf_warnings(subject, msg = nil) = assert_pdf(Matchers::HaveNoWarnings.new, subject, msg)
21
+ def assert_pdf_structure(subject, tree, msg = nil) = assert_pdf(Matchers::HaveStructure.new(tree), subject, msg)
22
+ def assert_tagged_content(subject, msg = nil) = assert_pdf(Matchers::HaveTaggedContent.new, subject, msg)
20
23
 
21
24
  def refute_pdf_text(subject, expected, msg = nil)
22
25
  matcher = Matchers::HaveText.new(expected)
@@ -8,8 +8,11 @@ module Stationery
8
8
  class Page
9
9
  # Room for the page number of `anchor`, right-aligned in `width` from `x`
10
10
  # on `baseline` (top-left coordinates), drawn in `style`. `link` is an
11
- # [x, y, w, h] area linked to the anchor only once it resolves.
12
- Slot = Data.define(:anchor, :x, :baseline, :width, :style, :link)
11
+ # [x, y, w, h] area linked to the anchor only once it resolves. `tags` are
12
+ # its [link, number] structure elements in a tagged PDF.
13
+ Slot = Data.define(:anchor, :x, :baseline, :width, :style, :link, :tags) do
14
+ def initialize(anchor:, x:, baseline:, width:, style:, link:, tags: nil) = super
15
+ end
13
16
 
14
17
  SIZES = {
15
18
  a3: [841.89, 1190.55], a4: [595.28, 841.89], a5: [419.53, 595.28],
@@ -7,8 +7,9 @@ module Stationery
7
7
  # Paints a document's headers, footers and page templates on every
8
8
  # finished page.
9
9
  class PageTemplates
10
- def initialize(document, book:, resources:, debug: false, regions: nil, warnings: [])
10
+ def initialize(document, book:, resources:, debug: false, regions: nil, warnings: [], tagging: nil)
11
11
  @document = document
12
+ @tagging = tagging
12
13
  @debug = debug
13
14
  @book = book
14
15
  @resources = resources
@@ -38,7 +39,7 @@ module Stationery
38
39
  reserved = @regions.height_of(region, info.number)
39
40
  overflow(info.number, height, reserved)
40
41
  y = slot == :header ? box.y : box.bottom - height
41
- paint(page, root, Rect.new(box.x, y, box.width, height))
42
+ paint(page, root, Rect.new(box.x, y, box.width, height), subtype: slot)
42
43
  end
43
44
  end
44
45
 
@@ -50,9 +51,11 @@ module Stationery
50
51
 
51
52
  def root(info, block) = @document.template_root(info, book: @book, &block)
52
53
 
53
- def paint(page, root, rect, layer = :foreground)
54
+ # Everything a template paints is a pagination artifact in a tagged PDF.
55
+ def paint(page, root, rect, layer = :foreground, subtype: nil)
54
56
  mark = page.content.bytesize
55
- root.paint(Canvas.new(page, @resources, template: true, debug: @debug), rect.x, rect.y, rect.width)
57
+ canvas = Canvas.new(page, @resources, template: true, debug: @debug, tagging: @tagging)
58
+ canvas.artifact(type: :pagination, subtype:) { root.paint(canvas, rect.x, rect.y, rect.width) }
56
59
  page.content.prepend(page.content.slice!(mark..)) if layer == :background
57
60
  end
58
61
  end
@@ -4,7 +4,11 @@ module Stationery
4
4
  module PDF
5
5
  # Writes finished pages, their shared resources and document info as PDF.
6
6
  class Assembler
7
- def initialize(pages:, resources:, info: {}, outline: [], encryption: nil)
7
+ # `tagging:` (a Tagging::Tree) writes the structure tree of a tagged PDF;
8
+ # `lang:` is the document's natural language.
9
+ def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil)
10
+ @tagging = tagging
11
+ @lang = lang
8
12
  @pages = pages
9
13
  @resources = resources
10
14
  @info = info
@@ -14,28 +18,43 @@ module Stationery
14
18
 
15
19
  def render
16
20
  writer = Writer.new(encryption: @encryption)
21
+ @form = Forms::AcroForm.new(writer)
17
22
  tree = writer.reserve
18
23
  refs = @resources.build(writer)
19
24
  kids = @kids = @pages.map { writer.reserve }
25
+ @structure = @tagging && Tagging::Writer.new(@tagging, pages: @pages, refs: kids)
20
26
  @pages.each_with_index { |page, index| write_page(writer, page, kids[index], tree, refs) }
21
27
  writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
22
- root = writer.add(catalog(tree, OutlineWriter.new(writer, @outline, kids).write))
28
+ outlines = OutlineWriter.new(writer, @outline, kids).write
29
+ root = writer.add(catalog(tree, outlines, @form.write).merge(accessibility(writer)))
23
30
  writer.render(root:, info: writer.add(info_dictionary))
24
31
  end
25
32
 
26
33
  private
27
34
 
28
- def catalog(tree, outlines)
35
+ def catalog(tree, outlines, form)
29
36
  catalog = { Type: :Catalog, Pages: tree }
37
+ catalog[:AcroForm] = form if form
30
38
  outlines ? catalog.merge(Outlines: outlines, PageMode: :UseOutlines) : catalog
31
39
  end
32
40
 
41
+ def accessibility(writer)
42
+ entries = @lang ? { Lang: PDF::TextString.new(@lang.to_s) } : {}
43
+ return entries unless @structure
44
+
45
+ entries.merge!(@structure.write(writer))
46
+ @info[:Title] ? entries.merge(ViewerPreferences: { DisplayDocTitle: true }) : entries
47
+ end
48
+
33
49
  def write_page(writer, page, ref, tree, refs)
34
50
  dictionary = {
35
51
  Type: :Page, Parent: tree, MediaBox: [0, 0, *page.size],
36
52
  Contents: writer.add(Stream.new(page.content)), Resources: page_resources(page, refs)
37
53
  }
38
- dictionary[:Annots] = page.annotations.map { |link| writer.add(annotation(link)) } if page.annotations.any?
54
+ if page.annotations.any?
55
+ dictionary[:Annots] = page.annotations.map { |annot| annotation_ref(writer, annot, ref) }
56
+ end
57
+ dictionary.merge!(@structure.page_entries(page)) if @structure
39
58
  writer.set(ref, dictionary)
40
59
  end
41
60
 
@@ -45,6 +64,19 @@ module Stationery
45
64
  end
46
65
  end
47
66
 
67
+ def annotation_ref(writer, annotation, page)
68
+ if annotation[:widget]
69
+ return @form.add(annotation[:widget], annotation[:rect], page) do |widget_ref|
70
+ @structure ? @structure.annotation(annotation, widget_ref) : {}
71
+ end
72
+ end
73
+
74
+ ref = writer.reserve
75
+ dictionary = annotation(annotation)
76
+ dictionary = dictionary.merge(@structure.annotation(annotation, ref)) if @structure
77
+ writer.set(ref, dictionary)
78
+ end
79
+
48
80
  def annotation(link)
49
81
  target = if (dest = link[:dest])
50
82
  { Dest: [@kids.fetch(dest.page), :XYZ, nil, dest.top, nil] }
@@ -4,15 +4,19 @@ require "stationery"
4
4
 
5
5
  module Stationery
6
6
  # Browsable sample documents, like ActionMailer previews. Each public method
7
- # returns one document:
7
+ # returns one document. `around_render` wraps building and rendering it,
8
+ # for anything that must be in effect while the document paints:
8
9
  #
9
10
  # # spec/pdfs/previews/invoice_pdf_preview.rb
10
11
  # class InvoicePdfPreview < Stationery::Preview
11
12
  # def paid = InvoicePdf.new(Invoice.first)
12
13
  # def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id")))
14
+ #
15
+ # def around_render(_name, params) = I18n.with_locale(params.fetch("locale", I18n.default_locale)) { yield }
13
16
  # end
14
17
  class Preview
15
18
  REGISTRY_LOCK = Mutex.new
19
+ HOOKS = %w[around_render].freeze
16
20
 
17
21
  class << self
18
22
  def inherited(subclass)
@@ -40,7 +44,7 @@ module Stationery
40
44
  end
41
45
 
42
46
  def preview_name = underscore(name.delete_suffix("Preview"))
43
- def pdfs = public_instance_methods(false).sort.map(&:to_s)
47
+ def pdfs = public_instance_methods(false).sort.map(&:to_s) - HOOKS
44
48
 
45
49
  protected
46
50
 
@@ -53,6 +57,19 @@ module Stationery
53
57
  end
54
58
  end
55
59
 
60
+ # The PDF bytes of one preview, built and rendered inside around_render.
61
+ # `debug: true` reaches to_pdf when the document accepts it.
62
+ def to_pdf(name, params = {}, debug: false)
63
+ around_render(name, params) do
64
+ document = render(name, params)
65
+ document.to_pdf(**(debug && accepts_debug?(document) ? { debug: true } : {}))
66
+ end
67
+ end
68
+
69
+ # Override to wrap the preview: set an I18n locale, Current attributes,
70
+ # a time zone. Both the preview method and to_pdf run inside the block.
71
+ def around_render(_name, _params) = yield
72
+
56
73
  def render(name, params = {})
57
74
  method = public_method(name)
58
75
  document = method.arity.zero? ? method.call : method.call(params)
@@ -60,5 +77,9 @@ module Stationery
60
77
 
61
78
  raise Error, "#{self.class}##{name} must return a Stationery::Document (got #{document.class})"
62
79
  end
80
+
81
+ private
82
+
83
+ def accepts_debug?(document) = document.method(:to_pdf).parameters.include?(%i[key debug])
63
84
  end
64
85
  end
@@ -19,8 +19,8 @@ module Stationery
19
19
  klass, pdf = Preview.find(params[:path])
20
20
  return head(:not_found) unless klass
21
21
 
22
- document = klass.new.render(pdf, request.query_parameters.except("debug"))
23
- send_data document.to_pdf(**debug_option(document)), type: "application/pdf", disposition: "inline"
22
+ pdf = klass.new.to_pdf(pdf, request.query_parameters.except("debug"), debug: params[:debug].present?)
23
+ send_data pdf, type: "application/pdf", disposition: "inline"
24
24
  end
25
25
 
26
26
  private
@@ -35,11 +35,6 @@ module Stationery
35
35
  Preview.load(Railtie.preview_paths(::Rails.root, config.preview_paths))
36
36
  end
37
37
 
38
- def debug_option(document)
39
- debug = params[:debug].present? && document.method(:to_pdf).parameters.include?(%i[key debug])
40
- debug ? { debug: true } : {}
41
- end
42
-
43
38
  def page(paths)
44
39
  items = paths.map do |path|
45
40
  href = ERB::Util.html_escape("#{request.path.chomp("/")}/#{path}")
@@ -19,16 +19,24 @@ module Stationery
19
19
  bold: [[:b]], italic: [[:i]], underline: [[:u]], strike: [[:strikethrough]]
20
20
  }.freeze
21
21
 
22
- def initialize(runs, styles)
22
+ def initialize(runs, styles, links = Links.new(:all, nil))
23
23
  @runs = runs
24
24
  @styles = styles
25
+ @links = links
25
26
  end
26
27
 
27
28
  def write(inlines) = inlines.grep(Inline).each { |inline| inline.break? ? @runs.br : write_one(inline) }
28
29
 
29
30
  private
30
31
 
31
- def write_one(inline) = nest(inline.marks.flat_map { |mark, value| steps(mark, value) }, inline.text)
32
+ def write_one(inline) = nest(marks_of(inline).flat_map { |mark, value| steps(mark, value) }, inline.text)
33
+
34
+ def marks_of(inline)
35
+ marks = inline.marks
36
+ return marks unless marks.key?(:link) && !@links.allowed?(marks[:link])
37
+
38
+ marks.except(:link)
39
+ end
32
40
 
33
41
  def nest(steps, text)
34
42
  return @runs.plain(text) if steps.empty?
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Rich
5
+ class Renderer
6
+ # Which hrefs become link annotations: `#anchor` always, otherwise only
7
+ # the allowed URL schemes (case-insensitive). A dropped href keeps its
8
+ # text and is reported once as a DroppedLink warning. `:all` keeps every
9
+ # href, for trusted sources.
10
+ class Links
11
+ SCHEME = /\A([a-z][a-z0-9+.-]*):/i
12
+
13
+ def initialize(schemes, warnings)
14
+ @schemes = schemes == :all ? :all : Array(schemes).map { |scheme| scheme.to_s.downcase }
15
+ @warnings = warnings
16
+ end
17
+
18
+ def allowed?(href)
19
+ return true if @schemes == :all || href.start_with?("#")
20
+ return true if @schemes.include?(href[SCHEME, 1].to_s.downcase)
21
+
22
+ @warnings << Warnings::DroppedLink.new(href:)
23
+ false
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "nodes"
4
4
  require_relative "styles"
5
5
  require_relative "renderer/inlines"
6
+ require_relative "renderer/links"
6
7
 
7
8
  module Stationery
8
9
  module Rich
@@ -11,8 +12,9 @@ module Stationery
11
12
  # from files under `base_path:`; remote URLs are never fetched.
12
13
  class Renderer
13
14
  REMOTE = /\A[a-z][a-z0-9+.-]*:/i
15
+ LINK_SCHEMES = %w[http https mailto tel].freeze
14
16
 
15
- def initialize(component, builder, gap:, styles:, images:, base_path:, bookmarks: false)
17
+ def initialize(component, builder, gap:, styles:, images:, base_path:, bookmarks: false, links: nil)
16
18
  @component = component
17
19
  @builder = builder
18
20
  @gap = gap
@@ -20,6 +22,7 @@ module Stationery
20
22
  @images = images
21
23
  @base_path = base_path && File.expand_path(base_path.to_s)
22
24
  @bookmarks = bookmarks
25
+ @links = Links.new(links || LINK_SCHEMES, builder.warnings)
23
26
  end
24
27
 
25
28
  def render(blocks) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
@@ -31,7 +34,7 @@ module Stationery
31
34
  when Paragraph then paragraph(block.inlines, **@styles[:p])
32
35
  when Heading then heading(block)
33
36
  when List then list(block)
34
- when Blockquote then @component.box(**@styles[:blockquote]) { render(block.blocks) }
37
+ when Blockquote then @component.box(role: :blockquote, **@styles[:blockquote]) { render(block.blocks) }
35
38
  when CodeBlock then code(block)
36
39
  when Rule then @component.rule(**@styles[:hr])
37
40
  when Table then table(block)
@@ -40,14 +43,14 @@ module Stationery
40
43
  end
41
44
 
42
45
  def paragraph(inlines, **)
43
- @component.text(**) { |runs| Inlines.new(runs, @styles).write(inlines) }
46
+ @component.text(**) { |runs| Inlines.new(runs, @styles, @links).write(inlines) }
44
47
  end
45
48
 
46
49
  def heading(heading)
47
50
  style = @styles[:"h#{heading.level}"]
48
51
  size = style[:size] || (@builder.style.size * style.fetch(:scale, 1))
49
52
  bookmark = heading_bookmark(heading)
50
- paragraph(heading.inlines, **style.except(:scale), size:, **({ bookmark: } if bookmark))
53
+ paragraph(heading.inlines, **style.except(:scale), size:, heading: heading.level, **({ bookmark: } if bookmark))
51
54
  end
52
55
 
53
56
  def heading_bookmark(heading)
@@ -57,7 +60,7 @@ module Stationery
57
60
  end
58
61
 
59
62
  def list(list)
60
- options = list.ordered ? { start: list.start || 1 } : {}
63
+ options = list.ordered ? { **@styles[:ol], start: list.start || 1 } : @styles[:ul]
61
64
  @component.public_send(list.ordered ? :ol : :ul, **options) do
62
65
  list.items.each do |blocks|
63
66
  @component.li { @component.text_style(**@styles[:li]) { render(blocks) } }
@@ -86,7 +89,7 @@ module Stationery
86
89
  return unless source
87
90
 
88
91
  loaded = Images.load(source)
89
- @component.image(loaded, **dimensions(node, loaded))
92
+ @component.image(loaded, alt: node.alt, **dimensions(node, loaded))
90
93
  rescue UnsupportedImage => e
91
94
  skip(node.src, e.message)
92
95
  end