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
@@ -4,8 +4,9 @@ module Stationery
4
4
  module Rich
5
5
  class Renderer
6
6
  # What a block's CSS style becomes around the block: margins as spacers,
7
- # a background and padding as a box, page-break rules as page breaks and
8
- # kept-together groups, borders and widths as table options.
7
+ # a background and padding as a box, a column count as columns,
8
+ # page-break rules as page breaks and kept-together groups, borders and
9
+ # widths as table options.
9
10
  module Boxes
10
11
  PADDING = { padding_top: 0, padding_right: 1, padding_bottom: 2, padding_left: 3 }.freeze
11
12
 
@@ -16,7 +17,16 @@ module Stationery
16
17
  def styled(style, &)
17
18
  return yield if style.empty?
18
19
 
19
- spaced(style) { boxed(style, &) }
20
+ spaced(style) { boxed(style) { columned(style, &) } }
21
+ end
22
+
23
+ # The block's content poured through balanced columns.
24
+ def columned(style, &)
25
+ count = style[:columns]
26
+ return yield unless count && count > 1
27
+
28
+ gap = style[:column_gap]
29
+ @component.columns(count:, **({ gap: } if gap), &)
20
30
  end
21
31
 
22
32
  # Page breaks and margins around the block, and the rule that keeps it
@@ -69,6 +79,18 @@ module Stationery
69
79
 
70
80
  def aligned(style) = style[:align] ? { align: style[:align] } : {}
71
81
 
82
+ # Where an image goes: floated to a side, its CSS margins (else
83
+ # `styles[:img][:float_margin]`) kept around it, or aligned.
84
+ def placed(style)
85
+ return aligned(style) unless style[:float]
86
+
87
+ margin = style[:margin]&.dup
88
+ margin ||= [0, 0, 0, 0] if style[:margin_top] || style[:margin_bottom]
89
+ margin[0] = style[:margin_top] if style[:margin_top]
90
+ margin[2] = style[:margin_bottom] if style[:margin_bottom]
91
+ { float: style[:float], margin: margin || @styles[:img][:float_margin] }
92
+ end
93
+
72
94
  def border_options(border)
73
95
  return {} unless border
74
96
  return { borders: [] } if border[:width].zero?
@@ -79,7 +79,9 @@ module Stationery
79
79
  end
80
80
 
81
81
  def blockquote(quote)
82
- @component.box(role: :blockquote, **@styles[:blockquote], **box_options(quote.style)) { group(quote.blocks) }
82
+ @component.box(role: :blockquote, **@styles[:blockquote], **box_options(quote.style)) do
83
+ columned(quote.style) { group(quote.blocks) }
84
+ end
83
85
  end
84
86
 
85
87
  def list(list)
@@ -94,7 +96,7 @@ module Stationery
94
96
  def code(block)
95
97
  font = @styles[:code][:font]
96
98
  @component.box(**@styles[:pre], **box_options(block.style)) do
97
- @component.text(block.text, **({ font: } if font))
99
+ columned(block.style) { @component.text(block.text, **({ font: } if font)) }
98
100
  end
99
101
  end
100
102
 
@@ -117,7 +119,7 @@ module Stationery
117
119
  return unless source
118
120
 
119
121
  loaded = Images.load(source)
120
- @component.image(loaded, alt: node.alt, **aligned(node.style), **dimensions(node, loaded))
122
+ @component.image(loaded, alt: node.alt, **placed(node.style), **dimensions(node, loaded))
121
123
  rescue UnsupportedImage => e
122
124
  skip(node.src, e.message)
123
125
  end
@@ -21,7 +21,7 @@ module Stationery
21
21
  ul: {},
22
22
  ol: {},
23
23
  li: {},
24
- img: { max_width: nil }
24
+ img: { max_width: nil, float_margin: 8 }
25
25
  }.freeze
26
26
 
27
27
  module_function
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # The hook for complex-script shaping. Stationery maps characters to glyphs
5
+ # one by one, with the font's ligatures and pair kerning; it does not shape
6
+ # Arabic, Indic or Thai text and does not reorder right-to-left text. An
7
+ # application that has a native shaper (HarfBuzz) plugs it in here:
8
+ #
9
+ # class Invoice < Stationery::Document
10
+ # shaper MyShaper
11
+ # end
12
+ #
13
+ # Invoice.new.to_pdf(shaper: MyShaper)
14
+ #
15
+ # A shaper is any object answering
16
+ #
17
+ # call(text, font, size:, features:, language:) # => [Shaper::Glyph, …] or nil
18
+ #
19
+ # and should take `**options`, so an option added later does not break it.
20
+ #
21
+ # `text` is one stretch of a line in one font and style (see below);
22
+ # `font` is a Shaper::Face. `size:` is the size drawn at in points,
23
+ # `features:` a frozen Hash of OpenType feature tags to switches
24
+ # (`{ "kern" => true, "liga" => false, "smcp" => true }`: `kern` and `liga`
25
+ # are always there and follow the style's `kerning:` and `ligatures:`, the
26
+ # rest are its `features:`), `language:` the document's `metadata lang:` or
27
+ # nil. Direction and script are not passed, stationery knows neither: the
28
+ # shaper works them out from the text.
29
+ #
30
+ # The answer is the glyphs in visual order, left to right, each a
31
+ # Shaper::Glyph (or a Hash of its fields). Answering nil declines: the text
32
+ # is then drawn as it is without a shaper.
33
+ #
34
+ # What is shaped: line breaking measures each word and each stretch of
35
+ # spaces on its own, so it needs no more than the advances; every line is
36
+ # then cut where the font or the style changes, and each of those stretches
37
+ # is shaped whole, measured and drawn from that one answer. The shaper
38
+ # reorders within the stretch it is given and nowhere else.
39
+ module Shaper
40
+ # One glyph of a shaper's answer. `gid` is the glyph id in the font,
41
+ # `advance` how far the pen moves after it and `x_offset`/`y_offset` where
42
+ # it is drawn from the pen (to the right and up), all in font units;
43
+ # `cluster` is the index in `text`, in characters, of the first character
44
+ # the glyph stands for. The glyphs of one cluster stand together for its
45
+ # characters: those from `cluster` up to the next cluster in the answer.
46
+ Glyph = Data.define(:gid, :advance, :x_offset, :y_offset, :cluster) do
47
+ def initialize(gid:, advance:, cluster:, x_offset: 0, y_offset: 0)
48
+ super
49
+ end
50
+ end
51
+
52
+ # The font a shaper is asked to shape with. `data` is the font program as
53
+ # stationery read it (an sfnt: a WOFF is already unpacked) and `index` the
54
+ # face in it when it is a collection; `path` is the file it came from, or
55
+ # nil when it was not read from one.
56
+ Face = Data.define(:path, :index, :data, :units_per_em, :postscript_name, :glyph_count) do
57
+ def inspect = "#<#{self.class} #{postscript_name} index=#{index} glyphs=#{glyph_count}>"
58
+ end
59
+
60
+ METRICS = %i[advance x_offset y_offset].freeze
61
+
62
+ module_function
63
+
64
+ # `shaper` when it can be called, nil for none.
65
+ def check(shaper)
66
+ return shaper if shaper.nil? || shaper.respond_to?(:call)
67
+
68
+ raise ArgumentError, "shaper must answer call(text, font, **options), got #{shaper.inspect}"
69
+ end
70
+
71
+ # A shaper's answer for `text` as Glyphs, or a ShaperError naming what is
72
+ # wrong with it.
73
+ def glyphs(answer, text, face)
74
+ return answer.map { |glyph| glyph(glyph, text, face) } if answer.is_a?(Array)
75
+
76
+ raise ShaperError, "shaper must answer an Array of glyphs or nil, got #{answer.inspect}"
77
+ end
78
+
79
+ def glyph(value, text, face)
80
+ glyph = value.is_a?(Hash) ? from_hash(value) : value
81
+ raise ShaperError, "shaper answered #{value.inspect}, not a Stationery::Shaper::Glyph" unless glyph.is_a?(Glyph)
82
+
83
+ within(glyph, :gid, face.glyph_count, "the font has #{face.glyph_count} glyphs")
84
+ within(glyph, :cluster, text.length, "the text has #{text.length} characters")
85
+ METRICS.each { |field| finite(glyph, field) }
86
+ glyph
87
+ end
88
+
89
+ def from_hash(fields)
90
+ Glyph.new(**fields)
91
+ rescue ArgumentError => e
92
+ raise ShaperError, "shaper answered #{fields.inspect}: #{e.message}"
93
+ end
94
+
95
+ def within(glyph, field, count, limit)
96
+ value = glyph.public_send(field)
97
+ return if value.is_a?(Integer) && value >= 0 && value < count
98
+
99
+ raise ShaperError, "shaper answered #{field} #{value.inspect}: #{limit}"
100
+ end
101
+
102
+ def finite(glyph, field)
103
+ value = glyph.public_send(field)
104
+ return if value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)
105
+
106
+ raise ShaperError, "shaper answered #{field} #{value.inspect}, not a number of font units"
107
+ end
108
+ end
109
+ end
@@ -16,6 +16,8 @@ module Stationery
16
16
  set_character_spacing set_word_spacing set_text_rise set_horizontal_text_scaling
17
17
  set_text_rendering_mode].freeze
18
18
 
19
+ UTF16_BOM = "\xFE\xFF".b
20
+
19
21
  def_delegators :@state, *STATE
20
22
 
21
23
  # { MCID => text } and the strings shown outside marked content.
@@ -31,20 +33,30 @@ module Stationery
31
33
  @unmarked = []
32
34
  end
33
35
 
36
+ # A sequence with ActualText reads as that text, whatever it shows.
34
37
  def begin_marked_content_with_pl(_tag, properties)
35
- @open << (properties[:MCID] if properties.is_a?(Hash))
38
+ properties = {} unless properties.is_a?(Hash)
39
+ @open << properties[:MCID]
40
+ return unless properties[:ActualText]
41
+
42
+ @actual = decode(properties[:ActualText])
43
+ @actual_depth = @open.size
36
44
  end
37
45
 
38
46
  def begin_marked_content(_tag) = @open << nil
39
- def end_marked_content = @open.pop
47
+
48
+ def end_marked_content
49
+ @actual = nil if @actual_depth && @open.size <= @actual_depth
50
+ @open.pop
51
+ end
52
+
40
53
  def show_text(string) = append(string)
41
54
  def show_text_with_positioning(params) = params.grep(String).each { |string| append(string) }
42
55
 
43
56
  private
44
57
 
45
58
  def append(string)
46
- font = @state.current_font
47
- text = font.unpack(string).map { |code| font.to_utf8(code) }.join
59
+ text = shown(string)
48
60
  return @unmarked << text if @open.empty?
49
61
 
50
62
  mcid = @open.compact.last
@@ -55,6 +67,24 @@ module Stationery
55
67
  @texts[mcid] = "#{@texts[mcid]}#{separator}#{text}"
56
68
  @baselines[mcid] = baseline
57
69
  end
70
+
71
+ # The characters a string shows: its ActualText once, when it has one.
72
+ def shown(string)
73
+ if @actual
74
+ text = @actual
75
+ @actual = ""
76
+ return text
77
+ end
78
+
79
+ font = @state.current_font
80
+ font.unpack(string).map { |code| font.to_utf8(code) }.join
81
+ end
82
+
83
+ def decode(text)
84
+ return text.dup.force_encoding(Encoding::UTF_8) unless text.b.start_with?(UTF16_BOM)
85
+
86
+ text.b.byteslice(2..).force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8)
87
+ end
58
88
  end
59
89
  end
60
90
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Text
5
+ # The room floats take from the lines beside them: bands from `top` to
6
+ # `bottom` below the top of whatever is laid out (a paragraph, a flow),
7
+ # each taking `left` points from the left edge and `right` from the right.
8
+ # Compared by its bands, so a paragraph wrapped around the same floats is
9
+ # wrapped once.
10
+ Exclusions = Data.define(:bands) do
11
+ def self.of(bands) = bands.empty? ? nil : new(bands)
12
+
13
+ # [left, right]: what a line from `top` to `top + height` gives up on
14
+ # each side; a band it only touches takes nothing.
15
+ def insets(top, height)
16
+ left = right = 0
17
+ bands.each do |band|
18
+ next unless band.top < top + height - Exclusions::EPSILON && band.bottom > top + Exclusions::EPSILON
19
+
20
+ left = band.left if band.left > left
21
+ right = band.right if band.right > right
22
+ end
23
+ [left, right]
24
+ end
25
+ end
26
+
27
+ class Exclusions
28
+ EPSILON = 0.0001
29
+ Band = Data.define(:top, :bottom, :left, :right)
30
+ end
31
+ end
32
+ end
@@ -9,12 +9,17 @@ module Stationery
9
9
  # One laid-out line. Vertical metrics come from its tallest fragment (or
10
10
  # from the paragraph's base style when the line is empty). A soft-wrapped
11
11
  # line is justifiable; the last line and one ended by a newline are not.
12
+ # Beside a float it starts `offset` points into the paragraph and has
13
+ # `available` points of width; anywhere else that is 0 and nil, the
14
+ # paragraph's own width.
12
15
  class Line
13
- attr_reader :fragments, :width, :ascent, :descent, :height
16
+ attr_reader :fragments, :width, :ascent, :descent, :height, :offset, :available
14
17
 
15
- def initialize(fragments, fallback, justifiable: false)
18
+ def initialize(fragments, fallback, justifiable: false, offset: 0, available: nil)
16
19
  @fragments = fragments
17
20
  @justifiable = justifiable
21
+ @offset = offset
22
+ @available = available
18
23
  @width = fragments.sum(&:width)
19
24
  metrics = fragments.empty? ? [fallback] : fragments.map { |f| [f.font, f.style.render_size] }
20
25
  @ascent = metrics.map { |font, size| font.ascender(size) }.max
@@ -3,20 +3,42 @@
3
3
  module Stationery
4
4
  module Text
5
5
  # Wrapped lines of runs at a width, ready to measure, split across pages
6
- # and draw.
6
+ # and draw. Beside floats (`exclusions:`) every line has its own width
7
+ # and offset; the lines a split carries over are wrapped again without
8
+ # the floats, which stay on the page the first lines are on.
9
+ #
10
+ # A piece of a split paragraph keeps the runs of the whole and knows
11
+ # where it starts: after `skipped` lines of a wrap that was free of the
12
+ # floats from line `free_from`. The piece that holds the end of the text
13
+ # (`last`) can so be wrapped again at another width (#at).
7
14
  class Paragraph
8
15
  MIN_SHRINK_SIZE = 4
9
16
 
10
- attr_reader :runs, :lines, :width, :align, :leading
17
+ attr_reader :runs, :lines, :width, :align, :leading, :exclusions
11
18
 
12
- def initialize(runs, book:, width:, align: :left, leading: 0, lines: nil, fallback_style: nil)
19
+ def initialize(runs, book:, width:, align: :left, leading: 0, lines: nil, fallback_style: nil, exclusions: nil,
20
+ skipped: 0, free_from: nil, last: true)
13
21
  @runs = lines ? runs : book.fallback(runs)
14
22
  @book = book
15
23
  @width = width
16
24
  @align = align
17
25
  @leading = leading
18
26
  @fallback_style = fallback_style || runs.first&.style
19
- @lines = lines || Wrapper.new(book).wrap(@runs, width, fallback_style: @fallback_style)
27
+ @exclusions = exclusions
28
+ @skipped = skipped
29
+ @free_from = free_from
30
+ @last = last
31
+ @lines = lines || wrap
32
+ end
33
+
34
+ # This paragraph where the width is another: what it has left of its
35
+ # text, wrapped again. At its own width, and for a piece whose end a
36
+ # split cut off, that is the paragraph itself.
37
+ def at(width)
38
+ return self if !@last || (width - @width).abs <= Wrapper::EPSILON
39
+
40
+ (@others ||= {})[width] ||= self.class.new(left, book: @book, width:, align: @align, leading: @leading,
41
+ fallback_style: @fallback_style)
20
42
  end
21
43
 
22
44
  def height
@@ -31,7 +53,7 @@ module Stationery
31
53
  return [self, nil] if count == @lines.size
32
54
  return [nil, self] if count.zero?
33
55
 
34
- [with_lines(@lines.first(count)), with_lines(@lines.drop(count))]
56
+ [with_lines(@lines.first(count), last: false), rest_from(count)]
35
57
  end
36
58
 
37
59
  # [first `count` lines, the rest]; nil for an empty side.
@@ -39,7 +61,7 @@ module Stationery
39
61
  return [nil, self] if count <= 0
40
62
  return [self, nil] if count >= @lines.size
41
63
 
42
- [with_lines(@lines.first(count)), with_lines(@lines.drop(count))]
64
+ [with_lines(@lines.first(count), last: false), rest_from(count)]
43
65
  end
44
66
 
45
67
  # A paragraph that fits `max_height`: truncated to whole lines, shrunk
@@ -58,7 +80,7 @@ module Stationery
58
80
  canvas.tag_runs(tag) do |mark|
59
81
  top = y
60
82
  @lines.each do |line|
61
- offset = x + Geometry.align_offset(@align, @width, line.width)
83
+ offset = x + line.offset + Geometry.align_offset(@align, line.available || @width, line.width)
62
84
  extra = word_spacing(line)
63
85
  spaces = 0
64
86
  line.fragments.each do |fragment|
@@ -90,9 +112,30 @@ module Stationery
90
112
  end.size
91
113
  end
92
114
 
93
- def with_lines(lines)
115
+ def wrap(free_from: @free_from)
116
+ Wrapper.new(@book).wrap(@runs, @width, fallback_style: @fallback_style, exclusions: @exclusions,
117
+ leading: @leading, free_from:)
118
+ end
119
+
120
+ # The runs of the text from this piece's first line on.
121
+ def left
122
+ Wrapper.new(@book).rest(@runs, @width, @skipped, fallback_style: @fallback_style, exclusions: @exclusions,
123
+ leading: @leading, free_from: @free_from)
124
+ end
125
+
126
+ # The lines after the first `count`, wrapped again when a float is
127
+ # beside any of them.
128
+ def rest_from(count)
129
+ rest = @lines.drop(count)
130
+ from = @skipped + count
131
+ return with_lines(rest, skipped: from) unless rest.any? { |line| line.available && line.available < @width }
132
+
133
+ with_lines(wrap(free_from: from).drop(from), skipped: from, free_from: from)
134
+ end
135
+
136
+ def with_lines(lines, skipped: @skipped, free_from: @free_from, last: @last)
94
137
  self.class.new(@runs, book: @book, width: @width, align: @align, leading: @leading, lines:,
95
- fallback_style: @fallback_style)
138
+ fallback_style: @fallback_style, exclusions: @exclusions, skipped:, free_from:, last:)
96
139
  end
97
140
 
98
141
  def shrink(max_height)
@@ -114,7 +157,7 @@ module Stationery
114
157
  def word_spacing(line)
115
158
  return 0 unless @align == :justify && line.justifiable? && line.space_count.positive?
116
159
 
117
- [(@width - line.width) / line.space_count, 0].max
160
+ [((line.available || @width) - line.width) / line.space_count, 0].max
118
161
  end
119
162
 
120
163
  def draw_fragment(canvas, fragment, offset, top, line, word_spacing, link: nil)
@@ -132,12 +175,28 @@ module Stationery
132
175
  end
133
176
 
134
177
  def count_missing(fragment)
178
+ return count_shaped(fragment) if @book.shaper
179
+
135
180
  fragment.text.each_char do |char|
136
181
  next if Fonts::Fallback.carried?(char) || fragment.font.glyph?(char)
137
182
 
138
183
  @book.warnings.missing_glyph(char, fragment.style.family)
139
184
  end
140
185
  end
186
+
187
+ # With a shaper a missing glyph is one the shaper answered glyph 0 for;
188
+ # text it declined is drawn, and so counted, without it.
189
+ def count_shaped(fragment)
190
+ style = fragment.style
191
+ run = fragment.font.shaped(fragment.text, style.render_size, letter_spacing: style.letter_spacing,
192
+ kerning: style.kerning,
193
+ ligatures: style.ligatures,
194
+ features: style.features)
195
+ chars = run ? run.missing : fragment.text.each_char.reject { |char| covered?(fragment.font, char) }
196
+ chars.each { |char| @book.warnings.missing_glyph(char, style.family) }
197
+ end
198
+
199
+ def covered?(font, char) = Fonts::Fallback.carried?(char) || font.glyph?(char)
141
200
  end
142
201
  end
143
202
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Text
5
+ class Wrapper
6
+ # What is left to wrap after the first lines of a paragraph: the text a
7
+ # page break carries over to a place of another width, where it is
8
+ # wrapped again. Found by wrapping as before and stopping after those
9
+ # lines, so a word broken at the stop hands on the part that did not
10
+ # fit, soft hyphens and all.
11
+ module Remainder
12
+ # The runs left after the first `count` lines of `wrap` with the same arguments.
13
+ def rest(runs, max_width, count, **)
14
+ return runs if count.zero?
15
+
16
+ @stop = count
17
+ carried = catch(:stopped) do
18
+ wrap(runs, max_width, **)
19
+ return []
20
+ end
21
+ runs_of(carried + @ahead.flat_map { |item| segments_of(item, carried.last || @ahead.first) })
22
+ ensure
23
+ @stop = nil
24
+ end
25
+
26
+ private
27
+
28
+ def place_all(items)
29
+ return items.each { |item| place(item) } unless @stop
30
+
31
+ items.each_with_index do |item, index|
32
+ @ahead = items.drop(index + 1)
33
+ place(item)
34
+ end
35
+ end
36
+
37
+ # A newline and a zero-width space are items without a segment; they
38
+ # go on in the style of the text around them.
39
+ def segments_of((kind, segments), neighbour)
40
+ return segments unless kind == :newline || segments.empty?
41
+
42
+ [Segment.new(kind == :newline ? "\n" : ZERO_WIDTH_SPACE, style_of(neighbour))]
43
+ end
44
+
45
+ def style_of(neighbour)
46
+ return neighbour.style if neighbour.is_a?(Segment)
47
+
48
+ neighbour[1]&.first&.style || @fallback
49
+ end
50
+
51
+ def runs_of(segments)
52
+ segments.chunk_while { |a, b| a.style == b.style }.map do |group|
53
+ Run.new(group.map(&:text).join, group.first.style)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "wrapper/remainder"
4
+
3
5
  module Stationery
4
6
  module Text
5
7
  # Greedy line breaking over styled runs. Breaks at spaces (the space is
@@ -10,7 +12,16 @@ module Stationery
10
12
  # is broken between characters. A word that changes style midway
11
13
  # ("<b>Tot</b>al") is still one word. Soft hyphens are never measured or
12
14
  # drawn: a broken word draws "-" at the break and nothing otherwise.
15
+ #
16
+ # Beside floats (`exclusions:`) every line has the width left at its own
17
+ # top, found from the heights of the lines above it and the `leading`
18
+ # between them; how tall the line will be is not known before it is
19
+ # filled, so it is taken to be as tall as a line of the base style. The
20
+ # lines from `free_from` on are wrapped as if the floats were gone: what
21
+ # is left of a paragraph after a page break.
13
22
  class Wrapper
23
+ include Remainder
24
+
14
25
  TOKEN = /\n|[ \t]+|[^ \t\n-]*-+|[^ \t\n-]+/
15
26
  SOFT_HYPHEN = "­"
16
27
  ZERO_WIDTH_SPACE = Breaks::ZERO_WIDTH_SPACE
@@ -22,14 +33,15 @@ module Stationery
22
33
  @book = book
23
34
  end
24
35
 
25
- def wrap(runs, max_width, fallback_style: runs.first&.style)
36
+ def wrap(runs, max_width, fallback_style: runs.first&.style, exclusions: nil, leading: 0, free_from: nil)
26
37
  @max = max_width
27
38
  @lines = []
28
39
  @current = []
29
40
  @pending_space = []
30
41
  @fallback = fallback_style
31
42
  @widths = {}.compare_by_identity
32
- items(runs).each { |item| place(item) }
43
+ beside(exclusions, max_width, leading, free_from) if exclusions
44
+ place_all(items(runs))
33
45
  finish unless @current.empty? && @lines.any? && !@ended_with_newline
34
46
  @lines
35
47
  end
@@ -116,10 +128,10 @@ module Stationery
116
128
  @current.concat(@pending_space, word)
117
129
  elsif (head, tail = hyphenated(word, @max - line_width - width(@pending_space)))
118
130
  @current.concat(@pending_space, head)
119
- finish(wrapped: true)
131
+ finish(wrapped: true, carry: tail)
120
132
  return place_word(tail, explicit:)
121
133
  else
122
- finish(wrapped: true)
134
+ finish(wrapped: true, carry: word)
123
135
  @current.concat(word)
124
136
  end
125
137
  @pending_space = []
@@ -133,7 +145,7 @@ module Stationery
133
145
  head, tail = hyphenated(@current, @max)
134
146
  if head
135
147
  @current = head
136
- finish(wrapped: true)
148
+ finish(wrapped: true, carry: tail)
137
149
  @current = tail
138
150
  else
139
151
  break_long_word
@@ -190,21 +202,53 @@ module Stationery
190
202
  end
191
203
 
192
204
  def keep_overflow(fitting, piece)
193
- rest = remaining_after(fitting.size)
205
+ rest = [piece, *remaining_after(fitting.size).drop(1)]
194
206
  @current = fitting
195
- finish(wrapped: true)
196
- @current = [piece, *rest.drop(1)]
207
+ finish(wrapped: true, carry: rest)
208
+ @current = rest
197
209
  end
198
210
 
199
211
  def remaining_after(count)
200
212
  @current.flat_map { |segment| segment.text.chars.map { |char| Segment.new(char, segment.style) } }.drop(count)
201
213
  end
202
214
 
203
- def finish(wrapped: false)
215
+ # `carry` is what the word broken at the end of the line has left.
216
+ def finish(wrapped: false, carry: nil)
204
217
  segments = wrapped ? @current : @current + @pending_space
205
- @lines << Line.new(fragments(trim_trailing(segments)), fallback_metrics, justifiable: wrapped)
218
+ @lines << line(fragments(trim_trailing(segments)), wrapped)
219
+ throw :stopped, carry.to_a if @stop == @lines.size
206
220
  @current = []
207
221
  @pending_space = []
222
+ next_line if @exclusions
223
+ end
224
+
225
+ def line(fragments, wrapped)
226
+ return Line.new(fragments, fallback_metrics, justifiable: wrapped) unless @exclusions
227
+
228
+ Line.new(fragments, fallback_metrics, justifiable: wrapped, offset: @offset, available: @max)
229
+ end
230
+
231
+ def beside(exclusions, width, leading, free_from)
232
+ @exclusions = exclusions
233
+ @full = width
234
+ @leading = leading
235
+ @free_from = free_from
236
+ @top = 0
237
+ font, size = fallback_metrics
238
+ @probe = font.line_height(size)
239
+ start_line
240
+ end
241
+
242
+ def next_line
243
+ @top += @lines.last.height + @leading
244
+ start_line
245
+ end
246
+
247
+ # The width and the offset of the line about to be filled.
248
+ def start_line
249
+ left, right = @free_from && @lines.size >= @free_from ? [0, 0] : @exclusions.insets(@top, @probe)
250
+ @offset = left
251
+ @max = [@full - left - right, 0].max
208
252
  end
209
253
 
210
254
  def trim_trailing(segments)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.0"
5
5
  end