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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +143 -34
- data/lib/stationery/{svg → css}/selector.rb +1 -1
- data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
- data/lib/stationery/css.rb +20 -0
- data/lib/stationery/document.rb +21 -9
- data/lib/stationery/elements/rich.rb +10 -2
- data/lib/stationery/elements.rb +9 -4
- data/lib/stationery/errors.rb +4 -0
- data/lib/stationery/fonts/fallback.rb +1 -1
- data/lib/stationery/html/css.rb +276 -0
- data/lib/stationery/html/document.rb +5 -3
- data/lib/stationery/html/tokenizer.rb +15 -4
- data/lib/stationery/html/tree_builder/blocks.rb +90 -40
- data/lib/stationery/html/tree_builder/collector.rb +9 -3
- data/lib/stationery/html/tree_builder.rb +11 -1
- data/lib/stationery/layout/flow.rb +29 -0
- data/lib/stationery/layout/image.rb +12 -6
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +4 -0
- data/lib/stationery/layout/text.rb +47 -10
- data/lib/stationery/pdf/assembler.rb +14 -4
- data/lib/stationery/pdf/signature/cms.rb +21 -13
- data/lib/stationery/pdf/signature/timestamp.rb +120 -0
- data/lib/stationery/pdf/signature.rb +16 -9
- data/lib/stationery/pdf/writer.rb +73 -19
- data/lib/stationery/rich/nodes.rb +37 -11
- data/lib/stationery/rich/renderer/boxes.rb +109 -0
- data/lib/stationery/rich/renderer/indents.rb +1 -0
- data/lib/stationery/rich/renderer/inlines.rb +9 -2
- data/lib/stationery/rich/renderer.rb +32 -15
- data/lib/stationery/svg/style.rb +4 -6
- data/lib/stationery/testing/inspector.rb +49 -6
- data/lib/stationery/text/breaks.rb +123 -0
- data/lib/stationery/text/paragraph.rb +8 -0
- data/lib/stationery/text/runs_builder.rb +4 -0
- data/lib/stationery/text/wrapper.rb +57 -8
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +9 -0
- data/lib/stationery.rb +3 -2
- 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
|
-
|
|
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]
|
|
9
|
-
# Names are lowercased, text and attribute values entity-decoded. Comments,
|
|
10
|
-
# content of script,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 "
|
|
54
|
-
when "
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|