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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/README.md +247 -30
  4. data/lib/stationery/builder.rb +9 -0
  5. data/lib/stationery/canvas/text.rb +8 -2
  6. data/lib/stationery/document.rb +56 -14
  7. data/lib/stationery/elements.rb +31 -3
  8. data/lib/stationery/errors.rb +5 -1
  9. data/lib/stationery/fonts/font.rb +54 -4
  10. data/lib/stationery/fonts/font_book.rb +14 -5
  11. data/lib/stationery/fonts/glyph_run.rb +35 -5
  12. data/lib/stationery/fonts/shaped_run.rb +160 -0
  13. data/lib/stationery/fonts/shaping.rb +63 -0
  14. data/lib/stationery/forms/typeface.rb +10 -3
  15. data/lib/stationery/html/css.rb +38 -3
  16. data/lib/stationery/html/tree_builder/blocks.rb +9 -3
  17. data/lib/stationery/images/images.rb +6 -6
  18. data/lib/stationery/images/resampled.rb +3 -2
  19. data/lib/stationery/images/webp/bit_reader.rb +78 -0
  20. data/lib/stationery/images/webp/container.rb +62 -0
  21. data/lib/stationery/images/webp/image_stream.rb +168 -0
  22. data/lib/stationery/images/webp/lossless.rb +101 -0
  23. data/lib/stationery/images/webp/predictor.rb +118 -0
  24. data/lib/stationery/images/webp/prefix_code.rb +168 -0
  25. data/lib/stationery/images/webp/transforms.rb +93 -0
  26. data/lib/stationery/images/webp.rb +71 -0
  27. data/lib/stationery/layout/columns/balancer.rb +66 -0
  28. data/lib/stationery/layout/columns/pour.rb +47 -0
  29. data/lib/stationery/layout/columns.rb +191 -0
  30. data/lib/stationery/layout/floated.rb +50 -0
  31. data/lib/stationery/layout/flow/floating.rb +51 -0
  32. data/lib/stationery/layout/flow/placement.rb +147 -0
  33. data/lib/stationery/layout/flow/splitter.rb +194 -0
  34. data/lib/stationery/layout/flow.rb +20 -117
  35. data/lib/stationery/layout/image.rb +1 -1
  36. data/lib/stationery/layout/mark.rb +3 -1
  37. data/lib/stationery/layout/node.rb +14 -0
  38. data/lib/stationery/layout/paginator.rb +7 -2
  39. data/lib/stationery/layout/table.rb +52 -6
  40. data/lib/stationery/layout/text.rb +31 -12
  41. data/lib/stationery/page.rb +26 -1
  42. data/lib/stationery/page_templates.rb +1 -1
  43. data/lib/stationery/pdf/assembler.rb +20 -4
  44. data/lib/stationery/pdf/conformance.rb +11 -1
  45. data/lib/stationery/pdf/page_sealer.rb +35 -0
  46. data/lib/stationery/rich/nodes.rb +3 -2
  47. data/lib/stationery/rich/renderer/boxes.rb +25 -3
  48. data/lib/stationery/rich/renderer.rb +5 -3
  49. data/lib/stationery/rich/styles.rb +1 -1
  50. data/lib/stationery/shaper.rb +109 -0
  51. data/lib/stationery/testing/marked_text.rb +34 -4
  52. data/lib/stationery/text/exclusions.rb +32 -0
  53. data/lib/stationery/text/line.rb +7 -2
  54. data/lib/stationery/text/paragraph.rb +69 -10
  55. data/lib/stationery/text/wrapper/remainder.rb +59 -0
  56. data/lib/stationery/text/wrapper.rb +54 -10
  57. data/lib/stationery/version.rb +1 -1
  58. data/lib/stationery.rb +10 -0
  59. 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
- def width(text, size) = runs(text, size).sum { |font, run| font.width_of(run.text, size, ligatures: false) }
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 and are reported, as in any other text.
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
@@ -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. Everything else is ignored and reported
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(:width, :align))
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 PNG images, embedded as image XObjects.
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
- UNSUPPORTED = {
9
- "GIF8" => "GIF", "RIFF" => "WebP", "II*\0" => "TIFF", "MM\0*" => "TIFF", "BM" => "BMP"
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.is_a?(JPEG) ? "JPEG" : "PNG"
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 JPEG images are supported"
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 never comes here.
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