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
|
@@ -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,
|
|
8
|
-
# kept-together groups, borders and
|
|
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))
|
|
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, **
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/stationery/text/line.rb
CHANGED
|
@@ -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
|
-
@
|
|
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)),
|
|
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)),
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 <<
|
|
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)
|
data/lib/stationery/version.rb
CHANGED