rolfedh-doc-utils 0.1.29__py3-none-any.whl → 0.1.31__py3-none-any.whl

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.
@@ -38,6 +38,7 @@ class AsciiDocTable:
38
38
  end_line: int
39
39
  attributes: str # Table attributes like [cols="1,3"]
40
40
  rows: List[TableRow]
41
+ title: str = "" # Block title like ".Table description"
41
42
 
42
43
 
43
44
  class TableParser:
@@ -47,8 +48,8 @@ class TableParser:
47
48
  TABLE_START = re.compile(r'^\[.*?\]$')
48
49
  TABLE_DELIMITER = re.compile(r'^\|===\s*$')
49
50
 
50
- # Pattern for table cell separator
51
- CELL_SEPARATOR = re.compile(r'^\|')
51
+ # Pattern for table cell separator (| or cell type specifier like a|, s|, etc.)
52
+ CELL_SEPARATOR = re.compile(r'^(\||[ashdmev]\|)')
52
53
 
53
54
  # Pattern for conditional directives
54
55
  IFDEF_PATTERN = re.compile(r'^(ifdef::|ifndef::).+\[\]\s*$')
@@ -56,6 +57,64 @@ class TableParser:
56
57
 
57
58
  # Pattern for callout number (used for callout table detection)
58
59
  CALLOUT_NUMBER = re.compile(r'^<(\d+)>\s*$')
60
+ PLAIN_NUMBER = re.compile(r'^(\d+)\s*$')
61
+
62
+ def _is_callout_or_number(self, text: str) -> tuple[bool, int]:
63
+ """
64
+ Check if text is a callout number (<1>) or plain number (1).
65
+ Returns (is_match, number) or (False, 0) if no match.
66
+ """
67
+ text = text.strip()
68
+
69
+ # Try callout format first: <1>
70
+ match = self.CALLOUT_NUMBER.match(text)
71
+ if match:
72
+ return (True, int(match.group(1)))
73
+
74
+ # Try plain number format: 1
75
+ match = self.PLAIN_NUMBER.match(text)
76
+ if match:
77
+ return (True, int(match.group(1)))
78
+
79
+ return (False, 0)
80
+
81
+ def _finalize_row_if_complete(self, current_row_cells, conditionals_before_row,
82
+ conditionals_after_row, expected_columns, rows):
83
+ """
84
+ Check if we have enough cells for a complete row, and if so, save it.
85
+
86
+ Returns: (new_current_row_cells, new_conditionals_before, new_conditionals_after)
87
+ """
88
+ if expected_columns > 0 and len(current_row_cells) >= expected_columns:
89
+ # Row is complete - save it
90
+ rows.append(TableRow(
91
+ cells=current_row_cells.copy(),
92
+ conditionals_before=conditionals_before_row.copy(),
93
+ conditionals_after=conditionals_after_row.copy()
94
+ ))
95
+ return [], [], [] # Reset for next row
96
+
97
+ # Row not complete yet
98
+ return current_row_cells, conditionals_before_row, conditionals_after_row
99
+
100
+ def _parse_column_count(self, attributes: str) -> int:
101
+ """
102
+ Parse the cols attribute to determine number of columns.
103
+
104
+ Example: '[cols="1,7a"]' returns 2
105
+ '[cols="1,2,3"]' returns 3
106
+ """
107
+ import re
108
+ # Match cols="..." or cols='...'
109
+ match = re.search(r'cols=["\']([^"\']+)["\']', attributes)
110
+ if not match:
111
+ return 0 # Unknown column count
112
+
113
+ cols_spec = match.group(1)
114
+ # Count comma-separated values
115
+ # Handle formats like: "1,2", "1a,2a", "1,2,3", etc.
116
+ columns = cols_spec.split(',')
117
+ return len(columns)
59
118
 
60
119
  def find_tables(self, lines: List[str]) -> List[AsciiDocTable]:
61
120
  """Find all tables in the document."""
@@ -65,16 +124,27 @@ class TableParser:
65
124
  while i < len(lines):
66
125
  # Look for table delimiter
67
126
  if self.TABLE_DELIMITER.match(lines[i]):
68
- # Check if there are attributes on the line before
127
+ # Check for attributes and title before the table
69
128
  attributes = ""
129
+ title = ""
70
130
  start_line = i
71
131
 
132
+ # Check line before delimiter for attributes [cols="..."]
72
133
  if i > 0 and self.TABLE_START.match(lines[i - 1]):
73
134
  attributes = lines[i - 1]
74
135
  start_line = i - 1
75
136
 
137
+ # Check line before attributes for title .Title
138
+ if i > 1 and lines[i - 2].strip().startswith('.') and not lines[i - 2].strip().startswith('..'):
139
+ title = lines[i - 2].strip()
140
+ start_line = i - 2
141
+ elif i > 0 and lines[i - 1].strip().startswith('.') and not lines[i - 1].strip().startswith('..'):
142
+ # Title directly before delimiter (no attributes)
143
+ title = lines[i - 1].strip()
144
+ start_line = i - 1
145
+
76
146
  # Parse table content
77
- table = self._parse_table(lines, start_line, i)
147
+ table = self._parse_table(lines, start_line, i, title)
78
148
  if table:
79
149
  tables.append(table)
80
150
  i = table.end_line + 1
@@ -83,11 +153,13 @@ class TableParser:
83
153
 
84
154
  return tables
85
155
 
86
- def _parse_table(self, lines: List[str], start_line: int, delimiter_line: int) -> Optional[AsciiDocTable]:
156
+ def _parse_table(self, lines: List[str], start_line: int, delimiter_line: int, title: str = "") -> Optional[AsciiDocTable]:
87
157
  """
88
158
  Parse a single table starting at the delimiter.
89
159
 
90
160
  AsciiDoc table format:
161
+ .Optional title
162
+ [optional attributes]
91
163
  |===
92
164
  |Cell1
93
165
  |Cell2
@@ -96,12 +168,28 @@ class TableParser:
96
168
  |Cell4
97
169
  |===
98
170
  """
171
+ # Get attributes and parse column count
172
+ attributes = ""
173
+ if start_line < delimiter_line:
174
+ # Check if start line is title or attributes
175
+ start_content = lines[start_line].strip()
176
+ if start_content.startswith('.') and not start_content.startswith('..'):
177
+ # Start line is title, attributes might be on next line
178
+ if start_line + 1 < delimiter_line:
179
+ attributes = lines[start_line + 1]
180
+ else:
181
+ # Start line is attributes
182
+ attributes = lines[start_line]
183
+
184
+ expected_columns = self._parse_column_count(attributes)
185
+
99
186
  i = delimiter_line + 1
100
187
  rows = []
101
188
  current_row_cells = []
102
189
  current_cell_lines = []
103
190
  conditionals_before_row = []
104
191
  conditionals_after_row = []
192
+ in_asciidoc_cell = False # Track if we're in an a| (AsciiDoc) cell
105
193
 
106
194
  while i < len(lines):
107
195
  line = lines[i]
@@ -124,36 +212,40 @@ class TableParser:
124
212
  conditionals_after=conditionals_after_row.copy()
125
213
  ))
126
214
 
127
- # Get attributes if present
128
- attributes = ""
129
- if start_line < delimiter_line:
130
- attributes = lines[start_line]
131
-
215
+ # Get attributes if present (already extracted above)
132
216
  return AsciiDocTable(
133
217
  start_line=start_line,
134
218
  end_line=i,
135
219
  attributes=attributes,
136
- rows=rows
220
+ rows=rows,
221
+ title=title
137
222
  )
138
223
 
139
224
  # Check for conditional directives
140
225
  if self.IFDEF_PATTERN.match(line) or self.ENDIF_PATTERN.match(line):
141
- if not current_row_cells:
226
+ # If we're building a cell (current_cell_lines is not empty) OR
227
+ # we're in an AsciiDoc cell, add conditional to cell content
228
+ if current_cell_lines or in_asciidoc_cell:
229
+ # Inside a cell - conditional is part of cell content
230
+ current_cell_lines.append(line)
231
+ elif current_row_cells:
232
+ # Between cells in the same row
233
+ conditionals_after_row.append(line)
234
+ else:
142
235
  # Conditional before any cells in this row
143
236
  conditionals_before_row.append(line)
144
- else:
145
- # Conditional after cells started - treat as part of current context
146
- if current_cell_lines:
147
- # Inside a cell
148
- current_cell_lines.append(line)
149
- else:
150
- # Between cells in the same row
151
- conditionals_after_row.append(line)
152
237
  i += 1
153
238
  continue
154
239
 
155
- # Blank line separates rows
240
+ # Blank line handling
156
241
  if not line.strip():
242
+ # In AsciiDoc cells (a|), blank lines are part of cell content
243
+ if in_asciidoc_cell:
244
+ current_cell_lines.append(line)
245
+ i += 1
246
+ continue
247
+
248
+ # Otherwise, blank line separates rows
157
249
  # Save pending cell if exists
158
250
  if current_cell_lines:
159
251
  current_row_cells.append(TableCell(
@@ -161,6 +253,7 @@ class TableParser:
161
253
  conditionals=[]
162
254
  ))
163
255
  current_cell_lines = []
256
+ in_asciidoc_cell = False
164
257
 
165
258
  # Save row if we have cells
166
259
  if current_row_cells:
@@ -176,8 +269,16 @@ class TableParser:
176
269
  i += 1
177
270
  continue
178
271
 
179
- # Check for cell separator (|)
272
+ # Check for cell separator (|) or cell type specifier (a|, s|, etc.)
180
273
  if self.CELL_SEPARATOR.match(line):
274
+ # Determine if line starts with | or with a cell type specifier
275
+ if line.startswith('|'):
276
+ # Standard cell separator
277
+ cell_content = line[1:] # Remove leading |
278
+ else:
279
+ # Cell type specifier without leading | (e.g., "a|text")
280
+ cell_content = line
281
+
181
282
  # Save previous cell if exists
182
283
  if current_cell_lines:
183
284
  current_row_cells.append(TableCell(
@@ -185,9 +286,24 @@ class TableParser:
185
286
  conditionals=[]
186
287
  ))
187
288
  current_cell_lines = []
289
+ in_asciidoc_cell = False # Reset for next cell
290
+
291
+ # Check if row is complete (have enough cells based on cols attribute)
292
+ current_row_cells, conditionals_before_row, conditionals_after_row = \
293
+ self._finalize_row_if_complete(
294
+ current_row_cells, conditionals_before_row,
295
+ conditionals_after_row, expected_columns, rows
296
+ )
188
297
 
189
- # Extract cell content from this line (text after |)
190
- cell_content = line[1:].strip() # Remove leading |
298
+ # Check for cell type specifier (a|, s|, etc.)
299
+ # Type specifiers are single characters followed by |
300
+ if len(cell_content) > 1 and cell_content[0] in 'ashdmev' and cell_content[1] == '|':
301
+ # Track if this is an AsciiDoc cell (a|)
302
+ if cell_content[0] == 'a':
303
+ in_asciidoc_cell = True
304
+ cell_content = cell_content[2:] # Remove type specifier and |
305
+
306
+ cell_content = cell_content.strip()
191
307
 
192
308
  # Check if there are multiple cells on the same line (e.g., |Cell1 |Cell2 |Cell3)
193
309
  if '|' in cell_content:
@@ -220,6 +336,39 @@ class TableParser:
220
336
  i += 1
221
337
  continue
222
338
 
339
+ # Check for cell type specifier on its own line (e.g., "a|", "s|", "h|")
340
+ # This is actually a cell SEPARATOR with type specifier
341
+ # Example:
342
+ # |<1> ← Cell 1
343
+ # a| ← Start cell 2 with type 'a' (AsciiDoc)
344
+ # content... ← Cell 2 content
345
+ stripped_line = line.strip()
346
+ if (len(stripped_line) == 2 and
347
+ stripped_line[0] in 'ashdmev' and
348
+ stripped_line[1] == '|' and
349
+ (current_cell_lines or current_row_cells)):
350
+ # Save previous cell if we have one
351
+ if current_cell_lines:
352
+ current_row_cells.append(TableCell(
353
+ content=current_cell_lines.copy(),
354
+ conditionals=[]
355
+ ))
356
+ current_cell_lines = []
357
+
358
+ # Check if row is complete
359
+ current_row_cells, conditionals_before_row, conditionals_after_row = \
360
+ self._finalize_row_if_complete(
361
+ current_row_cells, conditionals_before_row,
362
+ conditionals_after_row, expected_columns, rows
363
+ )
364
+
365
+ # Set cell type for the NEW cell we're starting
366
+ if stripped_line[0] == 'a':
367
+ in_asciidoc_cell = True
368
+ # Start collecting content for the new cell (no content on this line)
369
+ i += 1
370
+ continue
371
+
223
372
  # Regular content line (continuation of current cell)
224
373
  if current_cell_lines or current_row_cells:
225
374
  current_cell_lines.append(line)
@@ -233,6 +382,8 @@ class TableParser:
233
382
  """
234
383
  Determine if a table is a callout explanation table.
235
384
  A callout table has two columns: callout number and explanation.
385
+ Accepts both callout format (<1>) and plain numbers (1).
386
+ Skips header rows if present.
236
387
  """
237
388
  if not table.rows:
238
389
  return False
@@ -241,15 +392,23 @@ class TableParser:
241
392
  if not all(len(row.cells) == 2 for row in table.rows):
242
393
  return False
243
394
 
244
- # Check if first cell of each row is a callout number
245
- for row in table.rows:
395
+ # Determine if there's a header row and skip it
396
+ has_header = self._has_header_row(table)
397
+ data_rows = table.rows[1:] if has_header else table.rows
398
+
399
+ if not data_rows:
400
+ return False
401
+
402
+ # Check if first cell of each data row is a callout number (either <1> or 1)
403
+ for row in data_rows:
246
404
  first_cell = row.cells[0]
247
405
  if not first_cell.content:
248
406
  return False
249
407
 
250
- # First line of first cell should be a callout number
408
+ # First line of first cell should be a callout number or plain number
251
409
  first_line = first_cell.content[0].strip()
252
- if not self.CALLOUT_NUMBER.match(first_line):
410
+ is_match, _ = self._is_callout_or_number(first_line)
411
+ if not is_match:
253
412
  return False
254
413
 
255
414
  return True
@@ -258,23 +417,36 @@ class TableParser:
258
417
  """
259
418
  Check if table has a header row.
260
419
  Common header patterns: "Item", "Value", "Description", "Column", etc.
420
+
421
+ A row is a header if:
422
+ - It does NOT start with a callout number (<1> or 1)
423
+ - It contains common header keywords in the cells
261
424
  """
262
425
  if not table.rows:
263
426
  return False
264
427
 
265
428
  first_row = table.rows[0]
266
- if not first_row.cells:
429
+ if not first_row.cells or len(first_row.cells) < 2:
267
430
  return False
268
431
 
432
+ # If first cell is a callout number, this is NOT a header
433
+ first_cell = first_row.cells[0]
434
+ if first_cell.content:
435
+ first_cell_text = first_cell.content[0].strip()
436
+ is_callout, _ = self._is_callout_or_number(first_cell_text)
437
+ if is_callout:
438
+ return False
439
+
269
440
  # Collect text from all cells in first row
270
441
  header_text = ' '.join(
271
442
  cell.content[0] if cell.content else ''
272
443
  for cell in first_row.cells
273
444
  ).lower()
274
445
 
275
- # Check for common header keywords
446
+ # Check for common header keywords (as whole words)
276
447
  header_keywords = ['item', 'description', 'value', 'column', 'parameter', 'field', 'name']
277
- return any(keyword in header_text for keyword in header_keywords)
448
+ import re
449
+ return any(re.search(r'\b' + re.escape(keyword) + r'\b', header_text) for keyword in header_keywords)
278
450
 
279
451
  def is_3column_callout_table(self, table: AsciiDocTable) -> bool:
280
452
  """
@@ -300,19 +472,52 @@ class TableParser:
300
472
  if not all(len(row.cells) == 3 for row in data_rows):
301
473
  return False
302
474
 
303
- # Check if first cell of each data row contains a plain number (1, 2, 3...)
475
+ # Check if first cell of each data row contains a callout or plain number (1, 2, 3... or <1>, <2>...)
304
476
  for row in data_rows:
305
477
  first_cell = row.cells[0]
306
478
  if not first_cell.content:
307
479
  return False
308
480
 
309
- # First line of first cell should be a number
481
+ # First line of first cell should be a callout number or plain number
310
482
  first_line = first_cell.content[0].strip()
311
- if not first_line.isdigit():
483
+ is_match, _ = self._is_callout_or_number(first_line)
484
+ if not is_match:
312
485
  return False
313
486
 
314
487
  return True
315
488
 
489
+ def get_table_callout_numbers(self, table: AsciiDocTable) -> List[int]:
490
+ """
491
+ Extract just the callout numbers from a table (in order, with duplicates).
492
+ Used for validation and diagnostics.
493
+
494
+ Returns:
495
+ List of callout numbers in the order they appear in the table.
496
+ Preserves duplicates to help identify table errors.
497
+ """
498
+ callout_numbers = []
499
+
500
+ # Determine if there's a header row and skip it
501
+ has_header = self._has_header_row(table)
502
+ data_rows = table.rows[1:] if has_header else table.rows
503
+
504
+ for row in data_rows:
505
+ # Handle both 2-column and 3-column tables
506
+ if len(row.cells) < 2:
507
+ continue
508
+
509
+ first_cell = row.cells[0]
510
+ if not first_cell.content:
511
+ continue
512
+
513
+ # Extract callout number (supports both <1> and 1 formats)
514
+ first_line = first_cell.content[0].strip()
515
+ is_match, callout_num = self._is_callout_or_number(first_line)
516
+ if is_match:
517
+ callout_numbers.append(callout_num)
518
+
519
+ return callout_numbers
520
+
316
521
  def extract_callout_explanations_from_table(self, table: AsciiDocTable) -> Dict[int, Tuple[List[str], List[str]]]:
317
522
  """
318
523
  Extract callout explanations from a table.
@@ -320,43 +525,46 @@ class TableParser:
320
525
 
321
526
  The conditionals list includes any ifdef/ifndef/endif statements that should
322
527
  be preserved when converting the table to other formats.
528
+
529
+ Accepts both callout format (<1>) and plain numbers (1).
530
+ Skips header rows if present.
531
+
532
+ Note: If table contains duplicate callout numbers, the last one wins.
533
+ Use get_table_callout_numbers() to detect duplicates.
323
534
  """
324
535
  explanations = {}
325
536
 
326
- for row in table.rows:
537
+ # Determine if there's a header row and skip it
538
+ has_header = self._has_header_row(table)
539
+ data_rows = table.rows[1:] if has_header else table.rows
540
+
541
+ for row in data_rows:
327
542
  if len(row.cells) != 2:
328
543
  continue
329
544
 
330
545
  callout_cell = row.cells[0]
331
546
  explanation_cell = row.cells[1]
332
547
 
333
- # Extract callout number
548
+ # Extract callout number (supports both <1> and 1 formats)
334
549
  first_line = callout_cell.content[0].strip()
335
- match = self.CALLOUT_NUMBER.match(first_line)
336
- if not match:
550
+ is_match, callout_num = self._is_callout_or_number(first_line)
551
+ if not is_match:
337
552
  continue
338
553
 
339
- callout_num = int(match.group(1))
340
-
341
- # Collect explanation lines
554
+ # Collect explanation lines, preserving blank lines and conditionals inline
555
+ # Blank lines will need to become continuation markers (+) in definition lists
342
556
  explanation_lines = []
343
557
  for line in explanation_cell.content:
344
- # Skip conditional directives in explanation (preserve them separately)
345
- if not (self.IFDEF_PATTERN.match(line) or self.ENDIF_PATTERN.match(line)):
346
- explanation_lines.append(line)
347
-
348
- # Collect all conditionals for this row
349
- all_conditionals = []
350
- all_conditionals.extend(row.conditionals_before)
351
-
352
- # Extract conditionals from explanation cell
353
- for line in explanation_cell.content:
354
- if self.IFDEF_PATTERN.match(line) or self.ENDIF_PATTERN.match(line):
355
- all_conditionals.append(line)
558
+ # Preserve ALL lines including conditionals and blank lines
559
+ # Empty lines will be marked as '' which signals need for continuation marker
560
+ explanation_lines.append(line)
356
561
 
357
- all_conditionals.extend(row.conditionals_after)
562
+ # Collect conditionals that appear before/after the row
563
+ row_conditionals = []
564
+ row_conditionals.extend(row.conditionals_before)
565
+ row_conditionals.extend(row.conditionals_after)
358
566
 
359
- explanations[callout_num] = (explanation_lines, all_conditionals)
567
+ explanations[callout_num] = (explanation_lines, row_conditionals)
360
568
 
361
569
  return explanations
362
570
 
@@ -366,12 +574,14 @@ class TableParser:
366
574
  Returns dict mapping callout number to tuple of (value_lines, description_lines, conditionals).
367
575
 
368
576
  Format: Item | Value | Description
369
- - Item: Number (1, 2, 3...) corresponding to callout number
577
+ - Item: Number (1, 2, 3...) or callout (<1>, <2>...) corresponding to callout number
370
578
  - Value: The code/value being explained
371
579
  - Description: Explanation text
372
580
 
373
581
  The conditionals list includes any ifdef/ifndef/endif statements that should
374
582
  be preserved when converting the table to other formats.
583
+
584
+ Accepts both callout format (<1>) and plain numbers (1).
375
585
  """
376
586
  explanations = {}
377
587
 
@@ -387,47 +597,31 @@ class TableParser:
387
597
  value_cell = row.cells[1]
388
598
  desc_cell = row.cells[2]
389
599
 
390
- # Extract item number (maps to callout number)
600
+ # Extract item number (maps to callout number) - supports both <1> and 1 formats
391
601
  if not item_cell.content:
392
602
  continue
393
603
 
394
604
  item_num_str = item_cell.content[0].strip()
395
- if not item_num_str.isdigit():
605
+ is_match, callout_num = self._is_callout_or_number(item_num_str)
606
+ if not is_match:
396
607
  continue
397
608
 
398
- callout_num = int(item_num_str)
399
-
400
- # Collect value lines (column 2)
609
+ # Collect value lines (column 2), preserving all content including conditionals
401
610
  value_lines = []
402
611
  for line in value_cell.content:
403
- # Skip conditional directives in value (preserve them separately)
404
- if not (self.IFDEF_PATTERN.match(line) or self.ENDIF_PATTERN.match(line)):
405
- value_lines.append(line)
612
+ value_lines.append(line)
406
613
 
407
- # Collect description lines (column 3)
614
+ # Collect description lines (column 3), preserving all content including conditionals
408
615
  description_lines = []
409
616
  for line in desc_cell.content:
410
- # Skip conditional directives in description (preserve them separately)
411
- if not (self.IFDEF_PATTERN.match(line) or self.ENDIF_PATTERN.match(line)):
412
- description_lines.append(line)
413
-
414
- # Collect all conditionals for this row
415
- all_conditionals = []
416
- all_conditionals.extend(row.conditionals_before)
417
-
418
- # Extract conditionals from value cell
419
- for line in value_cell.content:
420
- if self.IFDEF_PATTERN.match(line) or self.ENDIF_PATTERN.match(line):
421
- all_conditionals.append(line)
422
-
423
- # Extract conditionals from description cell
424
- for line in desc_cell.content:
425
- if self.IFDEF_PATTERN.match(line) or self.ENDIF_PATTERN.match(line):
426
- all_conditionals.append(line)
617
+ description_lines.append(line)
427
618
 
428
- all_conditionals.extend(row.conditionals_after)
619
+ # Collect conditionals that appear before/after the row
620
+ row_conditionals = []
621
+ row_conditionals.extend(row.conditionals_before)
622
+ row_conditionals.extend(row.conditionals_after)
429
623
 
430
- explanations[callout_num] = (value_lines, description_lines, all_conditionals)
624
+ explanations[callout_num] = (value_lines, description_lines, row_conditionals)
431
625
 
432
626
  return explanations
433
627
 
@@ -442,8 +636,12 @@ class TableParser:
442
636
  Returns:
443
637
  AsciiDocTable if a callout table is found, None otherwise
444
638
  """
445
- # Skip blank lines and continuation markers after code block
639
+ # Skip the closing delimiter of the code block (----, ...., etc.)
446
640
  i = code_block_end + 1
641
+ if i < len(lines) and lines[i].strip() in ['----', '....', '====']:
642
+ i += 1
643
+
644
+ # Skip blank lines and continuation markers after code block
447
645
  while i < len(lines) and (not lines[i].strip() or lines[i].strip() == '+'):
448
646
  i += 1
449
647
 
@@ -465,12 +663,26 @@ class TableParser:
465
663
 
466
664
  # Check for table delimiter
467
665
  if self.TABLE_DELIMITER.match(line):
468
- # Found a table, parse it
666
+ # Found a table, extract attributes and title
667
+ attributes = ""
668
+ title = ""
469
669
  start_line = j
670
+
671
+ # Check line before delimiter for attributes [cols="..."]
470
672
  if j > 0 and self.TABLE_START.match(lines[j - 1]):
673
+ attributes = lines[j - 1]
674
+ start_line = j - 1
675
+
676
+ # Check line before attributes for title .Title
677
+ if j > 1 and lines[j - 2].strip().startswith('.') and not lines[j - 2].strip().startswith('..'):
678
+ title = lines[j - 2].strip()
679
+ start_line = j - 2
680
+ elif j > 0 and lines[j - 1].strip().startswith('.') and not lines[j - 1].strip().startswith('..'):
681
+ # Title directly before delimiter (no attributes)
682
+ title = lines[j - 1].strip()
471
683
  start_line = j - 1
472
684
 
473
- table = self._parse_table(lines, start_line, j)
685
+ table = self._parse_table(lines, start_line, j, title)
474
686
  if table and (self.is_callout_table(table) or self.is_3column_callout_table(table)):
475
687
  return table
476
688
 
@@ -307,7 +307,20 @@ class InteractiveCalloutConverter:
307
307
  explanations, explanation_end = self.detector.extract_callout_explanations(new_lines, block.end_line)
308
308
 
309
309
  if not explanations:
310
- print_colored(f"Warning: No explanations found for block at line {block.start_line + 1}", Colors.YELLOW)
310
+ # Get callout numbers for warning message
311
+ all_callout_nums = []
312
+ for group in callout_groups:
313
+ all_callout_nums.extend(group.callout_numbers)
314
+
315
+ warning_msg = (
316
+ f"WARNING: {input_file.name} line {block.start_line + 1}: "
317
+ f"Code block has callouts {sorted(set(all_callout_nums))} but no explanations found after it. "
318
+ f"This may indicate: explanations are shared with another code block, "
319
+ f"explanations are in an unexpected location, or documentation error (missing explanations). "
320
+ f"Consider reviewing this block manually."
321
+ )
322
+ print_colored(warning_msg, Colors.YELLOW)
323
+ self.warnings.append(warning_msg)
311
324
  continue
312
325
 
313
326
  # Validate
@@ -332,9 +345,9 @@ class InteractiveCalloutConverter:
332
345
  else:
333
346
  converted_content = self.detector.remove_callouts_from_code(block.content)
334
347
  if format_choice == 'bullets':
335
- output_list = BulletListConverter.convert(callout_groups, explanations)
348
+ output_list = BulletListConverter.convert(callout_groups, explanations, self.detector.last_table_title)
336
349
  else: # deflist
337
- output_list = DefListConverter.convert(callout_groups, explanations)
350
+ output_list = DefListConverter.convert(callout_groups, explanations, self.detector.last_table_title)
338
351
 
339
352
  # Replace in document
340
353
  has_source_prefix = self.detector.CODE_BLOCK_START.match(new_lines[block.start_line])
@@ -521,6 +534,8 @@ Examples:
521
534
  print_colored(f"\n⚠ {len(converter.warnings)} Warning(s):", Colors.YELLOW)
522
535
  for warning in converter.warnings:
523
536
  print_colored(f" {warning}", Colors.YELLOW)
537
+ print()
538
+ print_colored("Suggestion: Fix the callout mismatches in the files above and rerun this command.", Colors.YELLOW)
524
539
 
525
540
  if args.dry_run and files_modified > 0:
526
541
  print_colored("\nDRY RUN - No files were modified", Colors.YELLOW)