asciidoctor-rhrev 1.8.2 → 1.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 155f025dbf45d15c912cbb3f624bc9214344472bd3d33e21c8cfb8dcca21f54a
4
- data.tar.gz: b6ceffec703149d7b45f2c3007ff32099421532cf9e73f9cff763200706f6b00
3
+ metadata.gz: e018143599f73eace12e5f22c99a54119721a6358e61abb67d5040b81118712b
4
+ data.tar.gz: 194a1c032bd7602dfffe37c6bf87e069ff64d19e172244e3f05fab5effdf7947
5
5
  SHA512:
6
- metadata.gz: eb676e02167dad226f2ef494bf8de843df79497d8f6fd419d44d815c727e635ea6d62ca9767283540630a515ae27c201695d67875b57d6684158cb1dc15f26e2
7
- data.tar.gz: 4369e143e5614067d6f0f28039788e5004a5f772bc5383beacfd56c6f20c3dd7970808515bbcf9b459337d4d7b726e79f3b1d20bbdaa46a689cc26bb639fa6f4
6
+ metadata.gz: f1f9463fa7dc56dbb4f0d521ff547806724927ffb4b4f87289a30262bf2f7aba2812b6c99eb5eb58927d30e346b80740591f16fe0458559e1e1c2341765b2c64
7
+ data.tar.gz: 69433c330dca726113054335cff0ac2856882f6f18d07eaf31866ecec4f9543f6276a871f9c8409f942e1e2ee784a69b0ae06943c68b222c054c8c57d73677f9
data/CHANGELOG.adoc CHANGED
@@ -1,5 +1,16 @@
1
1
  = Changelog
2
2
 
3
+ == 1.9.0 (2026-10-27)
4
+
5
+ * Third-level revisions such as `1.3.2`. The `rhrev-third-level` attribute selects `collapse` or `expand`, and `rhrev-third-level-kind` selects `draft`, the default, or `amendment`. With either mode, every mark of the current `x.y` release up to the current third level draws a change bar. `collapse` groups the revision history table by `x.y`, merging change texts and `all` and `cover` entries, with the group header reading `x-y-prevrev` and falling back to the lowest third-level `prevrev`. `expand` keeps one group per revision. `draft` orders `x.y` after its drafts, and a bare `x.y` mark beside its own drafts logs a warning and is ignored everywhere, `all` and `cover` entries included; `amendment` orders `x.y` before its amendments. New `third_level.rb` and `antora-extension/third-level.js`; the Antora extensions parse every page first so a release ignored on one page is ignored on all. Unset, output is unchanged (gem and npm)
6
+ * Change bar style `table`, set with `rhrev-change-bars-style` or the theme key `rhrev_change_bars_style` (PDF backend). In expand mode every changed block gets a box in the margin with one column per revision, its own revisions' columns filled, and each page labels the columns at the top of its body. The `table` theme keys set `column-width`, `max-columns`, `border-color`, `border-width`, `grid-color`, `grid-width`, and the label font family, style, size, and color. Beyond `max-columns`, the first column accumulates the oldest revisions and carries the newest one's label; labels wider than a column turn 90 degrees. The default style `bar` and collapse mode draw the single bar (gem)
7
+ * New regression files `tests/regression/third-level.rb` and `tests/regression/js-third-level.js`
8
+
9
+ == 1.8.3 (2026-10-22)
10
+
11
+ * Fix per-item list change bars landing on another revision's items when marks for several revisions stack one per attribute line (PDF backend). Asciidoctor numbers positional entries per attribute line and merges the lines into one hash, so a later line's targets overwrote an earlier line's: `[rhrev1-2="...",2]` followed by `[rhrev1-3="..."]` barred item 2 for revision 1.3 instead of the whole list, and a current mark on the first line took the targets of the line below it. The new `mark_targets.rb` hooks `AttributeList#parse_into`, reads each block attribute line's own memoized parse result, and records which positional entries follow which mark on that line; `Converter#list_item_change_bar_targets` reads the current mark's own record and falls back to every positional entry only for a list built without one. Reported against a real document. Stacked-line checks in `tests/regression/list-item-target-change-bars.rb` cover a current mark with no targets, a current mark on the first line, a later line with fewer targets, a custom `revhistoryprefix`, and a style before the mark, plus a render spy that fails (bars on the wrong items) without the fix
12
+ * Note: gem-only; the Antora extension has no per-item list targets, npm still bumps concurrently per the standing 1.5.1 policy
13
+
3
14
  == 1.8.2 (2026-10-06)
4
15
 
5
16
  * Change the initial-release table's default text from `Initial Release` to `Initial release` and render it bold (PDF backend), matching the bold `Page` cell beside it; `rhrev-customization-initial-release-text` still overrides the text and is rendered bold too
@@ -1,3 +1,5 @@
1
+ require_relative 'third_level'
2
+
1
3
  module Asciidoctor
2
4
  module Rhrev
3
5
  class Catalog
@@ -5,11 +7,21 @@ module Asciidoctor
5
7
  attr_accessor :all_entries
6
8
  attr_accessor :cover_entries
7
9
 
8
- def initialize
10
+ # third_level is the rhrev-third-level mode; in collapse mode every
11
+ # revision is stored under its x-y group (ThirdLevel.group_key).
12
+ # kind is the rhrev-third-level-kind, which orders an x-y revision
13
+ # relative to its x-y-z revisions (ThirdLevel.sort_key).
14
+ def initialize third_level: nil, kind: nil
9
15
  @entries = {} # Hash of revision => [entries]
10
16
  @all_entries = {} # Hash of revision => text for 'all' entries
11
17
  @cover_entries = {} # Hash of revision => text for 'cover' entries
12
18
  @sequence_counter = 0 # Counter to track document order
19
+ @third_level = third_level
20
+ @kind = kind
21
+ end
22
+
23
+ def revision_key revision
24
+ ::Asciidoctor::Rhrev::ThirdLevel.group_key revision, @third_level
13
25
  end
14
26
 
15
27
  def self.default_labels
@@ -24,15 +36,26 @@ module Asciidoctor
24
36
  end
25
37
 
26
38
  def add_entry revision, anchor, change_text, reftext: nil, title: nil, sectnum: nil, context: nil, is_chapter: false, sectname: nil, caption_number: nil, source_line: nil
27
- @entries[revision] ||= []
28
-
29
- # Prevent duplicates (e.g. if collected multiple times)
30
- return if @entries[revision].any? { |e| e[:anchor] == anchor }
39
+ key = revision_key revision
40
+ @entries[key] ||= []
41
+
42
+ # Prevent duplicates (e.g. if collected multiple times). In collapse
43
+ # mode, one anchor marked in several revisions of the same group
44
+ # merges into one entry, its change texts joined as bullets.
45
+ if (existing = @entries[key].find { |e| e[:anchor] == anchor })
46
+ return if existing[:revisions].include? revision
47
+ existing[:revisions] << revision
48
+ unless change_text.to_s.empty?
49
+ existing[:change] = existing[:change].to_s.empty? ? change_text : "#{existing[:change]} * #{change_text}"
50
+ end
51
+ return
52
+ end
31
53
 
32
54
  @sequence_counter += 1
33
55
  # Use source_line if available, otherwise fall back to sequence counter
34
56
  sequence = source_line || @sequence_counter
35
- @entries[revision] << {
57
+ @entries[key] << {
58
+ revisions: [revision],
36
59
  anchor: anchor,
37
60
  change: change_text,
38
61
  dest: nil,
@@ -48,11 +71,23 @@ module Asciidoctor
48
71
  end
49
72
 
50
73
  def add_all_entry revision, change_text
51
- @all_entries[revision] = change_text
74
+ @all_entries[revision_key revision] = change_text
52
75
  end
53
76
 
54
77
  def add_cover_entry revision, change_text
55
- @cover_entries[revision] = change_text
78
+ @cover_entries[revision_key revision] = change_text
79
+ end
80
+
81
+ # Adds change_text to the revision's 'all' or 'cover' text, joined as
82
+ # another bullet when the group already has one.
83
+ def append_all_entry revision, change_text
84
+ existing = @all_entries[revision_key revision]
85
+ add_all_entry revision, (existing.to_s.empty? ? change_text : "#{existing} * #{change_text}")
86
+ end
87
+
88
+ def append_cover_entry revision, change_text
89
+ existing = @cover_entries[revision_key revision]
90
+ add_cover_entry revision, (existing.to_s.empty? ? change_text : "#{existing} * #{change_text}")
56
91
  end
57
92
 
58
93
  def link_dest_to_page anchor, physical_page_number, start_page_number, y: nil
@@ -121,7 +156,7 @@ module Asciidoctor
121
156
 
122
157
  def parse_revision rev_string
123
158
  # Parse "1-1", "1-2", "1-10-2" into comparable array [1, 1], [1, 2], [1, 10, 2]
124
- rev_string.split('-').map(&:to_i)
159
+ ::Asciidoctor::Rhrev::ThirdLevel.sort_key rev_string, @kind
125
160
  end
126
161
  end
127
162
  end
@@ -8,16 +8,33 @@ module Asciidoctor
8
8
  # Enabled by the rhrev-change-bars document attribute.
9
9
  #
10
10
  # Styling comes from the PDF theme by default (rhrev_change_bars category:
11
- # color, width, offset, side), overridable per document via the
12
- # rhrev-change-bars-color/-width/-offset/-side attributes.
11
+ # style, color, width, offset, side, and the table_* keys), the first
12
+ # five overridable per document via the rhrev-change-bars-style/
13
+ # -color/-width/-offset/-side attributes.
14
+ #
15
+ # With rhrev-third-level set, every mark of the current x.y release
16
+ # draws the bar (ThirdLevel::Matcher). In expand mode a mark naming a
17
+ # revision belongs to a lane of its own, the lowest revision nearest
18
+ # the text (assign_change_bar_lanes), so every lane stands for exactly
19
+ # one revision. Style bar draws one bar whatever the lanes. Style
20
+ # table draws the lanes as a table: every changed block draws a thin
21
+ # box with one column per lane, the columns of its own revisions
22
+ # filled, and every page with a box labels each column at the top of
23
+ # its body, so an isolated change still shows which revision it is.
24
+ # Without lanes, as in collapse mode, table draws the bar too.
13
25
  module ChangeBars
26
+ CHANGE_BAR_STYLES = %w(bar table).freeze
27
+
14
28
  def init_change_bars doc
15
29
  @change_bar_attr = nil
30
+ @change_bar_matcher = nil
31
+ @change_bar_lane_index = nil
16
32
  @change_bar_settings = nil
17
33
  return unless doc.attr? 'rhrev-change-bars'
18
34
  # Without a revnumber there is no "current" revision to match
19
35
  return unless (revnumber = doc.attr 'revnumber')
20
- @change_bar_attr = %(#{@revision_prefix}#{revnumber.to_s.tr '.', '-'})
36
+ @change_bar_matcher = ::Asciidoctor::Rhrev::ThirdLevel.matcher doc, @revision_prefix, revnumber
37
+ @change_bar_attr = @change_bar_matcher.exact
21
38
  # Capture attribute values only; holding the Document in an ivar here
22
39
  # would leak it into the scratch prototype, which must stay marshalable
23
40
  @change_bar_overrides = {
@@ -25,9 +42,11 @@ module Asciidoctor
25
42
  width: (doc.attr 'rhrev-change-bars-width'),
26
43
  offset: (doc.attr 'rhrev-change-bars-offset'),
27
44
  side: (doc.attr 'rhrev-change-bars-side'),
45
+ style: (doc.attr 'rhrev-change-bars-style'),
28
46
  }
29
47
  @change_bar_starts = {}
30
48
  @change_bar_section_stack = []
49
+ @change_bar_grid_labeled_pages = {}
31
50
  end
32
51
 
33
52
  # Resolved lazily on first ink: the theme (and @media/@folio_placement)
@@ -40,22 +59,76 @@ module Asciidoctor
40
59
  theme = @theme
41
60
  side = overrides[:side] || theme&.rhrev_change_bars_side ||
42
61
  (@media == 'prepress' ? 'outer' : 'right')
62
+ color = change_bar_color overrides[:color] || theme&.rhrev_change_bars_color || 'FF0000'
63
+ border_color = change_bar_color theme&.rhrev_change_bars_table_border_color || '999999'
64
+ border_width = (theme&.rhrev_change_bars_table_border_width || 0.25).to_f
43
65
  {
44
- color: (overrides[:color] || theme&.rhrev_change_bars_color || 'FF0000').to_s.delete_prefix('#').upcase,
66
+ style: (change_bar_style overrides[:style] || theme&.rhrev_change_bars_style),
67
+ color: color,
45
68
  width: (overrides[:width] || theme&.rhrev_change_bars_width || 2).to_f,
46
69
  offset: (overrides[:offset] || theme&.rhrev_change_bars_offset || 16).to_f,
47
70
  side: side.to_s,
71
+ table_column_width: (theme&.rhrev_change_bars_table_column_width || 4).to_f,
72
+ table_max_columns: [(theme&.rhrev_change_bars_table_max_columns || 8).to_i, 1].max,
73
+ table_border_color: border_color,
74
+ table_border_width: border_width,
75
+ table_grid_color: (change_bar_color theme&.rhrev_change_bars_table_grid_color || border_color),
76
+ table_grid_width: (theme&.rhrev_change_bars_table_grid_width || border_width).to_f,
77
+ table_label_font_family: theme&.rhrev_change_bars_table_label_font_family,
78
+ table_label_font_style: (theme&.rhrev_change_bars_table_label_font_style || 'normal').to_sym,
79
+ table_label_font_size: (theme&.rhrev_change_bars_table_label_font_size || 5).to_f,
80
+ table_label_font_color: (change_bar_color theme&.rhrev_change_bars_table_label_font_color || '666666'),
48
81
  }
49
82
  end
50
83
  end
51
84
 
85
+ def change_bar_color value
86
+ value.to_s.delete_prefix('#').upcase
87
+ end
88
+
89
+ # bar or table, bar when unset. An unrecognized style warns once
90
+ # and draws the bar.
91
+ def change_bar_style value
92
+ return 'bar' if value.nil? || value.to_s.empty?
93
+ return value.to_s if CHANGE_BAR_STYLES.include? value.to_s
94
+ logger.warn %(rhrev-change-bars-style must be bar or table, got '#{value}'; using bar)
95
+ 'bar'
96
+ end
97
+
98
+ # Numbers the lanes from the revisions of the current x.y release
99
+ # the document marks, so a revision keeps the same lane on every
100
+ # page: the x.y release itself first when the document marks it,
101
+ # nil standing for it, then each third level in ascending order.
102
+ def assign_change_bar_lanes third_levels
103
+ return unless @change_bar_matcher&.mode == 'expand'
104
+ levels = third_levels.uniq
105
+ ordered = ((levels.include? nil) ? [nil] : []) + levels.compact.sort
106
+ @change_bar_lane_index = ordered.each_with_index.to_h
107
+ end
108
+
109
+ # The lanes a node's own attributes fill, one per matching mark,
110
+ # sorted, or nil when none of its marks belongs to the current
111
+ # revision. Without lanes, as in collapse mode, every mark fills
112
+ # lane 0.
113
+ def change_bar_lanes attributes
114
+ return nil unless @change_bar_matcher
115
+ marks = @change_bar_matcher.marks attributes
116
+ return nil if marks.empty?
117
+ marks.values.map {|z| @change_bar_lane_index&.[](z) || 0 }.uniq.sort
118
+ end
119
+
120
+ # The lanes a node's own marks draw, or nil.
121
+ def node_change_bar_lanes node
122
+ return nil unless @change_bar_attr && node.respond_to?(:attributes) && node.attributes
123
+ change_bar_lanes node.attributes
124
+ end
125
+
52
126
  def change_bar? node
53
- @change_bar_attr && node.respond_to?(:attributes) &&
54
- node.attributes && (node.attributes.key? @change_bar_attr)
127
+ !(node_change_bar_lanes node).nil?
55
128
  end
56
129
 
57
130
  def record_change_bar_start node
58
- @change_bar_starts[node] = { page: page_number, cursor: cursor }
131
+ @change_bar_starts[node] = { page: page_number, cursor: cursor, lanes: (node_change_bar_lanes node) }
59
132
  end
60
133
 
61
134
  def take_change_bar_start node
@@ -112,39 +185,133 @@ module Asciidoctor
112
185
  # number (honoring pdf-folio-placement inversion), matching prepress
113
186
  # physical folio placement.
114
187
  def change_bar_x pgnum, settings
188
+ (change_bar_on_right? pgnum, settings) ? bounds.right + settings[:offset] : bounds.left - settings[:offset] - settings[:width]
189
+ end
190
+
191
+ def change_bar_on_right? pgnum, settings
115
192
  case settings[:side]
116
193
  when 'right'
117
- on_right = true
194
+ true
118
195
  when 'outer', 'inner'
119
196
  recto = (page_side pgnum, @folio_placement && @folio_placement[:inverted]) == :recto
120
- on_right = settings[:side] == 'outer' ? recto : !recto
197
+ settings[:side] == 'outer' ? recto : !recto
198
+ else
199
+ false
200
+ end
201
+ end
202
+
203
+ # Fills a bar or a table column between top and bottom on the
204
+ # current page.
205
+ def ink_change_bar_fill x, top, bottom, width, color
206
+ bounding_box [x, top], width: width, height: top - bottom do
207
+ fill_bounds color
121
208
  end
122
- on_right ? bounds.right + settings[:offset] : bounds.left - settings[:offset] - settings[:width]
123
209
  end
124
210
 
125
- # Paints the bar page by page; from/to are { page:, cursor: }.
211
+ # Whether bars draw as a table: with style table, once the
212
+ # document's revisions have lanes, as in expand mode.
213
+ def change_bar_table? settings
214
+ settings[:style] == 'table' && !(@change_bar_lane_index.nil? || @change_bar_lane_index.empty?)
215
+ end
216
+
217
+ # The table's columns, at most max of them: an array mapping each
218
+ # lane to its column. Up to max lanes, each lane has a column of its
219
+ # own. Beyond max, the first column accumulates the oldest lanes and
220
+ # every newer lane keeps a column of its own, so each further
221
+ # revision shifts the others one column toward the text.
222
+ def change_bar_table_columns total, max
223
+ return (0...total).to_a if total <= max
224
+ merged = total - max + 1
225
+ (0...total).map {|lane| lane < merged ? 0 : lane - merged + 1 }
226
+ end
227
+
228
+ # Inks one page's share of a table box between top and bottom: the
229
+ # columns from change_bar_table_columns, the columns of lanes
230
+ # filled, the first column nearest the text.
231
+ def ink_change_bar_table pgnum, top, bottom, lanes, settings
232
+ width = settings[:table_column_width]
233
+ on_right = change_bar_on_right? pgnum, settings
234
+ column_of = change_bar_table_columns @change_bar_lane_index.size, settings[:table_max_columns]
235
+ count = column_of.last + 1
236
+ left = on_right ? bounds.right + settings[:offset] : bounds.left - settings[:offset] - count * width
237
+ column_x = ->(column) { on_right ? left + column * width : left + (count - 1 - column) * width }
238
+ lanes.map {|lane| column_of[lane] }.uniq.each do |column|
239
+ ink_change_bar_fill (column_x.call column), top, bottom, width, settings[:color]
240
+ end
241
+ save_graphics_state do
242
+ if settings[:table_grid_width] > 0
243
+ stroke_color settings[:table_grid_color]
244
+ line_width settings[:table_grid_width]
245
+ (1...count).each {|i| stroke_vertical_line top, bottom, at: left + i * width }
246
+ end
247
+ if settings[:table_border_width] > 0
248
+ stroke_color settings[:table_border_color]
249
+ line_width settings[:table_border_width]
250
+ stroke_rectangle [left, top], count * width, top - bottom
251
+ end
252
+ end
253
+ ink_change_bar_table_labels pgnum, column_of, column_x, settings unless @change_bar_grid_labeled_pages[pgnum]
254
+ end
255
+
256
+ # Labels each column at the top of the page body with the
257
+ # third-level number of the newest lane it holds, 0 for the x.y
258
+ # release itself. The labels sit above every box on the page,
259
+ # whichever box inks first. When any label is wider than a column,
260
+ # every label reads upward, turned 90 degrees and centered on its
261
+ # column. A label font size of 0 omits the labels.
262
+ def ink_change_bar_table_labels pgnum, column_of, column_x, settings
263
+ @change_bar_grid_labeled_pages[pgnum] = true
264
+ return unless (size = settings[:table_label_font_size]) > 0
265
+ revisions = @change_bar_lane_index.invert
266
+ newest = {}
267
+ column_of.each_with_index {|column, lane| newest[column] = lane }
268
+ save_graphics_state do
269
+ fill_color settings[:table_label_font_color]
270
+ font (settings[:table_label_font_family] || font.family), style: settings[:table_label_font_style] do
271
+ font_size size do
272
+ width = settings[:table_column_width]
273
+ labels = newest.map {|column, lane| [column, (revisions[lane] || 0).to_s] }
274
+ if labels.any? {|_, label| (width_of label) > width }
275
+ # Turned text extends from its baseline toward lower x, so
276
+ # the baseline sits half a digit height past the center.
277
+ shift = (font.ascender - font.descender) * 0.35
278
+ labels.each do |column, label|
279
+ draw_text label, at: [(column_x.call column) + width / 2.0 + shift, bounds.top + 2], rotate: 90
280
+ end
281
+ else
282
+ labels.each do |column, label|
283
+ draw_text label, at: [(column_x.call column) + (width - (width_of label)) / 2.0, bounds.top + 2], rotate: 0
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
290
+
291
+ # Paints the bar page by page; from/to are { page:, cursor: }, and
292
+ # from[:lanes] lists the table columns to fill, the first when absent.
126
293
  # Called immediately after the block is rendered, so all spanned pages exist.
127
294
  def ink_change_bar from, to
128
295
  return if scratch? || from.nil? || to.nil?
129
296
  return if to[:page] < from[:page]
130
297
  return if to[:page] == from[:page] && to[:cursor] >= from[:cursor]
131
298
  settings = change_bar_settings
299
+ lanes = from[:lanes] || [0]
132
300
  float do
133
301
  (from[:page]..to[:page]).each do |pgnum|
134
302
  go_to_page pgnum unless page_number == pgnum
135
303
  top = pgnum == from[:page] ? from[:cursor] : bounds.top
136
304
  bottom = pgnum == to[:page] ? to[:cursor] : 0
137
305
  next if (height = top - bottom) <= 0
138
- bounding_box [(change_bar_x pgnum, settings), top], width: settings[:width], height: height do
139
- fill_bounds settings[:color]
140
- end
306
+ next ink_change_bar_table pgnum, top, bottom, lanes, settings if change_bar_table? settings
307
+ ink_change_bar_fill (change_bar_x pgnum, settings), top, bottom, settings[:width], settings[:color]
141
308
  end
142
309
  end
143
310
  end
144
311
 
145
- def ink_change_bar_for_extent extent
312
+ def ink_change_bar_for_extent extent, lanes = nil
146
313
  return unless extent
147
- ink_change_bar({ page: extent.from.page, cursor: extent.from.cursor },
314
+ ink_change_bar({ page: extent.from.page, cursor: extent.from.cursor, lanes: lanes },
148
315
  { page: extent.to.page, cursor: extent.to.cursor })
149
316
  end
150
317
 
@@ -164,7 +331,7 @@ module Asciidoctor
164
331
  # The extent yielded here is exact across page breaks.
165
332
  def arrange_block node, &block
166
333
  if @change_bar_attr && !scratch? &&
167
- (ARRANGED_BLOCK_CONTEXTS.include? node.context) && (change_bar? node)
334
+ (ARRANGED_BLOCK_CONTEXTS.include? node.context) && (lanes = node_change_bar_lanes node)
168
335
  super node do |extent|
169
336
  if scratch?
170
337
  # measurement pass: upstream instance_execs this wrapper on the scratch
@@ -172,7 +339,7 @@ module Asciidoctor
172
339
  instance_exec(&block)
173
340
  else
174
341
  block.call extent
175
- ink_change_bar_for_extent extent
342
+ ink_change_bar_for_extent extent, lanes
176
343
  end
177
344
  end
178
345
  else
@@ -188,8 +355,8 @@ module Asciidoctor
188
355
  # out as a shared helper since none of the four need any special-
189
356
  # casing the others don't.
190
357
  def bracket_change_bar node
191
- if @change_bar_attr && !scratch? && (change_bar? node)
192
- bar_from = { page: page_number, cursor: cursor }
358
+ if @change_bar_attr && !scratch? && (lanes = node_change_bar_lanes node)
359
+ bar_from = { page: page_number, cursor: cursor, lanes: lanes }
193
360
  end
194
361
  result = yield
195
362
  ink_change_bar bar_from, { page: page_number, cursor: cursor } if bar_from
@@ -37,7 +37,7 @@ module Asciidoctor
37
37
  end
38
38
 
39
39
  def revision_history
40
- @catalog ||= Catalog.new
40
+ @catalog ||= Catalog.new third_level: @third_level_mode, kind: @third_level_kind
41
41
  end
42
42
 
43
43
  # The physical PDF page where the document's own body content starts
@@ -159,6 +159,12 @@ module Asciidoctor
159
159
 
160
160
  def convert_document node
161
161
  @revision_prefix = node.attr 'revhistoryprefix', 'rhrev'
162
+ @third_level_mode = ::Asciidoctor::Rhrev::ThirdLevel.mode node
163
+ @third_level_kind = ::Asciidoctor::Rhrev::ThirdLevel.kind node
164
+ # Lifting cell text marks first lets the mixed-release check see
165
+ # them; prescan_document lifts again, which finds nothing left.
166
+ ::Asciidoctor::Rhrev::CellTextMarks.lift node, @revision_prefix
167
+ ::Asciidoctor::Rhrev::ThirdLevel.drop_mixed_release_marks node, @revision_prefix
162
168
  init_change_bars node
163
169
 
164
170
  # Check if we're in manual mode (pagerhref support needed)
@@ -304,10 +310,10 @@ module Asciidoctor
304
310
  # Skip recording when the upstream table-container rewrap will trigger
305
311
  # (condition replicated from asciidoctor-pdf convert_table); the
306
312
  # re-entrant conversion of the attribute-preserving dup records instead
307
- if @change_bar_attr && !scratch? && (change_bar? node) &&
313
+ if @change_bar_attr && !scratch? && (lanes = node_change_bar_lanes node) &&
308
314
  !(!at_page_top? && ((node.option? 'unbreakable') ||
309
315
  ((node.option? 'breakable') && (node.id || node.title?))))
310
- bar_from = { page: page_number, cursor: cursor }
316
+ bar_from = { page: page_number, cursor: cursor, lanes: lanes }
311
317
  end
312
318
  result = super
313
319
  catalog_block_anchor node
@@ -323,16 +329,16 @@ module Asciidoctor
323
329
  collect_revision_entries node unless @prescan_complete
324
330
  update_revision_entry_metadata node
325
331
  # PDF targets import whole pages; there is no cursor flow to mark
326
- if @change_bar_attr && !scratch? && (change_bar? node) &&
332
+ if @change_bar_attr && !scratch? && (lanes = node_change_bar_lanes node) &&
327
333
  !((node.attr 'target').to_s.downcase.end_with? '.pdf')
328
- bar_from = { page: page_number, cursor: cursor }
334
+ bar_from = { page: page_number, cursor: cursor, lanes: lanes }
329
335
  end
330
336
  result = super
331
337
  catalog_block_anchor node
332
338
  if bar_from
333
339
  bar_to = { page: page_number, cursor: cursor }
334
340
  # Images never split; a page change means the image moved wholesale
335
- bar_from = { page: bar_to[:page], cursor: bounds.top } if bar_to[:page] > bar_from[:page]
341
+ bar_from = { page: bar_to[:page], cursor: bounds.top, lanes: bar_from[:lanes] } if bar_to[:page] > bar_from[:page]
336
342
  ink_change_bar bar_from, bar_to
337
343
  end
338
344
  result
@@ -435,20 +441,19 @@ module Asciidoctor
435
441
 
436
442
  def convert_dlist node
437
443
  targets = list_item_change_bar_targets node
438
- if (::Set === targets) && !(DLIST_STYLES_ROUTING_THROUGH_LIST_ITEM.include? node.style)
444
+ if targets && !targets[:items].empty? && !(DLIST_STYLES_ROUTING_THROUGH_LIST_ITEM.include? node.style)
439
445
  log :warn, %(rhrev per-item list marking is not supported for a dlist using the '#{node.style || 'default'}' style; marking the whole block instead)
440
- targets = :all
446
+ targets = { whole: (targets[:items].values.flatten + targets[:whole]).uniq.sort, items: {} }
441
447
  end
442
448
  convert_targeted_list(node, targets) { super }
443
449
  end
444
450
 
445
451
  # Shared by convert_ulist/convert_olist/convert_dlist: computes the
446
- # target set (unless the caller, convert_dlist, already resolved
447
- # it), pushes it so convert_list_item can read the innermost
448
- # enclosing list's own targets, and either brackets the whole list
449
- # (targets == :all) or leaves bar-inking to convert_list_item
450
- # entirely (targets is a real index Set, or nil, not marked at
451
- # all). The stack, not a single ivar, matters for a list nested
452
+ # targets (unless the caller, convert_dlist, already resolved
453
+ # them), pushes them so convert_list_item can read the innermost
454
+ # enclosing list's own targets, brackets the whole list with the
455
+ # lanes of every mark that targets the whole list, and leaves each
456
+ # targeted item's lanes to convert_list_item. The stack, not a single ivar, matters for a list nested
452
457
  # inside a marked item's own content: the same shape
453
458
  # @change_bar_section_stack already uses for nested sections.
454
459
  #
@@ -464,8 +469,11 @@ module Asciidoctor
464
469
  def convert_targeted_list node, targets = (list_item_change_bar_targets node)
465
470
  @list_change_bar_target_stack ||= []
466
471
  @list_change_bar_target_stack.push targets
467
- if targets == :all
468
- bracket_change_bar(node) { yield }
472
+ if targets && !targets[:whole].empty? && !scratch?
473
+ bar_from = { page: page_number, cursor: cursor, lanes: targets[:whole] }
474
+ result = yield
475
+ ink_change_bar bar_from, { page: page_number, cursor: cursor }
476
+ result
469
477
  else
470
478
  yield
471
479
  end
@@ -473,29 +481,51 @@ module Asciidoctor
473
481
  @list_change_bar_target_stack.pop
474
482
  end
475
483
 
476
- # The target set for the list currently being converted: :all
477
- # (bar the whole list, the only behavior before this feature
478
- # existed), nil (not marked for the current revision at all), or a
479
- # Set of 1-based item positions parsed from one or more trailing
480
- # positional attributes, expanding an N-M token into a range.
481
- # Reuses change_bar? for the marked/not-marked check, the same
482
- # predicate every other bar-inking decision in this file already
483
- # uses, so this only ever answers for the current revnumber.
484
+ # The targets for the list currently being converted, or nil when
485
+ # no mark on it belongs to the current revision. Otherwise a Hash:
486
+ # :whole holds the lanes drawn over the whole list, :items maps a
487
+ # 1-based item position to the lanes drawn over that item alone.
488
+ # Each matching mark contributes its own lanes (change_bar_lanes) to
489
+ # the whole list when it names no item, or to each item its
490
+ # trailing positional attributes name, an N-M token naming a range.
491
+ #
492
+ # Only a mark's own positional attributes count. With stacked
493
+ # attribute lines the merged hash mixes every line's positional
494
+ # entries, so the per-line record MarkTargets keeps at parse time
495
+ # decides; a list built without one (through the extension API, for
496
+ # instance) falls back to every positional entry.
484
497
  def list_item_change_bar_targets node
485
498
  return nil unless change_bar? node
486
499
  raw = node.instance_variable_get(:@attributes)
487
- positional_keys = raw.keys.select {|k| ::Integer === k }.sort
488
- return :all if positional_keys.empty?
489
- targets = ::Set.new
490
- positional_keys.each do |key|
491
- token = raw[key].to_s
492
- if (m = /\A(\d+)-(\d+)\z/.match token)
493
- (m[1].to_i..m[2].to_i).each {|i| targets << i }
494
- elsif /\A\d+\z/.match? token
495
- targets << token.to_i
500
+ by_mark = raw[::Asciidoctor::Rhrev::MarkTargets::KEY]
501
+ whole = []
502
+ items = {}
503
+ @change_bar_matcher.marks(raw).each_key do |mark|
504
+ lanes = change_bar_lanes({ mark => true })
505
+ tokens = if by_mark && (by_mark.key? mark)
506
+ by_mark[mark]
507
+ else
508
+ raw.keys.select {|k| ::Integer === k }.sort.map {|k| raw[k] }
509
+ end
510
+ positions = ::Set.new
511
+ tokens.each do |token|
512
+ token = token.to_s
513
+ if (m = /\A(\d+)-(\d+)\z/.match token)
514
+ (m[1].to_i..m[2].to_i).each {|i| positions << i }
515
+ elsif /\A\d+\z/.match? token
516
+ positions << token.to_i
517
+ end
518
+ end
519
+ if positions.empty?
520
+ whole |= lanes
521
+ else
522
+ positions.each {|i| items[i] = (items[i] || []) | lanes }
496
523
  end
497
524
  end
498
- targets.empty? ? :all : targets
525
+ whole.sort!
526
+ items.transform_values! {|lanes| (lanes - whole).sort }
527
+ items.reject! {|_, lanes| lanes.empty? }
528
+ { whole: whole, items: items }
499
529
  end
500
530
 
501
531
  # A single item within a ulist/olist (and a dlist using the
@@ -527,15 +557,15 @@ module Asciidoctor
527
557
  # the "should this bar" question on its own.
528
558
  def convert_list_item node, list, opts = {}
529
559
  targets = @list_change_bar_target_stack&.last
530
- if (::Set === targets) && (idx = list.items.index node) && (targets.include? idx + 1)
531
- ink_targeted_list_item_change_bar { super }
560
+ if targets && (idx = list.items.index node) && (lanes = targets[:items][idx + 1])
561
+ ink_targeted_list_item_change_bar(lanes) { super }
532
562
  else
533
563
  super
534
564
  end
535
565
  end
536
566
 
537
- def ink_targeted_list_item_change_bar
538
- bar_from = { page: page_number, cursor: cursor } if @change_bar_attr && !scratch?
567
+ def ink_targeted_list_item_change_bar lanes
568
+ bar_from = { page: page_number, cursor: cursor, lanes: lanes } if @change_bar_attr && !scratch?
539
569
  result = yield
540
570
  ink_change_bar bar_from, { page: page_number, cursor: cursor } if bar_from
541
571
  result
@@ -620,7 +650,7 @@ module Asciidoctor
620
650
 
621
651
  debug_log "Found entry candidate on #{node.context} id=#{node.id}", @document
622
652
 
623
- attr_entries.each do |key, value|
653
+ ::Asciidoctor::Rhrev::ThirdLevel.sort_marks(attr_entries, @revision_prefix, @third_level_kind).each do |key, value|
624
654
  key_str = key.to_s
625
655
  next unless key_str.start_with?(@revision_prefix)
626
656
  next if key_str.include?('-all') || key_str.end_with?('-cover')
@@ -720,7 +750,7 @@ module Asciidoctor
720
750
  section = enclosing_section node
721
751
  has_section = section && section.id
722
752
 
723
- attr_entries.each do |key, value|
753
+ ::Asciidoctor::Rhrev::ThirdLevel.sort_marks(attr_entries, @revision_prefix, @third_level_kind).each do |key, value|
724
754
  key_str = key.to_s
725
755
  next unless key_str.start_with?(@revision_prefix)
726
756
  next if key_str.include?('-all') || key_str.end_with?('-cover')
@@ -753,7 +783,7 @@ module Asciidoctor
753
783
  # directly, so a synthesized table entry looks the same as one from
754
784
  # the normal collect_revision_entries path.
755
785
  def append_child_change_to_entry revision, anchor_node, change_text
756
- entries = revision_history.entries[revision] ||= []
786
+ entries = revision_history.entries[revision_history.revision_key revision] ||= []
757
787
  entry = entries.find { |e| e[:anchor] == anchor_node.id }
758
788
  if entry
759
789
  entry[:change] = entry[:change].to_s.empty? ? change_text : "#{entry[:change]} * #{change_text}"
@@ -777,14 +807,9 @@ module Asciidoctor
777
807
  end
778
808
 
779
809
  # Appends onto the document's existing -all change text for this
780
- # revision, or sets it fresh if none exists yet. Catalog#add_all_entry
781
- # overwrites unconditionally, so the existing value has to be read
782
- # and folded in here, unlike add_entry it has no anchor to guard
783
- # against being called more than once.
810
+ # revision, or sets it fresh if none exists yet.
784
811
  def append_child_change_to_all revision, change_text
785
- existing = revision_history.all_entries[revision]
786
- combined = existing.to_s.empty? ? change_text : "#{existing} * #{change_text}"
787
- revision_history.add_all_entry revision, combined
812
+ revision_history.append_all_entry revision, change_text
788
813
  end
789
814
 
790
815
  # A table cell marked with an rhrev attribute is not a rollup context
@@ -906,7 +931,7 @@ module Asciidoctor
906
931
 
907
932
  revision = key_str.sub("#{@revision_prefix}", '')
908
933
 
909
- entries = revision_history.entries[revision]
934
+ entries = revision_history.entries[revision_history.revision_key revision]
910
935
  next unless entries
911
936
 
912
937
  entry = entries.find { |e| e[:anchor] == node.id }
@@ -1048,25 +1073,53 @@ module Asciidoctor
1048
1073
  @document_entries_collected = true
1049
1074
 
1050
1075
  # Discover revisions from *-prevrev attributes so any number of
1051
- # segments works (1-1, 1-2-0, ...), not just major-minor
1076
+ # segments works (1-1, 1-2-0, ...), not just major-minor. With
1077
+ # rhrev-third-level set, a third-level revision often declares no
1078
+ # prevrev of its own, so its -all and -cover attributes count too.
1079
+ # Ascending order keeps merged collapse-mode text in revision order.
1080
+ revisions = []
1052
1081
  doc.attributes.each_key do |key|
1053
1082
  key_str = key.to_s
1054
- next unless key_str.end_with?('-prevrev')
1055
- revision = key_str.delete_suffix('-prevrev')
1056
- next unless revision.match?(/\A\d+(?:-\d+)*\z/)
1083
+ if key_str.end_with?('-prevrev')
1084
+ revision = key_str.delete_suffix('-prevrev')
1085
+ elsif @third_level_mode && (m = /-(?:all|cover)\z/.match(key_str))
1086
+ revision = ::Asciidoctor::Rhrev::ThirdLevel.revision_of key_str.delete_suffix(m[0]), @revision_prefix
1087
+ else
1088
+ next
1089
+ end
1090
+ revisions << revision if revision&.match?(/\A\d+(?:-\d+)*\z/)
1091
+ end
1057
1092
 
1093
+ revisions.uniq.sort_by { |revision| revision_history.parse_revision revision }.each do |revision|
1058
1094
  all_attr_name = "#{@revision_prefix}#{revision}-all"
1059
1095
  if (all_value = doc.attr(all_attr_name))
1060
- revision_history.add_all_entry revision, all_value
1096
+ revision_history.append_all_entry revision, all_value
1061
1097
  end
1062
1098
 
1063
1099
  cover_attr_name = "#{@revision_prefix}#{revision}-cover"
1064
1100
  if (cover_value = doc.attr(cover_attr_name))
1065
- revision_history.add_cover_entry revision, cover_value
1101
+ revision_history.append_cover_entry revision, cover_value
1066
1102
  end
1067
1103
  end
1068
1104
  end
1069
1105
 
1106
+ # Every revision of the current x.y release the document marks
1107
+ # anywhere, block, list, or table cell, as its third-level number,
1108
+ # or nil for the x.y release itself, for the expand-mode lanes.
1109
+ def discover_third_level_lanes doc
1110
+ return unless @change_bar_matcher&.mode == 'expand'
1111
+ found = []
1112
+ collect = proc do |attributes|
1113
+ @change_bar_matcher.marks(attributes).each_value { |z| found << z }
1114
+ end
1115
+ doc.find_by(traverse_documents: true) { true }.each do |node|
1116
+ collect.call node.attributes if node.respond_to?(:attributes)
1117
+ next unless node.context == :table
1118
+ (node.rows[:head] + node.rows[:body] + node.rows[:foot]).flatten.each { |cell| collect.call cell.attributes }
1119
+ end
1120
+ assign_change_bar_lanes found
1121
+ end
1122
+
1070
1123
  def prescan_document doc
1071
1124
  debug_log "Starting prescan_document", doc
1072
1125
 
@@ -1094,6 +1147,7 @@ module Asciidoctor
1094
1147
  propagate_cell_content_marks doc
1095
1148
  rollup_child_revision_entries doc
1096
1149
  rollup_table_cell_revision_entries doc
1150
+ discover_third_level_lanes doc
1097
1151
 
1098
1152
  @prescan_complete = true
1099
1153
  debug_log "Prescan complete. Catalog has #{revision_history.entries.values.flatten.size} entries.", doc
@@ -55,11 +55,9 @@ module Asciidoctor
55
55
 
56
56
  # Process each revision
57
57
  revision_history.sorted_revisions.each do |revision|
58
- prevrev_attr = "#{revision}-prevrev"
59
- prevrevdate_attr = "#{revision}-prevrevdate"
60
58
  revision_label = doc.attr('version-label', 'Revision')
61
- prev_title = doc.attr(prevrev_attr) || revision.tr('-', '.')
62
- prev_date = doc.attr(prevrevdate_attr) || ""
59
+ prev_title = ::Asciidoctor::Rhrev::ThirdLevel.group_attr(doc, revision, 'prevrev', @third_level_mode) || revision.tr('-', '.')
60
+ prev_date = ::Asciidoctor::Rhrev::ThirdLevel.group_attr(doc, revision, 'prevrevdate', @third_level_mode) || ""
63
61
  prev_rev_text = format_prev_rev(prev_title, prev_date, doc)
64
62
 
65
63
  # Build header text, avoiding "Revision Revision" repetition
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asciidoctor
4
+ module Rhrev
5
+ # Records which positional list-item targets belong to which rhrev mark,
6
+ # one block attribute line at a time.
7
+ #
8
+ # Stacked attribute lines merge into one attribute hash, and Asciidoctor
9
+ # numbers positional entries per line, so a later line's positional
10
+ # entries overwrite an earlier line's at the same index:
11
+ #
12
+ # [rhrev1-2="old",2]
13
+ # [rhrev1-3="new"]
14
+ #
15
+ # leaves {"rhrev1-2"=>"old", 2=>"2", "rhrev1-3"=>"new"}, and the merged
16
+ # hash no longer says whether target 2 belongs to rhrev1-2 or rhrev1-3.
17
+ # Each line is still parsed on its own, through AttributeList#parse_into,
18
+ # whose per-line result is memoized, so reading it here costs no second
19
+ # parse. Every positional entry on a line belongs to the nearest mark
20
+ # before it on that same line; a mark with no positional entry after it
21
+ # targets the whole list.
22
+ #
23
+ # The record is stored on the block's own attribute hash under KEY, as a
24
+ # Hash of mark name => Array of raw target tokens. KEY never starts with
25
+ # an attribute name character, so it collides with no AsciiDoc attribute
26
+ # and is never mistaken for an rhrev mark by any prefix scan.
27
+ module MarkTargets
28
+ KEY = '@rhrev-mark-targets'
29
+
30
+ def self.record attributes, line, prefix
31
+ by_mark = current = nil
32
+ line.each do |key, value|
33
+ if ::String === key
34
+ next unless key.start_with? prefix
35
+ (by_mark ||= (attributes[KEY] ||= {}))[current = key] = []
36
+ elsif current && ::Integer === key
37
+ by_mark[current] << value
38
+ end
39
+ end
40
+ end
41
+
42
+ # Only a block attribute line passes the document itself as the block
43
+ # (Parser.parse_block_metadata_line, sub_result: true); every other
44
+ # caller, such as a block macro's attribute list, passes none.
45
+ module AttributeListHook
46
+ def parse_into attributes, positional_attrs = []
47
+ if ::Asciidoctor::Document === @block
48
+ MarkTargets.record attributes, (parse positional_attrs), (@block.attr 'revhistoryprefix', 'rhrev')
49
+ end
50
+ super
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ ::Asciidoctor::AttributeList.prepend ::Asciidoctor::Rhrev::MarkTargets::AttributeListHook
@@ -438,11 +438,9 @@ module Asciidoctor
438
438
  cover_text = doc.attr 'rhrev-localization-cover', 'Cover'
439
439
 
440
440
  revision_history.sorted_revisions.each do |revision|
441
- prevrev_attr = "#{revision}-prevrev"
442
- prevrevdate_attr = "#{revision}-prevrevdate"
443
441
  revision_label = doc.attr('version-label', 'Revision')
444
- prev_title = doc.attr(prevrev_attr) || "#{revision.tr('-', '.')}"
445
- prev_date = doc.attr(prevrevdate_attr) || ""
442
+ prev_title = ::Asciidoctor::Rhrev::ThirdLevel.group_attr(doc, revision, 'prevrev', @third_level_mode) || "#{revision.tr('-', '.')}"
443
+ prev_date = ::Asciidoctor::Rhrev::ThirdLevel.group_attr(doc, revision, 'prevrevdate', @third_level_mode) || ""
446
444
  prev_rev_text = format_prev_rev(prev_title, prev_date, doc)
447
445
 
448
446
  if prev_title.strip.downcase.start_with?(revision_label.downcase)
@@ -42,6 +42,7 @@ module Asciidoctor
42
42
  prefix = document.attr 'revhistoryprefix', 'rhrev'
43
43
 
44
44
  ::Asciidoctor::Rhrev::CellTextMarks.lift document, prefix
45
+ ::Asciidoctor::Rhrev::ThirdLevel.drop_mixed_release_marks document, prefix
45
46
 
46
47
  document.find_by(context: :table).each do |table|
47
48
  cells = (table.rows[:head] + table.rows[:body] + table.rows[:foot]).flatten
@@ -91,11 +92,11 @@ module Asciidoctor
91
92
  return document unless document.attr? 'rhrev-change-bars'
92
93
  return document unless (revnumber = document.attr 'revnumber')
93
94
  prefix = document.attr 'revhistoryprefix', 'rhrev'
94
- change_attr = %(#{prefix}#{revnumber.to_s.tr '.', '-'})
95
+ matcher = ThirdLevel.matcher document, prefix, revnumber
95
96
 
96
97
  tagged = false
97
98
  document.find_by { |b| TRACKED_CONTEXTS.include? b.context }.each do |block|
98
- next unless block.attributes && (block.attributes.key? change_attr)
99
+ next unless block.attributes && (matcher.match? block.attributes)
99
100
  roles = (block.attr 'role')
100
101
  block.set_attr 'role', [roles, 'rhrev-changed'].compact.join(' ')
101
102
  tagged = true
@@ -173,7 +174,7 @@ module Asciidoctor
173
174
  return super
174
175
  end
175
176
  prefix = doc.attr 'revhistoryprefix', 'rhrev'
176
- change_attr = %(#{prefix}#{revnumber.to_s.tr '.', '-'})
177
+ matcher = ThirdLevel.matcher doc, prefix, revnumber
177
178
 
178
179
  result = []
179
180
  id_attribute = node.id ? %( id="#{node.id}") : ''
@@ -231,7 +232,7 @@ module Asciidoctor
231
232
 
232
233
  cell_tag_name = (tsec == :head || cell.style == :header ? 'th' : 'td')
233
234
  cell_classes = ['tableblock', %(halign-#{cell.attr 'halign'}), %(valign-#{cell.attr 'valign'})]
234
- cell_classes << 'rhrev-changed' if cell.attributes && (cell.attributes.key? change_attr)
235
+ cell_classes << 'rhrev-changed' if cell.attributes && (matcher.match? cell.attributes)
235
236
  cell_class_attribute = %( class="#{cell_classes.join ' '}")
236
237
  cell_colspan_attribute = cell.colspan ? %( colspan="#{cell.colspan}") : ''
237
238
  cell_rowspan_attribute = cell.rowspan ? %( rowspan="#{cell.rowspan}") : ''
@@ -405,7 +406,7 @@ module Asciidoctor
405
406
  # take catalog as an argument since collect_all_entries builds a fresh
406
407
  # one locally rather than reading an instance-level revision_history.
407
408
  def append_child_change_to_entry catalog, revision, anchor_node, change_text
408
- entries = catalog.entries[revision] ||= []
409
+ entries = catalog.entries[catalog.revision_key revision] ||= []
409
410
  entry = entries.find { |e| e[:anchor] == anchor_node.id }
410
411
  if entry
411
412
  entry[:change] = entry[:change].to_s.empty? ? change_text : "#{entry[:change]} * #{change_text}"
@@ -426,9 +427,7 @@ module Asciidoctor
426
427
  # or sets it fresh if none exists yet. Same shape as
427
428
  # Converter#append_child_change_to_all on the PDF side.
428
429
  def append_child_change_to_all catalog, revision, change_text
429
- existing = catalog.all_entries[revision]
430
- combined = existing.to_s.empty? ? change_text : "#{existing} * #{change_text}"
431
- catalog.add_all_entry revision, combined
430
+ catalog.append_all_entry revision, change_text
432
431
  end
433
432
 
434
433
  # Gathers every marked rollup-context node in one unified, document-order
@@ -447,7 +446,7 @@ module Asciidoctor
447
446
  section = enclosing_section node
448
447
  has_section = section && section.id
449
448
 
450
- attr_entries.each do |key, value|
449
+ ThirdLevel.sort_marks(attr_entries, prefix, (ThirdLevel.kind doc)).each do |key, value|
451
450
  key_str = key.to_s
452
451
  next unless key_str.start_with?(prefix)
453
452
  next if key_str.include?('-all') || key_str.end_with?('-cover')
@@ -500,21 +499,33 @@ module Asciidoctor
500
499
  end
501
500
 
502
501
  def collect_all_entries(doc)
503
- catalog = Catalog.new
502
+ third_level = ThirdLevel.mode doc
503
+ kind = ThirdLevel.kind doc
504
+ catalog = Catalog.new third_level: third_level, kind: kind
504
505
  prefix = doc.attr 'revhistoryprefix', 'rhrev'
505
506
 
506
507
  # Collect document-level entries (-all)
507
508
  # Discover revisions from *-prevrev attributes so any number of
508
- # segments works (1-1, 1-2-0, ...), not just major-minor
509
+ # segments works (1-1, 1-2-0, ...), not just major-minor. With
510
+ # rhrev-third-level set, a revision's -all attribute counts too,
511
+ # same as Converter#collect_document_level_entries on the PDF side.
512
+ revisions = []
509
513
  doc.attributes.each_key do |key|
510
514
  key_str = key.to_s
511
- next unless key_str.end_with?('-prevrev')
512
- revision = key_str.delete_suffix('-prevrev')
513
- next unless revision.match?(/\A\d+(?:-\d+)*\z/)
515
+ if key_str.end_with?('-prevrev')
516
+ revision = key_str.delete_suffix('-prevrev')
517
+ elsif third_level && key_str.end_with?('-all')
518
+ revision = ThirdLevel.revision_of key_str.delete_suffix('-all'), prefix
519
+ else
520
+ next
521
+ end
522
+ revisions << revision if revision&.match?(/\A\d+(?:-\d+)*\z/)
523
+ end
514
524
 
525
+ revisions.uniq.sort_by { |revision| catalog.parse_revision revision }.each do |revision|
515
526
  all_attr_name = "#{prefix}#{revision}-all"
516
527
  if (all_value = doc.attr(all_attr_name))
517
- catalog.add_all_entry revision, all_value
528
+ catalog.append_all_entry revision, all_value
518
529
  end
519
530
 
520
531
  # Skip -cover entries for HTML
@@ -526,7 +537,7 @@ module Asciidoctor
526
537
  next unless block.id
527
538
 
528
539
  if block.respond_to?(:attributes)
529
- block.attributes.each do |key, value|
540
+ ThirdLevel.sort_marks(block.attributes, prefix, kind).each do |key, value|
530
541
  key_str = key.to_s
531
542
  next unless key_str.start_with?(prefix)
532
543
  next if key_str.include?('-all') || key_str.end_with?('-cover')
@@ -596,11 +607,10 @@ module Asciidoctor
596
607
  all_text = doc.attr 'rhrev-localization-all', 'All'
597
608
 
598
609
  catalog.sorted_revisions.each do |revision|
599
- prevrev_attr = "#{revision}-prevrev"
600
- prevrevdate_attr = "#{revision}-prevrevdate"
610
+ third_level = ThirdLevel.mode doc
601
611
  revision_label = doc.attr('version-label', 'Revision')
602
- prev_title = doc.attr(prevrev_attr) || revision.tr('-', '.')
603
- prev_date = doc.attr(prevrevdate_attr) || ""
612
+ prev_title = ThirdLevel.group_attr(doc, revision, 'prevrev', third_level) || revision.tr('-', '.')
613
+ prev_date = ThirdLevel.group_attr(doc, revision, 'prevrevdate', third_level) || ""
604
614
  prev_rev_text = format_prev_rev(prev_title, prev_date, doc)
605
615
 
606
616
  # Build header text
@@ -48,13 +48,15 @@ if defined? ::Prawn::Table
48
48
  module PDF
49
49
  module Rhrev
50
50
  module TableCellChangeBarPatch
51
+ # The change-bar lanes the cell draws (ChangeBars#change_bar_lanes),
52
+ # or nil for an unmarked cell.
51
53
  attr_accessor :rhrev_marked
52
54
 
53
55
  def draw_background pt
54
56
  if @rhrev_marked && @pdf.respond_to?(:ink_change_bar)
55
57
  x, y = pt
56
58
  page = @pdf.page_number
57
- @pdf.ink_change_bar({ page: page, cursor: y }, { page: page, cursor: y - height })
59
+ @pdf.ink_change_bar({ page: page, cursor: y, lanes: @rhrev_marked }, { page: page, cursor: y - height })
58
60
  end
59
61
  super
60
62
  end
@@ -76,12 +78,12 @@ if defined? ::Prawn::Table
76
78
  rows = node.rows
77
79
  (rows[:head] + rows[:body] + rows[:foot]).each_with_index do |row, r|
78
80
  row.each_with_index do |cell, c|
79
- next unless cell.respond_to?(:attributes) && cell.attributes && (cell.attributes.key? change_attr)
81
+ next unless cell.respond_to?(:attributes) && cell.attributes && (lanes = document.change_bar_lanes cell.attributes)
80
82
  next unless (cell_data = data[r] && data[r][c]).is_a?(::Hash)
81
83
  if (content = cell_data[:content]).is_a? ::Prawn::Table::Cell
82
- content.rhrev_marked = true
84
+ content.rhrev_marked = lanes
83
85
  else
84
- cell_data[:rhrev_marked] = true
86
+ cell_data[:rhrev_marked] = lanes
85
87
  end
86
88
  end
87
89
  end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asciidoctor
4
+ module Rhrev
5
+ # Support for x.y.z revision numbers, selected by the rhrev-third-level
6
+ # document attribute:
7
+ #
8
+ # collapse:: the revision history table groups every revision by its
9
+ # first two levels, and one change bar covers every mark of the
10
+ # current x.y release.
11
+ # expand:: the table lists every revision on its own, as without the
12
+ # attribute; the PDF backend draws one lane for every mark of the
13
+ # current x.y release plus one lane per third-level revision further
14
+ # out in the margin.
15
+ #
16
+ # Without the attribute, only a mark for exactly the current revnumber
17
+ # draws a bar, and every revision gets its own table group.
18
+ #
19
+ # The rhrev-third-level-kind attribute says what an x.y.z revision is:
20
+ #
21
+ # draft:: the default; x.y.1, x.y.2, ... are drafts leading up to the
22
+ # release x.y, so x.y sorts after its drafts, an x.y mark draws no bar
23
+ # while the revnumber names a draft, and an x.y mark beside marks of
24
+ # its own drafts is dropped with a warning (drop_mixed_release_marks).
25
+ # amendment:: x.y.1, x.y.2, ... amend the released x.y, so x.y sorts
26
+ # before them and its marks draw a bar for every amendment.
27
+ module ThirdLevel
28
+ MODES = %w(expand collapse).freeze
29
+ KINDS = %w(draft amendment).freeze
30
+ DEFAULT_KIND = 'draft'
31
+ RevisionRx = /\A\d+(?:-\d+)*\z/
32
+
33
+ # The document's mode, 'expand' or 'collapse', or nil when the
34
+ # attribute is unset. An unrecognized value warns once per document
35
+ # and falls back to nil.
36
+ def self.mode doc
37
+ return nil unless doc.attr? 'rhrev-third-level'
38
+ value = (doc.attr 'rhrev-third-level').to_s
39
+ return value if MODES.include? value
40
+ unless doc.instance_variable_get :@rhrev_third_level_warned
41
+ doc.instance_variable_set :@rhrev_third_level_warned, true
42
+ doc.logger.warn %(rhrev-third-level must be expand or collapse, got '#{value}'; ignoring it)
43
+ end
44
+ nil
45
+ end
46
+
47
+ # The document's kind, 'draft' or 'amendment', or nil when
48
+ # rhrev-third-level is unset. An unrecognized value warns once per
49
+ # document and falls back to the default.
50
+ def self.kind doc
51
+ return nil unless mode doc
52
+ return DEFAULT_KIND unless doc.attr? 'rhrev-third-level-kind'
53
+ value = (doc.attr 'rhrev-third-level-kind').to_s
54
+ return value if KINDS.include? value
55
+ unless doc.instance_variable_get :@rhrev_third_level_kind_warned
56
+ doc.instance_variable_set :@rhrev_third_level_kind_warned, true
57
+ doc.logger.warn %(rhrev-third-level-kind must be draft or amendment, got '#{value}'; using #{DEFAULT_KIND})
58
+ end
59
+ DEFAULT_KIND
60
+ end
61
+
62
+ # The comparable form of a revision. In draft kind an x-y revision
63
+ # is the release its x-y-z drafts lead up to, so it sorts after them;
64
+ # otherwise x-y sorts before x-y-1.
65
+ def self.sort_key revision, kind
66
+ parts = revision.split('-').map(&:to_i)
67
+ kind == 'draft' && parts.size == 2 ? (parts << ::Float::INFINITY) : parts
68
+ end
69
+
70
+ # The x-y groups whose release mark appears beside marks of its own
71
+ # drafts, each with the draft revisions found, from a list of the
72
+ # revisions a draft-kind document marks.
73
+ def self.mixed_draft_groups revisions
74
+ revisions = revisions.uniq
75
+ revisions.select {|revision| revision.count('-') == 1 }.filter_map do |release|
76
+ drafts = revisions.select {|revision| revision.start_with?(%(#{release}-)) && revision.count('-') == 2 }
77
+ [release, drafts.sort_by {|revision| sort_key revision, nil }] unless drafts.empty?
78
+ end
79
+ end
80
+
81
+ # Calls the block with the attributes of every node that carries
82
+ # marks: the document, every block in it and in nested documents,
83
+ # and every table cell. Cell text marks count only once
84
+ # CellTextMarks.lift has run.
85
+ def self.each_marked_attributes doc
86
+ doc.find_by(traverse_documents: true) { true }.each do |node|
87
+ yield node.attributes
88
+ next unless node.context == :table
89
+ (node.rows[:head] + node.rows[:body] + node.rows[:foot]).flatten.each {|cell| yield cell.attributes }
90
+ end
91
+ end
92
+
93
+ # The revision an attribute names as a mark, or as an -all or -cover
94
+ # entry, or nil.
95
+ def self.marked_revision_of key, prefix
96
+ revision_of key.to_s.sub(/-(?:all|cover)\z/, ''), prefix
97
+ end
98
+
99
+ # In a draft-kind document, removes every mark, -all, and -cover
100
+ # entry of an x-y release that the document also marks drafts of,
101
+ # warning once per release, so no backend shows a release beside its
102
+ # own drafts. Runs once per document, after CellTextMarks.lift.
103
+ def self.drop_mixed_release_marks doc, prefix
104
+ return unless kind(doc) == 'draft'
105
+ return if doc.instance_variable_get :@rhrev_mixed_releases_dropped
106
+ doc.instance_variable_set :@rhrev_mixed_releases_dropped, true
107
+ revisions = []
108
+ each_marked_attributes(doc) do |attributes|
109
+ attributes.each_key {|key| (revision = marked_revision_of key, prefix) && (revisions << revision) }
110
+ end
111
+ releases = mixed_draft_groups(revisions).map do |release, drafts|
112
+ doc.logger.warn %(#{prefix}#{release} marks the release that its drafts #{drafts.map {|d| prefix + d }.join ', '} lead up to; ignoring the #{prefix}#{release} marks, mark each change with the draft that made it)
113
+ release
114
+ end
115
+ return if releases.empty?
116
+ each_marked_attributes(doc) do |attributes|
117
+ attributes.delete_if {|key, _| releases.include? marked_revision_of(key, prefix) }
118
+ end
119
+ end
120
+
121
+ # The revision a mark names, "1-3-2" for rhrev1-3-2, or nil for any
122
+ # other attribute, including -all, -cover, and configuration keys
123
+ # sharing the prefix.
124
+ def self.revision_of key, prefix
125
+ key = key.to_s
126
+ return nil unless key.start_with? prefix
127
+ revision = key.delete_prefix prefix
128
+ (RevisionRx.match? revision) ? revision : nil
129
+ end
130
+
131
+ # The attribute pairs with every mark in ascending revision order and
132
+ # every other attribute after them, so change texts merged into one
133
+ # collapse-mode entry follow revision order, not source order.
134
+ def self.sort_marks attributes, prefix, kind = nil
135
+ attributes.sort_by.with_index do |(key, _), index|
136
+ revision = revision_of key, prefix
137
+ revision ? [0, (sort_key revision, kind), index] : [1, [], index]
138
+ end
139
+ end
140
+
141
+ # The table group a revision belongs to: its first two levels in
142
+ # collapse mode, the revision itself otherwise.
143
+ def self.group_key revision, mode
144
+ return revision unless mode == 'collapse'
145
+ parts = revision.split '-'
146
+ parts.size > 2 ? (parts.take 2).join('-') : revision
147
+ end
148
+
149
+ # A table group's prevrev or prevrevdate value: the group's own
150
+ # attribute, or in collapse mode, the lowest third-level revision's
151
+ # attribute that the document sets.
152
+ def self.group_attr doc, revision, suffix, mode
153
+ if (value = doc.attr %(#{revision}-#{suffix}))
154
+ return value
155
+ end
156
+ return nil unless mode == 'collapse' && revision.count('-') == 1
157
+ rx = /\A#{::Regexp.escape revision}-(\d+)-#{suffix}\z/
158
+ lowest = doc.attributes.keys.filter_map {|key| (m = rx.match key.to_s) && [m[1].to_i, key] }.min
159
+ lowest && (doc.attr lowest[1])
160
+ end
161
+
162
+ # The Matcher for a document's revnumber, mode, and kind.
163
+ def self.matcher doc, prefix, revnumber
164
+ Matcher.new prefix, revnumber, (mode doc), (kind doc)
165
+ end
166
+
167
+ # Decides which marks on a node draw a change bar for the current
168
+ # revnumber, and which third-level revision each belongs to.
169
+ class Matcher
170
+ attr_reader :mode, :kind, :exact
171
+
172
+ def initialize prefix, revnumber, mode, kind = DEFAULT_KIND
173
+ @prefix = prefix
174
+ @mode = mode
175
+ @kind = kind
176
+ revnumber = revnumber.to_s
177
+ @exact = %(#{prefix}#{revnumber.tr '.', '-'})
178
+ if mode && (/\A\d+(?:\.\d+)+\z/.match? revnumber)
179
+ parts = revnumber.split('.').map(&:to_i)
180
+ @base = parts.take 2
181
+ @current_z = parts[2]
182
+ else
183
+ @mode = nil
184
+ end
185
+ end
186
+
187
+ # A Hash of each matching mark name to its third-level number, or
188
+ # to nil for a mark without one. Empty when nothing matches.
189
+ def marks attributes
190
+ return {} unless attributes
191
+ return ((attributes.key? @exact) ? { @exact => nil } : {}) unless @mode
192
+ result = {}
193
+ attributes.each_key do |key|
194
+ next unless ::String === key && (revision = ThirdLevel.revision_of key, @prefix)
195
+ parts = revision.split('-').map(&:to_i)
196
+ next unless (parts.take 2) == @base
197
+ z = parts[2]
198
+ if @current_z
199
+ next if z ? z > @current_z : @kind == 'draft'
200
+ end
201
+ result[key] = z
202
+ end
203
+ result
204
+ end
205
+
206
+ def match? attributes
207
+ !(marks attributes).empty?
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -7,6 +7,8 @@ require 'asciidoctor' unless defined? Asciidoctor
7
7
  require_relative 'rhrev/processors'
8
8
  require_relative 'rhrev/helpers'
9
9
  require_relative 'rhrev/cell_text_marks'
10
+ require_relative 'rhrev/mark_targets'
11
+ require_relative 'rhrev/third_level'
10
12
  require_relative 'rhrev/catalog'
11
13
 
12
14
  # Try to load the PDF converter support if asciidoctor-pdf is available
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asciidoctor-rhrev
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.2
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - 白一百 baiyibai
@@ -43,12 +43,14 @@ files:
43
43
  - lib/asciidoctor/rhrev/converter.rb
44
44
  - lib/asciidoctor/rhrev/exporter.rb
45
45
  - lib/asciidoctor/rhrev/helpers.rb
46
+ - lib/asciidoctor/rhrev/mark_targets.rb
46
47
  - lib/asciidoctor/rhrev/processors.rb
47
48
  - lib/asciidoctor/rhrev/renderer.rb
48
49
  - lib/asciidoctor/rhrev/rhrev_html.rb
49
50
  - lib/asciidoctor/rhrev/rhrev_pdf.rb
50
51
  - lib/asciidoctor/rhrev/table_cell_change_bar_patch.rb
51
52
  - lib/asciidoctor/rhrev/table_draw_patch.rb
53
+ - lib/asciidoctor/rhrev/third_level.rb
52
54
  homepage: https://gitlab.com/baiyibai/rhrev-asciidoctor-automatic-revision-history
53
55
  licenses:
54
56
  - CC0-1.0