stationery 0.7.0 → 0.9.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +251 -23
  4. data/lib/stationery/canvas.rb +22 -1
  5. data/lib/stationery/cli/render.rb +19 -4
  6. data/lib/stationery/document.rb +69 -9
  7. data/lib/stationery/elements/forms.rb +11 -6
  8. data/lib/stationery/elements/rich.rb +13 -6
  9. data/lib/stationery/elements.rb +11 -3
  10. data/lib/stationery/errors.rb +13 -0
  11. data/lib/stationery/fonts/font.rb +28 -18
  12. data/lib/stationery/forms/acro_form.rb +95 -25
  13. data/lib/stationery/forms/appearance.rb +92 -30
  14. data/lib/stationery/forms/field.rb +20 -11
  15. data/lib/stationery/forms/metrics.rb +3 -17
  16. data/lib/stationery/forms/typeface.rb +113 -0
  17. data/lib/stationery/html/document.rb +8 -2
  18. data/lib/stationery/html/tree_builder.rb +34 -4
  19. data/lib/stationery/images/jpeg.rb +2 -1
  20. data/lib/stationery/layout/image.rb +9 -1
  21. data/lib/stationery/layout/table/cell.rb +14 -4
  22. data/lib/stationery/layout/table.rb +72 -14
  23. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  24. data/lib/stationery/markdown/block_parser.rb +9 -3
  25. data/lib/stationery/markdown/document.rb +8 -2
  26. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  27. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  28. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  29. data/lib/stationery/markdown/inline_parser.rb +9 -3
  30. data/lib/stationery/minitest.rb +16 -0
  31. data/lib/stationery/pdf/assembler.rb +51 -11
  32. data/lib/stationery/pdf/attachments.rb +76 -0
  33. data/lib/stationery/pdf/conformance.rb +141 -0
  34. data/lib/stationery/pdf/factur_x.rb +117 -0
  35. data/lib/stationery/pdf/icc/LICENSE.md +14 -0
  36. data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
  37. data/lib/stationery/pdf/serializer.rb +3 -1
  38. data/lib/stationery/pdf/signature/cms.rb +87 -0
  39. data/lib/stationery/pdf/signature.rb +167 -0
  40. data/lib/stationery/pdf/stream.rb +5 -3
  41. data/lib/stationery/pdf/types.rb +4 -0
  42. data/lib/stationery/pdf/xmp.rb +149 -0
  43. data/lib/stationery/resources.rb +3 -0
  44. data/lib/stationery/rich/nesting.rb +30 -0
  45. data/lib/stationery/rich/renderer/indents.rb +42 -0
  46. data/lib/stationery/rich/renderer.rb +22 -6
  47. data/lib/stationery/svg/clip_path.rb +69 -0
  48. data/lib/stationery/svg/document.rb +34 -26
  49. data/lib/stationery/svg/painter.rb +11 -0
  50. data/lib/stationery/svg/parser.rb +51 -11
  51. data/lib/stationery/svg/region.rb +25 -0
  52. data/lib/stationery/svg/shapes.rb +2 -0
  53. data/lib/stationery/svg/style.rb +25 -14
  54. data/lib/stationery/svg/viewport.rb +78 -0
  55. data/lib/stationery/svg/walker.rb +144 -0
  56. data/lib/stationery/tagging/element.rb +1 -1
  57. data/lib/stationery/testing/inspector.rb +123 -0
  58. data/lib/stationery/testing/matchers.rb +92 -0
  59. data/lib/stationery/text/wrapper.rb +12 -3
  60. data/lib/stationery/version.rb +1 -1
  61. data/lib/stationery/warnings.rb +8 -0
  62. data/lib/stationery.rb +11 -0
  63. metadata +16 -1
@@ -6,28 +6,49 @@ module Stationery
6
6
  # a form looks right without relying on the viewer to regenerate it.
7
7
  # Variable text sits between `/Tx BMC … EMC`, the part a viewer redraws
8
8
  # when the value changes.
9
+ #
10
+ # It is drawn when the widget is placed, so the glyphs it uses are in the
11
+ # fonts before they are subset, and written as streams once the fonts
12
+ # have their references (#streams).
9
13
  class Appearance
10
14
  PADDING = 2
11
15
  LEADING = 1.15
12
- CHECK = { glyph: "4", width: 0.846, middle: 0.345 }.freeze
16
+ # The check mark's corners in the unit square (y down) and its stroke.
17
+ CHECK = { points: [[0.22, 0.52], [0.42, 0.72], [0.78, 0.28]], stroke: 0.12 }.freeze
13
18
  DOT = 0.45
14
19
  SIGNATURE = { rule: 14, label_size: 7, label_baseline: 4, label_gray: 0.42 }.freeze
15
20
 
16
- def initialize(field, width, height, fonts)
21
+ # `resources` are the render's, where the field's fonts get their names.
22
+ def initialize(field, width, height, resources = nil)
17
23
  @field = field
18
24
  @width = width
19
25
  @height = height
20
- @fonts = fonts
26
+ @type = field.typeface.with(resources)
27
+ @contents = contents
28
+ keep
29
+ end
30
+
31
+ # Whether everything it draws with is embedded in the file.
32
+ def embedded? = @type.names.empty? || @type.embedded?
33
+
34
+ # The resource names of the fonts it draws with or keeps for editing.
35
+ def font_names = @type.names
36
+
37
+ # What the field's /DA says: the font and size a viewer redraws the
38
+ # value with. nil for a field without variable text.
39
+ def default_appearance
40
+ return unless @field.variable_text?
41
+
42
+ "/#{@type.name} #{num(@field.font_size)} Tf 0 g"
21
43
  end
22
44
 
23
45
  # One stream, or a Hash of streams by appearance state for buttons.
24
- def normal
25
- case @field.kind
26
- when :text, :select then stream(frame + variable_text(text_lines))
27
- when :checkbox then { @field.on_state => stream(frame + check), Off: stream(frame) }
28
- when :radio then { @field.on_state => stream(circle + dot), Off: stream(circle) }
29
- when :signature then stream(signature)
30
- end
46
+ # `fonts` are the references by resource name.
47
+ def streams(fonts)
48
+ resources = font_names.empty? ? {} : { Font: fonts.slice(*font_names) }
49
+ return stream(@contents, resources) unless @contents.is_a?(Hash)
50
+
51
+ @contents.transform_values { |content| stream(content, resources) }
31
52
  end
32
53
 
33
54
  private
@@ -36,9 +57,29 @@ module Stationery
36
57
  def size = @field.font_size
37
58
  def num(value) = PDF::Serializer.number(value.is_a?(Float) && value == value.round ? value.round : value)
38
59
 
39
- def stream(content)
60
+ def contents
61
+ case @field.kind
62
+ when :text, :select then frame + variable_text(text_lines)
63
+ when :checkbox then { @field.on_state => frame + check, Off: frame }
64
+ when :radio then { @field.on_state => circle + dot, Off: circle }
65
+ when :signature then signature
66
+ end
67
+ end
68
+
69
+ # What a viewer may draw after an edit: the other options of a select,
70
+ # and the base repertoire of a field that can be typed into.
71
+ def keep
72
+ return unless @field.variable_text?
73
+
74
+ @type.name
75
+ return if options[:read_only]
76
+
77
+ @type.keep(Array(options[:options]).join, size, repertoire: true)
78
+ end
79
+
80
+ def stream(content, resources)
40
81
  PDF::Stream.new(content, { Type: :XObject, Subtype: :Form, BBox: [0, 0, @width, @height],
41
- Resources: { Font: @fonts } })
82
+ Resources: resources })
42
83
  end
43
84
 
44
85
  def frame
@@ -50,34 +91,49 @@ module Stationery
50
91
  end
51
92
  end
52
93
 
53
- # [x, baseline, WinAnsi bytes] runs in PDF space.
94
+ def middle_baseline = (@height / 2.0) - ((@type.ascent(size) - @type.descent(size)) / 2)
95
+
96
+ # [x, baseline, text] runs in PDF space.
54
97
  def text_lines
55
98
  return comb_cells if options[:comb]
56
99
  return multiline_runs if options[:multiline]
57
100
 
58
- [[PADDING, (@height / 2.0) - (size * (Metrics::ASCENT + Metrics::DESCENT) / 2), Metrics.encode(@field.value)]]
101
+ [[PADDING, middle_baseline, @field.value.to_s]]
59
102
  end
60
103
 
61
104
  def multiline_runs
62
- top = @height - PADDING - (size * Metrics::ASCENT)
63
- Metrics.wrap(@field.value, @width - (2 * PADDING), size).each_with_index.map do |line, index|
105
+ top = @height - PADDING - @type.ascent(size)
106
+ wrap(@field.value.to_s, @width - (2 * PADDING)).each_with_index.map do |line, index|
64
107
  [PADDING, top - (index * size * LEADING), line]
65
108
  end
66
109
  end
67
110
 
111
+ # The value broken into lines no wider than `width`, keeping its own
112
+ # line breaks; a word wider than a line stands alone.
113
+ def wrap(text, width)
114
+ text.split("\n", -1).flat_map do |paragraph|
115
+ paragraph.split.each_with_object([+""]) do |word, lines|
116
+ candidate = lines.last.empty? ? word : "#{lines.last} #{word}"
117
+ if lines.last.empty? || @type.width(candidate, size) <= width
118
+ lines[-1] = candidate
119
+ else
120
+ lines << word
121
+ end
122
+ end
123
+ end
124
+ end
125
+
68
126
  def comb_cells
69
127
  cell = @width.fdiv(@field.max_length)
70
- baseline = (@height / 2.0) - (size * (Metrics::ASCENT + Metrics::DESCENT) / 2)
71
- Metrics.encode(@field.value)[0, @field.max_length].chars.each_with_index.map do |char, index|
72
- [(index * cell) + ((cell - Metrics.width(char, size)) / 2), baseline, char]
128
+ @field.value.to_s[0, @field.max_length].chars.each_with_index.map do |char, index|
129
+ [(index * cell) + ((cell - @type.width(char, size)) / 2), middle_baseline, char]
73
130
  end
74
131
  end
75
132
 
76
133
  def variable_text(runs)
77
134
  ops = ["/Tx BMC", "q", "1 1 #{num(@width - 2)} #{num(@height - 2)} re W n", "0 g"]
78
- runs.reject { |_, _, bytes| bytes.empty? }.each do |x, y, bytes|
79
- ops.push("BT", "/#{Field::FONT} #{num(size)} Tf", "#{num(x)} #{num(y)} Td",
80
- "#{PDF::Serializer.literal(bytes)} Tj", "ET")
135
+ runs.reject { |_, _, text| text.empty? }.each do |x, y, text|
136
+ ops.push("BT", "#{num(x)} #{num(y)} Td", *@type.show(text, size), "ET")
81
137
  end
82
138
  ops.push("Q", "EMC").join("\n") << "\n"
83
139
  end
@@ -93,11 +149,11 @@ module Stationery
93
149
  def signature
94
150
  y = @height - SIGNATURE[:rule]
95
151
  content = draw { |canvas| canvas.line(PADDING, y, @width - PADDING, y, color: options[:border], width: 0.75) }
96
- label = Metrics.encode(options[:label])
152
+ label = options[:label].to_s
97
153
  return content if label.empty?
98
154
 
99
- ops = ["q", "#{SIGNATURE[:label_gray]} g", "BT", "/#{Field::FONT} #{SIGNATURE[:label_size]} Tf",
100
- "#{PADDING} #{SIGNATURE[:label_baseline]} Td", "#{PDF::Serializer.literal(label)} Tj", "ET", "Q"]
155
+ ops = ["q", "#{SIGNATURE[:label_gray]} g", "BT", "#{PADDING} #{SIGNATURE[:label_baseline]} Td",
156
+ *@type.show(label, SIGNATURE[:label_size]), "ET", "Q"]
101
157
  "#{content}#{ops.join("\n")}\n"
102
158
  end
103
159
 
@@ -107,12 +163,18 @@ module Stationery
107
163
  page.content
108
164
  end
109
165
 
166
+ # A check mark stroked as a path, so no symbol font is needed.
110
167
  def check
111
- glyph = [@width, @height].min * 0.8
112
- x = (@width - (glyph * CHECK[:width])) / 2
113
- y = (@height / 2.0) - (glyph * CHECK[:middle])
114
- ["q", "0 g", "BT", "/ZaDb #{num(glyph)} Tf", "#{num(x)} #{num(y)} Td", "(#{CHECK[:glyph]}) Tj", "ET", "Q"]
115
- .join("\n") << "\n"
168
+ side = [@width, @height].min * 0.8
169
+ left = (@width - side) / 2.0
170
+ top = (@height - side) / 2.0
171
+ first, *rest = CHECK[:points].map { |x, y| [left + (x * side), top + (y * side)] }
172
+ draw do |canvas|
173
+ canvas.path(stroke: "#000000", line_width: side * CHECK[:stroke], cap: :round, join: :round) do |path|
174
+ path.move_to(*first)
175
+ rest.each { |point| path.line_to(*point) }
176
+ end
177
+ end
116
178
  end
117
179
  end
118
180
  end
@@ -6,26 +6,29 @@ module Stationery
6
6
  module Forms
7
7
  # One form field widget: its kind, full (dotted) name, value and options.
8
8
  # Widgets sharing a name form one field (a radio group); dotted names are
9
- # grouped under parent fields.
9
+ # grouped under parent fields. `typeface:` is what its appearance is set
10
+ # in: the document's fonts from the element DSL, the standard Helvetica
11
+ # otherwise. `tooltip:` is its accessible name (/TU), the name by default.
10
12
  class Field
11
13
  TYPES = { text: :Tx, checkbox: :Btn, radio: :Btn, select: :Ch, signature: :Sig }.freeze
12
14
  # Field flag bit positions (PDF 32000-1, 12.7.3.1 and 12.7.4).
13
15
  BITS = { read_only: 1, required: 2, multiline: 13, no_toggle_to_off: 15, radio: 16, combo: 18, edit: 19,
14
16
  comb: 25 }.freeze
15
17
  DEFAULTS = { font_size: 10, read_only: false, required: false, border: "#9CA3AF", background: "#FFFFFF",
16
- radius: 2 }.freeze
18
+ radius: 2, tooltip: nil }.freeze
17
19
  OPTIONS = {
18
20
  text: %i[multiline max_length comb], checkbox: [], radio: %i[checked], select: %i[options editable],
19
21
  signature: %i[label]
20
22
  }.freeze
21
- FONT = "Helv"
23
+ VARIABLE_TEXT = %i[text select].freeze
22
24
 
23
- attr_reader :kind, :name, :value, :options
25
+ attr_reader :kind, :name, :value, :options, :typeface
24
26
 
25
- def initialize(kind, name, value: nil, **options)
27
+ def initialize(kind, name, value: nil, typeface: Standard.new, **options)
26
28
  @kind = kind
27
29
  @name = validate_name(name.to_s)
28
30
  @value = value
31
+ @typeface = typeface
29
32
  unknown = options.keys - DEFAULTS.keys - OPTIONS.fetch(kind)
30
33
  raise ArgumentError, "unknown #{kind} field option: #{unknown.join(", ")}" if unknown.any?
31
34
 
@@ -36,7 +39,10 @@ module Stationery
36
39
  def segments = @name.split(".")
37
40
  def type = TYPES.fetch(@kind)
38
41
  def font_size = @options[:font_size]
39
- def default_appearance = "/#{FONT} #{PDF::Serializer.number(font_size)} Tf 0 g"
42
+ # Whether a viewer redraws its text when the value changes.
43
+ def variable_text? = VARIABLE_TEXT.include?(@kind)
44
+ # The accessible name: `tooltip:`, a signature's label, else the name.
45
+ def tooltip = (@options[:tooltip] || @options[:label]).to_s.strip.then { |text| text.empty? ? @name : text }
40
46
  def max_length = @options[:comb].is_a?(Integer) ? @options[:comb] : @options[:max_length]
41
47
  def radio? = @kind == :radio
42
48
  def checked? = radio? ? @options[:checked] == true : @value == true
@@ -52,8 +58,10 @@ module Stationery
52
58
 
53
59
  # The field-level entries; the widget's come from #widget_entries.
54
60
  # `value` overrides this widget's own: a radio group's checked choice.
55
- def field_entries(value = field_value)
56
- entries = { FT: type, DA: default_appearance }
61
+ # `default_appearance` is the /DA of a field with variable text.
62
+ def field_entries(value = field_value, default_appearance: nil)
63
+ entries = { FT: type, TU: PDF::TextString.new(tooltip) }
64
+ entries[:DA] = default_appearance if default_appearance
57
65
  entries[:Ff] = flags if flags.positive?
58
66
  entries[:V] = value unless value.nil?
59
67
  entries[:MaxLen] = max_length if max_length
@@ -70,9 +78,10 @@ module Stationery
70
78
  end
71
79
  end
72
80
 
73
- # `state` overrides the button's own appearance state.
74
- def widget_entries(width, height, fonts, state: nil)
75
- normal = Appearance.new(self, width, height, fonts).normal
81
+ # `appearance` is the widget's Appearance and `fonts` the references by
82
+ # resource name; `state` overrides the button's own appearance state.
83
+ def widget_entries(appearance, fonts, state: nil)
84
+ normal = appearance.streams(fonts)
76
85
  entries = { Type: :Annot, Subtype: :Widget, F: 4, AP: { N: normal } }
77
86
  entries[:MK] = appearance_characteristics unless @kind == :signature
78
87
  entries[:AS] = state || (checked? ? on_state : :Off) if normal.is_a?(Hash)
@@ -3,8 +3,9 @@
3
3
  module Stationery
4
4
  module Forms
5
5
  # Helvetica's advance widths (the standard-14 AFM, in 1/1000 em) for the
6
- # printable ASCII range, enough to lay out a field's appearance; other
7
- # WinAnsi characters count as a digit's width.
6
+ # printable ASCII range, enough to lay out the appearance of a field made
7
+ # without a font book (see Standard); other WinAnsi characters count as a
8
+ # digit's width.
8
9
  module Metrics
9
10
  ASCII = [
10
11
  278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, # space - /
@@ -29,21 +30,6 @@ module Stationery
29
30
  def width(bytes, size)
30
31
  bytes.each_byte.sum { |byte| (byte in 32..126) ? ASCII[byte - 32] : DEFAULT } * size / 1000.0
31
32
  end
32
-
33
- # `text` broken into WinAnsi lines no wider than `width`, keeping its own
34
- # line breaks; a word wider than a line stands alone.
35
- def wrap(text, width, size)
36
- encode(text).split("\n", -1).flat_map do |paragraph|
37
- paragraph.split.each_with_object([+""]) do |word, lines|
38
- candidate = lines.last.empty? ? word : "#{lines.last} #{word}"
39
- if lines.last.empty? || width(candidate, size) <= width
40
- lines[-1] = candidate
41
- else
42
- lines << word
43
- end
44
- end
45
- end
46
- end
47
33
  end
48
34
  end
49
35
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Forms
5
+ # What a field's appearance is set in. A field built by the element DSL
6
+ # draws with the document's own fonts (Typeface): embedded, subset and
7
+ # Identity-H encoded like any other text, with the book's fallbacks per
8
+ # character, so a value in any script the fonts cover renders. A field
9
+ # made without a font book (`Forms::Field.new` on a bare canvas) draws
10
+ # with the standard Helvetica in Windows-1252 (Standard).
11
+ #
12
+ # Both answer the same questions for an Appearance once bound to a
13
+ # render's resources with #with.
14
+ class Typeface
15
+ # What an editable field's font keeps beyond the value it shows, so the
16
+ # text a viewer redraws after an edit has its glyphs: printable ASCII
17
+ # and Latin-1. A read-only field keeps its value's glyphs only.
18
+ REPERTOIRE = [*0x20..0x7E, *0xA0..0xFF].pack("U*").freeze
19
+
20
+ def initialize(book, style, resources = nil)
21
+ @book = book
22
+ @style = style
23
+ @resources = resources
24
+ @names = {}
25
+ end
26
+
27
+ # This typeface naming its fonts in `resources` (a render's Resources).
28
+ def with(resources) = self.class.new(@book, @style, resources)
29
+
30
+ def embedded? = true
31
+ def ascent(size) = primary.ascender(size)
32
+ def descent(size) = primary.descender(size)
33
+ def width(text, size) = runs(text, size).sum { |font, run| font.width_of(run.text, size, ligatures: false) }
34
+
35
+ # The resource names of the fonts drawn or kept so far.
36
+ def names = @names.values
37
+
38
+ # The name the field's /DA selects: the font of the surrounding text.
39
+ def name = name_of(primary)
40
+
41
+ # The operators showing `text` at the current text position: a font
42
+ # selection and a string per run of one font. Characters no font has
43
+ # draw as .notdef and are reported, as in any other text.
44
+ def show(text, size)
45
+ runs(text, size).flat_map do |font, run|
46
+ report(font, run)
47
+ ["/#{name_of(font)} #{PDF::Serializer.number(size)} Tf", glyphs(font, run.text).to_operator]
48
+ end
49
+ end
50
+
51
+ # Keeps the glyphs of `text` in the embedded fonts without drawing it
52
+ # (a select's other options), and the REPERTOIRE when `repertoire:`.
53
+ def keep(text, size, repertoire: false)
54
+ runs(text, size).each do |font, run|
55
+ name_of(font)
56
+ glyphs(font, run.text)
57
+ end
58
+ return unless repertoire
59
+
60
+ name
61
+ glyphs(primary, REPERTOIRE.each_char.select { |char| primary.glyph?(char) }.join)
62
+ end
63
+
64
+ private
65
+
66
+ def primary = @primary ||= @book.resolve(@style).first
67
+ def name_of(font) = @names[font] ||= @resources.font(font)
68
+
69
+ # Marks the glyphs as used, so the subset embeds them.
70
+ def glyphs(font, text) = font.glyph_run(text, ligatures: false)
71
+
72
+ # [font, run] per stretch of `text` one font draws.
73
+ def runs(text, size)
74
+ return [] if text.empty?
75
+
76
+ @book.fallback([Text::Run.new(text, @style.with(size:))]).map { |run| [@book.resolve(run.style).first, run] }
77
+ end
78
+
79
+ def report(font, run)
80
+ run.text.each_char do |char|
81
+ next if Fonts::Fallback.carried?(char) || font.glyph?(char)
82
+
83
+ @book.warnings.missing_glyph(char, run.style.family)
84
+ end
85
+ end
86
+ end
87
+
88
+ # The standard Helvetica, not embedded: text is Windows-1252 bytes and
89
+ # characters outside it become "?".
90
+ class Standard
91
+ NAME = :Helv
92
+ FONT = { Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding }.freeze
93
+
94
+ def with(_resources) = self.class.new
95
+ def embedded? = false
96
+ def ascent(size) = size * Metrics::ASCENT
97
+ def descent(size) = size * -Metrics::DESCENT
98
+ def width(text, size) = Metrics.width(Metrics.encode(text), size)
99
+ def names = @named ? [NAME] : []
100
+ # Nothing to keep: a standard font is the viewer's, whole.
101
+ def keep(*, **) = nil
102
+
103
+ def name
104
+ @named = true
105
+ NAME
106
+ end
107
+
108
+ def show(text, size)
109
+ ["/#{name} #{PDF::Serializer.number(size)} Tf", "#{PDF::Serializer.literal(Metrics.encode(text))} Tj"]
110
+ end
111
+ end
112
+ end
113
+ end
@@ -1,14 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../rich/nesting"
3
4
  require_relative "tokenizer"
4
5
  require_relative "tree_builder"
5
6
 
6
7
  module Stationery
7
8
  # Lenient HTML to rich-text blocks (see Stationery::Rich).
8
9
  module HTML
9
- def self.parse(source)
10
+ # Elements nested deeper than `max_depth` are flattened into the deepest
11
+ # one kept; the block is then given how deep the source went.
12
+ def self.parse(source, max_depth: Rich::Nesting::DEFAULT)
13
+ nesting = Rich::Nesting.new(max_depth)
10
14
  Stationery.instrument("parse.stationery", format: :html, bytes: source.bytesize) do
11
- TreeBuilder.parse(Tokenizer.tokenize(source))
15
+ TreeBuilder.parse(Tokenizer.tokenize(source), nesting).tap do
16
+ yield nesting.deepest if nesting.exceeded? && block_given?
17
+ end
12
18
  end
13
19
  end
14
20
  end
@@ -6,7 +6,9 @@ module Stationery
6
6
  module HTML
7
7
  # Builds a lenient element tree from tokens, closing what HTML5 closes implicitly (a p before a block,
8
8
  # an li before the next li, cells and rows before the next), ignoring stray end tags, then converts
9
- # the tree into rich-text blocks.
9
+ # the tree into rich-text blocks. An element nested deeper than the Rich::Nesting limit is left out
10
+ # of the tree: what it holds goes to the deepest element kept, so everything that walks the tree
11
+ # (and the layout it becomes) recurses no deeper than the limit.
10
12
  class TreeBuilder
11
13
  Element = Data.define(:name, :attributes, :children)
12
14
 
@@ -16,6 +18,7 @@ module Stationery
16
18
  ].freeze
17
19
  HEADINGS = %w[h1 h2 h3 h4 h5 h6].freeze
18
20
  SECTIONS = %w[thead tbody tfoot].freeze
21
+ BLOCKS = (CLOSES_P + SECTIONS + %w[tr td th caption]).freeze
19
22
  IMPLIED = {
20
23
  "li" => [%w[li], %w[ul ol]],
21
24
  "dt" => [%w[dt dd], %w[dl]], "dd" => [%w[dt dd], %w[dl]],
@@ -24,11 +27,13 @@ module Stationery
24
27
  "thead" => [SECTIONS, %w[table]], "tbody" => [SECTIONS, %w[table]], "tfoot" => [SECTIONS, %w[table]]
25
28
  }.freeze
26
29
 
27
- def self.parse(tokens) = Blocks.convert(new.build(tokens))
30
+ def self.parse(tokens, nesting = Rich::Nesting.new) = Blocks.convert(new(nesting).build(tokens))
28
31
 
29
- def initialize
32
+ def initialize(nesting = Rich::Nesting.new)
30
33
  @root = Element.new("#root", {}, [])
31
34
  @stack = [@root]
35
+ @nesting = nesting
36
+ @flattened = [] # the names of the open elements left out of the tree
32
37
  end
33
38
 
34
39
  def build(tokens)
@@ -45,9 +50,13 @@ module Stationery
45
50
  private
46
51
 
47
52
  def start(name, attributes, self_closing)
53
+ return flatten(name, attributes, self_closing) unless @flattened.empty?
54
+
48
55
  close_nearest(%w[p], %w[table td th caption]) if CLOSES_P.include?(name)
49
56
  close_nearest(HEADINGS, []) if HEADINGS.include?(name) && HEADINGS.include?(@stack.last.name)
50
57
  close_nearest(*IMPLIED[name]) if IMPLIED.key?(name)
58
+ return flatten(name, attributes, self_closing) if @stack.size > @nesting.limit
59
+
51
60
  push("tr", {}, false) if %w[td th].include?(name) && %w[table thead tbody tfoot].include?(@stack.last.name)
52
61
  push(name, attributes, self_closing)
53
62
  end
@@ -58,9 +67,30 @@ module Stationery
58
67
  @stack << element unless self_closing
59
68
  end
60
69
 
70
+ # Too deep for the tree: a void element (br, img, hr) still takes its place in the deepest
71
+ # element kept, anything else is only remembered until its end tag, its content going there too.
72
+ def flatten(name, attributes, self_closing)
73
+ return push(name, attributes, true) if self_closing
74
+
75
+ @flattened << name
76
+ @nesting.record(@stack.size - 1 + @flattened.size)
77
+ separate(name)
78
+ end
79
+
61
80
  def finish(name)
81
+ flattened = @flattened.rindex(name)
82
+ return @flattened.slice!(flattened..).each { |left_out| separate(left_out) } if flattened
83
+
62
84
  index = @stack.rindex { |element| element.name == name }
63
- @stack.slice!(index..) if index&.positive?
85
+ return unless index&.positive?
86
+
87
+ @stack.slice!(index..)
88
+ @flattened.clear
89
+ end
90
+
91
+ # A flattened block no longer parts its text from its neighbours'; a space does.
92
+ def separate(name)
93
+ @stack.last.children << " " if BLOCKS.include?(name)
64
94
  end
65
95
 
66
96
  def close_nearest(names, boundary)
@@ -21,9 +21,10 @@ module Stationery
21
21
  end
22
22
 
23
23
  def inspect = "#<#{self.class} #{@width}x#{@height}>"
24
+ def color_space = COLOR_SPACES.fetch(@components)
24
25
 
25
26
  def build(writer)
26
- dictionary = Images.xobject(@width, @height, COLOR_SPACES.fetch(@components), @bits).merge(Filter: :DCTDecode)
27
+ dictionary = Images.xobject(@width, @height, color_space, @bits).merge(Filter: :DCTDecode)
27
28
  # Adobe CMYK JPEGs store inverted values.
28
29
  dictionary[:Decode] = [1, 0] * 4 if @components == 4 && @adobe
29
30
  writer.add(PDF::Stream.new(@data, dictionary))
@@ -25,7 +25,7 @@ module Stationery
25
25
  super()
26
26
  @tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
27
27
  @image = source.respond_to?(:build) ? source : Images.load(source)
28
- @name = source.is_a?(String) || source.is_a?(Pathname) ? File.basename(source.to_s) : "inline image"
28
+ @name = name_of(source)
29
29
  @max_ppi = max_ppi
30
30
  @downscale = downscale
31
31
  @width = width
@@ -58,6 +58,14 @@ module Stationery
58
58
 
59
59
  private
60
60
 
61
+ # What a warning calls the image: the file's name for a path, a
62
+ # Pathname or an open File (anything with a path), else "inline image".
63
+ # Asked by duck type: Pathname is not loaded in plain Ruby.
64
+ def name_of(source)
65
+ path = source.is_a?(String) ? source : (source.to_path if source.respond_to?(:to_path))
66
+ path ? File.basename(path) : "inline image"
67
+ end
68
+
61
69
  def paint_clipped(canvas, x, y, w, h)
62
70
  return paint_image(canvas, x, y, w, h) unless @fit == :cover || @radius.positive?
63
71
 
@@ -6,6 +6,7 @@ module Stationery
6
6
  # One table cell: content (a String, markup String or layout node) and its
7
7
  # options, which selections change until the table is laid out.
8
8
  class Cell
9
+ SIDES = %i[top right bottom left].freeze
9
10
  TEXT_OPTIONS = { size: :size, color: :color, weight: :weight, style: :style, font: :family,
10
11
  letter_spacing: :letter_spacing }.freeze
11
12
 
@@ -103,14 +104,23 @@ module Stationery
103
104
  canvas.debug_rect(inner.x, inner.y, inner.width, inner.height, :cell_padding)
104
105
  end
105
106
 
107
+ # One path for the cell's border, centred on its edges: the rectangle
108
+ # when all four sides are drawn, else a line per side.
106
109
  def paint_borders(canvas, rect)
107
110
  width = @options[:border_width]
108
111
  return if width.nil? || width.zero?
109
112
 
110
- color = @options[:border_color]
111
- Array(@options[:borders]).each do |side|
112
- x1, y1, x2, y2 = edge(side, rect)
113
- canvas.line(x1, y1, x2, y2, color:, width:)
113
+ sides = Array(@options[:borders]).uniq
114
+ return if sides.empty?
115
+
116
+ canvas.path(stroke: @options[:border_color], line_width: width) do |path|
117
+ next path.rect(rect.x, rect.y, rect.width, rect.height) if (SIDES - sides).empty?
118
+
119
+ sides.each do |side|
120
+ x1, y1, x2, y2 = edge(side, rect)
121
+ path.move_to(x1, y1)
122
+ path.line_to(x2, y2)
123
+ end
114
124
  end
115
125
  end
116
126