rolfedh-doc-utils 0.1.30__tar.gz → 0.1.32__tar.gz

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.
Files changed (63) hide show
  1. {rolfedh_doc_utils-0.1.30/rolfedh_doc_utils.egg-info → rolfedh_doc_utils-0.1.32}/PKG-INFO +1 -1
  2. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/callout_lib/converter_bullets.py +10 -2
  3. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/callout_lib/converter_deflist.py +10 -2
  4. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/callout_lib/detector.py +38 -8
  5. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/callout_lib/table_parser.py +170 -55
  6. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/convert_callouts_interactive.py +26 -3
  7. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/convert_callouts_to_deflist.py +145 -14
  8. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/version.py +1 -1
  9. rolfedh_doc_utils-0.1.32/doc_utils/warnings_report.py +237 -0
  10. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/pyproject.toml +1 -1
  11. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32/rolfedh_doc_utils.egg-info}/PKG-INFO +1 -1
  12. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/rolfedh_doc_utils.egg-info/SOURCES.txt +1 -0
  13. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/LICENSE +0 -0
  14. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/README.md +0 -0
  15. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/archive_unused_files.py +0 -0
  16. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/archive_unused_images.py +0 -0
  17. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/callout_lib/__init__.py +0 -0
  18. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/callout_lib/converter_comments.py +0 -0
  19. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/check_scannability.py +0 -0
  20. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/__init__.py +0 -0
  21. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/extract_link_attributes.py +0 -0
  22. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/file_utils.py +0 -0
  23. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/format_asciidoc_spacing.py +0 -0
  24. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/replace_link_attributes.py +0 -0
  25. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/scannability.py +0 -0
  26. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/spinner.py +0 -0
  27. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/topic_map_parser.py +0 -0
  28. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/unused_adoc.py +0 -0
  29. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/unused_attributes.py +0 -0
  30. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/unused_images.py +0 -0
  31. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/validate_links.py +0 -0
  32. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils/version_check.py +0 -0
  33. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/doc_utils_cli.py +0 -0
  34. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/extract_link_attributes.py +0 -0
  35. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/find_unused_attributes.py +0 -0
  36. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/format_asciidoc_spacing.py +0 -0
  37. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/replace_link_attributes.py +0 -0
  38. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/rolfedh_doc_utils.egg-info/dependency_links.txt +0 -0
  39. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/rolfedh_doc_utils.egg-info/entry_points.txt +0 -0
  40. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/rolfedh_doc_utils.egg-info/requires.txt +0 -0
  41. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/rolfedh_doc_utils.egg-info/top_level.txt +0 -0
  42. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/setup.cfg +0 -0
  43. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/setup.py +0 -0
  44. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_archive_unused_files.py +0 -0
  45. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_archive_unused_images.py +0 -0
  46. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_auto_discovery.py +0 -0
  47. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_check_scannability.py +0 -0
  48. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_cli_entry_points.py +0 -0
  49. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_extract_link_attributes.py +0 -0
  50. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_file_utils.py +0 -0
  51. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_fixture_archive_unused_files.py +0 -0
  52. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_fixture_archive_unused_images.py +0 -0
  53. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_fixture_check_scannability.py +0 -0
  54. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_parse_exclude_list.py +0 -0
  55. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_replace_link_attributes.py +0 -0
  56. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_symlink_handling.py +0 -0
  57. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_table_callout_conversion.py +0 -0
  58. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_table_parser.py +0 -0
  59. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_topic_map_parser.py +0 -0
  60. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_unused_attributes.py +0 -0
  61. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_validate_links.py +0 -0
  62. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/tests/test_version_check.py +0 -0
  63. {rolfedh_doc_utils-0.1.30 → rolfedh_doc_utils-0.1.32}/validate_links.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rolfedh-doc-utils
3
- Version: 0.1.30
3
+ Version: 0.1.32
4
4
  Summary: CLI tools for AsciiDoc documentation projects
5
5
  Author: Rolfe Dlugy-Hegwer
6
6
  License: MIT License
@@ -16,7 +16,7 @@ class BulletListConverter:
16
16
  USER_VALUE_PATTERN = re.compile(r'(?<!<)<([a-zA-Z][^>]*)>')
17
17
 
18
18
  @staticmethod
19
- def convert(callout_groups: List[CalloutGroup], explanations: Dict[int, Callout]) -> List[str]:
19
+ def convert(callout_groups: List[CalloutGroup], explanations: Dict[int, Callout], table_title: str = "") -> List[str]:
20
20
  """
21
21
  Create bulleted list from callout groups and explanations.
22
22
 
@@ -33,12 +33,20 @@ class BulletListConverter:
33
33
 
34
34
  Args:
35
35
  callout_groups: List of CalloutGroup objects from code block
36
+ table_title: Optional table title (e.g., ".Descriptions of delete event")
37
+ Will be converted to lead-in sentence
36
38
  explanations: Dict mapping callout numbers to Callout objects
37
39
 
38
40
  Returns:
39
41
  List of strings representing the bulleted list
40
42
  """
41
- lines = [''] # Start with blank line before list
43
+ # Convert table title to lead-in sentence if present
44
+ if table_title:
45
+ # Remove leading dot and trailing period if present
46
+ title_text = table_title.lstrip('.').rstrip('.')
47
+ lines = [f'\n{title_text}:'] # Use colon for bulleted list lead-in
48
+ else:
49
+ lines = [''] # Start with blank line before list
42
50
 
43
51
  # Process each group (which may contain one or more callouts)
44
52
  for group in callout_groups:
@@ -16,7 +16,7 @@ class DefListConverter:
16
16
  USER_VALUE_PATTERN = re.compile(r'(?<!<)<([a-zA-Z][^>]*)>')
17
17
 
18
18
  @staticmethod
19
- def convert(callout_groups: List[CalloutGroup], explanations: Dict[int, Callout]) -> List[str]:
19
+ def convert(callout_groups: List[CalloutGroup], explanations: Dict[int, Callout], table_title: str = "") -> List[str]:
20
20
  """
21
21
  Create definition list from callout groups and explanations.
22
22
 
@@ -29,11 +29,19 @@ class DefListConverter:
29
29
  Args:
30
30
  callout_groups: List of CalloutGroup objects from code block
31
31
  explanations: Dict mapping callout numbers to Callout objects
32
+ table_title: Optional table title (e.g., ".Descriptions of delete event")
33
+ Will be converted to lead-in sentence (e.g., "Descriptions of delete event, where:")
32
34
 
33
35
  Returns:
34
36
  List of strings representing the definition list
35
37
  """
36
- lines = ['\nwhere:']
38
+ # Convert table title to lead-in sentence if present
39
+ if table_title:
40
+ # Remove leading dot and trailing period if present
41
+ title_text = table_title.lstrip('.').rstrip('.')
42
+ lines = [f'\n{title_text}, where:']
43
+ else:
44
+ lines = ['\nwhere:']
37
45
 
38
46
  # Process each group (which may contain one or more callouts)
39
47
  for group in callout_groups:
@@ -60,6 +60,8 @@ class CalloutDetector:
60
60
  def __init__(self):
61
61
  """Initialize detector with table parser."""
62
62
  self.table_parser = TableParser()
63
+ self.last_table_title = "" # Track title from most recent table extraction
64
+ self.last_table = None # Track last table found for validation diagnostics
63
65
 
64
66
  def find_code_blocks(self, lines: List[str]) -> List[CodeBlock]:
65
67
  """Find all code blocks in the document."""
@@ -181,6 +183,10 @@ class CalloutDetector:
181
183
 
182
184
  def _extract_from_table(self, table) -> Tuple[Dict[int, Callout], int]:
183
185
  """Extract callout explanations from a table format."""
186
+ # Store table for use by converters and validation
187
+ self.last_table = table
188
+ self.last_table_title = table.title if hasattr(table, 'title') else ""
189
+
184
190
  explanations = {}
185
191
  table_data = self.table_parser.extract_callout_explanations_from_table(table)
186
192
 
@@ -215,6 +221,10 @@ class CalloutDetector:
215
221
  Extract callout explanations from a 3-column table format.
216
222
  Format: Item (number) | Value | Description
217
223
  """
224
+ # Store table for use by converters and validation
225
+ self.last_table = table
226
+ self.last_table_title = table.title if hasattr(table, 'title') else ""
227
+
218
228
  explanations = {}
219
229
  table_data = self.table_parser.extract_3column_callout_explanations(table)
220
230
 
@@ -256,6 +266,10 @@ class CalloutDetector:
256
266
 
257
267
  def _extract_from_list(self, lines: List[str], start_line: int) -> Tuple[Dict[int, Callout], int]:
258
268
  """Extract callout explanations from list format (<1> text)."""
269
+ # Clear table data since list format doesn't have tables
270
+ self.last_table = None
271
+ self.last_table_title = ""
272
+
259
273
  explanations = {}
260
274
  i = start_line + 1 # Start after the closing delimiter
261
275
 
@@ -310,17 +324,33 @@ class CalloutDetector:
310
324
  cleaned.append(self.CALLOUT_WITH_COMMENT.sub('', line).rstrip())
311
325
  return cleaned
312
326
 
313
- def validate_callouts(self, callout_groups: List[CalloutGroup], explanations: Dict[int, Callout]) -> Tuple[bool, set, set]:
327
+ def validate_callouts(self, callout_groups: List[CalloutGroup], explanations: Dict[int, Callout]) -> Tuple[bool, List[int], List[int]]:
314
328
  """
315
329
  Validate that callout numbers in code match explanation numbers.
316
- Returns tuple of (is_valid, code_nums, explanation_nums).
330
+ Returns tuple of (is_valid, code_nums_list, explanation_nums_list).
331
+
332
+ Returns:
333
+ - is_valid: True if unique callout numbers match
334
+ - code_nums_list: List of callout numbers from code (unique, sorted)
335
+ - explanation_nums_list: List of callout numbers from explanations
336
+ (preserves duplicates if from table, sorted)
317
337
  """
318
- # Extract all callout numbers from groups
319
- code_nums = set()
338
+ # Extract unique callout numbers from code groups
339
+ code_nums_set = set()
320
340
  for group in callout_groups:
321
- code_nums.update(group.callout_numbers)
341
+ code_nums_set.update(group.callout_numbers)
342
+
343
+ # Get explanation numbers, preserving duplicates if from a table
344
+ if self.last_table:
345
+ # Use table parser to get raw callout numbers (with duplicates)
346
+ explanation_nums_list = self.table_parser.get_table_callout_numbers(self.last_table)
347
+ else:
348
+ # List format: dict keys are already unique
349
+ explanation_nums_list = list(explanations.keys())
350
+
351
+ explanation_nums_set = set(explanation_nums_list)
322
352
 
323
- explanation_nums = set(explanations.keys())
353
+ # Validation compares unique numbers only
354
+ is_valid = code_nums_set == explanation_nums_set
324
355
 
325
- is_valid = code_nums == explanation_nums
326
- return is_valid, code_nums, explanation_nums
356
+ return is_valid, sorted(code_nums_set), sorted(explanation_nums_list)
@@ -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,26 @@ 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)
59
80
 
60
81
  def _finalize_row_if_complete(self, current_row_cells, conditionals_before_row,
61
82
  conditionals_after_row, expected_columns, rows):
@@ -103,16 +124,27 @@ class TableParser:
103
124
  while i < len(lines):
104
125
  # Look for table delimiter
105
126
  if self.TABLE_DELIMITER.match(lines[i]):
106
- # Check if there are attributes on the line before
127
+ # Check for attributes and title before the table
107
128
  attributes = ""
129
+ title = ""
108
130
  start_line = i
109
131
 
132
+ # Check line before delimiter for attributes [cols="..."]
110
133
  if i > 0 and self.TABLE_START.match(lines[i - 1]):
111
134
  attributes = lines[i - 1]
112
135
  start_line = i - 1
113
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
+
114
146
  # Parse table content
115
- table = self._parse_table(lines, start_line, i)
147
+ table = self._parse_table(lines, start_line, i, title)
116
148
  if table:
117
149
  tables.append(table)
118
150
  i = table.end_line + 1
@@ -121,11 +153,13 @@ class TableParser:
121
153
 
122
154
  return tables
123
155
 
124
- 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]:
125
157
  """
126
158
  Parse a single table starting at the delimiter.
127
159
 
128
160
  AsciiDoc table format:
161
+ .Optional title
162
+ [optional attributes]
129
163
  |===
130
164
  |Cell1
131
165
  |Cell2
@@ -137,7 +171,15 @@ class TableParser:
137
171
  # Get attributes and parse column count
138
172
  attributes = ""
139
173
  if start_line < delimiter_line:
140
- attributes = lines[start_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]
141
183
 
142
184
  expected_columns = self._parse_column_count(attributes)
143
185
 
@@ -170,16 +212,13 @@ class TableParser:
170
212
  conditionals_after=conditionals_after_row.copy()
171
213
  ))
172
214
 
173
- # Get attributes if present
174
- attributes = ""
175
- if start_line < delimiter_line:
176
- attributes = lines[start_line]
177
-
215
+ # Get attributes if present (already extracted above)
178
216
  return AsciiDocTable(
179
217
  start_line=start_line,
180
218
  end_line=i,
181
219
  attributes=attributes,
182
- rows=rows
220
+ rows=rows,
221
+ title=title
183
222
  )
184
223
 
185
224
  # Check for conditional directives
@@ -230,19 +269,15 @@ class TableParser:
230
269
  i += 1
231
270
  continue
232
271
 
233
- # Check for cell separator (|)
272
+ # Check for cell separator (|) or cell type specifier (a|, s|, etc.)
234
273
  if self.CELL_SEPARATOR.match(line):
235
- # Check if this is a cell type specifier on its own line (e.g., "a|" or "s|")
236
- cell_content = line[1:].strip() # Remove leading | and whitespace
237
-
238
- # If line is just "a|", "s|", "h|", etc. (cell type specifier alone)
239
- if len(cell_content) == 2 and cell_content[0] in 'ashdmev' and cell_content[1] == '|':
240
- # This is a cell type specifier on its own line
241
- if cell_content[0] == 'a':
242
- in_asciidoc_cell = True
243
- # Don't create a cell yet - content comes on following lines
244
- i += 1
245
- continue
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
246
281
 
247
282
  # Save previous cell if exists
248
283
  if current_cell_lines:
@@ -260,16 +295,13 @@ class TableParser:
260
295
  conditionals_after_row, expected_columns, rows
261
296
  )
262
297
 
263
- # Extract cell content from this line (text after |)
264
- cell_content = line[1:] # Remove leading |
265
-
266
- # Check for inline cell type specifier (a|text, s|text, etc.)
267
- # Cell type specifiers are single characters followed by |
268
- if len(cell_content) > 0 and cell_content[0] in 'ashdmev' and len(cell_content) > 1 and cell_content[1] == '|':
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] == '|':
269
301
  # Track if this is an AsciiDoc cell (a|)
270
302
  if cell_content[0] == 'a':
271
303
  in_asciidoc_cell = True
272
- cell_content = cell_content[2:] # Remove type specifier and second |
304
+ cell_content = cell_content[2:] # Remove type specifier and |
273
305
 
274
306
  cell_content = cell_content.strip()
275
307
 
@@ -350,6 +382,8 @@ class TableParser:
350
382
  """
351
383
  Determine if a table is a callout explanation table.
352
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.
353
387
  """
354
388
  if not table.rows:
355
389
  return False
@@ -358,15 +392,23 @@ class TableParser:
358
392
  if not all(len(row.cells) == 2 for row in table.rows):
359
393
  return False
360
394
 
361
- # Check if first cell of each row is a callout number
362
- 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:
363
404
  first_cell = row.cells[0]
364
405
  if not first_cell.content:
365
406
  return False
366
407
 
367
- # First line of first cell should be a callout number
408
+ # First line of first cell should be a callout number or plain number
368
409
  first_line = first_cell.content[0].strip()
369
- if not self.CALLOUT_NUMBER.match(first_line):
410
+ is_match, _ = self._is_callout_or_number(first_line)
411
+ if not is_match:
370
412
  return False
371
413
 
372
414
  return True
@@ -375,23 +417,36 @@ class TableParser:
375
417
  """
376
418
  Check if table has a header row.
377
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
378
424
  """
379
425
  if not table.rows:
380
426
  return False
381
427
 
382
428
  first_row = table.rows[0]
383
- if not first_row.cells:
429
+ if not first_row.cells or len(first_row.cells) < 2:
384
430
  return False
385
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
+
386
440
  # Collect text from all cells in first row
387
441
  header_text = ' '.join(
388
442
  cell.content[0] if cell.content else ''
389
443
  for cell in first_row.cells
390
444
  ).lower()
391
445
 
392
- # Check for common header keywords
446
+ # Check for common header keywords (as whole words)
393
447
  header_keywords = ['item', 'description', 'value', 'column', 'parameter', 'field', 'name']
394
- 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)
395
450
 
396
451
  def is_3column_callout_table(self, table: AsciiDocTable) -> bool:
397
452
  """
@@ -417,19 +472,52 @@ class TableParser:
417
472
  if not all(len(row.cells) == 3 for row in data_rows):
418
473
  return False
419
474
 
420
- # 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>...)
421
476
  for row in data_rows:
422
477
  first_cell = row.cells[0]
423
478
  if not first_cell.content:
424
479
  return False
425
480
 
426
- # First line of first cell should be a number
481
+ # First line of first cell should be a callout number or plain number
427
482
  first_line = first_cell.content[0].strip()
428
- if not first_line.isdigit():
483
+ is_match, _ = self._is_callout_or_number(first_line)
484
+ if not is_match:
429
485
  return False
430
486
 
431
487
  return True
432
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
+
433
521
  def extract_callout_explanations_from_table(self, table: AsciiDocTable) -> Dict[int, Tuple[List[str], List[str]]]:
434
522
  """
435
523
  Extract callout explanations from a table.
@@ -437,24 +525,32 @@ class TableParser:
437
525
 
438
526
  The conditionals list includes any ifdef/ifndef/endif statements that should
439
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.
440
534
  """
441
535
  explanations = {}
442
536
 
443
- 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:
444
542
  if len(row.cells) != 2:
445
543
  continue
446
544
 
447
545
  callout_cell = row.cells[0]
448
546
  explanation_cell = row.cells[1]
449
547
 
450
- # Extract callout number
548
+ # Extract callout number (supports both <1> and 1 formats)
451
549
  first_line = callout_cell.content[0].strip()
452
- match = self.CALLOUT_NUMBER.match(first_line)
453
- if not match:
550
+ is_match, callout_num = self._is_callout_or_number(first_line)
551
+ if not is_match:
454
552
  continue
455
553
 
456
- callout_num = int(match.group(1))
457
-
458
554
  # Collect explanation lines, preserving blank lines and conditionals inline
459
555
  # Blank lines will need to become continuation markers (+) in definition lists
460
556
  explanation_lines = []
@@ -478,12 +574,14 @@ class TableParser:
478
574
  Returns dict mapping callout number to tuple of (value_lines, description_lines, conditionals).
479
575
 
480
576
  Format: Item | Value | Description
481
- - Item: Number (1, 2, 3...) corresponding to callout number
577
+ - Item: Number (1, 2, 3...) or callout (<1>, <2>...) corresponding to callout number
482
578
  - Value: The code/value being explained
483
579
  - Description: Explanation text
484
580
 
485
581
  The conditionals list includes any ifdef/ifndef/endif statements that should
486
582
  be preserved when converting the table to other formats.
583
+
584
+ Accepts both callout format (<1>) and plain numbers (1).
487
585
  """
488
586
  explanations = {}
489
587
 
@@ -499,16 +597,15 @@ class TableParser:
499
597
  value_cell = row.cells[1]
500
598
  desc_cell = row.cells[2]
501
599
 
502
- # Extract item number (maps to callout number)
600
+ # Extract item number (maps to callout number) - supports both <1> and 1 formats
503
601
  if not item_cell.content:
504
602
  continue
505
603
 
506
604
  item_num_str = item_cell.content[0].strip()
507
- if not item_num_str.isdigit():
605
+ is_match, callout_num = self._is_callout_or_number(item_num_str)
606
+ if not is_match:
508
607
  continue
509
608
 
510
- callout_num = int(item_num_str)
511
-
512
609
  # Collect value lines (column 2), preserving all content including conditionals
513
610
  value_lines = []
514
611
  for line in value_cell.content:
@@ -539,8 +636,12 @@ class TableParser:
539
636
  Returns:
540
637
  AsciiDocTable if a callout table is found, None otherwise
541
638
  """
542
- # Skip blank lines and continuation markers after code block
639
+ # Skip the closing delimiter of the code block (----, ...., etc.)
543
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
544
645
  while i < len(lines) and (not lines[i].strip() or lines[i].strip() == '+'):
545
646
  i += 1
546
647
 
@@ -562,12 +663,26 @@ class TableParser:
562
663
 
563
664
  # Check for table delimiter
564
665
  if self.TABLE_DELIMITER.match(line):
565
- # Found a table, parse it
666
+ # Found a table, extract attributes and title
667
+ attributes = ""
668
+ title = ""
566
669
  start_line = j
670
+
671
+ # Check line before delimiter for attributes [cols="..."]
567
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()
568
683
  start_line = j - 1
569
684
 
570
- table = self._parse_table(lines, start_line, j)
685
+ table = self._parse_table(lines, start_line, j, title)
571
686
  if table and (self.is_callout_table(table) or self.is_3column_callout_table(table)):
572
687
  return table
573
688
 
@@ -20,6 +20,9 @@ from callout_lib import (
20
20
  CodeBlock,
21
21
  )
22
22
 
23
+ # Import version
24
+ from doc_utils.version import __version__
25
+
23
26
 
24
27
  # Colors for output
25
28
  class Colors:
@@ -307,7 +310,20 @@ class InteractiveCalloutConverter:
307
310
  explanations, explanation_end = self.detector.extract_callout_explanations(new_lines, block.end_line)
308
311
 
309
312
  if not explanations:
310
- print_colored(f"Warning: No explanations found for block at line {block.start_line + 1}", Colors.YELLOW)
313
+ # Get callout numbers for warning message
314
+ all_callout_nums = []
315
+ for group in callout_groups:
316
+ all_callout_nums.extend(group.callout_numbers)
317
+
318
+ warning_msg = (
319
+ f"WARNING: {input_file.name} line {block.start_line + 1}: "
320
+ f"Code block has callouts {sorted(set(all_callout_nums))} but no explanations found after it. "
321
+ f"This may indicate: explanations are shared with another code block, "
322
+ f"explanations are in an unexpected location, or documentation error (missing explanations). "
323
+ f"Consider reviewing this block manually."
324
+ )
325
+ print_colored(warning_msg, Colors.YELLOW)
326
+ self.warnings.append(warning_msg)
311
327
  continue
312
328
 
313
329
  # Validate
@@ -332,9 +348,9 @@ class InteractiveCalloutConverter:
332
348
  else:
333
349
  converted_content = self.detector.remove_callouts_from_code(block.content)
334
350
  if format_choice == 'bullets':
335
- output_list = BulletListConverter.convert(callout_groups, explanations)
351
+ output_list = BulletListConverter.convert(callout_groups, explanations, self.detector.last_table_title)
336
352
  else: # deflist
337
- output_list = DefListConverter.convert(callout_groups, explanations)
353
+ output_list = DefListConverter.convert(callout_groups, explanations, self.detector.last_table_title)
338
354
 
339
355
  # Replace in document
340
356
  has_source_prefix = self.detector.CODE_BLOCK_START.match(new_lines[block.start_line])
@@ -431,6 +447,11 @@ Examples:
431
447
  """
432
448
  )
433
449
 
450
+ parser.add_argument(
451
+ '--version',
452
+ action='version',
453
+ version=f'%(prog)s {__version__}'
454
+ )
434
455
  parser.add_argument(
435
456
  'path',
436
457
  nargs='?',
@@ -521,6 +542,8 @@ Examples:
521
542
  print_colored(f"\n⚠ {len(converter.warnings)} Warning(s):", Colors.YELLOW)
522
543
  for warning in converter.warnings:
523
544
  print_colored(f" {warning}", Colors.YELLOW)
545
+ print()
546
+ print_colored("Suggestion: Fix the callout mismatches in the files above and rerun this command.", Colors.YELLOW)
524
547
 
525
548
  if args.dry_run and files_modified > 0:
526
549
  print_colored("\nDRY RUN - No files were modified", Colors.YELLOW)