stationery 0.6.0 → 0.7.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 +10 -0
- data/README.md +72 -15
- data/lib/stationery/builder.rb +5 -3
- data/lib/stationery/canvas/text.rb +4 -3
- data/lib/stationery/canvas.rb +5 -2
- data/lib/stationery/document.rb +26 -9
- data/lib/stationery/elements.rb +1 -1
- data/lib/stationery/fonts/fallback.rb +3 -3
- data/lib/stationery/fonts/font.rb +39 -21
- data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
- data/lib/stationery/fonts/gsub.rb +65 -20
- data/lib/stationery/fonts/ligatures.rb +6 -4
- data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
- data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
- data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
- data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
- data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
- data/lib/stationery/hyphenation.rb +132 -0
- data/lib/stationery/images/png.rb +66 -0
- data/lib/stationery/images/resampled.rb +80 -0
- data/lib/stationery/layout/image.rb +28 -2
- data/lib/stationery/layout/paginator.rb +1 -1
- data/lib/stationery/layout/text.rb +2 -2
- data/lib/stationery/minitest.rb +5 -0
- data/lib/stationery/page_templates.rb +2 -1
- data/lib/stationery/pdf/assembler.rb +6 -2
- data/lib/stationery/pdf/page_labels.rb +71 -0
- data/lib/stationery/testing/inspector.rb +13 -0
- data/lib/stationery/testing/matchers.rb +10 -0
- data/lib/stationery/text/entities.rb +14 -4
- data/lib/stationery/text/paragraph.rb +1 -1
- data/lib/stationery/text/style.rb +14 -3
- data/lib/stationery/text/wrapper.rb +73 -12
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +6 -0
- data/lib/stationery.rb +4 -0
- metadata +10 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# Liang's hyphenation over the TeX `hyph-utf8` patterns bundled under
|
|
5
|
+
# hyphenation/patterns/ (see LICENSES.md there). A language loads on first
|
|
6
|
+
# use and stays cached per process.
|
|
7
|
+
#
|
|
8
|
+
# Stationery::Hyphenation.hyphenate("Silbentrennung", "de") # => ["Sil", "ben", "tren", "nung"]
|
|
9
|
+
# Stationery::Hyphenation.points("hyphenation", "en") # => [2, 6]
|
|
10
|
+
module Hyphenation
|
|
11
|
+
PATTERNS = File.expand_path("hyphenation/patterns", __dir__)
|
|
12
|
+
|
|
13
|
+
# Language tags accepted by `hyphenate:`, mapped to a bundled pattern set.
|
|
14
|
+
LANGUAGES = {
|
|
15
|
+
"en" => "en-us", "en-us" => "en-us", "en_us" => "en-us",
|
|
16
|
+
"de" => "de-1996", "de-1996" => "de-1996", "de-de" => "de-1996",
|
|
17
|
+
"sv" => "sv", "sv-se" => "sv"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# Letters kept intact at either end of a word (the pattern sets' own
|
|
21
|
+
# typesetting minimums).
|
|
22
|
+
MINIMUMS = { "en-us" => [2, 3], "de-1996" => [2, 2], "sv" => [2, 2] }.freeze
|
|
23
|
+
|
|
24
|
+
LOCK = Mutex.new
|
|
25
|
+
|
|
26
|
+
# One pattern set: the patterns keyed by their letters, the longest
|
|
27
|
+
# pattern's length and the exception list.
|
|
28
|
+
class Patterns
|
|
29
|
+
attr_reader :left_min, :right_min
|
|
30
|
+
|
|
31
|
+
def initialize(tag)
|
|
32
|
+
@left_min, @right_min = MINIMUMS.fetch(tag)
|
|
33
|
+
@patterns = {}
|
|
34
|
+
@longest = 0
|
|
35
|
+
@exceptions = {}
|
|
36
|
+
load_patterns(File.join(PATTERNS, "#{tag}.pat.txt"))
|
|
37
|
+
exceptions = File.join(PATTERNS, "#{tag}.hyp.txt")
|
|
38
|
+
load_exceptions(exceptions) if File.exist?(exceptions)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Indexes where `word` may break (a break before word[index]),
|
|
42
|
+
# honouring the minimums.
|
|
43
|
+
def points(word)
|
|
44
|
+
key = word.downcase
|
|
45
|
+
return @exceptions[key] if @exceptions.key?(key)
|
|
46
|
+
|
|
47
|
+
levels = levels_for(".#{key}.")
|
|
48
|
+
(left_min..(word.length - right_min)).select { |index| levels[index].odd? }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def load_patterns(path)
|
|
54
|
+
File.foreach(path, chomp: true) do |line|
|
|
55
|
+
next if line.empty? || line.start_with?("%")
|
|
56
|
+
|
|
57
|
+
letters = line.delete("0-9")
|
|
58
|
+
values = Array.new(letters.length + 1, 0)
|
|
59
|
+
position = 0
|
|
60
|
+
line.each_char do |char|
|
|
61
|
+
if char.match?(/\d/) then values[position] = char.to_i
|
|
62
|
+
else position += 1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
@patterns[letters] = values
|
|
66
|
+
@longest = letters.length if letters.length > @longest
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# "as-so-ciate" → { "associate" => [2, 4] }
|
|
71
|
+
def load_exceptions(path)
|
|
72
|
+
File.foreach(path, chomp: true) do |line|
|
|
73
|
+
next if line.empty? || line.start_with?("%")
|
|
74
|
+
|
|
75
|
+
pieces = line.downcase.split("-")
|
|
76
|
+
positions = pieces[0..-2].each_with_object([]) { |piece, list| list << (list.last.to_i + piece.length) }
|
|
77
|
+
@exceptions[pieces.join] = positions
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Liang: the highest digit any matching pattern puts between two
|
|
82
|
+
# letters of the dotted word; odd means a break may go there. Index i
|
|
83
|
+
# in the result is the gap before letter i of the undotted word.
|
|
84
|
+
def levels_for(dotted)
|
|
85
|
+
levels = Array.new(dotted.length + 1, 0)
|
|
86
|
+
dotted.length.times do |start|
|
|
87
|
+
1.upto([@longest, dotted.length - start].min) do |length|
|
|
88
|
+
values = @patterns[dotted[start, length]] or next
|
|
89
|
+
values.each_with_index do |value, offset|
|
|
90
|
+
levels[start + offset] = value if value > levels[start + offset]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
levels.drop(1)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
module_function
|
|
99
|
+
|
|
100
|
+
# The bundled pattern set for a `hyphenate:` value: true is English,
|
|
101
|
+
# a tag is looked up case-insensitively; nil for false or nil.
|
|
102
|
+
def tag(value)
|
|
103
|
+
return nil if value.nil? || value == false
|
|
104
|
+
return "en-us" if value == true
|
|
105
|
+
|
|
106
|
+
LANGUAGES.fetch(value.to_s.downcase) do
|
|
107
|
+
raise ArgumentError, "unknown hyphenation language #{value.inspect} (bundled: #{LANGUAGES.keys.join(", ")})"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def patterns(language)
|
|
112
|
+
tag = tag(language)
|
|
113
|
+
@sets ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
114
|
+
LOCK.synchronize { @sets[tag] ||= Patterns.new(tag) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Indexes where `word` may be broken (before word[index]); an empty
|
|
118
|
+
# array for a word too short to break or holding anything but letters.
|
|
119
|
+
def points(word, language)
|
|
120
|
+
return [] unless word.match?(/\A\p{L}+\z/)
|
|
121
|
+
|
|
122
|
+
patterns(language).points(word)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The word cut at its break points.
|
|
126
|
+
def hyphenate(word, language)
|
|
127
|
+
last = 0
|
|
128
|
+
pieces = points(word, language).map { |index| word[last...index].tap { last = index } }
|
|
129
|
+
pieces << word[last..]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -9,6 +9,8 @@ module Stationery
|
|
|
9
9
|
# mask for grey/RGB tRNS).
|
|
10
10
|
class PNG
|
|
11
11
|
CHANNELS = { 0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4 }.freeze
|
|
12
|
+
# Colour samples per pixel once palette and alpha are unpacked.
|
|
13
|
+
COLOR_CHANNELS = { 0 => 1, 2 => 3, 3 => 3, 4 => 1, 6 => 3 }.freeze
|
|
12
14
|
|
|
13
15
|
attr_reader :width, :height
|
|
14
16
|
|
|
@@ -21,6 +23,13 @@ module Stationery
|
|
|
21
23
|
|
|
22
24
|
def inspect = "#<#{self.class} #{@width}x#{@height}>"
|
|
23
25
|
|
|
26
|
+
# This image scaled down to `width` pixels (a Resampled), memoised per
|
|
27
|
+
# width; the same instance is shared through the image cache.
|
|
28
|
+
def resample(width)
|
|
29
|
+
@resampled ||= {}
|
|
30
|
+
@resampled[width] ||= Resampled.new(pixels, width)
|
|
31
|
+
end
|
|
32
|
+
|
|
24
33
|
def build(writer)
|
|
25
34
|
return build_with_alpha(writer) if alpha_channel?
|
|
26
35
|
|
|
@@ -38,6 +47,63 @@ module Stationery
|
|
|
38
47
|
|
|
39
48
|
private
|
|
40
49
|
|
|
50
|
+
# Every sample as 8 bits, colour rows apart from alpha rows: palette and
|
|
51
|
+
# sub-byte greys expanded, 16-bit samples dropped to their high byte, a
|
|
52
|
+
# colour-key or palette transparency turned into an alpha row set.
|
|
53
|
+
def pixels
|
|
54
|
+
channels = CHANNELS[@color_type]
|
|
55
|
+
color = []
|
|
56
|
+
alpha = []
|
|
57
|
+
Scanlines.each(@idat, @height, [(channels * @bit_depth) / 8, 1].max,
|
|
58
|
+
((@width * channels * @bit_depth) + 7) / 8) do |row|
|
|
59
|
+
samples = samples_of(row)
|
|
60
|
+
case @color_type
|
|
61
|
+
when 3 then palette_pixels(samples, color, alpha)
|
|
62
|
+
when 4, 6 then split_pixels(samples, channels, color, alpha)
|
|
63
|
+
else keyed_pixels(samples, channels, color, alpha)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
Pixels.new(width: @width, height: @height, channels: COLOR_CHANNELS[@color_type],
|
|
67
|
+
color:, alpha: alpha.empty? ? nil : alpha)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A row's samples at 8 bits.
|
|
71
|
+
def samples_of(row)
|
|
72
|
+
case @bit_depth
|
|
73
|
+
when 8 then row
|
|
74
|
+
when 16 then row.each_slice(2).map(&:first)
|
|
75
|
+
else
|
|
76
|
+
scale = 255 / ((1 << @bit_depth) - 1)
|
|
77
|
+
bits = row.pack("C*").unpack1("B*")
|
|
78
|
+
values = bits.scan(/.{#{@bit_depth}}/o).first(@width).map { |b| b.to_i(2) }
|
|
79
|
+
@color_type == 3 ? values : values.map { |v| v * scale }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def palette_pixels(indexes, color, alpha)
|
|
84
|
+
palette = @palette.bytes
|
|
85
|
+
alphas = @transparency&.bytes
|
|
86
|
+
color << indexes.flat_map { |i| palette[i * 3, 3] }
|
|
87
|
+
alpha << indexes.map { |i| alphas[i] || 255 } if alphas
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def split_pixels(samples, channels, color, alpha)
|
|
91
|
+
color << samples.each_slice(channels).flat_map { |px| px[0...-1] }
|
|
92
|
+
alpha << samples.each_slice(channels).map(&:last)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Grey or RGB, with a tRNS colour key (compared at the file's bit depth)
|
|
96
|
+
# becoming a fully transparent alpha.
|
|
97
|
+
def keyed_pixels(samples, channels, color, alpha)
|
|
98
|
+
color << samples
|
|
99
|
+
return unless @transparency
|
|
100
|
+
|
|
101
|
+
key = @transparency.unpack("n*").first(channels).map do |v|
|
|
102
|
+
@bit_depth == 16 ? v >> 8 : v * (255 / ((1 << @bit_depth) - 1))
|
|
103
|
+
end
|
|
104
|
+
alpha << samples.each_slice(channels).map { |px| px == key ? 0 : 255 }
|
|
105
|
+
end
|
|
106
|
+
|
|
41
107
|
def read_chunks(data)
|
|
42
108
|
pos = PNG_SIGNATURE.bytesize
|
|
43
109
|
while pos + 8 <= data.bytesize
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module Stationery
|
|
6
|
+
module Images
|
|
7
|
+
# A decoded image scaled down to a pixel width with a box filter (every
|
|
8
|
+
# source pixel of a block weighs the same), embedded as 8-bit grey or RGB
|
|
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.
|
|
11
|
+
class Resampled
|
|
12
|
+
attr_reader :width, :height
|
|
13
|
+
|
|
14
|
+
# `pixels` is a Pixels: rows of 8-bit samples, `channels` colour samples
|
|
15
|
+
# per pixel (1 or 3) and an optional alpha row set.
|
|
16
|
+
def initialize(pixels, width)
|
|
17
|
+
@channels = pixels.channels
|
|
18
|
+
@width = [width, 1].max
|
|
19
|
+
@height = [(pixels.height * @width / pixels.width.to_f).round, 1].max
|
|
20
|
+
@color = shrink(pixels.color, pixels.width, pixels.height, @channels)
|
|
21
|
+
@alpha = pixels.alpha && shrink(pixels.alpha, pixels.width, pixels.height, 1)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def inspect = "#<#{self.class} #{@width}x#{@height}>"
|
|
25
|
+
|
|
26
|
+
def build(writer)
|
|
27
|
+
color_space = @channels == 1 ? :DeviceGray : :DeviceRGB
|
|
28
|
+
dictionary = Images.xobject(@width, @height, color_space, 8).merge(Filter: :FlateDecode)
|
|
29
|
+
if @alpha
|
|
30
|
+
mask = Images.xobject(@width, @height, :DeviceGray, 8).merge(Filter: :FlateDecode)
|
|
31
|
+
dictionary[:SMask] = writer.add(PDF::Stream.new(Zlib::Deflate.deflate(@alpha), mask))
|
|
32
|
+
end
|
|
33
|
+
writer.add(PDF::Stream.new(Zlib::Deflate.deflate(@color), dictionary))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Box filter: output pixel (ox, oy) averages the source block whose edges
|
|
39
|
+
# are the scaled output edges. Row sums are accumulated per output column
|
|
40
|
+
# first so every source sample is visited once.
|
|
41
|
+
def shrink(rows, source_width, source_height, channels)
|
|
42
|
+
columns = spans(source_width, @width)
|
|
43
|
+
bands = spans(source_height, @height)
|
|
44
|
+
out = String.new(capacity: @width * @height * channels, encoding: Encoding::BINARY)
|
|
45
|
+
bands.each do |y0, y1|
|
|
46
|
+
sums = Array.new(@width * channels, 0)
|
|
47
|
+
(y0...y1).each { |y| add_row(sums, rows[y], columns, channels) }
|
|
48
|
+
count = y1 - y0
|
|
49
|
+
columns.each_with_index do |(x0, x1), ox|
|
|
50
|
+
n = (x1 - x0) * count
|
|
51
|
+
channels.times { |c| out << (sums[(ox * channels) + c] / n) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
out
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def add_row(sums, row, columns, channels)
|
|
58
|
+
columns.each_with_index do |(x0, x1), ox|
|
|
59
|
+
base = ox * channels
|
|
60
|
+
(x0...x1).each do |x|
|
|
61
|
+
offset = x * channels
|
|
62
|
+
channels.times { |c| sums[base + c] += row[offset + c] }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# [start, end) source indexes for each of `target` output positions;
|
|
68
|
+
# every source index lands in exactly one span.
|
|
69
|
+
def spans(source, target)
|
|
70
|
+
Array.new(target) do |i|
|
|
71
|
+
[(i * source) / target, (((i + 1) * source) / target).clamp(((i * source) / target) + 1, source)]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Decoded PNG samples: `color` and `alpha` are Arrays of rows, each row an
|
|
77
|
+
# Array of 8-bit sample values (`channels` per pixel for colour, one for alpha).
|
|
78
|
+
Pixels = Data.define(:width, :height, :channels, :color, :alpha)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -9,14 +9,25 @@ module Stationery
|
|
|
9
9
|
# `radius:` clips to rounded corners and `rotate:` (degrees, clockwise)
|
|
10
10
|
# turns the painted image around the centre of its rectangle; neither
|
|
11
11
|
# changes the space the image takes up.
|
|
12
|
+
#
|
|
13
|
+
# A bitmap keeps its pixels: drawn at more than twice `max_ppi:` (300;
|
|
14
|
+
# nil disables) it is reported as a Warnings::OversizedImage, and with
|
|
15
|
+
# `downscale: true` a PNG is resampled to `max_ppi` at its drawn size
|
|
16
|
+
# instead (a JPEG is never re-encoded; resize it before embedding).
|
|
12
17
|
class Image < Node
|
|
18
|
+
DEFAULT_MAX_PPI = 300
|
|
19
|
+
|
|
13
20
|
# `alt:` describes the image in a tagged PDF; `alt: false` marks it decorative.
|
|
14
|
-
def initialize(source, width: nil, height: nil, fit: nil, opacity: nil, alt: nil, radius: 0, rotate: 0
|
|
21
|
+
def initialize(source, width: nil, height: nil, fit: nil, opacity: nil, alt: nil, radius: 0, rotate: 0,
|
|
22
|
+
max_ppi: DEFAULT_MAX_PPI, downscale: false)
|
|
15
23
|
raise ArgumentError, "fit: :cover needs width: and height:" if fit == :cover && !(width && height)
|
|
16
24
|
|
|
17
25
|
super()
|
|
18
26
|
@tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
|
|
19
27
|
@image = source.respond_to?(:build) ? source : Images.load(source)
|
|
28
|
+
@name = source.is_a?(String) || source.is_a?(Pathname) ? File.basename(source.to_s) : "inline image"
|
|
29
|
+
@max_ppi = max_ppi
|
|
30
|
+
@downscale = downscale
|
|
20
31
|
@width = width
|
|
21
32
|
@height = height
|
|
22
33
|
@fit = fit
|
|
@@ -63,7 +74,22 @@ module Stationery
|
|
|
63
74
|
w = cw
|
|
64
75
|
h = ch
|
|
65
76
|
end
|
|
66
|
-
canvas.image(
|
|
77
|
+
canvas.image(bitmap(canvas, w), x:, y:, width: w, height: h, opacity: @opacity)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The image to embed for a drawn width: resampled to the ppi limit when
|
|
81
|
+
# asked and possible, else the source, reported when it is oversized.
|
|
82
|
+
def bitmap(canvas, drawn_width)
|
|
83
|
+
return @image unless @max_ppi
|
|
84
|
+
|
|
85
|
+
wanted = (drawn_width / 72.0 * @max_ppi).ceil
|
|
86
|
+
return @image.resample(wanted) if @downscale && @image.width > wanted && @image.respond_to?(:resample)
|
|
87
|
+
|
|
88
|
+
ppi = (@image.width * 72.0 / drawn_width).round
|
|
89
|
+
if ppi > 2 * @max_ppi && canvas.warnings
|
|
90
|
+
canvas.warnings << Warnings::OversizedImage.new(source: @name, pixels: @image.width, ppi:, limit: @max_ppi)
|
|
91
|
+
end
|
|
92
|
+
@image
|
|
67
93
|
end
|
|
68
94
|
|
|
69
95
|
def requested
|
|
@@ -62,7 +62,7 @@ module Stationery
|
|
|
62
62
|
box = page.content_box
|
|
63
63
|
height = head.measure(box.width)
|
|
64
64
|
@warnings << Overflow.new(page: number, height:, available: box.height) if height > box.height + EPSILON
|
|
65
|
-
canvas = Canvas.new(page, @resources, debug: @debug, tagging: @tagging)
|
|
65
|
+
canvas = Canvas.new(page, @resources, debug: @debug, tagging: @tagging, warnings: @warnings)
|
|
66
66
|
head.paint(canvas, box.x, box.y, box.width)
|
|
67
67
|
canvas.debug_rect(box.x, box.y, box.width, box.height, :page)
|
|
68
68
|
end
|
|
@@ -42,9 +42,9 @@ module Stationery
|
|
|
42
42
|
@min_width ||= @runs.flat_map do |run|
|
|
43
43
|
style = run.style
|
|
44
44
|
font = @context.book.resolve(style).first
|
|
45
|
-
run.text.split(/[ \t\n]+/).map do |word|
|
|
45
|
+
run.text.delete(::Stationery::Text::Wrapper::SOFT_HYPHEN).split(/[ \t\n]+/).map do |word|
|
|
46
46
|
font.width_of(word, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
|
|
47
|
-
ligatures: style.ligatures)
|
|
47
|
+
ligatures: style.ligatures, features: style.features)
|
|
48
48
|
end
|
|
49
49
|
end.max || 0
|
|
50
50
|
end
|
data/lib/stationery/minitest.rb
CHANGED
|
@@ -17,6 +17,11 @@ module Stationery
|
|
|
17
17
|
def assert_image_count(subject, count, msg = nil) = assert_pdf(Matchers::HaveImageCount.new(count), subject, msg)
|
|
18
18
|
def assert_bookmark(subject, title, msg = nil) = assert_pdf(Matchers::HaveBookmark.new(title), subject, msg)
|
|
19
19
|
def assert_pdf_language(subject, lang, msg = nil) = assert_pdf(Matchers::HaveLanguage.new(lang), subject, msg)
|
|
20
|
+
|
|
21
|
+
def assert_page_labels(subject, labels, msg = nil)
|
|
22
|
+
assert_pdf(Matchers::HavePageLabels.new(labels), subject, msg)
|
|
23
|
+
end
|
|
24
|
+
|
|
20
25
|
def assert_no_pdf_warnings(subject, msg = nil) = assert_pdf(Matchers::HaveNoWarnings.new, subject, msg)
|
|
21
26
|
def assert_pdf_structure(subject, tree, msg = nil) = assert_pdf(Matchers::HaveStructure.new(tree), subject, msg)
|
|
22
27
|
def assert_tagged_content(subject, msg = nil) = assert_pdf(Matchers::HaveTaggedContent.new, subject, msg)
|
|
@@ -54,7 +54,8 @@ module Stationery
|
|
|
54
54
|
# Everything a template paints is a pagination artifact in a tagged PDF.
|
|
55
55
|
def paint(page, root, rect, layer = :foreground, subtype: nil)
|
|
56
56
|
mark = page.content.bytesize
|
|
57
|
-
canvas = Canvas.new(page, @resources, template: true, debug: @debug, tagging: @tagging
|
|
57
|
+
canvas = Canvas.new(page, @resources, template: true, debug: @debug, tagging: @tagging,
|
|
58
|
+
warnings: @warnings)
|
|
58
59
|
canvas.artifact(type: :pagination, subtype:) { root.paint(canvas, rect.x, rect.y, rect.width) }
|
|
59
60
|
page.content.prepend(page.content.slice!(mark..)) if layer == :background
|
|
60
61
|
end
|
|
@@ -5,10 +5,13 @@ module Stationery
|
|
|
5
5
|
# Writes finished pages, their shared resources and document info as PDF.
|
|
6
6
|
class Assembler
|
|
7
7
|
# `tagging:` (a Tagging::Tree) writes the structure tree of a tagged PDF;
|
|
8
|
-
# `lang:` is the document's natural language
|
|
9
|
-
|
|
8
|
+
# `lang:` is the document's natural language; `page_labels:` is the
|
|
9
|
+
# /PageLabels number tree from PageLabels.entries.
|
|
10
|
+
def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil,
|
|
11
|
+
page_labels: nil)
|
|
10
12
|
@tagging = tagging
|
|
11
13
|
@lang = lang
|
|
14
|
+
@page_labels = page_labels
|
|
12
15
|
@pages = pages
|
|
13
16
|
@resources = resources
|
|
14
17
|
@info = info
|
|
@@ -34,6 +37,7 @@ module Stationery
|
|
|
34
37
|
|
|
35
38
|
def catalog(tree, outlines, form)
|
|
36
39
|
catalog = { Type: :Catalog, Pages: tree }
|
|
40
|
+
catalog[:PageLabels] = @page_labels if @page_labels
|
|
37
41
|
catalog[:AcroForm] = form if form
|
|
38
42
|
outlines ? catalog.merge(Outlines: outlines, PageMode: :UseOutlines) : catalog
|
|
39
43
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module PDF
|
|
5
|
+
# The /PageLabels number tree: how viewers name pages ("i", "ii", "1",
|
|
6
|
+
# "A-1"). A spec maps a 1-based first page to the range starting there:
|
|
7
|
+
#
|
|
8
|
+
# { 1 => { style: :roman_lower }, 3 => { style: :decimal, start: 1, prefix: "A-" } }
|
|
9
|
+
#
|
|
10
|
+
# Styles are :decimal, :roman, :roman_lower, :alpha, :alpha_lower or nil
|
|
11
|
+
# (prefix only). Pages before the first range have no label; viewers show
|
|
12
|
+
# their physical number.
|
|
13
|
+
module PageLabels
|
|
14
|
+
STYLES = { decimal: :D, roman: :R, roman_lower: :r, alpha: :A, alpha_lower: :a }.freeze
|
|
15
|
+
NUMERALS = { D: :decimal, R: :upper_roman, r: :roman, A: :upper_alpha, a: :alpha }.freeze
|
|
16
|
+
KEYS = %i[style start prefix].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The number tree dictionary for a spec, or nil for none.
|
|
21
|
+
def entries(spec)
|
|
22
|
+
return if spec.nil? || spec.empty?
|
|
23
|
+
|
|
24
|
+
nums = spec.sort_by { |page, _| validate_page(page) }.flat_map do |page, range|
|
|
25
|
+
[page - 1, entry(page, range)]
|
|
26
|
+
end
|
|
27
|
+
{ Nums: nums }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The label of each of `count` pages from a /Nums array (as read back).
|
|
31
|
+
def labels(nums, count)
|
|
32
|
+
ranges = nums.each_slice(2).to_a
|
|
33
|
+
Array.new(count) do |index|
|
|
34
|
+
start, dict = ranges.reverse.find { |from, _| from <= index }
|
|
35
|
+
dict && label(dict, index - start)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_page(page)
|
|
40
|
+
return page if page.is_a?(Integer) && page >= 1
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, "page labels start at page 1, not page #{page.inspect}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def entry(page, range)
|
|
46
|
+
unknown = range.keys - KEYS
|
|
47
|
+
raise ArgumentError, "unknown page label option #{unknown.first.inspect} for page #{page}" if unknown.any?
|
|
48
|
+
|
|
49
|
+
style = range[:style]
|
|
50
|
+
raise ArgumentError, "unknown page label style #{style.inspect}" unless style.nil? || STYLES.key?(style)
|
|
51
|
+
|
|
52
|
+
start = range[:start]
|
|
53
|
+
if start && !(start.is_a?(Integer) && start >= 1)
|
|
54
|
+
raise ArgumentError, "page label start #{start.inspect} for page #{page} must be 1 or more"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
entry = {}
|
|
58
|
+
entry[:S] = STYLES[style] if style
|
|
59
|
+
entry[:St] = start if start
|
|
60
|
+
entry[:P] = TextString.new(range[:prefix].to_s) if range[:prefix]
|
|
61
|
+
entry
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def label(dict, offset)
|
|
65
|
+
numeral = NUMERALS[dict[:S]&.to_sym]
|
|
66
|
+
number = (dict[:St] || 1) + offset
|
|
67
|
+
"#{dict[:P]}#{ListMarkers.numeral(numeral, number) if numeral}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -70,6 +70,17 @@ module Stationery
|
|
|
70
70
|
decode(lang) if lang
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# One label per page from the catalog /PageLabels ("i", "A-1", …), nil
|
|
74
|
+
# for pages before the first labelled range; empty without labels.
|
|
75
|
+
def page_labels
|
|
76
|
+
labels = catalog[:PageLabels]
|
|
77
|
+
return [] unless labels
|
|
78
|
+
|
|
79
|
+
nums = objects.deref_array(objects.deref_hash(labels)[:Nums]).map { |value| objects.deref(value) }
|
|
80
|
+
nums = nums.map { |value| value.is_a?(Hash) ? value.transform_values { |v| decode_value(v) } : value }
|
|
81
|
+
PDF::PageLabels.labels(nums, page_count)
|
|
82
|
+
end
|
|
83
|
+
|
|
73
84
|
# The structure tree of a tagged PDF as nested arrays; see StructureReader.
|
|
74
85
|
def structure = @structure ||= StructureReader.new(reader).tree
|
|
75
86
|
|
|
@@ -109,6 +120,8 @@ module Stationery
|
|
|
109
120
|
[decode(item[:Title]), *titles(item[:First]), *titles(item[:Next])]
|
|
110
121
|
end
|
|
111
122
|
|
|
123
|
+
def decode_value(value) = value.is_a?(String) ? decode(value) : value
|
|
124
|
+
|
|
112
125
|
def decode(title)
|
|
113
126
|
return title.dup.force_encoding(Encoding::UTF_8) unless title.b.start_with?(UTF16_BOM)
|
|
114
127
|
|
|
@@ -110,6 +110,15 @@ module Stationery
|
|
|
110
110
|
def actual = @inspector.lang ? "got #{@inspector.lang.inspect}" : "got none"
|
|
111
111
|
end
|
|
112
112
|
|
|
113
|
+
class HavePageLabels < Base
|
|
114
|
+
def description = "have page labels #{@expected.inspect}"
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def match?(pdf) = pdf.page_labels == @expected
|
|
119
|
+
def actual = @inspector.page_labels.empty? ? "got none" : "got #{@inspector.page_labels.inspect}"
|
|
120
|
+
end
|
|
121
|
+
|
|
113
122
|
class HaveNoWarnings < Base
|
|
114
123
|
def description = "have no warnings"
|
|
115
124
|
|
|
@@ -150,6 +159,7 @@ module Stationery
|
|
|
150
159
|
def have_image_count(expected) = HaveImageCount.new(expected)
|
|
151
160
|
def have_bookmark(title) = HaveBookmark.new(title)
|
|
152
161
|
def have_pdf_language(lang) = HaveLanguage.new(lang)
|
|
162
|
+
def have_page_labels(labels) = HavePageLabels.new(labels)
|
|
153
163
|
def have_no_warnings = HaveNoWarnings.new
|
|
154
164
|
def have_structure(expected) = HaveStructure.new(expected)
|
|
155
165
|
def have_tagged_content = HaveTaggedContent.new
|
|
@@ -2,23 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Text
|
|
5
|
-
# Decodes the HTML
|
|
5
|
+
# Decodes the HTML character references markup text may carry: numeric
|
|
6
|
+
# (`'`, `'`, `'`) and named (the built-ins below, then the
|
|
7
|
+
# HTML 4 table). Each reference is decoded once, so `&#39;` yields the
|
|
8
|
+
# literal text `'`; an unknown name, a malformed reference or a code
|
|
9
|
+
# point outside Unicode stays as written.
|
|
6
10
|
module Entities
|
|
7
11
|
NAMED = { "amp" => "&", "lt" => "<", "gt" => ">", "quot" => "\"", "apos" => "'", "nbsp" => " " }.freeze
|
|
8
|
-
PATTERN = /&(#
|
|
12
|
+
PATTERN = /&(#[xX]\h+|#\d+|[a-zA-Z]+);/
|
|
13
|
+
MAX_CODE_POINT = 0x10FFFF
|
|
14
|
+
SURROGATES = (0xD800..0xDFFF)
|
|
9
15
|
|
|
10
16
|
module_function
|
|
11
17
|
|
|
12
18
|
def decode(text)
|
|
13
19
|
text.gsub(PATTERN) do |entity|
|
|
14
20
|
name = Regexp.last_match(1)
|
|
15
|
-
if name.start_with?("#x") then
|
|
16
|
-
elsif name.start_with?("#") then
|
|
21
|
+
if name.start_with?("#x", "#X") then character(name[2..].to_i(16), entity)
|
|
22
|
+
elsif name.start_with?("#") then character(name[1..].to_i, entity)
|
|
17
23
|
else NAMED.fetch(name) { html4.fetch(name, entity) }
|
|
18
24
|
end
|
|
19
25
|
end
|
|
20
26
|
end
|
|
21
27
|
|
|
28
|
+
def character(code, entity)
|
|
29
|
+
code.between?(1, MAX_CODE_POINT) && !SURROGATES.cover?(code) ? [code].pack("U") : entity
|
|
30
|
+
end
|
|
31
|
+
|
|
22
32
|
def html4
|
|
23
33
|
require_relative "entities/html4" unless defined?(HTML4)
|
|
24
34
|
HTML4
|
|
@@ -117,7 +117,7 @@ module Stationery
|
|
|
117
117
|
color: style.color, letter_spacing: style.letter_spacing, rise: style.rise,
|
|
118
118
|
opacity: style.opacity, underline: style.underline,
|
|
119
119
|
strikethrough: style.strikethrough, kerning: style.kerning,
|
|
120
|
-
ligatures: style.ligatures,
|
|
120
|
+
ligatures: style.ligatures, features: style.features,
|
|
121
121
|
synthetic_bold: fragment.face.synthetic_bold,
|
|
122
122
|
synthetic_oblique: fragment.face.synthetic_oblique, word_spacing:)
|
|
123
123
|
canvas.link(x, top, width, line.height, style.link, tag: link) if style.link
|
|
@@ -3,15 +3,26 @@
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Text
|
|
5
5
|
SCRIPT_SCALE = 0.583
|
|
6
|
+
NO_FEATURES = [].freeze
|
|
6
7
|
|
|
7
8
|
# How a run of text looks. Immutable; derive variants with #with.
|
|
9
|
+
# `hyphenate` is a bundled hyphenation language (see Hyphenation) or nil.
|
|
8
10
|
Style = Data.define(:family, :size, :weight, :style, :color, :letter_spacing,
|
|
9
11
|
:underline, :strikethrough, :script, :link, :opacity, :kerning,
|
|
10
|
-
:ligatures) do
|
|
12
|
+
:ligatures, :features, :hyphenate) do
|
|
13
|
+
# `features:` are OpenType feature tags (`:smcp`, `"onum"`, …) applied on
|
|
14
|
+
# top of `ligatures:`; kept as sorted frozen Strings so equal sets are
|
|
15
|
+
# equal styles.
|
|
11
16
|
def initialize(family:, size: 10, weight: :regular, style: :normal, color: "#000000", letter_spacing: 0,
|
|
12
17
|
underline: false, strikethrough: false, script: nil, link: nil, opacity: nil, kerning: true,
|
|
13
|
-
ligatures: true)
|
|
14
|
-
super
|
|
18
|
+
ligatures: true, features: NO_FEATURES, hyphenate: nil)
|
|
19
|
+
super(family:, size:, weight:, style:, color:, letter_spacing:, underline:, strikethrough:, script:, link:,
|
|
20
|
+
opacity:, kerning:, ligatures:, features: Style.features(features), hyphenate: Hyphenation.tag(hyphenate))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.features(tags)
|
|
24
|
+
tags = Array(tags).map(&:to_s).uniq.sort
|
|
25
|
+
tags.empty? ? NO_FEATURES : tags.freeze
|
|
15
26
|
end
|
|
16
27
|
|
|
17
28
|
# The size glyphs are drawn at: smaller for sub- and superscript.
|