stationery 0.9.0 → 0.10.1

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +146 -35
  4. data/lib/stationery/{svg → css}/selector.rb +1 -1
  5. data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
  6. data/lib/stationery/css.rb +20 -0
  7. data/lib/stationery/document.rb +21 -9
  8. data/lib/stationery/elements/rich.rb +10 -2
  9. data/lib/stationery/elements.rb +9 -4
  10. data/lib/stationery/errors.rb +4 -0
  11. data/lib/stationery/fonts/fallback.rb +1 -1
  12. data/lib/stationery/fonts/font.rb +5 -2
  13. data/lib/stationery/fonts/glyph_run.rb +35 -5
  14. data/lib/stationery/html/css.rb +276 -0
  15. data/lib/stationery/html/document.rb +5 -3
  16. data/lib/stationery/html/tokenizer.rb +15 -4
  17. data/lib/stationery/html/tree_builder/blocks.rb +90 -40
  18. data/lib/stationery/html/tree_builder/collector.rb +9 -3
  19. data/lib/stationery/html/tree_builder.rb +11 -1
  20. data/lib/stationery/layout/flow.rb +29 -0
  21. data/lib/stationery/layout/image.rb +12 -6
  22. data/lib/stationery/layout/mark.rb +2 -0
  23. data/lib/stationery/layout/node.rb +4 -0
  24. data/lib/stationery/layout/text.rb +47 -10
  25. data/lib/stationery/pdf/assembler.rb +14 -4
  26. data/lib/stationery/pdf/signature/cms.rb +21 -13
  27. data/lib/stationery/pdf/signature/timestamp.rb +120 -0
  28. data/lib/stationery/pdf/signature.rb +16 -9
  29. data/lib/stationery/pdf/writer.rb +73 -19
  30. data/lib/stationery/rich/nodes.rb +37 -11
  31. data/lib/stationery/rich/renderer/boxes.rb +109 -0
  32. data/lib/stationery/rich/renderer/indents.rb +1 -0
  33. data/lib/stationery/rich/renderer/inlines.rb +9 -2
  34. data/lib/stationery/rich/renderer.rb +32 -15
  35. data/lib/stationery/svg/style.rb +4 -6
  36. data/lib/stationery/testing/inspector.rb +49 -6
  37. data/lib/stationery/testing/marked_text.rb +34 -4
  38. data/lib/stationery/text/breaks.rb +123 -0
  39. data/lib/stationery/text/paragraph.rb +8 -0
  40. data/lib/stationery/text/runs_builder.rb +4 -0
  41. data/lib/stationery/text/wrapper.rb +57 -8
  42. data/lib/stationery/version.rb +1 -1
  43. data/lib/stationery/warnings.rb +9 -0
  44. data/lib/stationery.rb +3 -2
  45. metadata +8 -3
@@ -7,6 +7,11 @@ module Stationery
7
7
  # source text of each glyph (several characters for a ligature). All-zero
8
8
  # adjustments draw as a plain Tj string; otherwise as a TJ array. Glyphs
9
9
  # are written as the font's character codes (see Font#code).
10
+ #
11
+ # A character no font has is glyph 0, .notdef, which stands for no
12
+ # character in particular: each stretch of them is shown inside a Span
13
+ # whose ActualText is the characters, so the text still extracts, copies
14
+ # and reads aloud as written.
10
15
  GlyphRun = Data.define(:font, :gids, :adjust, :chars) do
11
16
  def width(size, letter_spacing: 0)
12
17
  units = gids.sum { |gid| font.ttf.advance(gid) }
@@ -18,20 +23,45 @@ module Stationery
18
23
  with(adjust: adjust.each_with_index.map { |a, i| chars[i] == " " ? a + extra : a })
19
24
  end
20
25
 
26
+ def missing? = gids.include?(0)
27
+
21
28
  def to_operator
22
- return "<#{hex(gids)}> Tj" unless adjusted?
29
+ return show unless missing?
30
+
31
+ last = gids.size - 1
32
+ gids.each_index.chunk_while { |a, b| gids[a].zero? == gids[b].zero? }.map do |indices|
33
+ piece = with(gids: gids.values_at(*indices), adjust: adjust.values_at(*indices),
34
+ chars: chars.values_at(*indices))
35
+ piece.marked(trailing: indices.last < last)
36
+ end.join("\n")
37
+ end
38
+
39
+ protected
40
+
41
+ # One stretch of a run: .notdef glyphs inside their Span. `trailing`
42
+ # keeps the adjustment after the last glyph, which a stretch followed
43
+ # by another needs and the end of a run does not.
44
+ def marked(trailing:)
45
+ return show(trailing:) unless missing?
46
+
47
+ "/Span <</ActualText <FEFF#{chars.join.encode(Encoding::UTF_16BE).unpack1("H*").upcase}>>> BDC\n" \
48
+ "#{show(trailing:)}\nEMC"
49
+ end
50
+
51
+ private
52
+
53
+ def show(trailing: false)
54
+ return "<#{hex(gids)}> Tj" unless adjusted?(trailing)
23
55
 
24
56
  parts = gids.each_index.slice_after { |i| !adjust[i].zero? }.flat_map do |indices|
25
57
  last = indices.last
26
58
  chunk = ["<#{hex(indices.map { |i| gids[i] })}>"]
27
- last == gids.size - 1 || adjust[last].zero? ? chunk : chunk << PDF::Serializer.number(-adjust[last])
59
+ (last == gids.size - 1 && !trailing) || adjust[last].zero? ? chunk : chunk << PDF::Serializer.number(-adjust[last])
28
60
  end
29
61
  "[#{parts.join(" ")}] TJ"
30
62
  end
31
63
 
32
- private
33
-
34
- def adjusted? = adjust[0...-1].any? { |a| !a.zero? }
64
+ def adjusted?(trailing) = (trailing ? adjust : adjust[0...-1]).any? { |a| !a.zero? }
35
65
 
36
66
  def hex(ids) = ids.map { |gid| font.code(gid) }.pack("n*").unpack1("H*").upcase
37
67
  end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../css"
4
+
5
+ module Stationery
6
+ module HTML
7
+ # The CSS the html element reads: `<style>` rules (element, class and id
8
+ # selectors) and inline `style` attributes, for a small set of properties
9
+ # that map onto the element DSL. Everything else is ignored and reported
10
+ # once per document through the Report. Values are never fetched or
11
+ # evaluated: `url()` and `@import` are dropped like any unknown value.
12
+ class Css
13
+ # What a document's CSS asked for and did not get: property names, or
14
+ # `property: value` for a known property with a value not read, and
15
+ # selectors with combinators.
16
+ class Report
17
+ attr_reader :properties, :selectors
18
+
19
+ def initialize
20
+ @properties = []
21
+ @selectors = []
22
+ end
23
+
24
+ def property(name)
25
+ @properties << name unless @properties.include?(name)
26
+ nil
27
+ end
28
+
29
+ def select(text)
30
+ @selectors << text unless @selectors.include?(text)
31
+ end
32
+
33
+ def any? = @properties.any? || @selectors.any?
34
+ end
35
+
36
+ # Properties read on text (inherited by everything inside the element).
37
+ INLINE = %w[color font-size font-weight font-style text-decoration].freeze
38
+ # Properties read on the block an element becomes.
39
+ BLOCK = %w[text-align background-color margin margin-top margin-bottom padding padding-top padding-right
40
+ padding-bottom padding-left border width page-break-before page-break-after break-before
41
+ break-after page-break-inside break-inside].freeze
42
+ FONT_SIZES = { "1" => 0.625, "2" => 0.8125, "3" => 1.0, "4" => 1.125, "5" => 1.5, "6" => 2.0,
43
+ "7" => 3.0 }.freeze
44
+ KEYWORD_SIZES = { "xx-small" => 0.5625, "x-small" => 0.625, "small" => 0.8125, "medium" => 1.0,
45
+ "large" => 1.125, "x-large" => 1.5, "xx-large" => 2.0, "smaller" => 0.8333,
46
+ "larger" => 1.2 }.freeze
47
+ NAMED_COLORS = {
48
+ "black" => "#000000", "white" => "#FFFFFF", "red" => "#FF0000", "green" => "#008000",
49
+ "blue" => "#0000FF", "yellow" => "#FFFF00", "cyan" => "#00FFFF", "aqua" => "#00FFFF",
50
+ "magenta" => "#FF00FF", "fuchsia" => "#FF00FF", "gray" => "#808080", "grey" => "#808080",
51
+ "silver" => "#C0C0C0", "maroon" => "#800000", "olive" => "#808000", "lime" => "#00FF00",
52
+ "teal" => "#008080", "navy" => "#000080", "purple" => "#800080", "orange" => "#FFA500",
53
+ "pink" => "#FFC0CB", "brown" => "#A52A2A", "gold" => "#FFD700", "darkgray" => "#A9A9A9",
54
+ "darkgrey" => "#A9A9A9", "lightgray" => "#D3D3D3", "lightgrey" => "#D3D3D3", "darkred" => "#8B0000",
55
+ "darkgreen" => "#006400", "darkblue" => "#00008B", "crimson" => "#DC143C", "coral" => "#FF7F50",
56
+ "salmon" => "#FA8072", "tomato" => "#FF6347", "indigo" => "#4B0082", "violet" => "#EE82EE",
57
+ "turquoise" => "#40E0D0", "beige" => "#F5F5DC", "ivory" => "#FFFFF0", "khaki" => "#F0E68C",
58
+ "tan" => "#D2B48C"
59
+ }.freeze
60
+ HEX = /\A#(\h{3}|\h{6})\z/
61
+ RGB = /\Argba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)\z/i
62
+ LENGTH = /\A(-?\d*\.?\d+)(px|pt|em|rem|%)?\z/
63
+ ALIGN = %w[left center right justify].freeze
64
+ BREAK_PAGE = %w[always page left right].freeze
65
+ CSS_PX = 0.75
66
+ LEGACY = %w[font center].freeze
67
+ NOTHING = [{}.freeze, {}.freeze].freeze
68
+
69
+ def self.parse(sheets, report = Report.new) = new(CSS::Stylesheet.parse(sheets.join("\n")), report)
70
+
71
+ attr_reader :report
72
+
73
+ def initialize(sheet, report)
74
+ @sheet = sheet
75
+ @report = report
76
+ sheet.unsupported.each { |selector| report.select(selector) }
77
+ end
78
+
79
+ # The declarations that apply to `element`: legacy attributes, then the
80
+ # stylesheet by specificity, then its inline style.
81
+ def declarations(element)
82
+ legacy(element).merge(@sheet.declarations(element), CSS.declarations(element.attributes["style"]))
83
+ end
84
+
85
+ # [marks, block]: the text marks the element's content inherits and the
86
+ # options its block takes.
87
+ def resolve(element)
88
+ return NOTHING if plain?(element)
89
+
90
+ marks = {}
91
+ block = {}
92
+ declarations(element).each do |name, value|
93
+ value = value.strip
94
+ next if value.empty?
95
+
96
+ if INLINE.include?(name) then inline(name, value, marks)
97
+ elsif BLOCK.include?(name) then block(name, value, block)
98
+ else report.property(name)
99
+ end
100
+ end
101
+ [marks, block]
102
+ end
103
+
104
+ private
105
+
106
+ # Nothing to read: no rules, no inline style, no legacy attributes.
107
+ def plain?(element)
108
+ @sheet.empty? && !element.attributes.key?("style") && !LEGACY.include?(element.name)
109
+ end
110
+
111
+ def legacy(element)
112
+ case element.name
113
+ when "font" then font_attributes(element.attributes)
114
+ when "center" then { "text-align" => "center" }
115
+ else {}
116
+ end
117
+ end
118
+
119
+ def font_attributes(attributes)
120
+ legacy = {}
121
+ legacy["color"] = attributes["color"] if attributes["color"]
122
+ legacy["font-size"] = "#{FONT_SIZES.fetch(attributes["size"].to_s.strip, 1.0)}em" if attributes["size"]
123
+ legacy
124
+ end
125
+
126
+ def inline(name, value, marks)
127
+ case name
128
+ when "color" then read(name, value, marks, :color) { color(value) }
129
+ when "font-size" then font_size(value, marks)
130
+ when "font-weight" then read(name, value, marks, :bold) { weight(value) }
131
+ when "font-style" then read(name, value, marks, :italic) { style(value) }
132
+ when "text-decoration" then decoration(value, marks)
133
+ end
134
+ end
135
+
136
+ def block(name, value, block)
137
+ case name
138
+ when "text-align" then read(name, value, block, :align) { align(value) }
139
+ when "background-color" then read(name, value, block, :background) { color(value) }
140
+ when "margin" then read(name, value, block, :margin) { box(value) }
141
+ when "padding" then read(name, value, block, :padding) { box(value) }
142
+ when /\A(margin|padding)-/ then read(name, value, block, name.tr("-", "_").to_sym) { points(value) }
143
+ when "border" then read(name, value, block, :border) { border(value) }
144
+ when "width" then read(name, value, block, :width) { width(value) }
145
+ when "page-break-before", "break-before" then read(name, value, block, :break_before) { page_break(value) }
146
+ when "page-break-after", "break-after" then read(name, value, block, :break_after) { page_break(value) }
147
+ when "page-break-inside", "break-inside" then read(name, value, block, :keep_together) { avoid(value) }
148
+ end
149
+ end
150
+
151
+ # Stores what the block yields for `key`, or reports `name: value` when
152
+ # the value is not one the property reads. `:none` is read and means
153
+ # "nothing" (a transparent background, no page break): it takes back
154
+ # what a less specific rule set.
155
+ def read(name, value, into, key)
156
+ result = yield
157
+ return report.property("#{name}: #{value}") if result.nil?
158
+
159
+ result == :none ? into.delete(key) : into[key] = result
160
+ end
161
+
162
+ def color(value)
163
+ return :none if value.casecmp?("transparent")
164
+ return NAMED_COLORS[value.downcase] if NAMED_COLORS.key?(value.downcase)
165
+ return value.upcase if value.match?(HEX)
166
+
167
+ red, green, blue = value.match(RGB)&.captures&.map { |part| part.to_i.clamp(0, 255) }
168
+ format("#%<red>02X%<green>02X%<blue>02X", red:, green:, blue:) if red
169
+ end
170
+
171
+ def align(value) = ALIGN.include?(value.downcase) ? value.downcase.to_sym : nil
172
+
173
+ def font_size(value, marks)
174
+ scale = KEYWORD_SIZES[value.downcase]
175
+ match = value.match(LENGTH)
176
+ return report.property("font-size: #{value}") unless scale || match
177
+
178
+ marks.delete(:size)
179
+ marks.delete(:scale)
180
+ number = match && match[1].to_f
181
+ case match && match[2]
182
+ when nil then scale ? marks[:scale] = scale : marks[:size] = number * CSS_PX
183
+ when "em", "rem" then marks[:scale] = number
184
+ when "%" then marks[:scale] = number / 100
185
+ when "pt" then marks[:size] = number
186
+ when "px" then marks[:size] = number * CSS_PX
187
+ end
188
+ end
189
+
190
+ def weight(value)
191
+ case value.downcase
192
+ when "bold", "bolder" then true
193
+ when "normal", "lighter" then false
194
+ when /\A\d{3}\z/ then value.to_i >= 600
195
+ end
196
+ end
197
+
198
+ def style(value)
199
+ case value.downcase
200
+ when "italic", "oblique" then true
201
+ when "normal" then false
202
+ end
203
+ end
204
+
205
+ def decoration(value, marks)
206
+ words = value.downcase.split
207
+ unless words.all? { |word| %w[none underline line-through].include?(word) }
208
+ return report.property("text-decoration: #{value}")
209
+ end
210
+
211
+ marks[:underline] = words.include?("underline")
212
+ marks[:strike] = words.include?("line-through")
213
+ end
214
+
215
+ # A length in points: px (at 0.75 pt), pt, or a bare 0.
216
+ def points(value)
217
+ match = value.match(LENGTH)
218
+ return nil unless match
219
+ return 0.0 if match[1].to_f.zero?
220
+
221
+ case match[2]
222
+ when "pt" then match[1].to_f
223
+ when "px", nil then match[1].to_f * CSS_PX
224
+ end
225
+ end
226
+
227
+ # One to four lengths as [top, right, bottom, left].
228
+ def box(value)
229
+ sides = value.split.map { |part| points(part) }
230
+ return nil if sides.empty? || sides.size > 4 || sides.any?(&:nil?)
231
+
232
+ top, right, bottom, left = sides
233
+ [top, right || top, bottom || top, left || right || top]
234
+ end
235
+
236
+ # `1px solid #ccc` in any order; `none` and `0` mean no border.
237
+ def border(value)
238
+ parts = value.split
239
+ return { width: 0 } if [["none"], ["0"]].include?(parts)
240
+
241
+ width = parts.filter_map { |part| points(part) }.first
242
+ colour = parts.filter_map { |part| color(part) }.grep(String).first
243
+ return nil if width.nil? && colour.nil?
244
+
245
+ { width: width || 0.75, color: colour || "#000000" }
246
+ end
247
+
248
+ # Points, or a fraction of the available width for a percentage.
249
+ def width(value)
250
+ return :none if value.casecmp?("auto")
251
+
252
+ match = value.match(LENGTH)
253
+ number = match && match[1].to_f
254
+ return nil unless number&.positive?
255
+
256
+ case match[2]
257
+ when "%" then [number / 100.0, 1.0].min
258
+ when "pt" then number
259
+ when "px", nil then number * CSS_PX
260
+ end
261
+ end
262
+
263
+ def page_break(value)
264
+ return true if BREAK_PAGE.include?(value.downcase)
265
+
266
+ :none if %w[auto avoid].include?(value.downcase)
267
+ end
268
+
269
+ def avoid(value)
270
+ return true if %w[avoid avoid-page].include?(value.downcase)
271
+
272
+ :none if value.casecmp?("auto")
273
+ end
274
+ end
275
+ end
276
+ end
@@ -8,11 +8,13 @@ module Stationery
8
8
  # Lenient HTML to rich-text blocks (see Stationery::Rich).
9
9
  module HTML
10
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)
11
+ # one kept; the block is then given how deep the source went. `css:` is a
12
+ # Css::Report that collects the styles the source asked for and did not
13
+ # get (see HTML::Css).
14
+ def self.parse(source, max_depth: Rich::Nesting::DEFAULT, css: nil)
13
15
  nesting = Rich::Nesting.new(max_depth)
14
16
  Stationery.instrument("parse.stationery", format: :html, bytes: source.bytesize) do
15
- TreeBuilder.parse(Tokenizer.tokenize(source), nesting).tap do
17
+ TreeBuilder.parse(Tokenizer.tokenize(source), nesting, css:).tap do
16
18
  yield nesting.deepest if nesting.exceeded? && block_given?
17
19
  end
18
20
  end
@@ -5,12 +5,14 @@ require_relative "../text/entities"
5
5
 
6
6
  module Stationery
7
7
  module HTML
8
- # Splits HTML into [:start, name, attributes, self_closing], [:end, name] and [:text, string] tokens.
9
- # Names are lowercased, text and attribute values entity-decoded. Comments, declarations and the
10
- # content of script, style, template and title elements are skipped; void elements never end.
8
+ # Splits HTML into [:start, name, attributes, self_closing], [:end, name], [:text, string] and
9
+ # [:style, css] tokens. Names are lowercased, text and attribute values entity-decoded. Comments,
10
+ # declarations and the content of script, template and title elements are skipped; a style
11
+ # element's text is kept raw for the stylesheet; void elements never end.
11
12
  class Tokenizer
12
13
  VOID = %w[area base br col embed hr img input keygen link meta param source track wbr].freeze
13
- SKIPPED = %w[script style template title].freeze
14
+ SKIPPED = %w[script template title].freeze
15
+ RAW_END = %r{</style\s*>}i
14
16
  COMMENT = /<!--.*?(?:-->|\z)/m
15
17
  CDATA = /<!\[CDATA\[(.*?)(?:\]\]>|\z)/m
16
18
  DECLARATION = /<[!?][^>]*>?/
@@ -58,10 +60,19 @@ module Stationery
58
60
  end
59
61
  ending = @scanner.scan(TAG_END) || @scanner.scan_until(/>/) || (@scanner.terminate && "")
60
62
  return skip_raw(name) if SKIPPED.include?(name)
63
+ return style if name == "style"
61
64
 
62
65
  @tokens << [:start, name, attributes, ending.include?("/") || VOID.include?(name)]
63
66
  end
64
67
 
68
+ def style
69
+ start = @scanner.pos
70
+ finish = @scanner.skip_until(RAW_END)
71
+ css = finish ? @scanner.string.byteslice(start, finish - @scanner.matched_size) : @scanner.rest
72
+ @scanner.terminate unless finish
73
+ @tokens << [:style, css.force_encoding(@scanner.string.encoding)]
74
+ end
75
+
65
76
  def finish(name)
66
77
  @tokens << [:end, name] unless VOID.include?(name)
67
78
  end
@@ -7,7 +7,9 @@ module Stationery
7
7
  module HTML
8
8
  class TreeBuilder
9
9
  # Converts an element tree into rich-text blocks. Containers flatten into their children, inline
10
- # elements add marks, and unknown elements keep their content.
10
+ # elements add marks, and unknown elements keep their content. Each element's CSS (see Css) adds
11
+ # marks to the text inside it and a style to the block it becomes; `text-align` is handed down
12
+ # to the blocks inside, the way CSS inherits it.
11
13
  class Blocks
12
14
  CONTAINERS = %w[
13
15
  #root html body div p section article header footer main figure figcaption aside nav address
@@ -23,59 +25,91 @@ module Stationery
23
25
  "sub" => { script: :sub }, "sup" => { script: :sup }
24
26
  }.freeze
25
27
  ALIGN = /\A\s*(left|center|right|justify)\s*\z/i
26
- TEXT_ALIGN = /text-align\s*:\s*(left|center|right|justify)/i
28
+ # What makes a container a block of its own instead of flattening into its parent.
29
+ BOXED = %i[background padding padding_top padding_right padding_bottom padding_left margin margin_top
30
+ margin_bottom break_before break_after keep_together].freeze
31
+ INHERITED = %i[align].freeze
32
+ EMPTY = {}.freeze
27
33
 
28
- def self.convert(root) = new.blocks_of(root, {})
34
+ def self.convert(root, css = Css.parse([])) = new(css).blocks_of(root, EMPTY, EMPTY)
29
35
 
30
- def blocks_of(element, marks)
36
+ def initialize(css = Css.parse([]))
37
+ @css = css
38
+ end
39
+
40
+ def blocks_of(element, marks, inherited, style = EMPTY)
31
41
  collector = Collector.new
32
- children(element, marks, collector)
42
+ collector.boundary(style) { children(element, marks, inherited, collector) }
33
43
  collector.blocks
34
44
  end
35
45
 
36
46
  private
37
47
 
38
- def children(element, marks, out)
39
- element.children.each { |child| visit(child, marks, out) }
48
+ def children(element, marks, inherited, out)
49
+ element.children.each { |child| visit(child, marks, inherited, out) }
40
50
  end
41
51
 
42
- def visit(node, marks, out)
52
+ def visit(node, marks, inherited, out)
43
53
  return out.text(node, marks) if node.is_a?(String)
44
54
 
55
+ own, block = @css.resolve(node)
56
+ marks = inherit(marks, own)
57
+ style = inherited.merge(block)
58
+ inherited = style.slice(*INHERITED)
45
59
  case node.name
46
- when *CONTAINERS then out.boundary { children(node, marks, out) }
47
- when *HEADINGS then out.block(heading(node, marks))
48
- when "ul", "ol" then out.block(list(node, marks))
49
- when "blockquote" then out.block(Rich::Blockquote.new(blocks: blocks_of(node, marks)))
50
- when "pre" then out.block(code(node))
60
+ when *CONTAINERS then container(node, marks, inherited, style, out)
61
+ when *HEADINGS then out.block(heading(node, marks, style))
62
+ when "ul", "ol" then out.block(list(node, marks, inherited, style.except(*INHERITED)))
63
+ when "blockquote" then out.block(quote(node, marks, inherited, style.except(*INHERITED)))
64
+ when "pre" then out.block(code(node, style.except(*INHERITED)))
51
65
  when "hr" then out.block(Rich::Rule.new)
52
66
  when "br" then out.inline(Rich::Inline.break)
53
- when "img" then out.inline(image(node.attributes))
54
- when "table" then out.block(table(node, marks))
55
- else children(node, marks.merge(marks_for(node)), out)
67
+ when "wbr" then out.inline(Rich::Inline.new(text: Text::Breaks::ZERO_WIDTH_SPACE, marks:))
68
+ when "img" then out.inline(image(node.attributes, style))
69
+ when "table" then out.block(table(node, marks, inherited, style.except(*INHERITED)))
70
+ else children(node, marks.merge(marks_for(node)), inherited, out)
56
71
  end
57
72
  end
58
73
 
59
- def heading(element, marks)
60
- inlines = blocks_of(element, marks).grep(Rich::Paragraph).flat_map(&:inlines)
61
- Rich::Heading.new(level: element.name[1].to_i, inlines:) unless inlines.empty?
74
+ # A `p` hands its whole style to its paragraphs. Any other container with a box of its own
75
+ # (a background, padding, margins, a page-break rule) becomes a Container; without one it
76
+ # flattens into its parent, as it always did.
77
+ def container(node, marks, inherited, style, out)
78
+ return out.boundary(style) { children(node, marks, inherited, out) } if node.name == "p"
79
+
80
+ boxed = style.slice(*BOXED)
81
+ return out.boundary(inherited) { children(node, marks, inherited, out) } if boxed.empty?
82
+
83
+ out.block(Rich::Container.new(blocks: blocks_of(node, marks, inherited, inherited), style: boxed))
62
84
  end
63
85
 
64
- def list(element, marks)
86
+ def heading(element, marks, style)
87
+ inlines = blocks_of(element, marks, EMPTY).grep(Rich::Paragraph).flat_map(&:inlines)
88
+ Rich::Heading.new(level: element.name[1].to_i, inlines:, style:) unless inlines.empty?
89
+ end
90
+
91
+ def list(element, marks, inherited, style)
65
92
  items = elements(element).map do |item|
66
- blocks_of(item.name == "li" ? item : Element.new("li", {}, [item]), marks)
93
+ item = Element.new("li", {}, [item]) unless item.name == "li"
94
+ own, block = @css.resolve(item)
95
+ within = inherited.merge(block.slice(*INHERITED))
96
+ blocks_of(item, inherit(marks, own), within, within)
67
97
  end
68
98
  return if items.empty?
69
99
 
70
100
  ordered = element.name == "ol"
71
101
  start = Integer(element.attributes["start"], exception: false) || 1 if ordered
72
- Rich::List.new(ordered:, start:, items:)
102
+ Rich::List.new(ordered:, start:, items:, style:)
103
+ end
104
+
105
+ def quote(element, marks, inherited, style)
106
+ Rich::Blockquote.new(blocks: blocks_of(element, marks, inherited, inherited), style:)
73
107
  end
74
108
 
75
- def code(element)
109
+ def code(element, style)
76
110
  language = elements(element).find { |child| child.name == "code" }&.attributes&.[]("class")
77
111
  Rich::CodeBlock.new(text: raw_text(element).delete_prefix("\n").chomp,
78
- language: language&.[](/lang(?:uage)?-(\S+)/, 1))
112
+ language: language&.[](/lang(?:uage)?-(\S+)/, 1), style:)
79
113
  end
80
114
 
81
115
  def raw_text(element)
@@ -86,21 +120,22 @@ module Stationery
86
120
  end
87
121
  end
88
122
 
89
- def image(attributes)
123
+ def image(attributes, style)
90
124
  return unless attributes["src"]
91
125
 
92
126
  Rich::Image.new(src: attributes["src"], alt: attributes["alt"],
93
- width: dimension(attributes["width"]), height: dimension(attributes["height"]))
127
+ width: dimension(attributes["width"]), height: dimension(attributes["height"]),
128
+ style: style.slice(:width, :align))
94
129
  end
95
130
 
96
131
  def dimension(value) = value&.[](/\A\s*(\d+)/, 1)&.to_i
97
132
 
98
- def table(element, marks)
133
+ def table(element, marks, inherited, style)
99
134
  rows = rows_of(element).filter_map do |row|
100
135
  cells = elements(row).select { |cell| %w[td th].include?(cell.name) }
101
- cells.map { |cell| cell(cell, marks) } unless cells.empty?
136
+ cells.map { |cell| cell(cell, marks, inherited) } unless cells.empty?
102
137
  end
103
- Rich::Table.new(rows:) unless rows.empty?
138
+ Rich::Table.new(rows:, style:) unless rows.empty?
104
139
  end
105
140
 
106
141
  def rows_of(element)
@@ -111,23 +146,38 @@ module Stationery
111
146
  end
112
147
  end
113
148
 
114
- def cell(element, marks)
115
- attributes = element.attributes
116
- align = attributes["align"]&.[](ALIGN, 1) || attributes["style"]&.[](TEXT_ALIGN, 1)
117
- Rich::Cell.new(header: element.name == "th", align: align&.downcase&.to_sym,
118
- blocks: blocks_of(element, marks))
149
+ def cell(element, marks, inherited)
150
+ own, block = @css.resolve(element)
151
+ marks = inherit(marks, own)
152
+ align = block[:align] || aligned(element.attributes["align"]) || inherited[:align]
153
+ Rich::Cell.new(header: element.name == "th", align:, blocks: blocks_of(element, marks, EMPTY),
154
+ style: block.except(:align))
155
+ end
156
+
157
+ # The marks an element's text has: its ancestors' with its own over them. A relative size
158
+ # (em, %) is relative to the size around it, so it multiplies what is inherited.
159
+ def inherit(marks, own)
160
+ return marks if own.empty?
161
+ return marks.merge(own) unless own[:scale]
162
+
163
+ merged = marks.merge(own.except(:scale))
164
+ if marks[:size] then merged[:size] = marks[:size] * own[:scale]
165
+ else merged[:scale] = marks.fetch(:scale, 1) * own[:scale]
166
+ end
167
+ merged
168
+ end
169
+
170
+ # The align attribute of a cell, as a symbol.
171
+ def aligned(value)
172
+ name = value && value[ALIGN, 1]
173
+ name&.downcase&.to_sym
119
174
  end
120
175
 
121
176
  def marks_for(element)
122
177
  return MARKS[element.name] if MARKS.key?(element.name)
123
178
  return { link: element.attributes["href"] } if element.name == "a" && element.attributes["href"]
124
- return {} unless element.name == "span"
125
179
 
126
- style = element.attributes["style"].to_s
127
- marks = {}
128
- marks[:bold] = true if style.match?(/font-weight\s*:\s*(bold|[6-9]00)/i)
129
- marks[:italic] = true if style.match?(/font-style\s*:\s*italic/i)
130
- marks
180
+ EMPTY
131
181
  end
132
182
 
133
183
  def elements(element) = element.children.grep(TreeBuilder::Element)
@@ -7,11 +7,13 @@ module Stationery
7
7
  module HTML
8
8
  class TreeBuilder
9
9
  # Gathers the blocks of one container: loose inline content between blocks becomes a paragraph,
10
- # with its whitespace collapsed the way a browser would.
10
+ # with its whitespace collapsed the way a browser would. The paragraphs of a boundary take the
11
+ # style it was opened with (a `p` with a text-align, a margin, a background).
11
12
  class Collector
12
13
  def initialize
13
14
  @blocks = []
14
15
  @pending = []
16
+ @style = {}
15
17
  end
16
18
 
17
19
  def text(string, marks) = @pending << Rich::Inline.new(text: string, marks:)
@@ -27,10 +29,14 @@ module Stationery
27
29
  @blocks << block
28
30
  end
29
31
 
30
- def boundary
32
+ def boundary(style = {})
31
33
  flush
34
+ outer = @style
35
+ @style = style
32
36
  yield
33
37
  flush
38
+ ensure
39
+ @style = outer
34
40
  end
35
41
 
36
42
  def blocks
@@ -41,7 +47,7 @@ module Stationery
41
47
  private
42
48
 
43
49
  def flush
44
- @blocks.concat(Rich::Inlines.paragraphs(@pending) { |run| Whitespace.collapse(run) })
50
+ @blocks.concat(Rich::Inlines.paragraphs(@pending, style: @style) { |run| Whitespace.collapse(run) })
45
51
  @pending = []
46
52
  end
47
53
  end