vivlio-starter-pdf 1.1.2 → 1.2.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.
@@ -17,7 +17,14 @@ module VivlioStarter
17
17
  end
18
18
 
19
19
  # Writes the given +items+ into the document outline.
20
- # @param items [Array<Hash>] entries with :level, :text, :page
20
+ #
21
+ # An entry with +:top+ (the y coordinate of the heading, in PDF units) jumps to the
22
+ # heading itself instead of the top of its page, so readers do not have to look for
23
+ # a section that sits near the bottom of the page. Left and zoom stay +nil+ so the
24
+ # reader keeps their own horizontal position and magnification. Entries without
25
+ # +:top+ (chapters, whose opener shows art above the heading) still show the whole page.
26
+ #
27
+ # @param items [Array<Hash>] entries with :level, :text, :page, and optionally :top
21
28
  # @return [Integer] number of inserted outline nodes
22
29
  def write(items)
23
30
  return 0 if @page_count.zero?
@@ -31,7 +38,7 @@ module VivlioStarter
31
38
 
32
39
  normalized.each do |entry|
33
40
  parent = parent_for(entry[:level], stack)
34
- destination = resolve_destination(entry[:page])
41
+ destination = resolve_destination(entry[:page], entry[:top])
35
42
  unless destination
36
43
  warn_skip("page #{entry[:page]} is outside 1-#{@page_count}")
37
44
  next
@@ -70,23 +77,22 @@ module VivlioStarter
70
77
  level = 1 if level < 1
71
78
  level = @max_level if level > @max_level
72
79
 
73
- { text:, page:, level: }
80
+ { text:, page:, level:, top: entry_top(raw) }
81
+ end
82
+
83
+ # The heading position, or +nil+ to show the whole page. Anything that is not a
84
+ # finite number falls back to the page rather than dropping the entry: a bookmark
85
+ # that lands on the right page is still useful.
86
+ def entry_top(raw)
87
+ top = fetch(raw, :top, nil)
88
+ top.is_a?(Numeric) && top.to_f.finite? ? top.to_f : nil
74
89
  end
75
90
 
76
91
  # Returns the trimmed title, or +nil+ when the entry must be skipped.
77
92
  #
78
- # A title carrying invalid UTF-8 bytes makes +String#strip+ raise
79
- # +Encoding::CompatibilityError: invalid byte sequence in UTF-8+. Letting that
80
- # escape costs the *entire* outline: +add_outline!+ rescues at the top, so one
81
- # mojibake heading would leave the book with no bookmarks at all. Every other
82
- # kind of bad entry is skipped one by one through +on_skip+ — this one must
83
- # behave the same way, and the message has to name the entry so the author can
84
- # fix the manuscript heading that produced it.
85
- #
86
- # Both +EncodingError+ and +ArgumentError+ are caught on purpose: which one
87
- # Ruby raises for broken bytes depends on the method (+strip+ raises
88
- # +Encoding::CompatibilityError+, +match?+ raises +ArgumentError+), and that
89
- # detail must not decide whether the book keeps its bookmarks.
93
+ # Invalid UTF-8 makes +strip+ raise +Encoding::CompatibilityError+ and +match?+ raise
94
+ # +ArgumentError+. Both are caught so that the entry is skipped through +on_skip+ and
95
+ # the rest of the outline is still written.
90
96
  def entry_title(raw)
91
97
  fetch(raw, :text, fetch(raw, :title, nil)).to_s.strip
92
98
  rescue EncodingError, ArgumentError
@@ -97,11 +103,8 @@ module VivlioStarter
97
103
 
98
104
  # Fetches a value from the entry by symbol or string key.
99
105
  #
100
- # Entries come from the caller, so a stray String or Integer can end up in the
101
- # array. Both answer +respond_to?(:[])+ but raise +TypeError+ when indexed with
102
- # a Symbol (+Integer#[]+ is bit access), which would abort the whole outline —
103
- # exactly what the per-entry skip exists to prevent. Duck typing is kept so a
104
- # Hash-like object still works; only the indexing failure falls back.
106
+ # A String or Integer in the array answers +respond_to?(:[])+ but raises +TypeError+
107
+ # when indexed with a Symbol, so that failure returns +fallback+.
105
108
  def fetch(raw, key, fallback)
106
109
  value = (raw[key] || raw[key.to_s] if raw.respond_to?(:[]))
107
110
  value.nil? ? fallback : value
@@ -118,16 +121,26 @@ module VivlioStarter
118
121
  stack.last[:node]
119
122
  end
120
123
 
121
- # Resolves the destination page for an entry.
122
- def resolve_destination(page_number)
124
+ # Resolves the destination for an entry: the heading position when +top+ lies on
125
+ # the page, otherwise the whole page.
126
+ def resolve_destination(page_number, top = nil)
123
127
  index = page_number.to_i - 1
124
128
  return nil unless index.between?(0, @page_count - 1)
125
129
 
126
- @doc.pages[index]
130
+ page = @doc.pages[index]
131
+ return page unless top && on_page?(page, top)
132
+
133
+ { type: :xyz, page:, top: }
127
134
  rescue StandardError
128
135
  nil
129
136
  end
130
137
 
138
+ # A +top+ outside the media box would scroll past the page, so it is not used.
139
+ def on_page?(page, top)
140
+ box = page.box(:media)
141
+ top.between?(box.bottom, box.top)
142
+ end
143
+
131
144
  # Recreates an empty outline root before inserting new nodes.
132
145
  def rebuild_outline_root
133
146
  @doc.catalog.delete(:Outlines)