stationery 0.10.0 → 0.11.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 +18 -0
- data/README.md +247 -30
- data/lib/stationery/builder.rb +9 -0
- data/lib/stationery/canvas/text.rb +8 -2
- data/lib/stationery/document.rb +56 -14
- data/lib/stationery/elements.rb +31 -3
- data/lib/stationery/errors.rb +5 -1
- data/lib/stationery/fonts/font.rb +54 -4
- data/lib/stationery/fonts/font_book.rb +14 -5
- data/lib/stationery/fonts/glyph_run.rb +35 -5
- data/lib/stationery/fonts/shaped_run.rb +160 -0
- data/lib/stationery/fonts/shaping.rb +63 -0
- data/lib/stationery/forms/typeface.rb +10 -3
- data/lib/stationery/html/css.rb +38 -3
- data/lib/stationery/html/tree_builder/blocks.rb +9 -3
- data/lib/stationery/images/images.rb +6 -6
- data/lib/stationery/images/resampled.rb +3 -2
- data/lib/stationery/images/webp/bit_reader.rb +78 -0
- data/lib/stationery/images/webp/container.rb +62 -0
- data/lib/stationery/images/webp/image_stream.rb +168 -0
- data/lib/stationery/images/webp/lossless.rb +101 -0
- data/lib/stationery/images/webp/predictor.rb +118 -0
- data/lib/stationery/images/webp/prefix_code.rb +168 -0
- data/lib/stationery/images/webp/transforms.rb +93 -0
- data/lib/stationery/images/webp.rb +71 -0
- data/lib/stationery/layout/columns/balancer.rb +66 -0
- data/lib/stationery/layout/columns/pour.rb +47 -0
- data/lib/stationery/layout/columns.rb +191 -0
- data/lib/stationery/layout/floated.rb +50 -0
- data/lib/stationery/layout/flow/floating.rb +51 -0
- data/lib/stationery/layout/flow/placement.rb +147 -0
- data/lib/stationery/layout/flow/splitter.rb +194 -0
- data/lib/stationery/layout/flow.rb +20 -117
- data/lib/stationery/layout/image.rb +1 -1
- data/lib/stationery/layout/mark.rb +3 -1
- data/lib/stationery/layout/node.rb +14 -0
- data/lib/stationery/layout/paginator.rb +7 -2
- data/lib/stationery/layout/table.rb +52 -6
- data/lib/stationery/layout/text.rb +31 -12
- data/lib/stationery/page.rb +26 -1
- data/lib/stationery/page_templates.rb +1 -1
- data/lib/stationery/pdf/assembler.rb +20 -4
- data/lib/stationery/pdf/conformance.rb +11 -1
- data/lib/stationery/pdf/page_sealer.rb +35 -0
- data/lib/stationery/rich/nodes.rb +3 -2
- data/lib/stationery/rich/renderer/boxes.rb +25 -3
- data/lib/stationery/rich/renderer.rb +5 -3
- data/lib/stationery/rich/styles.rb +1 -1
- data/lib/stationery/shaper.rb +109 -0
- data/lib/stationery/testing/marked_text.rb +34 -4
- data/lib/stationery/text/exclusions.rb +32 -0
- data/lib/stationery/text/line.rb +7 -2
- data/lib/stationery/text/paragraph.rb +69 -10
- data/lib/stationery/text/wrapper/remainder.rb +59 -0
- data/lib/stationery/text/wrapper.rb +54 -10
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery.rb +10 -0
- metadata +22 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# One font's side of the shaper hook (see Shaper): asks the document's
|
|
6
|
+
# shaper to place a text, checks the answer and remembers it per size,
|
|
7
|
+
# features and text, so measuring a stretch and then drawing it asks once.
|
|
8
|
+
# Like the font's own memos, what is remembered starts over after
|
|
9
|
+
# Font::MEMO_BYTES of text, so a long document does not keep every line.
|
|
10
|
+
class Shaping
|
|
11
|
+
def initialize(font, shaper, path: nil, language: nil)
|
|
12
|
+
@font = font
|
|
13
|
+
@shaper = shaper
|
|
14
|
+
@language = language
|
|
15
|
+
@path = path
|
|
16
|
+
@features = {}
|
|
17
|
+
@runs = {}
|
|
18
|
+
@memoised = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The ShapedRun of `text`, or nil when the shaper declines it.
|
|
22
|
+
def run(text, size, kerning:, ligatures:, features:)
|
|
23
|
+
features = switches(kerning ? true : false, ligatures ? true : false, features)
|
|
24
|
+
runs = ((@runs[size] ||= {}.compare_by_identity)[features] ||= {})
|
|
25
|
+
runs.fetch(text) do
|
|
26
|
+
forget if (@memoised += text.bytesize) > Font::MEMO_BYTES
|
|
27
|
+
runs[text] = shape(text, size, features)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# What the shaper is told about the font.
|
|
32
|
+
def face
|
|
33
|
+
@face ||= begin
|
|
34
|
+
ttf = @font.ttf
|
|
35
|
+
path, index = Registry::FACE.match(@path.to_s)&.captures || [@path, 0]
|
|
36
|
+
Shaper::Face.new(path: path && File.expand_path(path.to_s), index: index.to_i, data: ttf.data,
|
|
37
|
+
units_per_em: ttf.units_per_em, postscript_name: ttf.postscript_name,
|
|
38
|
+
glyph_count: ttf.num_glyphs)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def forget
|
|
45
|
+
@runs.each_value { |sizes| sizes.each_value(&:clear) }
|
|
46
|
+
@memoised = 0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# { tag => on }, one frozen Hash per set of switches so the runs are
|
|
50
|
+
# remembered by its identity.
|
|
51
|
+
def switches(kerning, ligatures, features)
|
|
52
|
+
tags = Text::Style.features(features)
|
|
53
|
+
@features[[kerning, ligatures, tags]] ||=
|
|
54
|
+
{ "kern" => kerning, "liga" => ligatures }.merge(tags.to_h { |tag| [tag, true] }).freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def shape(text, size, features)
|
|
58
|
+
answer = @shaper.call(text, face, size:, features:, language: @language)
|
|
59
|
+
answer && ShapedRun.build(@font, size, text, Shaper.glyphs(answer, text, face))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -30,7 +30,12 @@ module Stationery
|
|
|
30
30
|
def embedded? = true
|
|
31
31
|
def ascent(size) = primary.ascender(size)
|
|
32
32
|
def descent(size) = primary.descender(size)
|
|
33
|
-
|
|
33
|
+
|
|
34
|
+
# A field is not shaped (see Shaper): a viewer that redraws it after an
|
|
35
|
+
# edit would not shape it either.
|
|
36
|
+
def width(text, size)
|
|
37
|
+
runs(text, size).sum { |font, run| font.width_of(run.text, size, ligatures: false, shape: false) }
|
|
38
|
+
end
|
|
34
39
|
|
|
35
40
|
# The resource names of the fonts drawn or kept so far.
|
|
36
41
|
def names = @names.values
|
|
@@ -40,7 +45,8 @@ module Stationery
|
|
|
40
45
|
|
|
41
46
|
# The operators showing `text` at the current text position: a font
|
|
42
47
|
# selection and a string per run of one font. Characters no font has
|
|
43
|
-
# draw as .notdef
|
|
48
|
+
# draw as .notdef inside a Span with their ActualText (see
|
|
49
|
+
# Fonts::GlyphRun) and are reported, as in any other text.
|
|
44
50
|
def show(text, size)
|
|
45
51
|
runs(text, size).flat_map do |font, run|
|
|
46
52
|
report(font, run)
|
|
@@ -86,7 +92,8 @@ module Stationery
|
|
|
86
92
|
end
|
|
87
93
|
|
|
88
94
|
# The standard Helvetica, not embedded: text is Windows-1252 bytes and
|
|
89
|
-
# characters outside it become "?".
|
|
95
|
+
# characters outside it become "?". That is a glyph of its own, not
|
|
96
|
+
# .notdef, so it is shown without ActualText; the field's /V has the value.
|
|
90
97
|
class Standard
|
|
91
98
|
NAME = :Helv
|
|
92
99
|
FONT = { Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding }.freeze
|
data/lib/stationery/html/css.rb
CHANGED
|
@@ -6,7 +6,8 @@ module Stationery
|
|
|
6
6
|
module HTML
|
|
7
7
|
# The CSS the html element reads: `<style>` rules (element, class and id
|
|
8
8
|
# selectors) and inline `style` attributes, for a small set of properties
|
|
9
|
-
# that map onto the element DSL
|
|
9
|
+
# that map onto the element DSL (`float` on an `img` only, which the
|
|
10
|
+
# `align` attribute also sets). Everything else is ignored and reported
|
|
10
11
|
# once per document through the Report. Values are never fetched or
|
|
11
12
|
# evaluated: `url()` and `@import` are dropped like any unknown value.
|
|
12
13
|
class Css
|
|
@@ -38,7 +39,7 @@ module Stationery
|
|
|
38
39
|
# Properties read on the block an element becomes.
|
|
39
40
|
BLOCK = %w[text-align background-color margin margin-top margin-bottom padding padding-top padding-right
|
|
40
41
|
padding-bottom padding-left border width page-break-before page-break-after break-before
|
|
41
|
-
break-after page-break-inside break-inside].freeze
|
|
42
|
+
break-after page-break-inside break-inside column-count column-gap columns].freeze
|
|
42
43
|
FONT_SIZES = { "1" => 0.625, "2" => 0.8125, "3" => 1.0, "4" => 1.125, "5" => 1.5, "6" => 2.0,
|
|
43
44
|
"7" => 3.0 }.freeze
|
|
44
45
|
KEYWORD_SIZES = { "xx-small" => 0.5625, "x-small" => 0.625, "small" => 0.8125, "medium" => 1.0,
|
|
@@ -61,6 +62,7 @@ module Stationery
|
|
|
61
62
|
RGB = /\Argba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)\z/i
|
|
62
63
|
LENGTH = /\A(-?\d*\.?\d+)(px|pt|em|rem|%)?\z/
|
|
63
64
|
ALIGN = %w[left center right justify].freeze
|
|
65
|
+
FLOAT = %w[left right].freeze
|
|
64
66
|
BREAK_PAGE = %w[always page left right].freeze
|
|
65
67
|
CSS_PX = 0.75
|
|
66
68
|
LEGACY = %w[font center].freeze
|
|
@@ -95,6 +97,7 @@ module Stationery
|
|
|
95
97
|
|
|
96
98
|
if INLINE.include?(name) then inline(name, value, marks)
|
|
97
99
|
elsif BLOCK.include?(name) then block(name, value, block)
|
|
100
|
+
elsif name == "float" && element.name == "img" then read(name, value, block, :float) { float(value) }
|
|
98
101
|
else report.property(name)
|
|
99
102
|
end
|
|
100
103
|
end
|
|
@@ -105,17 +108,26 @@ module Stationery
|
|
|
105
108
|
|
|
106
109
|
# Nothing to read: no rules, no inline style, no legacy attributes.
|
|
107
110
|
def plain?(element)
|
|
108
|
-
@sheet.empty? && !element.attributes.key?("style") && !LEGACY.include?(element.name)
|
|
111
|
+
@sheet.empty? && !element.attributes.key?("style") && !LEGACY.include?(element.name) &&
|
|
112
|
+
!(element.name == "img" && element.attributes.key?("align"))
|
|
109
113
|
end
|
|
110
114
|
|
|
111
115
|
def legacy(element)
|
|
112
116
|
case element.name
|
|
113
117
|
when "font" then font_attributes(element.attributes)
|
|
114
118
|
when "center" then { "text-align" => "center" }
|
|
119
|
+
when "img" then image_attributes(element.attributes)
|
|
115
120
|
else {}
|
|
116
121
|
end
|
|
117
122
|
end
|
|
118
123
|
|
|
124
|
+
# `align="left"` and `align="right"` float an image; the vertical
|
|
125
|
+
# alignments are not read.
|
|
126
|
+
def image_attributes(attributes)
|
|
127
|
+
side = attributes["align"].to_s.strip.downcase
|
|
128
|
+
FLOAT.include?(side) ? { "float" => side } : {}
|
|
129
|
+
end
|
|
130
|
+
|
|
119
131
|
def font_attributes(attributes)
|
|
120
132
|
legacy = {}
|
|
121
133
|
legacy["color"] = attributes["color"] if attributes["color"]
|
|
@@ -145,6 +157,8 @@ module Stationery
|
|
|
145
157
|
when "page-break-before", "break-before" then read(name, value, block, :break_before) { page_break(value) }
|
|
146
158
|
when "page-break-after", "break-after" then read(name, value, block, :break_after) { page_break(value) }
|
|
147
159
|
when "page-break-inside", "break-inside" then read(name, value, block, :keep_together) { avoid(value) }
|
|
160
|
+
when "column-count", "columns" then read(name, value, block, :columns) { column_count(value) }
|
|
161
|
+
when "column-gap" then read(name, value, block, :column_gap) { column_gap(value) }
|
|
148
162
|
end
|
|
149
163
|
end
|
|
150
164
|
|
|
@@ -170,6 +184,12 @@ module Stationery
|
|
|
170
184
|
|
|
171
185
|
def align(value) = ALIGN.include?(value.downcase) ? value.downcase.to_sym : nil
|
|
172
186
|
|
|
187
|
+
def float(value)
|
|
188
|
+
return :none if value.casecmp?("none")
|
|
189
|
+
|
|
190
|
+
FLOAT.include?(value.downcase) ? value.downcase.to_sym : nil
|
|
191
|
+
end
|
|
192
|
+
|
|
173
193
|
def font_size(value, marks)
|
|
174
194
|
scale = KEYWORD_SIZES[value.downcase]
|
|
175
195
|
match = value.match(LENGTH)
|
|
@@ -271,6 +291,21 @@ module Stationery
|
|
|
271
291
|
|
|
272
292
|
:none if value.casecmp?("auto")
|
|
273
293
|
end
|
|
294
|
+
|
|
295
|
+
# A number of columns; a column width (`columns: 12em`) is not read.
|
|
296
|
+
def column_count(value)
|
|
297
|
+
return :none if value.casecmp?("auto")
|
|
298
|
+
|
|
299
|
+
count = Integer(value, 10, exception: false)
|
|
300
|
+
count if count&.positive?
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def column_gap(value)
|
|
304
|
+
return :none if value.casecmp?("normal")
|
|
305
|
+
|
|
306
|
+
gap = points(value)
|
|
307
|
+
gap unless gap&.negative?
|
|
308
|
+
end
|
|
274
309
|
end
|
|
275
310
|
end
|
|
276
311
|
end
|
|
@@ -27,8 +27,13 @@ module Stationery
|
|
|
27
27
|
ALIGN = /\A\s*(left|center|right|justify)\s*\z/i
|
|
28
28
|
# What makes a container a block of its own instead of flattening into its parent.
|
|
29
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
|
|
30
|
+
margin_bottom break_before break_after keep_together columns].freeze
|
|
31
|
+
# What a block of its own takes besides.
|
|
32
|
+
WITH_BOX = %i[column_gap].freeze
|
|
31
33
|
INHERITED = %i[align].freeze
|
|
34
|
+
# What an image takes from its style, and what a floated one takes too.
|
|
35
|
+
IMAGE = %i[width align].freeze
|
|
36
|
+
FLOATED = %i[width float margin margin_top margin_bottom].freeze
|
|
32
37
|
EMPTY = {}.freeze
|
|
33
38
|
|
|
34
39
|
def self.convert(root, css = Css.parse([])) = new(css).blocks_of(root, EMPTY, EMPTY)
|
|
@@ -72,7 +77,7 @@ module Stationery
|
|
|
72
77
|
end
|
|
73
78
|
|
|
74
79
|
# 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
|
|
80
|
+
# (a background, padding, margins, a page-break rule, columns) becomes a Container; without one it
|
|
76
81
|
# flattens into its parent, as it always did.
|
|
77
82
|
def container(node, marks, inherited, style, out)
|
|
78
83
|
return out.boundary(style) { children(node, marks, inherited, out) } if node.name == "p"
|
|
@@ -80,6 +85,7 @@ module Stationery
|
|
|
80
85
|
boxed = style.slice(*BOXED)
|
|
81
86
|
return out.boundary(inherited) { children(node, marks, inherited, out) } if boxed.empty?
|
|
82
87
|
|
|
88
|
+
boxed = style.slice(*BOXED, *WITH_BOX)
|
|
83
89
|
out.block(Rich::Container.new(blocks: blocks_of(node, marks, inherited, inherited), style: boxed))
|
|
84
90
|
end
|
|
85
91
|
|
|
@@ -125,7 +131,7 @@ module Stationery
|
|
|
125
131
|
|
|
126
132
|
Rich::Image.new(src: attributes["src"], alt: attributes["alt"],
|
|
127
133
|
width: dimension(attributes["width"]), height: dimension(attributes["height"]),
|
|
128
|
-
style: style.slice(:
|
|
134
|
+
style: style.slice(*(style[:float] ? FLOATED : IMAGE)))
|
|
129
135
|
end
|
|
130
136
|
|
|
131
137
|
def dimension(value) = value&.[](/\A\s*(\d+)/, 1)&.to_i
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
|
-
# JPEG and
|
|
4
|
+
# JPEG, PNG and lossless WebP images, embedded as image XObjects.
|
|
5
5
|
module Images
|
|
6
6
|
PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b
|
|
7
7
|
JPEG_SIGNATURE = "\xFF\xD8".b
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}.freeze
|
|
8
|
+
WEBP_SIGNATURE = /\ARIFF.{4}WEBP/mn
|
|
9
|
+
UNSUPPORTED = { "GIF8" => "GIF", "II*\0" => "TIFF", "MM\0*" => "TIFF", "BM" => "BMP" }.freeze
|
|
11
10
|
|
|
12
11
|
module_function
|
|
13
12
|
|
|
@@ -17,7 +16,7 @@ module Stationery
|
|
|
17
16
|
Cache.fetch(data) do
|
|
18
17
|
Stationery.instrument("image.stationery", bytes: data.bytesize) do |event|
|
|
19
18
|
parse(data).tap do |image|
|
|
20
|
-
event[:format] = image.
|
|
19
|
+
event[:format] = image.class.name.split("::").last
|
|
21
20
|
event[:width] = image.width
|
|
22
21
|
event[:height] = image.height
|
|
23
22
|
end
|
|
@@ -39,11 +38,12 @@ module Stationery
|
|
|
39
38
|
def parse(data)
|
|
40
39
|
return JPEG.new(data) if data.start_with?(JPEG_SIGNATURE)
|
|
41
40
|
return PNG.new(data) if data.start_with?(PNG_SIGNATURE)
|
|
41
|
+
return WebP.new(data) if data.match?(WEBP_SIGNATURE)
|
|
42
42
|
|
|
43
43
|
format = UNSUPPORTED.find { |magic, _| data.start_with?(magic.b) }&.last
|
|
44
44
|
raise UnsupportedImage, "#{format} images are not supported, convert to PNG or JPEG" if format
|
|
45
45
|
|
|
46
|
-
raise UnsupportedImage, "only PNG and
|
|
46
|
+
raise UnsupportedImage, "only JPEG, PNG and lossless WebP images are supported"
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def xobject(width, height, color_space, bits)
|
|
@@ -7,7 +7,8 @@ module Stationery
|
|
|
7
7
|
# A decoded image scaled down to a pixel width with a box filter (every
|
|
8
8
|
# source pixel of a block weighs the same), embedded as 8-bit grey or RGB
|
|
9
9
|
# with the alpha, when there is one, in a soft mask. Built by
|
|
10
|
-
# PNG#resample; JPEG data is never decoded, so it
|
|
10
|
+
# PNG#resample and WebP#resample; JPEG data is never decoded, so it
|
|
11
|
+
# never comes here.
|
|
11
12
|
class Resampled
|
|
12
13
|
attr_reader :width, :height
|
|
13
14
|
|
|
@@ -73,7 +74,7 @@ module Stationery
|
|
|
73
74
|
end
|
|
74
75
|
end
|
|
75
76
|
|
|
76
|
-
# Decoded PNG samples: `color` and `alpha` are Arrays of rows, each row an
|
|
77
|
+
# Decoded PNG or WebP samples: `color` and `alpha` are Arrays of rows, each row an
|
|
77
78
|
# Array of 8-bit sample values (`channels` per pixel for colour, one for alpha).
|
|
78
79
|
Pixels = Data.define(:width, :height, :channels, :color, :alpha)
|
|
79
80
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Images
|
|
5
|
+
class WebP
|
|
6
|
+
# Reads a VP8L bitstream least significant bit first. Bits are kept in
|
|
7
|
+
# one Integer window refilled four bytes at a time; past the end of the
|
|
8
|
+
# data the window fills with zeros and `overrun?` turns true, so the
|
|
9
|
+
# decoder's loops stay free of bounds checks and end on their own.
|
|
10
|
+
class BitReader
|
|
11
|
+
# Most bits one `read` may ask for; the window then stays a Fixnum.
|
|
12
|
+
MAX_BITS = 24
|
|
13
|
+
|
|
14
|
+
def initialize(data, pos = 0, limit = data.bytesize)
|
|
15
|
+
@data = data
|
|
16
|
+
@pos = pos
|
|
17
|
+
@limit = limit
|
|
18
|
+
@window = 0
|
|
19
|
+
@bits = 0
|
|
20
|
+
@missing = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def read(count)
|
|
24
|
+
fill if @bits < count
|
|
25
|
+
value = @window & ((1 << count) - 1)
|
|
26
|
+
@window >>= count
|
|
27
|
+
@bits -= count
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The next symbol of a prefix code, from its lookup table (see
|
|
32
|
+
# PrefixCode): an entry holds the code's length above bit 16, and one
|
|
33
|
+
# at LINK or above points into a second-level table for codes longer
|
|
34
|
+
# than ROOT_BITS.
|
|
35
|
+
def symbol(table)
|
|
36
|
+
fill if @bits < MAX_BITS
|
|
37
|
+
entry = table[@window & PrefixCode::ROOT_MASK]
|
|
38
|
+
if entry >= PrefixCode::LINK
|
|
39
|
+
@window >>= PrefixCode::ROOT_BITS
|
|
40
|
+
@bits -= PrefixCode::ROOT_BITS
|
|
41
|
+
entry = table[(entry & 0xFFFF) + (@window & ((1 << ((entry >> 16) & 0xF)) - 1))]
|
|
42
|
+
end
|
|
43
|
+
length = entry >> 16
|
|
44
|
+
@window >>= length
|
|
45
|
+
@bits -= length
|
|
46
|
+
entry & 0xFFFF
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# True once more bits were consumed than the data holds.
|
|
50
|
+
def overrun? = @bits < @missing
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def fill
|
|
55
|
+
if @pos + 4 <= @limit
|
|
56
|
+
@window |= @data.unpack1("V", offset: @pos) << @bits
|
|
57
|
+
@pos += 4
|
|
58
|
+
@bits += 32
|
|
59
|
+
else
|
|
60
|
+
fill_tail
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def fill_tail
|
|
65
|
+
while @bits < MAX_BITS
|
|
66
|
+
if @pos < @limit
|
|
67
|
+
@window |= @data.getbyte(@pos) << @bits
|
|
68
|
+
@pos += 1
|
|
69
|
+
else
|
|
70
|
+
@missing += 8
|
|
71
|
+
end
|
|
72
|
+
@bits += 8
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Images
|
|
5
|
+
class WebP
|
|
6
|
+
# The RIFF container: finds the lossless bitstream, either the file's
|
|
7
|
+
# only chunk or the image of an extended (VP8X) file, whose colour
|
|
8
|
+
# profile and metadata chunks are skipped. Lossy and animated files
|
|
9
|
+
# are refused by name.
|
|
10
|
+
class Container
|
|
11
|
+
HEADER_BYTES = 12
|
|
12
|
+
ANIMATED = 0x02
|
|
13
|
+
LOSSY = "lossy WebP images are not supported, convert to JPEG, PNG or lossless WebP"
|
|
14
|
+
ANIMATION = "animated WebP images are not supported, convert to JPEG, PNG or lossless WebP"
|
|
15
|
+
|
|
16
|
+
# The canvas size of an extended file, nil for a bare bitstream.
|
|
17
|
+
attr_reader :canvas
|
|
18
|
+
|
|
19
|
+
def initialize(data)
|
|
20
|
+
@data = data
|
|
21
|
+
@limit = data.unpack1("V", offset: 4) + 8 if data.bytesize >= HEADER_BYTES
|
|
22
|
+
raise UnsupportedImage, "truncated WebP image" if @limit.nil? || @limit > data.bytesize
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The bytes of the VP8L chunk.
|
|
26
|
+
def bitstream
|
|
27
|
+
pos = HEADER_BYTES
|
|
28
|
+
while pos < @limit
|
|
29
|
+
type, payload = chunk(pos)
|
|
30
|
+
case type
|
|
31
|
+
when "VP8L" then return payload
|
|
32
|
+
when "VP8X" then read_extension(payload)
|
|
33
|
+
when "VP8 ", "ALPH" then raise UnsupportedImage, LOSSY
|
|
34
|
+
when "ANIM", "ANMF" then raise UnsupportedImage, ANIMATION
|
|
35
|
+
end
|
|
36
|
+
pos += 8 + payload.bytesize + (payload.bytesize & 1)
|
|
37
|
+
end
|
|
38
|
+
raise UnsupportedImage, "invalid WebP image: no image data"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def chunk(pos)
|
|
44
|
+
raise UnsupportedImage, "truncated WebP image" if pos + 8 > @limit
|
|
45
|
+
|
|
46
|
+
type, size = @data.unpack("a4V", offset: pos)
|
|
47
|
+
raise UnsupportedImage, "truncated WebP image" if pos + 8 + size > @limit
|
|
48
|
+
|
|
49
|
+
[type, @data.byteslice(pos + 8, size)]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def read_extension(payload)
|
|
53
|
+
raise UnsupportedImage, "invalid WebP image: extended header" if payload.bytesize < 10
|
|
54
|
+
raise UnsupportedImage, ANIMATION if payload.getbyte(0).anybits?(ANIMATED)
|
|
55
|
+
|
|
56
|
+
# Width and height less one, 24 bits each.
|
|
57
|
+
@canvas = [4, 7].map { |at| "#{payload.byteslice(at, 3)}\0".unpack1("V") + 1 }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Images
|
|
5
|
+
class WebP
|
|
6
|
+
# One entropy-coded image of a VP8L bitstream, decoded to an Array of
|
|
7
|
+
# ARGB Integers: the pixels themselves (with the entropy image choosing
|
|
8
|
+
# a group of prefix codes per block) and the smaller images the
|
|
9
|
+
# transforms and the entropy image are stored as.
|
|
10
|
+
class ImageStream
|
|
11
|
+
LITERALS = 256
|
|
12
|
+
LENGTH_CODES = 24
|
|
13
|
+
DISTANCE_CODES = 40
|
|
14
|
+
MAX_CACHE_BITS = 11
|
|
15
|
+
HASH = 0x1E35A7BD
|
|
16
|
+
# Distance codes 1..120 name a pixel near the current one: the
|
|
17
|
+
# vertical offset in the high nibble, 8 minus the horizontal one in
|
|
18
|
+
# the low nibble.
|
|
19
|
+
NEIGHBOURS = [
|
|
20
|
+
0x18, 0x07, 0x17, 0x19, 0x28, 0x06, 0x27, 0x29, 0x16, 0x1a, 0x26, 0x2a, 0x38, 0x05, 0x37, 0x39, 0x15, 0x1b,
|
|
21
|
+
0x36, 0x3a, 0x25, 0x2b, 0x48, 0x04, 0x47, 0x49, 0x14, 0x1c, 0x35, 0x3b, 0x46, 0x4a, 0x24, 0x2c, 0x58, 0x45,
|
|
22
|
+
0x4b, 0x34, 0x3c, 0x03, 0x57, 0x59, 0x13, 0x1d, 0x56, 0x5a, 0x23, 0x2d, 0x44, 0x4c, 0x55, 0x5b, 0x33, 0x3d,
|
|
23
|
+
0x68, 0x02, 0x67, 0x69, 0x12, 0x1e, 0x66, 0x6a, 0x22, 0x2e, 0x54, 0x5c, 0x43, 0x4d, 0x65, 0x6b, 0x32, 0x3e,
|
|
24
|
+
0x78, 0x01, 0x77, 0x79, 0x53, 0x5d, 0x11, 0x1f, 0x64, 0x6c, 0x42, 0x4e, 0x76, 0x7a, 0x21, 0x2f, 0x75, 0x7b,
|
|
25
|
+
0x31, 0x3f, 0x63, 0x6d, 0x52, 0x5e, 0x00, 0x74, 0x7c, 0x41, 0x4f, 0x10, 0x20, 0x62, 0x6e, 0x30, 0x73, 0x7d,
|
|
26
|
+
0x51, 0x5f, 0x40, 0x72, 0x7e, 0x61, 0x6f, 0x50, 0x71, 0x7f, 0x60, 0x70
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
def self.decode(reader, width, height, top: false) = new(reader, width, height).decode(top:)
|
|
30
|
+
|
|
31
|
+
# The side of an image stored at one sample per 2**bits pixels.
|
|
32
|
+
def self.subsample(size, bits) = (size + (1 << bits) - 1) >> bits
|
|
33
|
+
|
|
34
|
+
def initialize(reader, width, height)
|
|
35
|
+
@reader = reader
|
|
36
|
+
@width = width
|
|
37
|
+
@height = height
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Only the top-level image may carry an entropy image.
|
|
41
|
+
def decode(top:)
|
|
42
|
+
read_cache
|
|
43
|
+
read_entropy_image if top && @reader.read(1) == 1
|
|
44
|
+
read_groups
|
|
45
|
+
pixels
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def read_cache
|
|
51
|
+
return if @reader.read(1).zero?
|
|
52
|
+
|
|
53
|
+
bits = @reader.read(4)
|
|
54
|
+
raise UnsupportedImage, "invalid WebP image: colour cache size" unless (1..MAX_CACHE_BITS).cover?(bits)
|
|
55
|
+
|
|
56
|
+
@cache = Array.new(1 << bits, 0)
|
|
57
|
+
@shift = 32 - bits
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read_entropy_image
|
|
61
|
+
@block_bits = @reader.read(3) + 2
|
|
62
|
+
@block_mask = (1 << @block_bits) - 1
|
|
63
|
+
@blocks_wide = self.class.subsample(@width, @block_bits)
|
|
64
|
+
image = self.class.decode(@reader, @blocks_wide, self.class.subsample(@height, @block_bits))
|
|
65
|
+
@entropy = image.map { |argb| (argb >> 8) & 0xFFFF }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Five prefix codes per group: green (with lengths and cache
|
|
69
|
+
# indexes), red, blue, alpha and distance. Groups no block refers to
|
|
70
|
+
# are read and dropped.
|
|
71
|
+
def read_groups
|
|
72
|
+
used = @entropy ? @entropy.uniq.to_h { |index| [index, true] } : { 0 => true }
|
|
73
|
+
green = LITERALS + LENGTH_CODES + (@cache ? @cache.size : 0)
|
|
74
|
+
@groups = Array.new(used.keys.max + 1) do |index|
|
|
75
|
+
codes = [green, LITERALS, LITERALS, LITERALS, DISTANCE_CODES].map { |size| PrefixCode.read(@reader, size) }
|
|
76
|
+
codes << literal_base(codes) if used[index]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Red, blue and alpha in place when all three codes hold one symbol,
|
|
81
|
+
# so a pixel is one green symbol away; nil otherwise.
|
|
82
|
+
def literal_base(codes)
|
|
83
|
+
red, blue, alpha = codes[1, 3].map { |table| table[0] }
|
|
84
|
+
return if [red, blue, alpha].any? { |entry| entry > 0xFFFF }
|
|
85
|
+
|
|
86
|
+
(alpha << 24) | (red << 16) | blue
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- the pixel loop, kept in one frame
|
|
90
|
+
def pixels
|
|
91
|
+
out = Array.new(@width * @height, 0)
|
|
92
|
+
total = out.size
|
|
93
|
+
reader = @reader
|
|
94
|
+
cache = @cache
|
|
95
|
+
group = @groups[0]
|
|
96
|
+
pos = 0
|
|
97
|
+
column = 0
|
|
98
|
+
while pos < total
|
|
99
|
+
group = group_at(pos) if @entropy && column.nobits?(@block_mask)
|
|
100
|
+
green = reader.symbol(group[0])
|
|
101
|
+
if green >= LITERALS && green < LITERALS + LENGTH_CODES
|
|
102
|
+
pos = copy(out, pos, extent(green - LITERALS), extent(reader.symbol(group[4])))
|
|
103
|
+
column = pos % @width
|
|
104
|
+
group = group_at(pos) if @entropy && pos < total
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
argb = if green >= LITERALS
|
|
109
|
+
cache[green - LITERALS - LENGTH_CODES]
|
|
110
|
+
elsif (base = group[5])
|
|
111
|
+
base | (green << 8)
|
|
112
|
+
else
|
|
113
|
+
red = reader.symbol(group[1])
|
|
114
|
+
blue = reader.symbol(group[2])
|
|
115
|
+
(reader.symbol(group[3]) << 24) | (red << 16) | (green << 8) | blue
|
|
116
|
+
end
|
|
117
|
+
out[pos] = argb
|
|
118
|
+
cache[((argb * HASH) & 0xFFFFFFFF) >> @shift] = argb if cache
|
|
119
|
+
pos += 1
|
|
120
|
+
column += 1
|
|
121
|
+
column = 0 if column == @width
|
|
122
|
+
end
|
|
123
|
+
out
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def group_at(pos)
|
|
127
|
+
@groups[@entropy[(((pos / @width) >> @block_bits) * @blocks_wide) + ((pos % @width) >> @block_bits)]]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The length or distance code a prefix symbol and its extra bits
|
|
131
|
+
# stand for.
|
|
132
|
+
def extent(symbol)
|
|
133
|
+
return symbol + 1 if symbol < 4
|
|
134
|
+
|
|
135
|
+
bits = (symbol - 2) >> 1
|
|
136
|
+
((2 + (symbol & 1)) << bits) + @reader.read(bits) + 1
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Repeats `length` pixels from `code` back (overlapping on purpose:
|
|
140
|
+
# a run repeats itself) and returns the position after them.
|
|
141
|
+
def copy(out, pos, length, code)
|
|
142
|
+
distance = distance_of(code)
|
|
143
|
+
stop = pos + length
|
|
144
|
+
raise UnsupportedImage, "invalid WebP image: backward reference out of range" if distance > pos
|
|
145
|
+
raise UnsupportedImage, "invalid WebP image: backward reference past the end" if stop > out.size
|
|
146
|
+
|
|
147
|
+
cache = @cache
|
|
148
|
+
source = pos - distance
|
|
149
|
+
while pos < stop
|
|
150
|
+
argb = out[source]
|
|
151
|
+
out[pos] = argb
|
|
152
|
+
cache[((argb * HASH) & 0xFFFFFFFF) >> @shift] = argb if cache
|
|
153
|
+
source += 1
|
|
154
|
+
pos += 1
|
|
155
|
+
end
|
|
156
|
+
stop
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def distance_of(code)
|
|
160
|
+
return code - NEIGHBOURS.size if code > NEIGHBOURS.size
|
|
161
|
+
|
|
162
|
+
neighbour = NEIGHBOURS[code - 1]
|
|
163
|
+
[((neighbour >> 4) * @width) + 8 - (neighbour & 0xF), 1].max
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|