Class: DotLeader

Inherits:
Object
  • Object
show all
Defined in:
lib/asciidoctor/dot_leader/dot_leader_pdf.rb

Instance Method Summary collapse

Instance Method Details

#convert_inline_dot_leader(node) ⇒ Object



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'lib/asciidoctor/dot_leader/dot_leader_pdf.rb', line 41

def convert_inline_dot_leader(node)
  # warn "Node attributes: #{node.attributes.inspect}"
  return nil unless node.type == :dot

  left_side = node.attr('l_text') || ''
  right_side = node.attr('r_text') || ''    
  
  # DEBUG: Initial context information
  is_column_box = defined?(::Prawn::Document::ColumnBox) && bounds.is_a?(::Prawn::Document::ColumnBox)
  current_column = is_column_box ? bounds.current_column : 'N/A'
  # log :info, %(DOT_LEADER_DEBUG: Starting - left="#{left_side}", right="#{right_side}", page=#{page_number}, cursor=#{cursor}, is_column=#{is_column_box}, current_column=#{current_column})
  
  if left_side.empty? # Allow items only on the right
    left_side = ' '
  end
  doc = node.document
  if doc.attr 'dot-leader-scale'
    scale = doc.attr 'dot-leader-scale'
    scale = scale.to_f
  else
    scale = 0.6
  end
  
  # 2025-07-17 BYB: Create the dot_leader array; copied from ink_toc
  dot_leader = theme_font :toc do    
    # TODO: we could simplify by using nested theme_font :toc_dot_leader #BYB: Original note from ink_toc
    if (dot_leader_font_style = @theme.toc_dot_leader_font_style&.to_sym || :normal) != font_style
      font_style dot_leader_font_style
    end
    font_size @theme.toc_dot_leader_font_size
    {
      font_color: @theme.toc_dot_leader_font_color || @font_color,
      font_style: dot_leader_font_style,
      font_size: font_size,
      levels: ((dot_leader_l = @theme.toc_dot_leader_levels) == 'none' ? ::Set.new :
          (dot_leader_l && dot_leader_l != 'all' ? dot_leader_l.to_s.split.map(&:to_i).to_set : nil)),
      text: (dot_leader_text = @theme.toc_dot_leader_content || DotLeaderTextDefault),
      width: dot_leader_text.empty? ? 0 : (rendered_width_of_string dot_leader_text),
      # TODO: spacer gives a little bit of room between dots and page number #BYB: Original note from ink_toc
      spacer: { text: NoBreakSpace, size: (spacer_font_size = @font_size * 0.25) },
      spacer_width: (rendered_width_of_char NoBreakSpace, size: spacer_font_size),
    }
  end

  # log :info, %(DOT_LEADER_DEBUG: Dot leader config - text="#{dot_leader[:text]}", width=#{dot_leader[:width]}, spacer_width=#{dot_leader[:spacer_width]})

  # 2025-07-17 BYB: Set toc_font_info
  toc_font_info = theme_font :toc do
    { font: font, size: @font_size }
  end

  # 2025-07-17 BYB: Set a dummy entry_level and entry_anchor
  entry_level = 2
  entry_anchor = nil

  # 2025-07-17 BYB: Store the cursor position
  start_cursor = cursor
  start_dots = nil   

  # 2025-07-17 BYB: left_side original
  left_side_inherited = (apply_text_decoration ::Set.new, :toc, entry_level).merge anchor: entry_anchor, color: @font_color
  # NOTE: use text formatter to add anchor overlay to avoid using inline format with synthetic anchor tag
  left_side_fragments = text_formatter.format left_side, inherited: left_side_inherited
  # warn "left_side_fragments: #{left_side_fragments.inspect}"
  
  # 2025-07-17 BYB: Try doing the same for the right side 
  right_side_inherited = (apply_text_decoration ::Set.new, :toc, entry_level).merge anchor: entry_anchor, color: @font_color
  # NOTE: use text formatter to add anchor overlay to avoid using inline format with synthetic anchor tag
  right_side_fragments = text_formatter.format right_side, inherited: right_side_inherited

  line_metrics = calc_line_metrics @base_line_height

  # 2025-07-17 BYB: This needs to be modified
  right_side_placeholder_width = rendered_width_of_fragments right_side_fragments, scale, node.roles.to_a

  left_side_test_width = rendered_width_of_fragments left_side_fragments, scale, node.roles.to_a
  
  # log :info, %(DOT_LEADER_DEBUG: Text widths - left=#{left_side_test_width}, right=#{right_side_placeholder_width})
  
  # 2025-07-17 Set hanging_indent to toc; potentially this could be styled
  hanging_indent = @theme.toc_hanging_indent
  start_page_number = page_number

  indent 0, right_side_placeholder_width do
    fragment_positions = []
    left_side_fragments.each do |fragment|
      fragment_positions << (fragment_position = ::Asciidoctor::PDF::FormattedText::FragmentPositionRenderer.new)
      (fragment[:callback] ||= []) << fragment_position
    end

    if right_side_placeholder_width > bounds.width
      log :warn, %(DOT_LEADER_DEBUG: Right side too wide; skipping: #{left_side} - right_width=#{right_side_placeholder_width}, bounds_width=#{bounds.width})
      return nil
    end

    typeset_formatted_text left_side_fragments, line_metrics, hanging_indent: hanging_indent, normalize_line_height: true

    break unless (last_fragment_position = fragment_positions.select(&:page_number)[-1])
    start_dots = last_fragment_position.right + hanging_indent
    last_fragment_cursor = last_fragment_position.top + line_metrics.padding_top
    start_cursor = last_fragment_cursor if last_fragment_position.page_number > start_page_number || (start_cursor - last_fragment_cursor) > line_metrics.height
  end
  
  # log :info, %(DOT_LEADER_DEBUG: After left text - start_dots=#{start_dots}, start_cursor=#{start_cursor})
  
  # 2025-07-17 No loop
  # NOTE: this will leave behind a gap where this entry would have been
  # break unless start_dots
  
  end_cursor = cursor
  move_cursor_to start_cursor
  # NOTE: we're guaranteed to be on the same page as the final line of the entry

  if dot_leader[:width] > 0 && (dot_leader[:levels] ? (dot_leader[:levels].include? entry_level.pred) : true)
    # 2025-07-17 BYB: Needed to calculate the right side width
    # right_side_width = rendered_width_of_string right_side
    right_side_width = right_side_placeholder_width
    
    right_side_font_settings = { color: @font_color, font: font_family, size: @font_size, styles: font_styles }
    save_font do
      # NOTE: the same font is used for dot leaders throughout toc
      set_font toc_font_info[:font], dot_leader[:font_size]
      font_style dot_leader[:font_style]
      # For true vertical alignment, dots must be at consistent absolute positions across all lines
      # CONTEXT-AWARE GRID ALIGNMENT:
      # Use appropriate grid origin for each context to ensure proper alignment
      # while maintaining correct positioning within bounds
      
      # Calculate the absolute position where dots will start
      # Note: start_dots may already be absolute in some contexts (like right column)
      # Check if start_dots is already in absolute coordinates
      if start_dots > bounds.width
        # start_dots appears to be already absolute (larger than column width)
        start_dots_absolute = start_dots
      else
        # start_dots is relative to bounds, convert to absolute
        start_dots_absolute = start_dots + bounds.absolute_left
      end
      
      if defined?(::Prawn::Document::ColumnBox) && bounds.is_a?(::Prawn::Document::ColumnBox)
        # In column context: use UNIVERSAL grid origin (page margin) for alignment across all columns
        # This ensures dots align consistently across left and right columns
        
        # Get column information for debugging
        current_col = bounds.current_column
        parent_bounds = bounds.instance_variable_get(:@parent)
        total_columns = bounds.instance_variable_get(:@columns) || 2
        
        # Calculate the actual column dimensions from the parent
        parent_width = parent_bounds.width
        column_width = parent_width / total_columns
        total_spacer_width = parent_width - (column_width * total_columns)
        spacer_width = total_columns > 1 ? total_spacer_width / (total_columns - 1) : 0
        column_offset = current_col * (column_width + spacer_width)
        
        # UNIVERSAL GRID: Use page margin as grid origin for all columns
        # This ensures dots align across the entire page
        grid_origin = page.margins[:left]
        
        # log :info, %(DOT_LEADER_DEBUG: Column context - current_column=#{current_col}, total_columns=#{total_columns}, column_width=#{column_width}, spacer_width=#{spacer_width}, column_offset=#{column_offset}, bounds_left=#{bounds.absolute_left}, grid_origin=#{grid_origin})
      else
        # In normal context: use the page margin as grid origin to ignore indentation
        # This ensures consistent alignment across all list levels
        grid_origin = page.margins[:left]
        # log :info, %(DOT_LEADER_DEBUG: Normal context - bounds_left=#{bounds.absolute_left}, page_margin=#{page.margins[:left]}, grid_origin=#{grid_origin})
      end
      
      # Calculate the position relative to the grid origin for alignment
      start_dots_from_grid_origin = start_dots_absolute - grid_origin
      
      # log :info, %(DOT_LEADER_DEBUG: Position calculations - start_dots_absolute=#{start_dots_absolute}, start_dots_from_grid_origin=#{start_dots_from_grid_origin})
      
      # Find the first grid position after the left text ends
      # Grid positions are at multiples of dot_leader[:width] from the grid origin
      first_dot_grid_index = (start_dots_from_grid_origin / dot_leader[:width]).ceil
      first_dot_position_absolute = grid_origin + (first_dot_grid_index * dot_leader[:width])
      
      # Convert back to the local bounds coordinate system for rendering
      # Use universal grid but fix coordinate conversion for right column
      first_dot_position = first_dot_position_absolute - bounds.absolute_left
      
      # Special handling for columns beyond the first (right column in 2-column layout)
      # We need to recalculate using the universal grid to ensure proper alignment
      if defined?(::Prawn::Document::ColumnBox) && bounds.is_a?(::Prawn::Document::ColumnBox)
        current_col = bounds.current_column
        if current_col > 0
          # Right column case: recalculate using universal grid
          # We need to find the first grid position within the column that comes AFTER the left text
        
        # Calculate the left text width to ensure dots start after it
        left_text_width = rendered_width_of_fragments left_side_fragments, scale, node.roles.to_a
        
        # Find the equivalent universal grid position that aligns with other columns
        # Calculate where text ends (without spacer first)
        # Use the absolute left position of the current column plus the text width
        text_end_no_spacer = bounds.absolute_left + left_text_width
        # Add the spacer to get the absolute position where dots should start
        text_end_absolute = text_end_no_spacer + dot_leader[:spacer_width]
        
        # Find the first universal grid position after the text ends
        # This ensures alignment with single column and other contexts
        text_end_from_grid_origin = text_end_absolute - grid_origin
        universal_grid_index = (text_end_from_grid_origin / dot_leader[:width]).ceil
        
        # Check if the previous grid position would still fit (with minimal spacing)
        # This allows us to fit one more dot if the previous grid position comes after the actual text
        if universal_grid_index > 0
          previous_grid_position = grid_origin + ((universal_grid_index - 1) * dot_leader[:width])
          # Check spacing from actual text end (not including the full spacer)
          spacing_from_text = previous_grid_position - text_end_no_spacer
          # Use previous position if it's at least 1pt after the text (minimal spacing)
          if spacing_from_text >= 1.0
            universal_grid_index -= 1
          end
        end
        
        # Calculate the absolute position using the universal grid
        adjusted_position_absolute = grid_origin + (universal_grid_index * dot_leader[:width])
        first_dot_position = adjusted_position_absolute - bounds.absolute_left
        first_dot_position_absolute = adjusted_position_absolute
        # CRITICAL: Update the grid index to match the adjusted position
        first_dot_grid_index = universal_grid_index
        
          # log :info, %(DOT_LEADER_DEBUG: Right column adjustment - left_text_width=#{left_text_width}, text_end_absolute=#{text_end_absolute}, text_end_from_grid_origin=#{text_end_from_grid_origin}, universal_grid_index=#{universal_grid_index}, adjusted_grid_index=#{first_dot_grid_index}, adjusted_position=#{first_dot_position})
        else
          # log :info, %(DOT_LEADER_DEBUG: Left column - no adjustment needed)
        end
      else
        # log :info, %(DOT_LEADER_DEBUG: Standard conversion - first_dot_position_absolute=#{first_dot_position_absolute}, bounds.absolute_left=#{bounds.absolute_left}, first_dot_position=#{first_dot_position})
      end
      
      # log :info, %(DOT_LEADER_DEBUG: First dot - grid_index=#{first_dot_grid_index}, position_absolute=#{first_dot_position_absolute}, position_local=#{first_dot_position})
      
      # Check if the first dot is too close to the left text
      # Calculate the actual left text width for precise spacing check
      left_text_width = rendered_width_of_fragments left_side_fragments, scale, node.roles.to_a
      space_after_left_text = first_dot_position - left_text_width
      
      dot_stripped = dot_leader[:text].rstrip
      dot_stripped_width = rendered_width_of_string dot_stripped
      
      # Calculate where the right text will start (in absolute coordinates)
      right_text_start_local = bounds.width - right_side_width - dot_leader[:spacer_width]
      
      # Convert to absolute coordinates: bounds.absolute_left already accounts for column position
      right_text_start_absolute = right_text_start_local + bounds.absolute_left
      
      # Find the last grid position before the right text starts (using the same grid origin)
      # Ensure the COMPLETE dot (start + width) fits before right text
      right_text_from_grid_origin = right_text_start_absolute - grid_origin
      
      # Calculate the last grid index where a complete dot would fit
      # A dot at position N has its right edge at N + dot_leader[:width]
      # The target: (last_dot_grid_index * dot_leader[:width]) + dot_leader[:width] <= right_text_from_grid_origin
      # Simplified: last_dot_grid_index <= (right_text_from_grid_origin / dot_leader[:width]) - 1
      # Add some buffer space to ensure the dot does not touch the right text
      last_possible_dot_end = right_text_from_grid_origin - (1.0 * dot_stripped_width)
      last_dot_grid_index = (last_possible_dot_end / dot_leader[:width]).floor
      
      # log :info, %(DOT_LEADER_DEBUG: Right text - start_local=#{right_text_start_local}, start_absolute=#{right_text_start_absolute}, from_grid_origin=#{right_text_from_grid_origin})
      
      # Calculate the number of dots that fit on the grid between left and right text
      num_dots = [last_dot_grid_index - first_dot_grid_index + 1, 0].max
      # log :info, %(DOT_LEADER_DEBUG: Last dot - last_possible_end=#{last_possible_dot_end}, last_grid_index=#{last_dot_grid_index}, num_dots=#{num_dots})

      # Calculate the indent to position the first dot at the correct grid position
      dot_indent = first_dot_position

      # log :info, %(DOT_LEADER_DEBUG: Final render - dot_indent=#{dot_indent}, num_dots=#{num_dots}, dot_text="#{dot_leader[:text]}")

      fragment_positions = []
      right_side_fragments.each do |fragment|
        fragment_positions << (fragment_position = ::Asciidoctor::PDF::FormattedText::FragmentPositionRenderer.new)
        (fragment[:callback] ||= []) << fragment_position
      end
      
      # Render the dots with a proper indent to align to the grid
      if num_dots > 0
        indent dot_indent, 0 do
          typeset_formatted_text [
            { text: dot_leader[:text] * num_dots, color: dot_leader[:font_color] }
          ], line_metrics, align: :left
        end
        # log :info, %(DOT_LEADER_DEBUG: Dots rendered successfully)
      else
        log :warn, %(DOT_LEADER_DEBUG: No dots to render - num_dots=#{num_dots})
      end
      
      # Move cursor back to render right text
      move_cursor_to start_cursor
      typeset_formatted_text [
      dot_leader[:spacer],
      *right_side_fragments.map { |fragment|
        fragment.dup.tap do |f|
          f[:styles] = Array(f[:styles])
          f[:styles] |= Array(right_side_font_settings[:styles]) if right_side_font_settings[:styles]
          f[:font] ||= right_side_font_settings[:font] if right_side_font_settings[:font]
          f[:size] ||= right_side_font_settings[:size] if right_side_font_settings[:size]
          f[:color] ||= right_side_font_settings[:color] if right_side_font_settings[:color]
          f[:anchor] = entry_anchor if entry_anchor
        end
      }
    ], line_metrics, align: :right
    end
  else
    # log :info, %(DOT_LEADER_DEBUG: Skipping dot rendering - width=#{dot_leader[:width]}, levels_check=#{dot_leader[:levels] ? (dot_leader[:levels].include? entry_level.pred) : true})
    typeset_formatted_text [{ text: right_side, color: @font_color, anchor: entry_anchor }], line_metrics, align: :right
  end
  move_cursor_to end_cursor

  # log :info, %(DOT_LEADER_DEBUG: Completed - final_cursor=#{cursor})
  # Return nil to avoid numbers being printed out
  nil
end

#rendered_width_of_fragments(fragments, scale, roles = []) ⇒ Object



9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/asciidoctor/dot_leader/dot_leader_pdf.rb', line 9

def rendered_width_of_fragments(fragments, scale, roles = [])
    fragments.sum do |fragment|
    styles = fragment[:styles] || Set.new
    # Use fragment's size if specified (for roles like .small, .big), otherwise use current font size
    size = fragment[:size] || font_size
    size *= scale if styles.include?(:subscript) || styles.include?(:superscript)
    font_style = if styles.include?(:bold) && styles.include?(:italic)
      :bold_italic
    elsif styles.include?(:bold)
      :bold
    elsif styles.include?(:italic)
      :italic
    else
      :normal
    end

    font_name = fragment[:font] || font_family
    
    width = nil
    # Handle inline image width
    if fragment[:image_format]
      # Use numeric value if it's a string
      image_width = fragment[:image_width]
      return image_width.to_f if image_width
    end
    font font_name, style: font_style, size: size do
      width = rendered_width_of_string(fragment[:text])
    end
    width
  end
end