asciidoctor-rhrev 1.0.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.
@@ -0,0 +1,92 @@
1
+ module Asciidoctor
2
+ module Rhrev
3
+ class Catalog
4
+ attr_accessor :entries
5
+ attr_accessor :all_entries
6
+ attr_accessor :cover_entries
7
+
8
+ def initialize
9
+ @entries = {} # Hash of revision => [entries]
10
+ @all_entries = {} # Hash of revision => text for 'all' entries
11
+ @cover_entries = {} # Hash of revision => text for 'cover' entries
12
+ @sequence_counter = 0 # Counter to track document order
13
+ end
14
+
15
+ def self.default_labels
16
+ {
17
+ 'page' => 'Page',
18
+ 'major_changes' => 'Major changes since'
19
+ }
20
+ end
21
+
22
+ def self.default_columns
23
+ '30,70' # location, change
24
+ end
25
+
26
+ 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 }
31
+
32
+ @sequence_counter += 1
33
+ # Use source_line if available, otherwise fall back to sequence counter
34
+ sequence = source_line || @sequence_counter
35
+ @entries[revision] << {
36
+ anchor: anchor,
37
+ change: change_text,
38
+ dest: nil,
39
+ sequence: sequence,
40
+ reftext: reftext,
41
+ title: title,
42
+ sectnum: sectnum,
43
+ context: context,
44
+ is_chapter: is_chapter,
45
+ sectname: sectname,
46
+ caption_number: caption_number
47
+ }
48
+ end
49
+
50
+ def add_all_entry revision, change_text
51
+ @all_entries[revision] = change_text
52
+ end
53
+
54
+ def add_cover_entry revision, change_text
55
+ @cover_entries[revision] = change_text
56
+ end
57
+
58
+ def link_dest_to_page anchor, physical_page_number, start_page_number, y: nil
59
+ @entries.each do |revision, entry_list|
60
+ entry_list.each do |entry|
61
+ next unless entry[:anchor] == anchor
62
+
63
+ virtual_page_number = physical_page_number - (start_page_number - 1)
64
+ entry[:dest] = {
65
+ anchor: anchor,
66
+ page: (virtual_page_number < 1 ?
67
+ (Asciidoctor::PDF::RomanNumeral.new physical_page_number, :lower) :
68
+ virtual_page_number).to_s,
69
+ page_sortable: physical_page_number,
70
+ y: y
71
+ }
72
+ end
73
+ end
74
+ end
75
+
76
+ def sorted_revisions
77
+ # Collect all unique revisions from entries, all_entries, and cover_entries
78
+ all_revisions = (@entries.keys + @all_entries.keys + @cover_entries.keys).uniq
79
+
80
+ # Sort revisions in descending order (newest first)
81
+ all_revisions.sort do |a, b|
82
+ parse_revision(b) <=> parse_revision(a)
83
+ end
84
+ end
85
+
86
+ def parse_revision rev_string
87
+ # Parse "1-1", "1-2", "1-10-2" into comparable array [1, 1], [1, 2], [1, 10, 2]
88
+ rev_string.split('-').map(&:to_i)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,437 @@
1
+ require 'asciidoctor/pdf' unless defined? Asciidoctor::PDF
2
+ require 'fileutils'
3
+ require_relative 'helpers'
4
+ require_relative 'catalog'
5
+ require_relative 'renderer'
6
+ require_relative 'exporter'
7
+ require_relative 'processors'
8
+
9
+ module Asciidoctor
10
+ module PDF
11
+ module Rhrev
12
+ # Import shared classes from parent namespace
13
+ Catalog = ::Asciidoctor::Rhrev::Catalog
14
+ Helpers = ::Asciidoctor::Rhrev::Helpers
15
+
16
+ class Converter < ::Asciidoctor::Converter.for('pdf')
17
+ register_for 'pdf'
18
+
19
+ include Helpers
20
+ include Renderer
21
+ include Exporter
22
+
23
+ def initialize *args
24
+ super
25
+ @revision_history_extent = nil
26
+ @revision_prefix = nil
27
+ @rhrev_deferred_pages = nil
28
+ @pagerhref_tables = []
29
+ @export_completed = false
30
+ @catalog = nil
31
+ @manual_mode = false
32
+ end
33
+
34
+ def revision_history
35
+ @catalog ||= Catalog.new
36
+ end
37
+
38
+ def convert_document node
39
+ @revision_prefix = node.attr 'revhistoryprefix', 'rhrev'
40
+
41
+ # Check if we're in manual mode (pagerhref support needed)
42
+ @manual_mode = node.attr('rhrev') == 'manual'
43
+
44
+ # Initialize catalog
45
+ @catalog = revision_history
46
+
47
+ # Prescan document to populate catalog before rendering
48
+ # This is necessary for accurate space allocation
49
+ prescan_document node
50
+
51
+ # Render document (this will visit all nodes and update entry metadata)
52
+ result = super
53
+
54
+ # Ink revision history if allocated
55
+ if @revision_history_extent && @catalog
56
+ ink_revision_history node, @revision_history_extent
57
+ end
58
+
59
+ # Re-render tables with pagerhrefs (only in manual mode)
60
+ ink_pagerhref_tables if @manual_mode && @pagerhref_tables && !@pagerhref_tables.empty?
61
+
62
+ # Export to file AFTER rendering (metadata like sectnum, caption_number are now populated)
63
+ if node.attr?('rhrev-export-to-file') && @catalog
64
+ export_to_adoc_file node
65
+ end
66
+
67
+ result
68
+ end
69
+
70
+ def convert_section node
71
+ # Skip collect during render if prescan already did it
72
+ collect_revision_entries node unless @prescan_complete
73
+ update_revision_entry_metadata node
74
+
75
+ if node.id
76
+ @anchor_catalog ||= {}
77
+ @anchor_catalog[node.id] ||= {
78
+ title: node.title,
79
+ reftext: node.reftext,
80
+ sectnum: node.sectnum,
81
+ context: node.context,
82
+ level: node.level
83
+ }
84
+ @anchor_catalog[node.id][:dest] = { page: page_number, y: cursor }
85
+
86
+ # Link destination to page for revision history
87
+ revision_history.link_dest_to_page node.id, page_number, 1, y: cursor
88
+ end
89
+
90
+ super
91
+ end
92
+
93
+ def convert_table node
94
+ has_id = node.id && !node.id.empty? rescue false
95
+ has_rhrev = false
96
+ if node.respond_to?(:attributes) && node.attributes
97
+ has_rhrev = node.attributes.keys.any? { |k| k.to_s.start_with?(@revision_prefix || 'rhrev') } rescue false
98
+ end
99
+
100
+ # Check for pagerhref macros in table (only in manual mode)
101
+ # This check is expensive so we skip it unless in manual mode
102
+ has_pagerhref = @manual_mode && !@rendering_deferred_table && table_contains_pagerhref?(node)
103
+
104
+ if !has_id && !has_rhrev && !has_pagerhref
105
+ return super
106
+ end
107
+
108
+ # Skip collect during render if prescan already did it
109
+ collect_revision_entries node unless @prescan_complete
110
+ update_revision_entry_metadata node
111
+
112
+ if has_pagerhref
113
+ allocate_pagerhref_table_extent node
114
+ else
115
+ result = super
116
+ catalog_block_anchor node
117
+ result
118
+ end
119
+ end
120
+
121
+ def convert_image node
122
+ # Skip collect during render if prescan already did it
123
+ collect_revision_entries node unless @prescan_complete
124
+ update_revision_entry_metadata node
125
+ result = super
126
+ catalog_block_anchor node
127
+ result
128
+ end
129
+
130
+ def convert_example node
131
+ # Skip collect during render if prescan already did it
132
+ collect_revision_entries node unless @prescan_complete
133
+ update_revision_entry_metadata node
134
+ result = super
135
+ catalog_block_anchor node
136
+ result
137
+ end
138
+
139
+ def convert_listing node
140
+ # Skip collect during render if prescan already did it
141
+ collect_revision_entries node unless @prescan_complete
142
+ update_revision_entry_metadata node
143
+ result = super
144
+ catalog_block_anchor node
145
+ result
146
+ end
147
+
148
+ def convert_floating_title node
149
+ # Skip collect during render if prescan already did it
150
+ collect_revision_entries node unless @prescan_complete
151
+ update_revision_entry_metadata node
152
+ result = super
153
+ catalog_block_anchor node
154
+ result
155
+ end
156
+
157
+ def convert_rhrev node
158
+ return unless node.document.attr? 'rhrev'
159
+ return if @revision_history_extent
160
+
161
+ collect_document_level_entries node.document
162
+ @revision_history_extent = allocate_revision_history_extent node.document
163
+ nil
164
+ end
165
+
166
+ def convert_inline_quoted node
167
+ if node.text&.match?(/^\[pagerhref:/)
168
+ if node.text =~ /^\[pagerhref:(.+)\]$/
169
+ anchor = $1
170
+ lookup_anchor = anchor.gsub('----', ':::')
171
+
172
+ if @anchor_catalog && @anchor_catalog[lookup_anchor] && @anchor_catalog[lookup_anchor][:dest]
173
+ page_num = @anchor_catalog[lookup_anchor][:dest][:page]
174
+ %(<a anchor="#{lookup_anchor}"><span class="pagerhref">#{page_num}</span></a>)
175
+ else
176
+ if scratch?
177
+ %(<a anchor="#{lookup_anchor}"><span class="pagerhref">99</span></a>)
178
+ else
179
+ %(<a anchor="#{lookup_anchor}"><span class="pagerhref">??</span></a>)
180
+ end
181
+ end
182
+ else
183
+ super
184
+ end
185
+ else
186
+ super
187
+ end
188
+ end
189
+
190
+ def start_new_chapter chapter
191
+ if !@revision_history_extent && chapter.document && (chapter.document.attr? 'rhrev')
192
+ rhrev_value = chapter.document.attr('rhrev')
193
+ export_to_file = chapter.document.attr? 'rhrev-export-to-file'
194
+ # Effectively only when rhrev is 'true', add the revision history section at the beginning of the document (after the title_page)
195
+ if rhrev_value != 'macro' && rhrev_value != 'manual' && !export_to_file
196
+ collect_document_level_entries chapter.document
197
+ @revision_history_extent = allocate_revision_history_extent chapter.document
198
+ end
199
+ end
200
+ super
201
+ end
202
+
203
+ def collect_revision_entries node
204
+ return if @prescan_complete
205
+ return unless node.respond_to?(:attributes)
206
+
207
+ is_document = node.class.to_s.include?('Document')
208
+ return if is_document
209
+
210
+ # Access raw attributes safely
211
+ attr_entries = node.instance_variable_get(:@attributes) rescue {}
212
+ return if attr_entries.nil? || attr_entries.empty?
213
+
214
+ # Check for rhrev attributes before checking ID to debug missing IDs
215
+ has_rhrev = attr_entries.keys.any? { |k| k.to_s.start_with?(@revision_prefix) }
216
+ return unless has_rhrev
217
+
218
+ has_id = node.id && !node.id.empty? rescue false
219
+ unless has_id
220
+ debug_log "Node #{node.context} has rhrev attributes but NO ID. Skipping. Attributes: #{attr_entries.keys.select{|k| k.to_s.start_with?(@revision_prefix)}}", @document
221
+ return
222
+ end
223
+
224
+ debug_log "Found entry candidate on #{node.context} id=#{node.id}", @document
225
+
226
+ attr_entries.each do |key, value|
227
+ key_str = key.to_s
228
+ next unless key_str.start_with?(@revision_prefix)
229
+ next if key_str.include?('-all') || key_str.end_with?('-cover')
230
+
231
+ revision = key_str.sub("#{@revision_prefix}", '')
232
+
233
+ debug_log "Adding entry: Rev=#{revision}, Anchor=#{node.id}, Change=#{value}", @document
234
+
235
+ revision_history.add_entry revision, node.id, value.to_s,
236
+ reftext: nil,
237
+ title: nil,
238
+ sectnum: nil,
239
+ context: node.context,
240
+ is_chapter: false,
241
+ sectname: nil,
242
+ caption_number: nil,
243
+ source_line: node.lineno
244
+ end
245
+ end
246
+
247
+ def update_revision_entry_metadata node
248
+ return unless node.id
249
+ return unless node.respond_to?(:attributes)
250
+
251
+ with_attribute_missing_suppressed node.document do
252
+ node.attributes.each do |key, value|
253
+ key_str = key.to_s
254
+ next unless key_str.start_with?(@revision_prefix)
255
+ next if key_str.include?('-all') || key_str.end_with?('-cover')
256
+
257
+ revision = key_str.sub("#{@revision_prefix}", '')
258
+
259
+ entries = revision_history.entries[revision]
260
+ next unless entries
261
+
262
+ entry = entries.find { |e| e[:anchor] == node.id }
263
+ next unless entry
264
+
265
+ if entry[:change].to_s.empty?
266
+ entry[:change] = value
267
+ end
268
+
269
+ entry[:reftext] = node.reftext
270
+ entry[:title] = node.title
271
+ entry[:sectnum] = node.respond_to?(:sectnum) ? node.sectnum : nil
272
+
273
+ if node.context == :section && node.document.doctype == 'book'
274
+ entry[:is_chapter] = (node.level == 0 || node.level == 1)
275
+ end
276
+
277
+ if node.respond_to?(:caption) && node.caption
278
+ if node.caption =~ /(\d+)/
279
+ entry[:caption_number] = $1
280
+ end
281
+ end
282
+
283
+ entry[:sectname] = node.sectname if node.respond_to?(:sectname)
284
+ end
285
+ end
286
+ end
287
+
288
+ def catalog_block_anchor node
289
+ @anchor_catalog ||= {}
290
+ if node.id
291
+ @anchor_catalog[node.id] ||= {
292
+ title: node.title,
293
+ context: node.context
294
+ }
295
+ page_num = page_number
296
+ @anchor_catalog[node.id][:dest] = { page: page_num, y: cursor }
297
+
298
+ revision_history.link_dest_to_page node.id, page_num, 1, y: cursor
299
+ end
300
+ end
301
+
302
+ def table_contains_pagerhref? node
303
+ return false unless node.context == :table
304
+
305
+ # Check cell content WITHOUT triggering attribute substitution
306
+ # This prevents counter attributes like {counter:tablecounter} from incrementing
307
+ # We MUST NOT call cell.text or access cell.inner_document as those trigger parsing
308
+ node.rows[:body].each do |row|
309
+ row.each do |cell|
310
+ # Check the raw @text instance variable if already evaluated
311
+ if cell.instance_variable_defined?(:@text)
312
+ raw_text = cell.instance_variable_get(:@text).to_s
313
+ return true if raw_text.include?('pagerhref:')
314
+ end
315
+ # Check the inner document's source lines if available (before parsing)
316
+ if cell.instance_variable_defined?(:@inner_document)
317
+ inner_doc = cell.instance_variable_get(:@inner_document)
318
+ if inner_doc && inner_doc.instance_variable_defined?(:@lines)
319
+ lines = inner_doc.instance_variable_get(:@lines) || []
320
+ return true if lines.any? { |l| l.to_s.include?('pagerhref:') }
321
+ end
322
+ end
323
+ # Last resort: check cell's style attribute for AsciiDoc cells
324
+ # AsciiDoc cells (a|) will have inner content that might contain pagerhref
325
+ if cell.style == :asciidoc
326
+ # For asciidoc cells, we need to check the source
327
+ # Access the cell's source blocks if available
328
+ if cell.instance_variable_defined?(:@inner_document)
329
+ inner = cell.instance_variable_get(:@inner_document)
330
+ if inner && inner.respond_to?(:blocks)
331
+ inner.blocks.each do |block|
332
+ if block.instance_variable_defined?(:@lines)
333
+ lines = block.instance_variable_get(:@lines) || []
334
+ return true if lines.any? { |l| l.to_s.include?('pagerhref:') }
335
+ end
336
+ end
337
+ end
338
+ end
339
+ end
340
+ end
341
+ end
342
+ false
343
+ end
344
+
345
+ def allocate_pagerhref_table_extent node
346
+ @rendering_deferred_table = true
347
+
348
+ extent = dry_run onto: self do
349
+ super_convert_table node
350
+ end
351
+
352
+ @rendering_deferred_table = false
353
+
354
+ # Move cursor to after the allocated space
355
+ extent.each_page { |first_page| start_new_page unless first_page }
356
+ move_cursor_to extent.to.cursor
357
+
358
+ # Store for later re-rendering with actual page numbers
359
+ @pagerhref_tables << {
360
+ node: node,
361
+ extent: extent
362
+ }
363
+ end
364
+
365
+ def super_convert_table node
366
+ # Call the parent class method directly to avoid recursion
367
+ self.class.superclass.instance_method(:convert_table).bind(self).call(node)
368
+ end
369
+
370
+ def ink_pagerhref_tables
371
+ return if scratch?
372
+
373
+ @anchor_catalog ||= {}
374
+
375
+ @pagerhref_tables.each do |table_info|
376
+ extent = table_info[:extent]
377
+ node = table_info[:node]
378
+
379
+ # Go back to the allocated space
380
+ go_to_page extent.from.page
381
+ move_cursor_to extent.from.cursor
382
+
383
+ # Re-render the table with flag set to prevent recursion
384
+ @rendering_deferred_table = true
385
+ super_convert_table node
386
+ @rendering_deferred_table = false
387
+ end
388
+ end
389
+
390
+ def collect_document_level_entries doc
391
+ return if @document_entries_collected
392
+ @document_entries_collected = true
393
+
394
+ (0..9).each do |major|
395
+ (0..9).each do |minor|
396
+ revision = "#{major}-#{minor}"
397
+ prevrev_attr = "#{revision}-prevrev"
398
+
399
+ if doc.attr(prevrev_attr)
400
+ all_attr_name = "#{@revision_prefix}#{revision}-all"
401
+ if (all_value = doc.attr(all_attr_name))
402
+ revision_history.add_all_entry revision, all_value
403
+ end
404
+
405
+ cover_attr_name = "#{@revision_prefix}#{revision}-cover"
406
+ if (cover_value = doc.attr(cover_attr_name))
407
+ revision_history.add_cover_entry revision, cover_value
408
+ end
409
+ end
410
+ end
411
+ end
412
+ end
413
+
414
+ def prescan_document doc
415
+ debug_log "Starting prescan_document", doc
416
+
417
+ # Suppress attribute missing warnings during prescan
418
+ # This prevents counter attributes like {counter:tablecounter} from incrementing
419
+ with_attribute_missing_suppressed doc do
420
+ collect_document_level_entries doc
421
+
422
+ # Use find_by with context filter - more efficient than single traversal
423
+ # because find_by(context:) can skip non-matching subtrees
424
+ [:section, :floating_title, :example, :listing, :table, :image].each do |ctx|
425
+ doc.find_by(context: ctx).each do |node|
426
+ collect_revision_entries node
427
+ end
428
+ end
429
+
430
+ @prescan_complete = true
431
+ debug_log "Prescan complete. Catalog has #{revision_history.entries.values.flatten.size} entries.", doc
432
+ end
433
+ end
434
+ end
435
+ end
436
+ end
437
+ end