asciidoctor-lists-extended 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 763b4829cf12a23c7ae9c993f9f10fc394919af9adb8c4e5eaf34f82a017ce76
4
+ data.tar.gz: 1a22a3265585f57715f25d0f14f8d3c44889eb9efd9976698dd7aa42b760dc46
5
+ SHA512:
6
+ metadata.gz: 3260b2cbe8a43684258c6a2a9e33f0e236b01453e99dc4cec4dcf967f15011c1f8f96daf833160d7e24d18593838d6752271050b22fc53a6f8edfcd58ccc77bd
7
+ data.tar.gz: f42056096abc9bd690b97abbffcc40ee61ad3dc8f64b4f041eec42ec5fbdf87de9c61e5605cdf608eb4e73b5e0105ec0ce04c74ff79e99e70ffe290035f4c3cf
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 白一百 baiyibai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.adoc ADDED
@@ -0,0 +1,424 @@
1
+ = asciidoctor-lists-extended
2
+ :toc: left
3
+ :toclevels: 2
4
+ :source-highlighter: rouge
5
+
6
+ A converter-aware asciidoctor extension that generates a *List of Figures*, *List of Tables*, *List of Listings*, and *List of Examples* — or any custom captioned block type.
7
+
8
+ Replaces and supersedes `asciidoctor-pdf-lofte`.
9
+ Compatible with the `asciidoctor-lists` macro syntax.
10
+
11
+ == Features
12
+
13
+ * Works with *HTML5* (xref links) and *asciidoctor-pdf* (dot-leader style matching the ToC)
14
+ * *No hardcoded `[#anchor]` required* — IDs are auto-generated via `SecureRandom.uuid`
15
+ * *Any element type* — image, table, listing, example, or custom block contexts
16
+ * *ToC integration* — lists optionally appear in the PDF Table of Contents and bookmark outline
17
+ * *Compatible with `asciidoctor-lists`* — same `list-of::element[]` macro syntax
18
+
19
+ == Installation
20
+
21
+ Add to your `Gemfile`:
22
+
23
+ [source,ruby]
24
+ ----
25
+ gem 'asciidoctor-lists-extended', path: '/path/to/asciidoctor-lists-extended'
26
+ ----
27
+
28
+ Or require directly:
29
+
30
+ [source,bash]
31
+ ----
32
+ asciidoctor-pdf -r /path/to/lib/asciidoctor-lists-extended.rb document.adoc
33
+ ----
34
+
35
+ For HTML5 only (no `asciidoctor-pdf` needed):
36
+
37
+ [source,bash]
38
+ ----
39
+ asciidoctor -r /path/to/lib/asciidoctor-lists-extended.rb document.adoc
40
+ ----
41
+
42
+ == Quick Start
43
+
44
+ [source,asciidoc]
45
+ ----
46
+ = My Document
47
+ :toc:
48
+ :doctype: book
49
+
50
+ \== List of Figures <1>
51
+ list-of::image[] <2>
52
+
53
+ \== List of Tables
54
+ list-of::table[]
55
+
56
+ \== Chapter 1
57
+
58
+ .My Architecture Diagram <3>
59
+ image::arch.png[]
60
+
61
+ .Configuration Parameters
62
+ |===
63
+ |Name |Value
64
+ |timeout |30
65
+ |===
66
+ ----
67
+ <1> The section heading becomes the title in the PDF front matter.
68
+ <2> The macro generates the list. In PDF mode the section is consumed and moved to the front matter; in HTML mode it is rendered inline.
69
+ <3> No `[#anchor]` needed — IDs are assigned automatically.
70
+
71
+ == Macro Syntax
72
+
73
+ [source,asciidoc]
74
+ ----
75
+ list-of::<element>[]
76
+ list-of::<element>[<positional-options>,key=value,...]
77
+ ----
78
+
79
+ === Positional Options
80
+
81
+ [cols="1,3"]
82
+ |===
83
+ |Option |Effect
84
+
85
+ |`enhanced_rendering`
86
+ |In HTML mode, renders caption and title on separate spans rather than concatenated.
87
+
88
+ |`hide_empty_section`
89
+ |Removes the containing section entirely if no captioned/titled elements of the requested type exist in the document.
90
+ In HTML mode, omitting this flag leaves an orphaned section heading with no content, which is rarely useful.
91
+ Kept as an explicit opt-in for backwards compatibility with the original `asciidoctor-lists` gem.
92
+
93
+ |`exclude_from_toc`
94
+ |PDF only. Omits this list from the PDF Table of Contents while keeping it in the bookmark outline.
95
+
96
+ |`exclude_from_outline`
97
+ |PDF only. Omits this list from the PDF bookmark outline while keeping it in the Table of Contents.
98
+ |===
99
+
100
+ === Named Parameters
101
+
102
+ [cols="1,1,3"]
103
+ |===
104
+ |Parameter |Backend |Description
105
+
106
+ |`title`
107
+ |Both
108
+ |Override the list heading. When set, takes precedence over the parent section title.
109
+
110
+ |`caption_prefix`
111
+ |Both
112
+ |Override the caption prefix label (e.g. `"Figure"`). Currently informational; caption is taken from the element's own `captioned_title`.
113
+ |===
114
+
115
+ === Supported Element Types
116
+
117
+ Any valid Asciidoctor block context string works:
118
+
119
+ [cols="1,2"]
120
+ |===
121
+ |Macro |Collects
122
+
123
+ |`list-of::image[]`
124
+ |Blocks with `context: :image` that have a title or caption.
125
+
126
+ |`list-of::table[]`
127
+ |Blocks with `context: :table` that have a title or caption.
128
+
129
+ |`list-of::listing[]`
130
+ |Blocks with `context: :listing` (source code, literal) that have a title or caption.
131
+
132
+ |`list-of::example[]`
133
+ |Blocks with `context: :example` that have a title or caption.
134
+
135
+ |`list-of::video[]`
136
+ |Any custom block type registered with that context name.
137
+ |===
138
+
139
+ Elements *without* a title or caption are silently skipped.
140
+
141
+ == Document Attributes
142
+
143
+ === Caption Attributes (standard Asciidoctor)
144
+
145
+ [source,asciidoc]
146
+ ----
147
+ :figure-caption: Figure
148
+ :table-caption: Table
149
+ :listing-caption: Listing
150
+ :example-caption: Example
151
+ ----
152
+
153
+ These control the prefix that appears in each list entry, e.g. `Figure 1. My Diagram`.
154
+
155
+ == PDF Behaviour
156
+
157
+ In PDF mode the extension hooks into `asciidoctor-pdf`'s ToC allocation/rendering lifecycle:
158
+
159
+ . *`allocate_toc`* — dry-runs each list to measure required page space, then reserves those pages immediately after the ToC.
160
+ . *`ink_toc`* — renders each list with ToC-style dot leaders and page numbers.
161
+ . The `== List of Figures` section and its `list-of::` macro are *removed from the document body* so they do not appear again as body text.
162
+
163
+ === Placement
164
+
165
+ All lists are placed in the *front matter* (directly after the ToC) regardless of where the `list-of::` macros appear in the source.
166
+ The macro order in the source determines the order of the lists in the front matter.
167
+
168
+ You can place additional `list-of::` macros inside chapter subsections to generate *chapter-scoped* lists.
169
+ The scope is determined automatically from where the macro appears in the document hierarchy:
170
+
171
+ [cols="1,2"]
172
+ |===
173
+ |Macro placement |Collection scope
174
+
175
+ |Inside a top-level section (`== …`) +
176
+ e.g. a front-matter `== List of Figures`
177
+ |*Document-wide* — collects all matching elements in the entire document.
178
+
179
+ |Inside a subsection (`=== …`) nested within a chapter +
180
+ e.g. `=== Chapter 1 Figures` inside `== Chapter 1`
181
+ |*Chapter-scoped* — collects only elements from that chapter and its child sections.
182
+ |===
183
+
184
+ .Example: global list plus a per-chapter list
185
+ [source,asciidoc]
186
+ ----
187
+ \== List of Figures <1>
188
+ list-of::image[]
189
+
190
+ \== Chapter 1
191
+
192
+ .Overview Diagram
193
+ image::arch.png[]
194
+
195
+ \=== Chapter 1 Figures <2>
196
+ list-of::image[]
197
+
198
+ .Detailed Flow
199
+ image::flow.png[]
200
+ ----
201
+ <1> Document-wide: collects all images.
202
+ <2> Chapter-scoped: collects only Chapter 1's images.
203
+
204
+ === Styling
205
+
206
+ Lists inherit all ToC styling from your PDF theme:
207
+
208
+ [source,yaml]
209
+ ----
210
+ toc:
211
+ font_size: 10
212
+ line_height: 1.5
213
+ dot_leader:
214
+ font_color: '#CCCCCC'
215
+ content: '. '
216
+ levels: all
217
+ ----
218
+
219
+ You can add a list-specific title style with a theme key based on the element type:
220
+
221
+ [source,yaml]
222
+ ----
223
+ image_list_title:
224
+ font_size: 16
225
+ font_style: bold
226
+ text_align: center
227
+ ----
228
+
229
+ == HTML Behaviour
230
+
231
+ In HTML5 mode the macro is replaced *in-place* with a list of cross-reference links:
232
+
233
+ [source,html]
234
+ ----
235
+ <p><a href="#uuid-of-figure-1">Figure 1.</a> Architecture Diagram<br>
236
+ <a href="#uuid-of-figure-2">Figure 2.</a> Data Model<br></p>
237
+ ----
238
+
239
+ The containing section and heading are rendered normally.
240
+
241
+ == Full Example
242
+
243
+ [source,asciidoc]
244
+ ----
245
+ = Technical Reference
246
+ :doctype: book
247
+ :toc:
248
+ :toclevels: 3
249
+ :figure-caption: Figure
250
+ :table-caption: Table
251
+ :listing-caption: Listing
252
+
253
+ \== List of Figures
254
+ list-of::image[] <1>
255
+
256
+ \== List of Tables
257
+ list-of::table[hide_empty_section] <2>
258
+
259
+ \== List of Code Examples
260
+ list-of::listing[title="Code Examples"] <3>
261
+
262
+ \== List of Appendix Examples
263
+ list-of::example[exclude_from_outline] <4>
264
+
265
+ \== Chapter 1: Architecture
266
+
267
+ .System Overview
268
+ image::arch.png[]
269
+
270
+ .Configuration Parameters
271
+ |===
272
+ |Name |Default |Description
273
+ |timeout |30 |Seconds
274
+ |===
275
+
276
+ .bootstrap.sh
277
+ [source,bash]
278
+ ----
279
+ #!/bin/bash
280
+ echo "Starting..."
281
+ ----
282
+ ----
283
+ <1> Plain usage — collects all images with captions. Appears in PDF ToC and outline by default.
284
+ <2> If no tables exist, the "List of Tables" section is omitted entirely.
285
+ <3> Per-list title override.
286
+ <4> Appears in the PDF Table of Contents but not in the PDF bookmark outline.
287
+
288
+ == Running the Tests
289
+
290
+ === HTML test (requires only `asciidoctor` gem):
291
+
292
+ [source,bash]
293
+ ----
294
+ ruby test/run_html.rb
295
+ ----
296
+
297
+ === PDF test (requires `asciidoctor-pdf` and its dependencies):
298
+
299
+ [source,bash]
300
+ ----
301
+ ruby test/run_pdf.rb
302
+ ----
303
+
304
+ Output files are written to `test/out/`.
305
+ All six test documents are rendered by both runners: every document produces both an HTML and a PDF output.
306
+
307
+ === VSCode AsciiDoc preview (HTML5)
308
+
309
+ To use the extension with the https://marketplace.visualstudio.com/items?itemName=asciidoctor.asciidoctor-vscode[AsciiDoc extension for VS Code] preview:
310
+
311
+ . A `.asciidoctor/lib/lists-extended.js` linker file is included in this repository — no setup needed.
312
+ . A `.vscode/settings.json` is also included that enables workspace extensions:
313
+ +
314
+ [source,json]
315
+ ----
316
+ {
317
+ "asciidoc.extensions.registerWorkspaceExtensions": true
318
+ }
319
+ ----
320
+ +
321
+ If you already have a `.vscode/settings.json`, add the key manually.
322
+ . Open any `.adoc` file and use the AsciiDoc preview button (or kbd:[Ctrl+Shift+V]).
323
+
324
+ The preview uses the JS extension (`js/lib/extension.js`) via Asciidoctor.js.
325
+ The `list-of::` macros render as `<ul>` lists with `<a href="#…">` links.
326
+
327
+ == Migration from asciidoctor-pdf-lofte
328
+
329
+ [cols="1,1"]
330
+ |===
331
+ |Old (lofte) |New (lists-extended)
332
+
333
+ |`:lof-title: List of Figures` +
334
+ `:lot-title: List of Tables`
335
+ |`\== List of Figures` +
336
+ `list-of::image[]` +
337
+ +
338
+ `\== List of Tables` +
339
+ `list-of::table[]`
340
+
341
+ |`:include-lists-in-toc:`
342
+ |Lists are included in the PDF ToC and bookmark outline by default.
343
+ Use `exclude_from_toc` or `exclude_from_outline` on individual macros to opt out.
344
+
345
+ |Required `[#anchor]` on every block
346
+ |Auto-generated — no anchors needed
347
+
348
+ |PDF only
349
+ |HTML5 + PDF
350
+
351
+ |4 copy-pasted converter classes (~1200 lines)
352
+ |1 unified converter class (~320 lines)
353
+ |===
354
+
355
+ == Architecture Overview
356
+
357
+ [source]
358
+ ----
359
+ lib/
360
+ asciidoctor-lists-extended.rb Main entry point (conditional PDF loading)
361
+ asciidoctor-lists-extended/
362
+ extensions.rb ListMacro + ListTreeprocessor
363
+ html_renderer.rb UUID → xref replacement for HTML5
364
+ pdf_renderer.rb Drawing primitives (ink_list_content, etc.)
365
+ pdf_converter.rb Lifecycle orchestration (allocate_toc, ink_toc)
366
+ ----
367
+
368
+ === Why pdf_converter.rb and pdf_renderer.rb are separate
369
+
370
+ `pdf_converter.rb` subclasses the asciidoctor-pdf converter and overrides the lifecycle hooks that the PDF engine calls during document generation.
371
+ It is responsible for *orchestration*: when to run, which pages to reserve, and in what order to render each list.
372
+
373
+ `pdf_renderer.rb` is a module mixed into the converter.
374
+ It contains the *drawing primitives*: how to render a heading, how to draw a dot-leader row, how to build the dot-leader configuration from the PDF theme.
375
+
376
+ The split exists because `ink_list_content` (the method that draws one complete list section) is called from two different places — once inside a dry run to measure page space, and once during real rendering to ink the output.
377
+ Keeping the drawing logic in a separate mixin makes that reuse clean: the converter orchestrates, the renderer draws, and neither knows the other's internal details.
378
+
379
+ === Process flow
380
+
381
+ ==== HTML backend
382
+
383
+ . *Macro registration* (`extensions.rb`) — when Asciidoctor parses a `list-of::element[]` macro, `ListMacro#process` runs.
384
+ It stores the macro options in a global hash keyed by a UUID and emits a plain paragraph containing only that UUID as a placeholder in the document AST.
385
+
386
+ . *Tree processing* (`extensions.rb`) — after the full AST is built, `ListTreeprocessor#process` runs.
387
+ It first auto-generates IDs for every captioned/titled element referenced by any `list-of::` macro (so manual `[#anchor]` blocks are not needed).
388
+ It then delegates to `HtmlRenderer#render`.
389
+
390
+ . *UUID replacement* (`html_renderer.rb`) — `HtmlRenderer` finds every UUID placeholder paragraph, looks up its config, collects the matching elements, and replaces the placeholder with xref links.
391
+ If `hide_empty_section` is set and no entries exist, it removes the enclosing section entirely.
392
+
393
+ ==== PDF backend
394
+
395
+ . *Macro registration* — identical to HTML: UUID placeholder paragraph is inserted into the AST.
396
+
397
+ . *Tree processing* — auto-generates IDs for referenced elements, then returns early (the placeholder paragraphs are left in place for the PDF converter to find).
398
+
399
+ . *`allocate_toc`* (`pdf_converter.rb`) — called by asciidoctor-pdf before any body rendering, after the ToC pages are reserved.
400
+ `PDFConverterWithLists` calls `super` first (reserving the real ToC), then iterates over every UUID placeholder.
401
+ For each one it captures the title of the enclosing `==` section, removes that section from the body AST (so it does not appear again as body text), then dry-runs `ink_list_content` to measure how many pages the list needs and reserves that space.
402
+
403
+ . *`ink_toc`* (`pdf_converter.rb`) — called by asciidoctor-pdf when it is time to render the front matter.
404
+ For each allocated list, `ink_list` navigates to the reserved pages and calls `ink_list_content`.
405
+ A virtual `Section` node is always inserted into the document AST.
406
+ If `exclude_from_toc` is set, the section is marked `list-exclude-from-toc` so the `get_entries_for_toc` override hides it from the visible ToC — but it remains in `doc.sections`, so the PDF bookmark outline still sees it.
407
+ If `exclude_from_outline` is set, the section is marked `list-exclude-from-outline` so the `add_outline_level` override filters it from the bookmark tree.
408
+ After all lists are rendered, `super` runs to render the real Table of Contents.
409
+
410
+ . *`ink_list_content`* (`pdf_renderer.rb`) — draws the heading (using the `==` section title captured in step 3) and then calls `ink_toc_level` to draw the dot-leader rows.
411
+
412
+ . *`ink_toc_level` override* (`pdf_converter.rb`) — when `@rendering_list` is true (set only during list rendering), dispatches to `ink_list_toc_level` which uses `captioned_title` (e.g. "Figure 1. My Diagram") for non-section entries instead of the bare title.
413
+ For real ToC rendering the override is bypassed and `super` is called unchanged.
414
+
415
+ . *`get_entries_for_toc` override* (`pdf_converter.rb`) — called by asciidoctor-pdf when collecting sections to render in the Table of Contents.
416
+ Filters out virtual list sections marked with `list-exclude-from-toc` before delegating to `super`.
417
+ Because the PDF outline builder reads `doc.sections` directly (not via this method), excluded sections remain visible in the bookmark outline.
418
+
419
+ . *`add_outline_level` override* (`pdf_converter.rb`) — called by asciidoctor-pdf when building the PDF bookmark outline.
420
+ Filters out any virtual list sections marked with `list-exclude-from-outline` before delegating to `super`, implementing the `exclude_from_outline` flag.
421
+
422
+ == Licence
423
+
424
+ MIT
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'asciidoctor'
4
+ require 'asciidoctor/extensions'
5
+ require 'securerandom'
6
+
7
+ module Asciidoctor
8
+ module ListsExtended
9
+ # Global hash storing macro configurations, keyed by UUID placeholder string.
10
+ # Ruby Hash preserves insertion order since 1.9, so document order of
11
+ # list-of:: macros is maintained automatically.
12
+ ListMacroAttributes = Hash.new
13
+
14
+ # BlockMacroProcessor for the list-of::element[] syntax.
15
+ #
16
+ # Supported parameters:
17
+ # enhanced_rendering — separate caption and title display (positional)
18
+ # hide_empty_section — remove the parent section if no entries found (positional)
19
+ # exclude_from_toc — omit this list from the PDF Table of Contents and outline (positional, PDF only)
20
+ # exclude_from_outline — omit this list from the PDF bookmark outline only (positional, PDF only)
21
+ # caption_prefix — override the caption prefix (e.g. "Figure")
22
+ # title — override the list heading
23
+ #
24
+ # Example:
25
+ # list-of::image[]
26
+ # list-of::table[hide_empty_section]
27
+ # list-of::listing[caption_prefix="Code",title="Code Examples"]
28
+ class ListMacro < ::Asciidoctor::Extensions::BlockMacroProcessor
29
+ use_dsl
30
+ named :'list-of'
31
+
32
+ def process(parent, target, attrs)
33
+ uuid = SecureRandom.uuid
34
+ # Positional flags like [enhanced_rendering] may be stored under integer or string keys
35
+ pos_flags = (1..10).flat_map { |i| [attrs[i], attrs[i.to_s]] }.compact
36
+ ListMacroAttributes[uuid] = {
37
+ element: target,
38
+ enhanced_rendering: attrs['enhanced_rendering'] || pos_flags.include?('enhanced_rendering'),
39
+ hide_empty_section: attrs['hide_empty_section'] || pos_flags.include?('hide_empty_section'),
40
+ exclude_from_toc: attrs['exclude_from_toc'] || pos_flags.include?('exclude_from_toc'),
41
+ exclude_from_outline: attrs['exclude_from_outline'] || pos_flags.include?('exclude_from_outline'),
42
+ caption_prefix: attrs['caption_prefix'],
43
+ title: attrs['title'],
44
+ }
45
+ create_paragraph parent, uuid, {}
46
+ end
47
+ end
48
+
49
+ # Treeprocessor that runs after the full document AST is built.
50
+ #
51
+ # Responsibilities:
52
+ # 1. Auto-generate IDs for all captioned/titled elements referenced by
53
+ # list-of:: macros. This eliminates the need for manual [#anchor] on
54
+ # every block.
55
+ # 2. For HTML5/other backends: replace UUID placeholder paragraphs with
56
+ # xref-based content (delegated to HtmlRenderer).
57
+ # 3. For PDF backend: leave UUID placeholder paragraphs in place so that
58
+ # PDFConverterWithLists can locate and render them during allocate_toc /
59
+ # ink_toc.
60
+ class ListTreeprocessor < ::Asciidoctor::Extensions::Treeprocessor
61
+ def process(document)
62
+ return if ListMacroAttributes.empty?
63
+
64
+ # Auto-generate IDs for all elements referenced by list-of:: macros.
65
+ # Works for both HTML and PDF — the IDs must exist before either renderer runs.
66
+ ListMacroAttributes.each_value do |config|
67
+ document
68
+ .find_by(traverse_documents: true, context: config[:element].to_sym)
69
+ .each do |element|
70
+ next unless element.caption || element.title
71
+ next if element.id
72
+
73
+ element.id = SecureRandom.uuid
74
+ document.catalog[:refs][element.id] = element
75
+ end
76
+ end
77
+
78
+ # PDF backend: UUID placeholders are handled by PDFConverterWithLists.
79
+ return if document.backend == 'pdf'
80
+
81
+ # HTML5 and other backends: replace UUID placeholders with xref content.
82
+ HtmlRenderer.new.render(document)
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ Asciidoctor::Extensions.register do
89
+ block_macro Asciidoctor::ListsExtended::ListMacro
90
+ treeprocessor Asciidoctor::ListsExtended::ListTreeprocessor
91
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asciidoctor
4
+ module ListsExtended
5
+ # Replaces list-of:: UUID placeholder paragraphs with AsciiDoc xref content
6
+ # for HTML5 and other non-PDF backends.
7
+ #
8
+ # Preserves full backwards-compatibility with the original asciidoctor-lists gem:
9
+ # - enhanced_rendering parameter: separates caption and title
10
+ # - hide_empty_section parameter: removes parent section when no entries found
11
+ class HtmlRenderer
12
+ def render(document)
13
+ uuid_blocks = document.find_by do |b|
14
+ b.content_model == :simple &&
15
+ b.lines.size == 1 &&
16
+ ListMacroAttributes.key?(b.lines[0])
17
+ end
18
+
19
+ uuid_blocks.each do |block|
20
+ params = ListMacroAttributes[block.lines[0]]
21
+ enhanced_rendering = params[:enhanced_rendering]
22
+ hide_empty_section = params[:hide_empty_section]
23
+
24
+ scope = scope_node_for(block, document)
25
+ elements = scope.find_by(
26
+ traverse_documents: true,
27
+ context: params[:element].to_sym
28
+ )
29
+
30
+ titled_elements = elements.select { |e| e.caption || e.title }
31
+
32
+ if titled_elements.empty? && hide_empty_section
33
+ block.parent.parent.blocks.delete(block.parent)
34
+ next
35
+ end
36
+
37
+ references_asciidoc = titled_elements.map do |element|
38
+ if enhanced_rendering
39
+ if element.caption
40
+ %(xref:#{element.id}[#{element.caption.rstrip}] #{element.instance_variable_get(:@title)} +)
41
+ else
42
+ %(xref:#{element.id}[#{element.instance_variable_get(:@title)}] +)
43
+ end
44
+ else
45
+ if element.caption
46
+ %(xref:#{element.id}[#{element.caption.rstrip}] #{element.title} +)
47
+ else
48
+ %(xref:#{element.id}[#{element.title}] +)
49
+ end
50
+ end
51
+ end
52
+
53
+ block_index = block.parent.blocks.index { |b| b == block }
54
+ parsed_blocks = parse_asciidoc(block.parent, references_asciidoc)
55
+ parsed_blocks.reverse_each { |b| block.parent.blocks.insert block_index, b }
56
+ block.parent.blocks.delete_at block_index + parsed_blocks.size
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ # Determine collection scope for a UUID placeholder block.
63
+ # Mirrors the logic in PdfRenderer#scope_node_for.
64
+ def scope_node_for(block, document)
65
+ node = block.parent
66
+ return document unless node.respond_to?(:context) && node.context == :section
67
+ node.parent.context == :document ? document : chapter_ancestor(node)
68
+ end
69
+
70
+ def chapter_ancestor(section)
71
+ node = section
72
+ node = node.parent while node.parent&.context == :section
73
+ node
74
+ end
75
+
76
+ # Parse AsciiDoc source lines in the context of +parent+, returning the
77
+ # resulting blocks without attaching them to the parent.
78
+ # Adapted from asciidoctor-bibtex / original asciidoctor-lists.
79
+ def parse_asciidoc(parent, content, attributes = {})
80
+ result = []
81
+ reader = ::Asciidoctor::Reader.new content
82
+ while reader.has_more_lines?
83
+ block = ::Asciidoctor::Parser.next_block reader, parent, attributes
84
+ result << block if block
85
+ end
86
+ result
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'asciidoctor/pdf' unless defined? Asciidoctor::PDF
4
+
5
+ require_relative 'pdf_renderer'
6
+
7
+ module Asciidoctor
8
+ module ListsExtended
9
+ # Single unified PDF converter that replaces the four copy-pasted classes
10
+ # (PDFConverterWithLOF, PDFConverterWithLOT, PDFConverterWithLOE,
11
+ # PDFConverterWithLOL) from asciidoctor-pdf-lofte.
12
+ #
13
+ # Hooks into asciidoctor-pdf's allocate_toc / ink_toc lifecycle to:
14
+ # 1. Reserve page space for each list-of:: macro found in the document
15
+ # (allocate_toc → allocate_list via dry_run).
16
+ # 2. Render each list with ToC-style dot leaders (ink_toc → ink_list).
17
+ # 3. Insert virtual Section nodes so lists appear in the PDF ToC and
18
+ # bookmark outline by default. Per-macro flags exclude_from_toc and
19
+ # exclude_from_outline opt individual lists out.
20
+ #
21
+ # ink_toc_level is overridden only for list rendering (guarded by
22
+ # @rendering_list flag), leaving normal ToC behavior untouched.
23
+ class PDFConverterWithLists < ::Asciidoctor::Converter.for('pdf')
24
+ register_for 'pdf'
25
+
26
+ include PdfRenderer
27
+
28
+ def initialize(*args)
29
+ super
30
+ @list_extents = {} # UUID → Extent (front-matter lists only)
31
+ @list_configs_ordered = [] # front-matter list configs in document order
32
+ @inline_list_configs = {} # UUID → config for chapter-scoped inline lists
33
+ @inline_num_front_matter_pages = 0 # saved from ink_toc for inline page-number math
34
+ @rendering_list = false
35
+ @list_toc_insert_idx = 0
36
+ end
37
+
38
+ # -----------------------------------------------------------------------
39
+ # ink_toc_level override
40
+ # -----------------------------------------------------------------------
41
+ # When @rendering_list is true (set by PdfRenderer#ink_list_content),
42
+ # dispatch to our list-specific implementation that uses captioned_title
43
+ # for non-section entries. Otherwise delegate to the parent unchanged so
44
+ # that the real Table of Contents is not affected.
45
+ def ink_toc_level(entries, num_levels, dot_leader, num_front_matter_pages)
46
+ if @rendering_list
47
+ ink_list_toc_level entries, num_levels, dot_leader, num_front_matter_pages
48
+ else
49
+ super
50
+ end
51
+ end
52
+
53
+ # -----------------------------------------------------------------------
54
+ # allocate_toc hook
55
+ # -----------------------------------------------------------------------
56
+ # Called by asciidoctor-pdf early in document generation to reserve page
57
+ # space for the ToC. We call super first (allocate the real ToC), then
58
+ # iterate over all list-of:: macros and allocate space for each list.
59
+ def allocate_toc(doc, num_levels, toc_start_cursor, break_after_toc)
60
+ result = super
61
+ collect_and_allocate_lists doc, num_levels, toc_start_cursor, break_after_toc
62
+ result
63
+ end
64
+
65
+ # -----------------------------------------------------------------------
66
+ # ink_toc hook
67
+ # -----------------------------------------------------------------------
68
+ # Render every allocated list before delegating to super (which renders
69
+ # the real ToC). Always inserts a virtual Section node for each list so
70
+ # it appears in the PDF bookmark outline. exclude_from_toc marks the
71
+ # section so get_entries_for_toc filters it from the visible ToC;
72
+ # exclude_from_outline marks it so add_outline_level skips it.
73
+ def ink_toc(doc, num_levels, toc_page_number, start_cursor, num_front_matter_pages = 0)
74
+ @inline_num_front_matter_pages = num_front_matter_pages
75
+ @list_toc_insert_idx = 0
76
+
77
+ @list_configs_ordered.each do |config|
78
+ extent = @list_extents[config[:uuid]]
79
+ next unless extent
80
+
81
+ ink_list doc, config, extent, num_levels, num_front_matter_pages
82
+
83
+ list_title = config[:title] || config[:section_title]
84
+ list_page_nums = extent.page_range
85
+ list_id = "_list_of_#{config[:element].tr('-', '_')}"
86
+ insert_list_into_toc_section doc, list_title, list_page_nums, list_id, @list_toc_insert_idx,
87
+ exclude_from_toc: config[:exclude_from_toc],
88
+ exclude_from_outline: config[:exclude_from_outline]
89
+ @list_toc_insert_idx += 1
90
+ end
91
+
92
+ super
93
+ end
94
+
95
+ # -----------------------------------------------------------------------
96
+ # get_entries_for_toc override
97
+ # -----------------------------------------------------------------------
98
+ # Filters out virtual list sections marked with 'list-exclude-from-toc'
99
+ # so they are hidden from the rendered Table of Contents while remaining
100
+ # present in doc.sections (and therefore still visible in the PDF outline).
101
+ def get_entries_for_toc(node)
102
+ super.reject { |s| s.attr? 'list-exclude-from-toc' }
103
+ end
104
+
105
+ # -----------------------------------------------------------------------
106
+ # add_outline_level override
107
+ # -----------------------------------------------------------------------
108
+ # Filters out virtual list sections marked with 'list-exclude-from-outline'
109
+ # before delegating to the parent implementation, which builds PDF bookmarks.
110
+ # This lets exclude_from_outline keep a list in the ToC while hiding it
111
+ # from the PDF bookmark outline.
112
+ def add_outline_level(outline, sections, num_levels, expand_levels)
113
+ super outline, sections.reject { |s| s.attr? 'list-exclude-from-outline' }, num_levels, expand_levels
114
+ end
115
+
116
+ # -----------------------------------------------------------------------
117
+ # convert_paragraph override
118
+ # -----------------------------------------------------------------------
119
+ # Intercepts UUID placeholder paragraphs belonging to chapter-scoped
120
+ # inline lists (those stored in @inline_list_configs by
121
+ # collect_and_allocate_lists). Renders the list entries at the current
122
+ # cursor position without a heading (the enclosing === section provides
123
+ # the heading) and without any page break before or after.
124
+ # All other paragraphs delegate to super unchanged.
125
+ def convert_paragraph(node)
126
+ if node.content_model == :simple && node.lines.size == 1 &&
127
+ (config = @inline_list_configs[node.lines[0]])
128
+ entries = get_list_entries(config[:scope_node], config[:element])
129
+ unless entries.empty?
130
+ entries.each { |e| e.level = 2 if e.title }
131
+ num_levels = (node.document.attr 'toclevels', 2).to_i
132
+ dot_leader = build_dot_leader_config(num_levels)
133
+ begin
134
+ @rendering_list = true
135
+ ink_toc_level entries, num_levels, dot_leader, @inline_num_front_matter_pages
136
+ ensure
137
+ @rendering_list = false
138
+ end
139
+ end
140
+ return
141
+ end
142
+ super
143
+ end
144
+
145
+ private
146
+
147
+ # -----------------------------------------------------------------------
148
+ # collect_and_allocate_lists
149
+ # -----------------------------------------------------------------------
150
+ # Scan the document AST for UUID placeholder paragraphs (in document
151
+ # order, which matches the order the author placed the list-of:: macros).
152
+ # For each placeholder that has at least one captioned/titled element,
153
+ # allocate page space via a dry run.
154
+ def collect_and_allocate_lists(doc, num_levels, start_cursor, break_after)
155
+ uuid_blocks = doc.find_by do |b|
156
+ b.content_model == :simple &&
157
+ b.lines.size == 1 &&
158
+ ListMacroAttributes.key?(b.lines[0])
159
+ end
160
+
161
+ uuid_blocks.each do |block|
162
+ uuid = block.lines[0]
163
+ config = ListMacroAttributes[uuid]
164
+ scope_node = scope_node_for(block)
165
+ entries = get_list_entries(scope_node, config[:element])
166
+ parent = block.parent
167
+
168
+ if scope_node.equal?(doc)
169
+ # ── Document-wide macro ────────────────────────────────────────
170
+ # Remove from body and render in PDF front matter after the ToC.
171
+ # Capture the parent section title first — it becomes the list heading.
172
+ section_title = (parent.context == :section) ? parent.title : nil
173
+ parent.blocks.delete(block)
174
+ if parent.context == :section && parent.blocks.empty?
175
+ parent.parent&.blocks&.delete(parent)
176
+ end
177
+
178
+ next if entries.empty?
179
+
180
+ config_with_uuid = config.merge(uuid: uuid, section_title: section_title, scope_node: scope_node)
181
+ @list_configs_ordered << config_with_uuid
182
+ @list_extents[uuid] = allocate_list(doc, config_with_uuid, num_levels, start_cursor, break_after)
183
+ else
184
+ # ── Chapter-scoped macro ───────────────────────────────────────
185
+ # Leave the placeholder in the body; convert_paragraph will render
186
+ # it inline at the macro's position without a page break.
187
+ # The enclosing section heading is preserved as-is.
188
+ config_with_uuid = config.merge(uuid: uuid, section_title: nil, scope_node: scope_node)
189
+ @inline_list_configs[uuid] = config_with_uuid
190
+ end
191
+ end
192
+ end
193
+
194
+ # -----------------------------------------------------------------------
195
+ # allocate_list
196
+ # -----------------------------------------------------------------------
197
+ # Dry-run render of one list to measure the space it needs, then reserve
198
+ # that many pages. Follows the same pattern as rhrev's
199
+ # allocate_revision_history_extent and asciidoctor-pdf-lofte's allocate_lof.
200
+ def allocate_list(doc, config, num_levels, _start_cursor, break_after)
201
+ to_page = nil
202
+ extent = dry_run onto: self do
203
+ to_page = ink_list(doc, config, nil, num_levels, 0).last
204
+ theme_margin :block, :bottom unless break_after
205
+ end
206
+
207
+ if to_page && to_page > extent.to.page
208
+ extent.to.page = to_page
209
+ extent.to.cursor = bounds.height
210
+ end
211
+
212
+ if break_after
213
+ extent.each_page { start_new_page }
214
+ else
215
+ extent.each_page { |first_page| start_new_page unless first_page }
216
+ move_cursor_to extent.to.cursor
217
+ end
218
+
219
+ extent
220
+ end
221
+
222
+ # -----------------------------------------------------------------------
223
+ # ink_list
224
+ # -----------------------------------------------------------------------
225
+ # Navigate to the allocated pages and render one list section.
226
+ # When called from inside a dry_run block (extent is nil / scratch? true),
227
+ # navigation is skipped and only layout measurement happens.
228
+ # Returns the page range occupied by the list.
229
+ def ink_list(doc, config, extent, num_levels, num_front_matter_pages)
230
+ if extent && !scratch?
231
+ go_to_page extent.from.page
232
+ move_cursor_to extent.from.cursor
233
+ end
234
+
235
+ start_page = page_number
236
+ entries = get_list_entries(config[:scope_node] || doc, config[:element])
237
+
238
+ # Set level = 2 on every entry so ink_list_toc_level uses toc_h3 indent,
239
+ # producing a flat indented list that matches asciidoctor-pdf-lofte output.
240
+ entries.each { |e| e.level = 2 if e.title }
241
+
242
+ ink_list_content doc, config, entries, num_levels, num_front_matter_pages
243
+
244
+ page_range = (start_page..(start_page + (page_number - start_page)))
245
+ go_to_page page_count unless scratch?
246
+ page_range
247
+ end
248
+
249
+ # -----------------------------------------------------------------------
250
+ # ink_list_toc_level
251
+ # -----------------------------------------------------------------------
252
+ # Custom ink_toc_level used exclusively when @rendering_list is true.
253
+ # Adapted from asciidoctor-pdf-lofte FormatTOC#ink_toc_level, which is
254
+ # itself a copy of the asciidoctor-pdf ink_toc_level implementation with
255
+ # two additions:
256
+ # a) Non-section entries (image, table, etc.) use captioned_title
257
+ # ("Figure 1. My Diagram") instead of bare title.
258
+ # b) The no-ID skip from the original lofte is removed — auto-generated
259
+ # UUIDs from the TreeProcessor guarantee all entries have IDs.
260
+ def ink_list_toc_level(entries, num_levels, dot_leader, num_front_matter_pages)
261
+ toc_font_info = theme_font(:toc) { { font: font, size: @font_size } }
262
+ hanging_indent = @theme.toc_hanging_indent
263
+
264
+ entries.each do |entry|
265
+ next if (num_levels_for_entry = (entry.attr 'toclevels', num_levels).to_i) <
266
+ (entry_level = entry.level + 1).pred ||
267
+ ((entry.option? 'notitle') && entry == entry.document.last_child && entry.empty?)
268
+
269
+ # Base title: sections use numbered_title; blocks use plain title or xreftext
270
+ entry_title = entry.context == :section \
271
+ ? entry.numbered_title \
272
+ : (entry.title? ? entry.title : (entry.xreftext 'basic'))
273
+
274
+ # For captioned blocks (image, table, example, listing, custom) prefer
275
+ # captioned_title, e.g. "Figure 1. My Diagram" over "My Diagram".
276
+ if entry.context != :section && entry.respond_to?(:captioned_title)
277
+ captioned = entry.captioned_title
278
+ entry_title = captioned unless captioned.nil_or_empty?
279
+ end
280
+
281
+ next if entry_title.nil_or_empty?
282
+
283
+ theme_font :toc, level: entry_level do
284
+ entry_title = transform_text entry_title, @text_transform if @text_transform
285
+ pgnum_label_placeholder_width = rendered_width_of_string '0' * @toc_max_pagenum_digits
286
+
287
+ if scratch?
288
+ # Dry-run pass: measure space only
289
+ indent 0, pgnum_label_placeholder_width do
290
+ ink_prose entry_title,
291
+ anchor: true,
292
+ normalize: false,
293
+ hanging_indent: hanging_indent,
294
+ normalize_line_height: true,
295
+ margin: 0
296
+ end
297
+ else
298
+ # Real render pass: resolve page number and draw dot leader
299
+ entry_anchor = (entry.attr 'pdf-anchor') || entry.id
300
+ unless (physical_pgnum = entry.attr 'pdf-page-start')
301
+ if (target_page_ref = (get_dest entry_anchor)&.first) &&
302
+ (target_page_idx = state.pages.index { |c| c.dictionary == target_page_ref })
303
+ physical_pgnum = target_page_idx + 1
304
+ end
305
+ end
306
+
307
+ pgnum_label = if physical_pgnum
308
+ virtual_pgnum = physical_pgnum - num_front_matter_pages
309
+ (virtual_pgnum < 1 \
310
+ ? Asciidoctor::PDF::RomanNumeral.new(physical_pgnum, :lower) \
311
+ : virtual_pgnum).to_s
312
+ else
313
+ '?'
314
+ end
315
+
316
+ start_page_number = page_number
317
+ start_cursor = cursor
318
+ start_dots = nil
319
+
320
+ entry_title_inherited = (apply_text_decoration ::Set.new, :toc, entry_level)
321
+ .merge(anchor: entry_anchor, color: @font_color)
322
+ entry_title_fragments = text_formatter.format entry_title,
323
+ inherited: entry_title_inherited
324
+ line_metrics = calc_line_metrics @base_line_height
325
+
326
+ indent 0, pgnum_label_placeholder_width do
327
+ fragment_positions = entry_title_fragments.map do |fragment|
328
+ fp = ::Asciidoctor::PDF::FormattedText::FragmentPositionRenderer.new
329
+ (fragment[:callback] ||= []) << fp
330
+ fp
331
+ end
332
+ typeset_formatted_text entry_title_fragments, line_metrics,
333
+ hanging_indent: hanging_indent,
334
+ normalize_line_height: true
335
+ last_fp = fragment_positions.select(&:page_number).last
336
+ break unless last_fp
337
+
338
+ start_dots = last_fp.right + hanging_indent
339
+ last_frag_cursor = last_fp.top + line_metrics.padding_top
340
+ if last_fp.page_number > start_page_number ||
341
+ (start_cursor - last_frag_cursor) > line_metrics.height
342
+ start_cursor = last_frag_cursor
343
+ end
344
+ end
345
+
346
+ break unless start_dots
347
+
348
+ end_cursor = cursor
349
+ move_cursor_to start_cursor
350
+
351
+ if dot_leader[:width] > 0
352
+ pgnum_label_width = rendered_width_of_string pgnum_label
353
+ pgnum_label_font_opts = {
354
+ color: @font_color,
355
+ font: font_family,
356
+ size: @font_size,
357
+ styles: font_styles,
358
+ }
359
+ save_font do
360
+ set_font toc_font_info[:font], dot_leader[:font_size]
361
+ font_style dot_leader[:font_style]
362
+ num_dots = [
363
+ ((bounds.width - start_dots - dot_leader[:spacer_width] - pgnum_label_width) /
364
+ dot_leader[:width]).floor,
365
+ 0,
366
+ ].max
367
+ typeset_formatted_text [
368
+ { text: dot_leader[:text] * num_dots, color: dot_leader[:font_color] },
369
+ dot_leader[:spacer],
370
+ ({ text: pgnum_label, anchor: entry_anchor }.merge pgnum_label_font_opts),
371
+ ], line_metrics, align: :right
372
+ end
373
+ else
374
+ typeset_formatted_text [
375
+ { text: pgnum_label, color: @font_color, anchor: entry_anchor },
376
+ ], line_metrics, align: :right
377
+ end
378
+
379
+ move_cursor_to end_cursor
380
+ end
381
+ end
382
+
383
+ # Recurse for child entries (typically none for images/tables, but
384
+ # preserves compatibility with any custom element type that has children)
385
+ if num_levels_for_entry >= entry_level
386
+ indent @theme.toc_indent do
387
+ ink_toc_level get_entries_for_toc(entry), num_levels_for_entry,
388
+ dot_leader, num_front_matter_pages
389
+ end
390
+ end
391
+ end
392
+ end
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asciidoctor
4
+ module ListsExtended
5
+ # Mixed into PDFConverterWithLists to provide list-rendering helpers.
6
+ # All methods here run in the context of the PDF converter instance,
7
+ # so asciidoctor-pdf methods (ink_general_heading, theme_font_cascade,
8
+ # add_dest_for_block, ink_toc_level, etc.) are available via self.
9
+ module PdfRenderer
10
+ # Render one list section: title heading + dot-leader entry rows.
11
+ # Called both during the dry run (scratch? == true) and the real ink pass.
12
+ #
13
+ # config keys used here:
14
+ # :element — element type string ('image', 'table', …)
15
+ # :title — explicit title override from macro attribute
16
+ # :section_title — title of the parent section captured before it was removed
17
+ #
18
+ # Sets @rendering_list = true around the ink_toc_level call so that
19
+ # PDFConverterWithLists#ink_toc_level activates the list-aware code path.
20
+ def ink_list_content(doc, config, entries, num_levels, num_front_matter_pages = 0)
21
+ element_type = config[:element]
22
+ dest_id = "_list_of_#{element_type.tr('-', '_')}"
23
+
24
+ list_title = config[:title] || config[:section_title]
25
+ unless list_title.nil_or_empty?
26
+ # Theme key mirrors asciidoctor-pdf-lofte convention, e.g. :image_list_title
27
+ theme_key_sym = :"#{element_type.tr('-', '_')}_list_title"
28
+ theme_font_cascade [[:heading, level: 2], theme_key_sym] do
29
+ align_method = :"#{theme_key_sym}_text_align"
30
+ title_align = (@theme.respond_to?(align_method) ? @theme.send(align_method) : nil) ||
31
+ @theme.heading_h2_text_align ||
32
+ @theme.heading_text_align ||
33
+ @base_text_align
34
+ ink_general_heading doc, list_title,
35
+ align: title_align.to_sym,
36
+ level: 2,
37
+ outdent: true,
38
+ role: theme_key_sym
39
+ add_dest_for_block doc, id: dest_id, y: (at_page_top? ? page_height : nil)
40
+ end
41
+ end
42
+
43
+ unless num_levels < 0 || entries.empty?
44
+ dot_leader = build_dot_leader_config(num_levels)
45
+ theme_margin :toc, :top
46
+ begin
47
+ @rendering_list = true
48
+ ink_toc_level entries, num_levels, dot_leader, num_front_matter_pages
49
+ ensure
50
+ @rendering_list = false
51
+ end
52
+ end
53
+ end
54
+
55
+ # Collect all captioned/titled elements of the requested type from scope_node.
56
+ # scope_node is either the whole document (document-wide) or a chapter section
57
+ # (chapter-scoped), as determined by scope_node_for at collection time.
58
+ def get_list_entries(scope_node, element_type)
59
+ scope_node
60
+ .find_by(traverse_documents: true, context: element_type.to_sym)
61
+ .select { |e| e.caption || e.title }
62
+ end
63
+
64
+ # Insert a virtual Section node into the document AST at the correct position
65
+ # so that asciidoctor-pdf's get_entries_for_toc picks it up and renders it
66
+ # in the Table of Contents.
67
+ #
68
+ # insert_idx is the 0-based position among sibling list sections being inserted,
69
+ # which preserves document order in the ToC.
70
+ def insert_list_into_toc_section(doc, list_title, list_page_nums, list_id, insert_idx, exclude_from_toc: false, exclude_from_outline: false)
71
+ if (doc.attr? 'toc-placement', 'macro') && (toc_node = (doc.find_by context: :toc)[0])
72
+ if (parent_section = toc_node.parent).context == :section
73
+ grandparent_section = parent_section.parent
74
+ toc_level = parent_section.level
75
+ base_idx = (grandparent_section.blocks.index parent_section) + 1
76
+ else
77
+ grandparent_section = doc
78
+ toc_level = doc.sections[0]&.level || 1
79
+ base_idx = 0
80
+ end
81
+ toc_dest = toc_node.attr 'pdf-destination'
82
+ else
83
+ grandparent_section = doc
84
+ toc_level = doc.sections[0]&.level || 1
85
+ base_idx = 0
86
+ toc_dest = dest_top list_page_nums.first
87
+ end
88
+
89
+ list_section = Asciidoctor::Section.new grandparent_section, toc_level, false,
90
+ attributes: { 'pdf-destination' => toc_dest }
91
+ list_section.title = list_title
92
+ list_section.id = list_id
93
+ list_section.set_attr 'list-exclude-from-toc', '' if exclude_from_toc
94
+ list_section.set_attr 'list-exclude-from-outline', '' if exclude_from_outline
95
+ grandparent_section.blocks.insert base_idx + insert_idx, list_section
96
+ list_section
97
+ end
98
+
99
+ private
100
+
101
+ # Build the dot-leader configuration hash expected by ink_toc_level.
102
+ # Reads theme variables from the asciidoctor-pdf theme loader.
103
+ # Falls back to sensible defaults if the theme does not define them.
104
+ def build_dot_leader_config(num_levels)
105
+ theme_font :toc do
106
+ dot_leader_font_style = @theme.toc_dot_leader_font_style&.to_sym || :normal
107
+ font_style dot_leader_font_style if dot_leader_font_style != font_style
108
+ font_size @theme.toc_dot_leader_font_size
109
+ dot_leader_text = @theme.toc_dot_leader_content || dot_leader_text_default
110
+ {
111
+ font_color: @theme.toc_dot_leader_font_color || @font_color,
112
+ font_style: dot_leader_font_style,
113
+ font_size: font_size,
114
+ levels: build_dot_leader_levels(num_levels),
115
+ text: dot_leader_text,
116
+ width: dot_leader_text.empty? ? 0 : (rendered_width_of_string dot_leader_text),
117
+ spacer: { text: no_break_space, size: (spacer_font_size = @font_size * 0.25) },
118
+ spacer_width: (rendered_width_of_char no_break_space, size: spacer_font_size),
119
+ }
120
+ end
121
+ end
122
+
123
+ def build_dot_leader_levels(num_levels)
124
+ dot_leader_l = @theme.toc_dot_leader_levels
125
+ if dot_leader_l == 'none'
126
+ ::Set.new
127
+ elsif dot_leader_l && dot_leader_l != 'all'
128
+ dot_leader_l.to_s.split.map(&:to_i).to_set
129
+ else
130
+ (0..num_levels).to_set
131
+ end
132
+ end
133
+
134
+ # Determine the collection scope for a list-of:: UUID placeholder block.
135
+ #
136
+ # Rule: if the macro's parent section is a top-level section (its parent is
137
+ # the Document), scope is the whole document. If the macro is nested inside
138
+ # a chapter (grandparent is another section), scope is the chapter ancestor —
139
+ # the first ancestor section whose parent is the Document.
140
+ #
141
+ # This means front-matter lists (== List of Figures) collect everything, while
142
+ # per-chapter lists (=== Chapter Figures inside == Chapter 1) collect only
143
+ # their chapter's content automatically.
144
+ def scope_node_for(block)
145
+ node = block.parent
146
+ return block.document unless node.respond_to?(:context) && node.context == :section
147
+ node.parent.context == :document ? block.document : chapter_ancestor(node)
148
+ end
149
+
150
+ # Walk up the parent chain to find the first section whose parent is the Document.
151
+ def chapter_ancestor(section)
152
+ node = section
153
+ node = node.parent while node.parent&.context == :section
154
+ node
155
+ end
156
+
157
+ # Safe accessor: use the asciidoctor-pdf constant if available, else fall back.
158
+ def dot_leader_text_default
159
+ defined?(DotLeaderTextDefault) ? DotLeaderTextDefault : '. '
160
+ end
161
+
162
+ def no_break_space
163
+ defined?(NoBreakSpace) ? NoBreakSpace : "\u00a0"
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # asciidoctor-lists-extended
4
+ # Converter-aware extension providing List of Figures, Tables, Listings, and Examples.
5
+ # Supports asciidoctor HTML5, asciidoctor-pdf, and Antora.
6
+ # Compatible with asciidoctor-lists macro syntax: list-of::element[]
7
+ #
8
+ # Macro flags (PDF only):
9
+ # exclude_from_toc — omit this list from the PDF ToC and bookmark outline
10
+ # exclude_from_outline — omit this list from the PDF bookmark outline only
11
+
12
+ require_relative 'asciidoctor-lists-extended/html_renderer'
13
+ require_relative 'asciidoctor-lists-extended/extensions'
14
+
15
+ # PDF support is optional — loaded only if asciidoctor-pdf is present
16
+ begin
17
+ require 'asciidoctor-pdf'
18
+ require_relative 'asciidoctor-lists-extended/pdf_renderer'
19
+ require_relative 'asciidoctor-lists-extended/pdf_converter'
20
+ rescue LoadError
21
+ # asciidoctor-pdf not available; HTML-only mode
22
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: asciidoctor-lists-extended
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - 白一百 baiyibai
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: asciidoctor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ description: |
27
+ An asciidoctor extension that generates List of Figures, List of Tables,
28
+ List of Listings, and List of Examples. Works with HTML5 (xref links) and
29
+ asciidoctor-pdf (dot-leader style matching the ToC, optional ToC integration,
30
+ PDF bookmarks). Compatible with asciidoctor-lists macro syntax.
31
+ No hardcoded anchors required — IDs are auto-generated.
32
+ email: rubygems@baiyibai.cyou
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - LICENSE
38
+ - README.adoc
39
+ - lib/asciidoctor-lists-extended.rb
40
+ - lib/asciidoctor-lists-extended/extensions.rb
41
+ - lib/asciidoctor-lists-extended/html_renderer.rb
42
+ - lib/asciidoctor-lists-extended/pdf_converter.rb
43
+ - lib/asciidoctor-lists-extended/pdf_renderer.rb
44
+ homepage: https://gitlab.com/baiyibai-asciidoc/asciidoctor-lists-extended
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ source_code_uri: https://gitlab.com/baiyibai-asciidoc/asciidoctor-lists-extended.git
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: 3.0.0
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.6.9
64
+ specification_version: 4
65
+ summary: Converter-aware list-of-figures/tables/listings/examples for asciidoctor
66
+ (HTML5 + PDF)
67
+ test_files: []