stationery 0.9.0 → 0.10.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +143 -34
  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/html/css.rb +276 -0
  13. data/lib/stationery/html/document.rb +5 -3
  14. data/lib/stationery/html/tokenizer.rb +15 -4
  15. data/lib/stationery/html/tree_builder/blocks.rb +90 -40
  16. data/lib/stationery/html/tree_builder/collector.rb +9 -3
  17. data/lib/stationery/html/tree_builder.rb +11 -1
  18. data/lib/stationery/layout/flow.rb +29 -0
  19. data/lib/stationery/layout/image.rb +12 -6
  20. data/lib/stationery/layout/mark.rb +2 -0
  21. data/lib/stationery/layout/node.rb +4 -0
  22. data/lib/stationery/layout/text.rb +47 -10
  23. data/lib/stationery/pdf/assembler.rb +14 -4
  24. data/lib/stationery/pdf/signature/cms.rb +21 -13
  25. data/lib/stationery/pdf/signature/timestamp.rb +120 -0
  26. data/lib/stationery/pdf/signature.rb +16 -9
  27. data/lib/stationery/pdf/writer.rb +73 -19
  28. data/lib/stationery/rich/nodes.rb +37 -11
  29. data/lib/stationery/rich/renderer/boxes.rb +109 -0
  30. data/lib/stationery/rich/renderer/indents.rb +1 -0
  31. data/lib/stationery/rich/renderer/inlines.rb +9 -2
  32. data/lib/stationery/rich/renderer.rb +32 -15
  33. data/lib/stationery/svg/style.rb +4 -6
  34. data/lib/stationery/testing/inspector.rb +49 -6
  35. data/lib/stationery/text/breaks.rb +123 -0
  36. data/lib/stationery/text/paragraph.rb +8 -0
  37. data/lib/stationery/text/runs_builder.rb +4 -0
  38. data/lib/stationery/text/wrapper.rb +57 -8
  39. data/lib/stationery/version.rb +1 -1
  40. data/lib/stationery/warnings.rb +9 -0
  41. data/lib/stationery.rb +3 -2
  42. metadata +8 -3
@@ -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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "css"
3
4
  require_relative "tree_builder/blocks"
4
5
 
5
6
  module Stationery
@@ -27,13 +28,21 @@ module Stationery
27
28
  "thead" => [SECTIONS, %w[table]], "tbody" => [SECTIONS, %w[table]], "tfoot" => [SECTIONS, %w[table]]
28
29
  }.freeze
29
30
 
30
- def self.parse(tokens, nesting = Rich::Nesting.new) = Blocks.convert(new(nesting).build(tokens))
31
+ # `css` is the Css::Report unsupported styles are told to.
32
+ def self.parse(tokens, nesting = Rich::Nesting.new, css: nil)
33
+ builder = new(nesting)
34
+ root = builder.build(tokens)
35
+ Blocks.convert(root, Css.parse(builder.sheets, css || Css::Report.new))
36
+ end
37
+
38
+ attr_reader :sheets
31
39
 
32
40
  def initialize(nesting = Rich::Nesting.new)
33
41
  @root = Element.new("#root", {}, [])
34
42
  @stack = [@root]
35
43
  @nesting = nesting
36
44
  @flattened = [] # the names of the open elements left out of the tree
45
+ @sheets = []
37
46
  end
38
47
 
39
48
  def build(tokens)
@@ -41,6 +50,7 @@ module Stationery
41
50
  case type
42
51
  when :start then start(*args)
43
52
  when :end then finish(*args)
53
+ when :style then @sheets << args.first
44
54
  else @stack.last.children << args.first
45
55
  end
46
56
  end
@@ -21,10 +21,23 @@ module Stationery
21
21
 
22
22
  def <<(child)
23
23
  @children << child
24
+ @breaks = nil
24
25
  forget_measures
25
26
  self
26
27
  end
27
28
 
29
+ # A flow holding a page break (at any depth) is cut there even when it
30
+ # would fit on the page it is on.
31
+ def breaks?
32
+ @breaks = @children.any? { |child| child.page_break? || child.breaks? } if @breaks.nil?
33
+ @breaks
34
+ end
35
+
36
+ def leading_break?
37
+ first = @children.first
38
+ !first.nil? && (first.page_break? || first.leading_break?)
39
+ end
40
+
28
41
  def splittable? = true
29
42
  def natural_width = @children.map(&:natural_width).max || 0
30
43
  def min_width = @children.map(&:min_width).max || 0
@@ -93,6 +106,8 @@ module Stationery
93
106
 
94
107
  gap = @placed.empty? ? 0 : @flow.gap
95
108
  remaining = @height - @used - gap
109
+ return broken(child, gap, remaining, rest) if child.breaks?
110
+
96
111
  height = child.measure(child.width_in(@width))
97
112
  return fits(child, height + gap, remaining - height, rest) if height <= remaining + EPSILON
98
113
  return [part(@placed), part(rest)] if child.is_a?(Spacer)
@@ -106,6 +121,20 @@ module Stationery
106
121
  [part(@placed), part(rest)]
107
122
  end
108
123
 
124
+ # A child holding a page break: what comes before the break stays on
125
+ # this page, the rest goes to the next, however much room is left. One
126
+ # that starts with the break goes to the next page whole.
127
+ def broken(child, gap, remaining, rest)
128
+ return [part(@placed), part([child, *rest])] if child.leading_break? && !@placed.empty?
129
+
130
+ head, tail = child.split(child.width_in(@width), remaining, fresh: @fresh && @placed.empty?)
131
+ return split_or_move(child, remaining, rest) unless head
132
+ return [part(@placed + [head]), part([tail, *rest])] if tail
133
+
134
+ height = head.measure(head.width_in(@width))
135
+ fits(head, height + gap, remaining - height, rest)
136
+ end
137
+
109
138
  def fits(child, consumed, left_after, rest)
110
139
  return [part(@placed), part([child, *rest])] if strand?(child, left_after, rest)
111
140