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,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Images
5
+ class WebP
6
+ # A VP8L bitstream (WebP Lossless Bitstream Specification): the header
7
+ # is read on creation, `pixels` decodes the image to an Array of ARGB
8
+ # Integers, row by row from the top left.
9
+ class Lossless
10
+ SIGNATURE = 0x2F
11
+ HEADER_BYTES = 5
12
+ # What one image may decode to; the format itself allows 16384 a
13
+ # side, which no page needs and an Array of pixels cannot hold cheaply.
14
+ MAX_PIXELS = 1 << 25
15
+ PREDICTOR = 0
16
+ CROSS_COLOR = 1
17
+ SUBTRACT_GREEN = 2
18
+
19
+ attr_reader :width, :height
20
+
21
+ def initialize(data)
22
+ raise UnsupportedImage, "truncated WebP image" if data.bytesize < HEADER_BYTES
23
+
24
+ @reader = BitReader.new(data)
25
+ raise UnsupportedImage, "invalid WebP image: not a lossless bitstream" unless @reader.read(8) == SIGNATURE
26
+
27
+ @width = @reader.read(14) + 1
28
+ @height = @reader.read(14) + 1
29
+ @alpha = @reader.read(1) == 1
30
+ raise UnsupportedImage, "invalid WebP image: unknown lossless version" unless @reader.read(3).zero?
31
+ return if @width * @height <= MAX_PIXELS
32
+
33
+ raise UnsupportedImage, "WebP image too large: #{@width}x#{@height} pixels"
34
+ end
35
+
36
+ # Whether the alpha channel means anything; when not, every pixel is opaque.
37
+ def alpha? = @alpha
38
+
39
+ def pixels
40
+ transforms = read_transforms
41
+ argb = ImageStream.decode(@reader, @coded_width, @height, top: true)
42
+ raise UnsupportedImage, "truncated WebP image" if @reader.overrun?
43
+
44
+ transforms.reverse_each { |transform| argb = transform.call(argb) }
45
+ argb
46
+ rescue UnsupportedImage
47
+ raise unless @reader.overrun?
48
+
49
+ raise UnsupportedImage, "truncated WebP image"
50
+ end
51
+
52
+ private
53
+
54
+ # The transforms in the order they were applied, each as a callable
55
+ # that undoes it. A transform may appear once.
56
+ def read_transforms
57
+ @coded_width = @width
58
+ seen = []
59
+ transforms = []
60
+ while @reader.read(1) == 1
61
+ type = @reader.read(2)
62
+ raise UnsupportedImage, "invalid WebP image: transform used twice" if seen.include?(type)
63
+
64
+ seen << type
65
+ transforms << read_transform(type)
66
+ end
67
+ transforms
68
+ end
69
+
70
+ def read_transform(type)
71
+ width = @coded_width
72
+ case type
73
+ when PREDICTOR
74
+ bits, image = read_blocks(width)
75
+ ->(argb) { Predictor.inverse(argb, width, @height, bits, image) }
76
+ when CROSS_COLOR
77
+ bits, image = read_blocks(width)
78
+ ->(argb) { Transforms.cross_color(argb, width, @height, bits, image) }
79
+ when SUBTRACT_GREEN then Transforms.method(:add_green)
80
+ else read_color_indexing(width)
81
+ end
82
+ end
83
+
84
+ # An image of one value per block of 2**bits pixels a side.
85
+ def read_blocks(width)
86
+ bits = @reader.read(3) + 2
87
+ [bits, ImageStream.decode(@reader, ImageStream.subsample(width, bits),
88
+ ImageStream.subsample(@height, bits))]
89
+ end
90
+
91
+ def read_color_indexing(width)
92
+ size = @reader.read(8) + 1
93
+ table = Transforms.color_table(ImageStream.decode(@reader, size, 1))
94
+ bits = Transforms.bundling(size)
95
+ @coded_width = ImageStream.subsample(width, bits)
96
+ ->(argb) { Transforms.color_indexing(argb, width, @height, bits, table) }
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Images
5
+ class WebP
6
+ # Reverses the predictor transform: every pixel is stored as its
7
+ # difference from a prediction made of its left (L), top (T), top-left
8
+ # (TL) and top-right (TR) neighbours, the mode chosen per block. All
9
+ # arithmetic is per channel, modulo 256, on packed ARGB Integers.
10
+ module Predictor
11
+ BLACK = 0xFF000000
12
+ ODD = 0xFF00FF00
13
+ EVEN = 0x00FF00FF
14
+ # Clears each channel's lowest bit, so halving cannot borrow from
15
+ # the channel above.
16
+ HALVES = 0xFEFEFEFE
17
+
18
+ module_function
19
+
20
+ def inverse(argb, width, height, bits, modes)
21
+ first_row(argb, width)
22
+ blocks_wide = ImageStream.subsample(width, bits)
23
+ (1...height).each do |y|
24
+ row = y * width
25
+ argb[row] = add(argb[row], argb[row - width])
26
+ column = 1
27
+ while column < width
28
+ mode = (modes[((y >> bits) * blocks_wide) + (column >> bits)] >> 8) & 0xF
29
+ stop = [((column >> bits) + 1) << bits, width].min
30
+ run(mode, argb, row + column, row + stop, width)
31
+ column = stop
32
+ end
33
+ end
34
+ argb
35
+ end
36
+
37
+ # The top-left pixel is predicted black, the rest of the row from the left.
38
+ def first_row(argb, width)
39
+ argb[0] = add(argb[0], BLACK)
40
+ (1...width).each { |x| argb[x] = add(argb[x], argb[x - 1]) }
41
+ end
42
+
43
+ def run(mode, argb, pos, stop, width)
44
+ while pos < stop
45
+ argb[pos] = add(argb[pos], predict(mode, argb, pos, width))
46
+ pos += 1
47
+ end
48
+ end
49
+
50
+ # rubocop:disable-next Metrics/AbcSize -- one branch per mode of the format, run per pixel
51
+ def predict(mode, argb, pos, width)
52
+ top = pos - width
53
+ case mode
54
+ when 1 then argb[pos - 1]
55
+ when 2 then argb[top]
56
+ when 3 then argb[top + 1]
57
+ when 4 then argb[top - 1]
58
+ when 5 then average(average(argb[pos - 1], argb[top + 1]), argb[top])
59
+ when 6 then average(argb[pos - 1], argb[top - 1])
60
+ when 7 then average(argb[pos - 1], argb[top])
61
+ when 8 then average(argb[top - 1], argb[top])
62
+ when 9 then average(argb[top], argb[top + 1])
63
+ when 10 then average(average(argb[pos - 1], argb[top - 1]), average(argb[top], argb[top + 1]))
64
+ when 11 then select(argb[pos - 1], argb[top], argb[top - 1])
65
+ when 12 then Gradient.full(argb[pos - 1], argb[top], argb[top - 1])
66
+ when 13 then Gradient.half(average(argb[pos - 1], argb[top]), argb[top - 1])
67
+ else BLACK # mode 0, and 14 and 15 which the format leaves unused
68
+ end
69
+ end
70
+
71
+ def add(a, b)
72
+ (((a & ODD) + (b & ODD)) & ODD) | (((a & EVEN) + (b & EVEN)) & EVEN)
73
+ end
74
+
75
+ def average(a, b)
76
+ (((a ^ b) & HALVES) >> 1) + (a & b)
77
+ end
78
+
79
+ # Left or top, whichever is nearer to the gradient L + T - TL.
80
+ def select(left, top, top_left)
81
+ distance(top, top_left) < distance(left, top_left) ? left : top
82
+ end
83
+
84
+ def distance(a, b)
85
+ ((a >> 24) - (b >> 24)).abs + (((a >> 16) & 0xFF) - ((b >> 16) & 0xFF)).abs +
86
+ (((a >> 8) & 0xFF) - ((b >> 8) & 0xFF)).abs + ((a & 0xFF) - (b & 0xFF)).abs
87
+ end
88
+
89
+ # Modes 12 and 13: the gradient of the neighbours, clamped per channel.
90
+ module Gradient
91
+ module_function
92
+
93
+ def full(left, top, top_left)
94
+ (clamp((left >> 24) + (top >> 24) - (top_left >> 24)) << 24) |
95
+ (clamp(((left >> 16) & 0xFF) + ((top >> 16) & 0xFF) - ((top_left >> 16) & 0xFF)) << 16) |
96
+ (clamp(((left >> 8) & 0xFF) + ((top >> 8) & 0xFF) - ((top_left >> 8) & 0xFF)) << 8) |
97
+ clamp((left & 0xFF) + (top & 0xFF) - (top_left & 0xFF))
98
+ end
99
+
100
+ def half(average, top_left)
101
+ (step(average >> 24, top_left >> 24) << 24) |
102
+ (step((average >> 16) & 0xFF, (top_left >> 16) & 0xFF) << 16) |
103
+ (step((average >> 8) & 0xFF, (top_left >> 8) & 0xFF) << 8) |
104
+ step(average & 0xFF, top_left & 0xFF)
105
+ end
106
+
107
+ # a + (a - b) / 2, the division rounding towards zero.
108
+ def step(a, b)
109
+ delta = a - b
110
+ clamp(a + (delta.negative? ? -(-delta >> 1) : delta >> 1))
111
+ end
112
+
113
+ def clamp(value) = value.clamp(0, 255)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Images
5
+ class WebP
6
+ # Canonical prefix (Huffman) codes as lookup tables. A table is a flat
7
+ # Array indexed by the next ROOT_BITS bits of the stream: an entry is
8
+ # `length << 16 | symbol`; codes longer than ROOT_BITS share a LINK
9
+ # entry (`LINK | bits << 16 | offset`) to a second-level table appended
10
+ # after the root. A code with a single symbol takes no bits at all.
11
+ module PrefixCode
12
+ ROOT_BITS = 8
13
+ ROOT_SIZE = 1 << ROOT_BITS
14
+ ROOT_MASK = ROOT_SIZE - 1
15
+ LINK = 1 << 20
16
+ MAX_LENGTH = 15
17
+ # The order the lengths of the code-length code are stored in.
18
+ LENGTH_ORDER = [17, 18, 0, 1, 2, 3, 4, 5, 16, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].freeze
19
+ # Extra bits and base of the repeat codes 16, 17 and 18.
20
+ REPEATS = [[2, 3], [3, 3], [7, 11]].freeze
21
+
22
+ module_function
23
+
24
+ # Reads one prefix code over `size` symbols and returns its table.
25
+ def read(reader, size)
26
+ build(reader.read(1) == 1 ? simple_lengths(reader, size) : coded_lengths(reader, size))
27
+ end
28
+
29
+ # One or two symbols stored as they are, the code being 0 or 1 bit.
30
+ def simple_lengths(reader, size)
31
+ count = reader.read(1) + 1
32
+ symbols = [reader.read(reader.read(1) == 1 ? 8 : 1)]
33
+ symbols << reader.read(8) if count == 2
34
+ raise UnsupportedImage, "invalid WebP image: prefix code symbol out of range" if symbols.max >= size
35
+
36
+ lengths = Array.new(size, 0)
37
+ symbols.each { |symbol| lengths[symbol] = 1 }
38
+ lengths
39
+ end
40
+
41
+ # Code lengths that are themselves prefix coded, with repeat codes.
42
+ def coded_lengths(reader, size)
43
+ code_lengths = Array.new(LENGTH_ORDER.size, 0)
44
+ (reader.read(4) + 4).times { |i| code_lengths[LENGTH_ORDER[i]] = reader.read(3) }
45
+ table = build(code_lengths)
46
+ remaining = reader.read(1) == 1 ? 2 + reader.read(2 + (2 * reader.read(3))) : size
47
+ raise UnsupportedImage, "invalid WebP image: too many code lengths" if remaining > size
48
+
49
+ expand(reader, table, size, remaining)
50
+ end
51
+
52
+ def expand(reader, table, size, remaining)
53
+ lengths = Array.new(size, 0)
54
+ previous = 8
55
+ symbol = 0
56
+ while symbol < size && remaining.positive?
57
+ remaining -= 1
58
+ length = reader.symbol(table)
59
+ if length < 16
60
+ lengths[symbol] = length
61
+ symbol += 1
62
+ previous = length unless length.zero?
63
+ else
64
+ symbol = repeat(reader, lengths, symbol, length, previous)
65
+ end
66
+ end
67
+ lengths
68
+ end
69
+
70
+ def repeat(reader, lengths, symbol, code, previous)
71
+ bits, base = REPEATS[code - 16]
72
+ count = reader.read(bits) + base
73
+ raise UnsupportedImage, "invalid WebP image: code lengths overflow" if symbol + count > lengths.size
74
+
75
+ lengths.fill(previous, symbol, count) if code == 16
76
+ symbol + count
77
+ end
78
+
79
+ # The lookup table of the canonical code with these lengths, which
80
+ # must be complete (every bit pattern decodes) or hold one symbol.
81
+ def build(lengths)
82
+ counts = Array.new(MAX_LENGTH + 1, 0)
83
+ lengths.each { |length| counts[length] += 1 }
84
+ used = lengths.size - counts[0]
85
+ counts[0] = 0
86
+ return Array.new(ROOT_SIZE, lengths.index(&:positive?)) if used == 1
87
+
88
+ space = (1..MAX_LENGTH).sum { |length| counts[length] << (MAX_LENGTH - length) }
89
+ raise UnsupportedImage, "invalid WebP image: incomplete prefix code" unless space == 1 << MAX_LENGTH
90
+
91
+ fill(lengths, counts)
92
+ end
93
+
94
+ def fill(lengths, counts)
95
+ codes = reversed_codes(lengths, counts)
96
+ table = Array.new(ROOT_SIZE, 0)
97
+ link(table, lengths, codes)
98
+ lengths.each_with_index do |length, symbol|
99
+ next if length.zero?
100
+
101
+ code = codes[symbol]
102
+ if length <= ROOT_BITS
103
+ spread(table, 0, ROOT_BITS, code, length, (length << 16) | symbol)
104
+ else
105
+ entry = table[code & ROOT_MASK]
106
+ spread(table, entry & 0xFFFF, (entry >> 16) & 0xF, code >> ROOT_BITS, length - ROOT_BITS,
107
+ ((length - ROOT_BITS) << 16) | symbol)
108
+ end
109
+ end
110
+ table
111
+ end
112
+
113
+ # Every symbol's code with its bits reversed, since the stream is
114
+ # read least significant bit first.
115
+ def reversed_codes(lengths, counts)
116
+ code = 0
117
+ first = Array.new(MAX_LENGTH + 1, 0)
118
+ (1..MAX_LENGTH).each { |length| first[length] = code = (code + counts[length - 1]) << 1 }
119
+ lengths.map do |length|
120
+ next 0 if length.zero?
121
+
122
+ value = first[length]
123
+ first[length] += 1
124
+ reverse(value, length)
125
+ end
126
+ end
127
+
128
+ def reverse(value, length)
129
+ reversed = 0
130
+ length.times do
131
+ reversed = (reversed << 1) | (value & 1)
132
+ value >>= 1
133
+ end
134
+ reversed
135
+ end
136
+
137
+ # Appends a second-level table for every root entry that long codes
138
+ # share, sized for the longest of them.
139
+ def link(table, lengths, codes)
140
+ longest = {}
141
+ lengths.each_with_index do |length, symbol|
142
+ next unless length > ROOT_BITS
143
+
144
+ root = codes[symbol] & ROOT_MASK
145
+ longest[root] = length if length > longest.fetch(root, 0)
146
+ end
147
+ longest.each do |root, length|
148
+ bits = length - ROOT_BITS
149
+ table[root] = LINK | (bits << 16) | table.size
150
+ table.concat(Array.new(1 << bits, 0))
151
+ end
152
+ end
153
+
154
+ # Writes `entry` at every index of a `bits`-wide table whose low
155
+ # `length` bits are `code`.
156
+ def spread(table, offset, bits, code, length, entry)
157
+ index = code
158
+ size = 1 << bits
159
+ step = 1 << length
160
+ while index < size
161
+ table[offset + index] = entry
162
+ index += step
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Images
5
+ class WebP
6
+ # The subtract-green, cross-colour and colour-indexing transforms,
7
+ # reversed (the predictor has a file of its own). Each takes and
8
+ # returns the image as an Array of ARGB Integers.
9
+ module Transforms
10
+ module_function
11
+
12
+ # Green was subtracted from red and blue.
13
+ def add_green(argb)
14
+ argb.map! do |pixel|
15
+ green = (pixel >> 8) & 0xFF
16
+ (pixel & 0xFF00FF00) | (((pixel & 0x00FF00FF) + ((green << 16) | green)) & 0x00FF00FF)
17
+ end
18
+ end
19
+
20
+ # Red was predicted from green, blue from green and red, with
21
+ # signed 3.5 fixed-point multipliers stored per block.
22
+ def cross_color(argb, width, height, bits, elements)
23
+ blocks_wide = ImageStream.subsample(width, bits)
24
+ height.times do |y|
25
+ row = y * width
26
+ column = 0
27
+ while column < width
28
+ element = elements[((y >> bits) * blocks_wide) + (column >> bits)]
29
+ stop = [((column >> bits) + 1) << bits, width].min
30
+ recolor(argb, row + column, row + stop, element)
31
+ column = stop
32
+ end
33
+ end
34
+ argb
35
+ end
36
+
37
+ def recolor(argb, pos, stop, element)
38
+ green_to_red = signed(element & 0xFF)
39
+ green_to_blue = signed((element >> 8) & 0xFF)
40
+ red_to_blue = signed((element >> 16) & 0xFF)
41
+ while pos < stop
42
+ pixel = argb[pos]
43
+ green = signed((pixel >> 8) & 0xFF)
44
+ red = ((pixel >> 16) + ((green_to_red * green) >> 5)) & 0xFF
45
+ blue = (pixel + ((green_to_blue * green) >> 5) + ((red_to_blue * signed(red)) >> 5)) & 0xFF
46
+ argb[pos] = (pixel & 0xFF00FF00) | (red << 16) | blue
47
+ pos += 1
48
+ end
49
+ end
50
+
51
+ def signed(byte) = byte > 127 ? byte - 256 : byte
52
+
53
+ # The colour table as stored: every entry is the difference from the
54
+ # one before it. Padded to 256 entries so any index finds a colour
55
+ # (transparent black past the end of the table).
56
+ def color_table(deltas)
57
+ previous = 0
58
+ table = deltas.map { |delta| previous = Predictor.add(delta, previous) }
59
+ table.fill(0, table.size...256)
60
+ end
61
+
62
+ # Green holds an index into the colour table, or 2, 4 or 8 of them
63
+ # packed from the lowest bits up when the table is small.
64
+ def color_indexing(argb, width, height, bits, table)
65
+ return argb.map! { |pixel| table[(pixel >> 8) & 0xFF] } if bits.zero?
66
+
67
+ packed_width = ImageStream.subsample(width, bits)
68
+ depth = 8 >> bits
69
+ mask = (1 << depth) - 1
70
+ out = Array.new(width * height, 0)
71
+ height.times do |y|
72
+ source = y * packed_width
73
+ row = y * width
74
+ width.times do |x|
75
+ packed = (argb[source + (x >> bits)] >> 8) & 0xFF
76
+ out[row + x] = table[(packed >> ((x & ((1 << bits) - 1)) * depth)) & mask]
77
+ end
78
+ end
79
+ out
80
+ end
81
+
82
+ # How many indexes share a pixel for a table of `size` colours, as
83
+ # the power of two.
84
+ def bundling(size)
85
+ return 0 if size > 16
86
+ return 1 if size > 4
87
+
88
+ size > 2 ? 2 : 3
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ require_relative "webp/container"
6
+ require_relative "webp/bit_reader"
7
+ require_relative "webp/prefix_code"
8
+ require_relative "webp/image_stream"
9
+ require_relative "webp/predictor"
10
+ require_relative "webp/transforms"
11
+ require_relative "webp/lossless"
12
+
13
+ module Stationery
14
+ module Images
15
+ # A lossless WebP (VP8L), decoded when it is loaded and embedded like a
16
+ # PNG with an alpha channel: RGB as a Flate stream, the alpha in a soft
17
+ # mask when any pixel is not opaque. Lossy and animated WebP are refused.
18
+ class WebP
19
+ attr_reader :width, :height
20
+
21
+ def initialize(data)
22
+ container = Container.new(data)
23
+ lossless = Lossless.new(container.bitstream)
24
+ @width = lossless.width
25
+ @height = lossless.height
26
+ if container.canvas && container.canvas != [@width, @height]
27
+ raise UnsupportedImage, "invalid WebP image: canvas and image sizes differ"
28
+ end
29
+
30
+ split(lossless.pixels, lossless.alpha?)
31
+ end
32
+
33
+ def inspect = "#<#{self.class} #{@width}x#{@height}>"
34
+
35
+ # This image scaled down to `width` pixels (a Resampled), memoised per
36
+ # width; the same instance is shared through the image cache.
37
+ def resample(width)
38
+ @resampled ||= {}
39
+ @resampled[width] ||= Resampled.new(pixels, width)
40
+ end
41
+
42
+ def build(writer)
43
+ dictionary = Images.xobject(@width, @height, :DeviceRGB, 8).merge(Filter: :FlateDecode)
44
+ if @alpha
45
+ mask = Images.xobject(@width, @height, :DeviceGray, 8).merge(Filter: :FlateDecode)
46
+ dictionary[:SMask] = writer.add(PDF::Stream.new(Zlib::Deflate.deflate(@alpha), mask))
47
+ end
48
+ writer.add(PDF::Stream.new(Zlib::Deflate.deflate(@color), dictionary))
49
+ end
50
+
51
+ private
52
+
53
+ # ARGB pixels as the bytes to embed: three per pixel of colour, and
54
+ # one of alpha unless the image is opaque.
55
+ def split(argb, alpha)
56
+ @color = String.new(capacity: argb.size * 3, encoding: Encoding::BINARY)
57
+ argb.each { |pixel| @color << ((pixel >> 16) & 0xFF) << ((pixel >> 8) & 0xFF) << (pixel & 0xFF) }
58
+ @alpha = argb.map { |pixel| pixel >> 24 }.pack("C*") if alpha && argb.any? { |pixel| pixel < 0xFF000000 }
59
+ end
60
+
61
+ def pixels
62
+ Pixels.new(width: @width, height: @height, channels: 3, color: rows(@color, @width * 3),
63
+ alpha: @alpha && rows(@alpha, @width))
64
+ end
65
+
66
+ def rows(bytes, stride)
67
+ Array.new(@height) { |y| bytes.byteslice(y * stride, stride).bytes }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ class Columns
6
+ # Finds the shortest column height at which a pour places everything,
7
+ # so the columns end at nearly the same height.
8
+ #
9
+ # The search runs between a height known to be too short and one known
10
+ # to fit. It starts at the single-column height divided by the column
11
+ # count (what perfectly divisible content needs) and, as the answer is
12
+ # rarely more than a line or two above that, climbs from there in
13
+ # doubling steps until a height fits, then halves the interval. A height
14
+ # that fits is lowered to the tallest column it produced, which is a
15
+ # real break position, so the result is never taller than its content.
16
+ # Fitting is not strictly monotonic in the height (keep_with_next,
17
+ # orphans and widows move content in steps), so the answer is the
18
+ # shortest height found, within TOLERANCE points of the shortest there
19
+ # is. The probe count is capped: the search ends whatever a split
20
+ # returns.
21
+ class Balancer
22
+ TOLERANCE = 0.5
23
+ MAX_PROBES = 40
24
+
25
+ # `limit` is the tallest a column may be; `fallback` is the pour at
26
+ # that height when the caller has it already.
27
+ def initialize(pour, limit: Float::INFINITY, fallback: nil)
28
+ @pour = pour
29
+ @limit = limit
30
+ @fallback = fallback
31
+ end
32
+
33
+ def call
34
+ high = [@limit, @pour.total].min
35
+ best = @fallback || @pour.call(high)
36
+ return best unless best.complete? && @pour.count > 1
37
+
38
+ search(best, [high, best.height(@pour.width)].min)
39
+ end
40
+
41
+ private
42
+
43
+ def search(best, high)
44
+ low = 0.0
45
+ probe = @pour.total / @pour.count
46
+ step = TOLERANCE
47
+ MAX_PROBES.times do
48
+ break if high - low <= TOLERANCE || probe >= high
49
+
50
+ poured = @pour.call(probe)
51
+ if poured.complete?
52
+ best = poured
53
+ high = [probe, poured.height(@pour.width)].min
54
+ step = nil
55
+ else
56
+ low = probe
57
+ end
58
+ step *= 2 if step
59
+ probe = [(low + high) / 2.0, step ? low + step : high].min
60
+ end
61
+ best
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ class Columns
6
+ # What one pour placed: a flow per column that took content, and the
7
+ # flow left over (nil when everything was placed).
8
+ Poured = Data.define(:columns, :rest) do
9
+ def complete? = rest.nil?
10
+ def height(width) = columns.map { |column| column.measure(width) }.max || 0
11
+ end
12
+
13
+ # Pours a flow into `count` columns of one width: column 1 takes what
14
+ # fits in the height, column 2 continues where it stopped, and so on. A
15
+ # column break is a page break with another height, so every column is
16
+ # cut by Flow#split. A column that places nothing ends the pour, which
17
+ # is what keeps a pour (and every search over pours) finite.
18
+ class Pour
19
+ attr_reader :width, :count
20
+
21
+ def initialize(flow, width, count)
22
+ @flow = flow
23
+ @width = width
24
+ @count = count
25
+ end
26
+
27
+ # The height of the flow in a single column.
28
+ def total = @flow.measure(@width)
29
+
30
+ # `fresh:` is true at the top of a page, where a column keeps a child
31
+ # too tall for it rather than placing nothing.
32
+ def call(height, fresh: false)
33
+ columns = []
34
+ rest = @flow.children.empty? ? nil : @flow
35
+ while rest && columns.size < @count
36
+ head, tail = rest.split(@width, height, fresh:)
37
+ break unless head
38
+
39
+ columns << head
40
+ rest = tail
41
+ end
42
+ Poured.new(columns:, rest:)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end