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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +72 -15
  4. data/lib/stationery/builder.rb +5 -3
  5. data/lib/stationery/canvas/text.rb +4 -3
  6. data/lib/stationery/canvas.rb +5 -2
  7. data/lib/stationery/document.rb +26 -9
  8. data/lib/stationery/elements.rb +1 -1
  9. data/lib/stationery/fonts/fallback.rb +3 -3
  10. data/lib/stationery/fonts/font.rb +39 -21
  11. data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
  12. data/lib/stationery/fonts/gsub.rb +65 -20
  13. data/lib/stationery/fonts/ligatures.rb +6 -4
  14. data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
  15. data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
  16. data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
  17. data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
  18. data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
  19. data/lib/stationery/hyphenation.rb +132 -0
  20. data/lib/stationery/images/png.rb +66 -0
  21. data/lib/stationery/images/resampled.rb +80 -0
  22. data/lib/stationery/layout/image.rb +28 -2
  23. data/lib/stationery/layout/paginator.rb +1 -1
  24. data/lib/stationery/layout/text.rb +2 -2
  25. data/lib/stationery/minitest.rb +5 -0
  26. data/lib/stationery/page_templates.rb +2 -1
  27. data/lib/stationery/pdf/assembler.rb +6 -2
  28. data/lib/stationery/pdf/page_labels.rb +71 -0
  29. data/lib/stationery/testing/inspector.rb +13 -0
  30. data/lib/stationery/testing/matchers.rb +10 -0
  31. data/lib/stationery/text/entities.rb +14 -4
  32. data/lib/stationery/text/paragraph.rb +1 -1
  33. data/lib/stationery/text/style.rb +14 -3
  34. data/lib/stationery/text/wrapper.rb +73 -12
  35. data/lib/stationery/version.rb +1 -1
  36. data/lib/stationery/warnings.rb +6 -0
  37. data/lib/stationery.rb +4 -0
  38. 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(@image, x:, y:, width: w, height: h, opacity: @opacity)
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
@@ -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
- def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil)
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 entities markup text may carry.
5
+ # Decodes the HTML character references markup text may carry: numeric
6
+ # (`&#39;`, `&#x27;`, `&#X27;`) and named (the built-ins below, then the
7
+ # HTML 4 table). Each reference is decoded once, so `&amp;#39;` yields the
8
+ # literal text `&#39;`; 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 = /&(#x\h+|#\d+|[a-zA-Z]+);/
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 [name[2..].to_i(16)].pack("U")
16
- elsif name.start_with?("#") then [name[1..].to_i].pack("U")
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.