alt-text-llm 0.1.0__py3-none-any.whl → 1.0__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.

Potentially problematic release.


This version of alt-text-llm might be problematic. Click here for more details.

alt_text_llm/__init__.py CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  __version__ = "0.1.0"
4
4
 
5
- from alt_text_llm import generate, label, main, scan, utils
5
+ from . import apply, generate, label, main, scan, utils
6
6
 
7
7
  __all__ = [
8
+ "apply",
8
9
  "generate",
9
10
  "label",
10
11
  "main",
alt_text_llm/apply.py ADDED
@@ -0,0 +1,510 @@
1
+ """Apply labeled alt text to markdown files."""
2
+
3
+ import json
4
+ import re
5
+ from collections import defaultdict
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+ from rich.text import Text
10
+
11
+ from alt_text_llm import utils
12
+
13
+
14
+ def _escape_markdown_alt_text(alt_text: str) -> str:
15
+ """
16
+ Escape special characters in alt text for markdown.
17
+
18
+ Args:
19
+ alt_text: The alt text to escape
20
+
21
+ Returns:
22
+ Escaped alt text safe for markdown
23
+ """
24
+ # Escape backslashes first to avoid double-escaping
25
+ alt_text = alt_text.replace("\\", "\\\\")
26
+ # Escape dollar signs to prevent LaTeX interpretation
27
+ alt_text = alt_text.replace("$", "\\$")
28
+ return alt_text
29
+
30
+
31
+ def _escape_html_alt_text(alt_text: str) -> str:
32
+ """
33
+ Escape special characters in alt text for HTML.
34
+
35
+ Args:
36
+ alt_text: The alt text to escape
37
+
38
+ Returns:
39
+ Escaped alt text safe for HTML attributes
40
+ """
41
+ # Escape HTML special characters
42
+ alt_text = alt_text.replace("&", "&")
43
+ alt_text = alt_text.replace("<", "&lt;")
44
+ alt_text = alt_text.replace(">", "&gt;")
45
+ alt_text = alt_text.replace('"', "&quot;")
46
+ return alt_text
47
+
48
+
49
+ def _apply_markdown_image_alt(
50
+ line: str, asset_path: str, new_alt: str
51
+ ) -> tuple[str, str | None]:
52
+ """
53
+ Apply alt text to a markdown image syntax.
54
+
55
+ Args:
56
+ line: The line containing the image
57
+ asset_path: The asset path to match
58
+ new_alt: The new alt text to apply
59
+
60
+ Returns:
61
+ Tuple of (modified line, old alt text or None)
62
+ """
63
+ # Match markdown image syntax: ![alt](path)
64
+ # Need to escape special regex chars in asset_path
65
+ escaped_path = re.escape(asset_path)
66
+ pattern = rf"!\[([^\]]*)\]\({escaped_path}\s*\)"
67
+
68
+ match = re.search(pattern, line)
69
+ if not match:
70
+ return line, None
71
+
72
+ old_alt = match.group(1) if match.group(1) else None
73
+ # Escape special characters in alt text
74
+ escaped_alt = _escape_markdown_alt_text(new_alt)
75
+ # Replace the alt text - use lambda to avoid backslash interpretation in replacement
76
+ new_line = re.sub(
77
+ pattern, lambda m: f"![{escaped_alt}]({asset_path})", line, count=1
78
+ )
79
+ return new_line, old_alt
80
+
81
+
82
+ def _apply_html_image_alt(
83
+ line: str, asset_path: str, new_alt: str
84
+ ) -> tuple[str, str | None]:
85
+ """
86
+ Apply alt text to an HTML img tag.
87
+
88
+ Args:
89
+ line: The line containing the img tag
90
+ asset_path: The asset path to match
91
+ new_alt: The new alt text to apply
92
+
93
+ Returns:
94
+ Tuple of (modified line, old alt text or None)
95
+ """
96
+ # Escape special regex chars in asset_path
97
+ escaped_path = re.escape(asset_path)
98
+
99
+ # Match img tag with this src (handles both > and /> endings)
100
+ # Capture group 1: attributes, Group 2: whitespace before closing, Group 3: closing slash
101
+ img_pattern = rf'<img\s+([^>]*src="{escaped_path}"[^/>]*?)(\s*)(/?)>'
102
+
103
+ match = re.search(img_pattern, line, re.IGNORECASE | re.DOTALL)
104
+ if not match:
105
+ return line, None
106
+
107
+ img_attrs = match.group(1).rstrip() # Remove trailing whitespace
108
+ old_alt: str | None = None
109
+ whitespace_before_close = match.group(2) # Whitespace before closing
110
+ closing_slash = match.group(3) # Either "/" or ""
111
+
112
+ # Check if alt attribute exists
113
+ alt_pattern = r'alt="([^"]*)"'
114
+ alt_match = re.search(alt_pattern, img_attrs, re.IGNORECASE)
115
+
116
+ # Escape special characters in alt text for HTML
117
+ escaped_alt = _escape_html_alt_text(new_alt)
118
+
119
+ if alt_match:
120
+ old_alt = alt_match.group(1)
121
+ # Replace existing alt - use lambda to avoid backslash interpretation
122
+ new_attrs = re.sub(
123
+ alt_pattern,
124
+ lambda m: f'alt="{escaped_alt}"',
125
+ img_attrs,
126
+ count=1,
127
+ flags=re.IGNORECASE,
128
+ )
129
+ else:
130
+ # Add alt attribute (insert before src or at the end)
131
+ # Use lambda to avoid backslash interpretation in replacement
132
+ new_attrs = re.sub(
133
+ rf'(src="{escaped_path}")',
134
+ lambda m: f'alt="{escaped_alt}" {m.group(1)}',
135
+ img_attrs,
136
+ count=1,
137
+ flags=re.IGNORECASE,
138
+ )
139
+
140
+ # Reconstruct the img tag with proper closing, preserving original whitespace
141
+ old_tag = f"<img {img_attrs}{whitespace_before_close}{closing_slash}>"
142
+ new_tag = f"<img {new_attrs}{whitespace_before_close}{closing_slash}>"
143
+ new_line = line.replace(old_tag, new_tag)
144
+ return new_line, old_alt
145
+
146
+
147
+ def _apply_wikilink_image_alt(
148
+ line: str, asset_path: str, new_alt: str
149
+ ) -> tuple[str, str | None]:
150
+ """
151
+ Apply alt text to a wikilink-style image syntax (e.g. Obsidian).
152
+
153
+ Args:
154
+ line: The line containing the image
155
+ asset_path: The asset path to match
156
+ new_alt: The new alt text to apply
157
+
158
+ Returns:
159
+ (modified line, old alt text or None)
160
+ """
161
+ # Match wikilink image syntax: ![[path]] or ![[path|alt]]
162
+ # Need to escape special regex chars in asset_path
163
+ escaped_path = re.escape(asset_path)
164
+ pattern = rf"!\[\[{escaped_path}(?:\|([^\]]*))?\]\]"
165
+
166
+ match = re.search(pattern, line)
167
+ if not match:
168
+ return line, None
169
+
170
+ old_alt = match.group(1) if match.group(1) else None
171
+ # Escape special characters in alt text (wikilinks are still markdown)
172
+ escaped_alt = _escape_markdown_alt_text(new_alt)
173
+ # Replace with new alt text - use lambda to avoid backslash interpretation
174
+ new_line = re.sub(
175
+ pattern, lambda m: f"![[{asset_path}|{escaped_alt}]]", line, count=1
176
+ )
177
+ return new_line, old_alt
178
+
179
+
180
+ def _display_unused_entries(
181
+ unused_entries: set[tuple[str, str]], console: Console
182
+ ) -> None:
183
+ if not unused_entries:
184
+ return
185
+
186
+ console.print(
187
+ f"[yellow]Note: {len(unused_entries)} {'entry' if len(unused_entries) == 1 else 'entries'} without 'final_alt' will be skipped:[/yellow]"
188
+ )
189
+ for markdown_file, asset_basename in sorted(unused_entries):
190
+ console.print(f"[dim] {markdown_file}: {asset_basename}[/dim]")
191
+
192
+
193
+ def _read_file_lines(md_path: Path) -> tuple[str, list[str]]:
194
+ """
195
+ Read a file and split it into lines.
196
+
197
+ Args:
198
+ md_path: Path to the markdown file
199
+
200
+ Returns:
201
+ Tuple of (original text, list of lines)
202
+ """
203
+ source_text = md_path.read_text(encoding="utf-8")
204
+ lines = source_text.splitlines()
205
+ return source_text, lines
206
+
207
+
208
+ def _try_all_image_formats(
209
+ line: str, asset_path: str, new_alt: str
210
+ ) -> tuple[str, str | None]:
211
+ """
212
+ Try applying alt text to all supported image formats.
213
+
214
+ Args:
215
+ line: The line to modify
216
+ asset_path: The asset path to match
217
+ new_alt: The new alt text to apply
218
+
219
+ Returns:
220
+ Tuple of (modified line, old alt text or None)
221
+ """
222
+ # Normalize alt text by replacing line breaks with ellipses
223
+ # Use + to collapse multiple consecutive line breaks into one ellipsis
224
+ normalized_alt = re.sub(r"(\r\n|\r|\n)+", " ... ", new_alt)
225
+
226
+ # Try markdown image first
227
+ modified_line, old_alt = _apply_markdown_image_alt(
228
+ line, asset_path, normalized_alt
229
+ )
230
+
231
+ # If no change, try wikilink image
232
+ if modified_line == line:
233
+ modified_line, old_alt = _apply_wikilink_image_alt(
234
+ line, asset_path, normalized_alt
235
+ )
236
+
237
+ # If no change, try HTML image
238
+ if modified_line == line:
239
+ modified_line, old_alt = _apply_html_image_alt(
240
+ line, asset_path, normalized_alt
241
+ )
242
+
243
+ return modified_line, old_alt
244
+
245
+
246
+ def _write_modified_lines(
247
+ md_path: Path, lines: list[str], original_text: str, dry_run: bool
248
+ ) -> None:
249
+ """
250
+ Write modified lines back to file.
251
+
252
+ Args:
253
+ md_path: Path to the markdown file
254
+ lines: Modified lines to write
255
+ original_text: Original file text (to preserve trailing newline)
256
+ dry_run: If True, don't actually write to file
257
+ """
258
+ if dry_run:
259
+ return
260
+
261
+ new_content = "\n".join(lines)
262
+ # Preserve trailing newline if original had one
263
+ if original_text.endswith("\n"):
264
+ new_content += "\n"
265
+ md_path.write_text(new_content, encoding="utf-8")
266
+
267
+
268
+ def _apply_caption_to_file(
269
+ md_path: Path,
270
+ caption_item: utils.AltGenerationResult,
271
+ console: Console,
272
+ dry_run: bool = False,
273
+ ) -> tuple[str | None, str] | None:
274
+ """
275
+ Apply a caption to all instances of an asset in a markdown file.
276
+
277
+ Args:
278
+ md_path: Path to the markdown file
279
+ caption_item: The AltGenerationResult with final_alt to apply
280
+ console: Rich console for output
281
+ dry_run: If True, don't actually modify files
282
+
283
+ Returns:
284
+ Tuple of (old_alt, new_alt) if successful, None otherwise
285
+ """
286
+ assert caption_item.final_alt is not None, "final_alt must be set"
287
+
288
+ source_text, lines = _read_file_lines(md_path)
289
+
290
+ modified_count = 0
291
+ last_old_alt: str | None = None
292
+
293
+ # Search all lines for the asset and replace
294
+ for line_idx, original_line in enumerate(lines):
295
+ modified_line, old_alt = _try_all_image_formats(
296
+ original_line, caption_item.asset_path, caption_item.final_alt
297
+ )
298
+
299
+ if modified_line != original_line:
300
+ lines[line_idx] = modified_line
301
+ modified_count += 1
302
+ last_old_alt = old_alt
303
+
304
+ if modified_count == 0:
305
+ console.print(
306
+ f"[orange]Warning: Could not find asset '{caption_item.asset_path}' in {md_path}[/orange]"
307
+ )
308
+ return None
309
+
310
+ _write_modified_lines(md_path, lines, source_text, dry_run)
311
+ return (last_old_alt, caption_item.final_alt)
312
+
313
+
314
+ def _load_and_parse_captions(
315
+ captions_path: Path,
316
+ ) -> tuple[list[utils.AltGenerationResult], set[tuple[str, str]]]:
317
+ """
318
+ Load captions from JSON and parse into AltGenerationResult objects.
319
+
320
+ Args:
321
+ captions_path: Path to the captions JSON file
322
+
323
+ Returns:
324
+ Tuple of (captions to apply, unused entries)
325
+ """
326
+ with open(captions_path, encoding="utf-8") as f:
327
+ captions_data = json.load(f)
328
+
329
+ captions_to_apply: list[utils.AltGenerationResult] = []
330
+ unused_entries: set[tuple[str, str]] = set()
331
+
332
+ for item in captions_data:
333
+ if item.get("final_alt") and item.get("final_alt").strip():
334
+ captions_to_apply.append(
335
+ utils.AltGenerationResult(
336
+ markdown_file=item["markdown_file"],
337
+ asset_path=item["asset_path"],
338
+ suggested_alt=item["suggested_alt"],
339
+ model=item["model"],
340
+ context_snippet=item["context_snippet"],
341
+ line_number=int(item["line_number"]),
342
+ final_alt=item["final_alt"],
343
+ )
344
+ )
345
+ else:
346
+ unused_entries.add(
347
+ (
348
+ item["markdown_file"],
349
+ Path(item["asset_path"]).name,
350
+ )
351
+ )
352
+
353
+ return captions_to_apply, unused_entries
354
+
355
+
356
+ def _group_captions_by_file(
357
+ captions: list[utils.AltGenerationResult],
358
+ ) -> dict[str, list[utils.AltGenerationResult]]:
359
+ """
360
+ Group captions by their markdown file.
361
+
362
+ Args:
363
+ captions: List of captions to group
364
+
365
+ Returns:
366
+ Dictionary mapping file paths to lists of captions
367
+ """
368
+ by_file: dict[str, list[utils.AltGenerationResult]] = defaultdict(list)
369
+ for item in captions:
370
+ by_file[item.markdown_file].append(item)
371
+ return by_file
372
+
373
+
374
+ def _display_caption_result(
375
+ result: tuple[str | None, str],
376
+ item: utils.AltGenerationResult,
377
+ console: Console,
378
+ dry_run: bool,
379
+ ) -> None:
380
+ """
381
+ Display the result of applying a caption.
382
+
383
+ Args:
384
+ result: Tuple of (old_alt, new_alt)
385
+ item: The caption item that was applied
386
+ console: Rich console for output
387
+ dry_run: Whether this is a dry run
388
+ """
389
+ old_alt, new_alt = result
390
+ status = "Would apply" if dry_run else "Applied"
391
+ old_text = f'"{old_alt}"' if old_alt else "(no alt)"
392
+
393
+ # Build message with Text to avoid markup parsing issues
394
+ message = Text(" ")
395
+ message.append(f"{status}:", style="green")
396
+ message.append(f' {old_text} → "{new_alt}"')
397
+ console.print(message)
398
+
399
+
400
+ def _process_file_captions(
401
+ md_path: Path,
402
+ items: list[utils.AltGenerationResult],
403
+ console: Console,
404
+ dry_run: bool,
405
+ ) -> int:
406
+ """
407
+ Process all captions for a single file.
408
+
409
+ Args:
410
+ md_path: Path to the markdown file
411
+ items: List of captions to apply to this file
412
+ console: Rich console for output
413
+ dry_run: If True, don't actually modify files
414
+
415
+ Returns:
416
+ Number of successfully applied captions
417
+ """
418
+ if not md_path.exists():
419
+ console.print(f"[yellow]Warning: File not found: {md_path}[/yellow]")
420
+ return 0
421
+
422
+ console.print(f"\n[dim]Processing {md_path} ({len(items)} captions)[/dim]")
423
+
424
+ applied_count = 0
425
+ for item in items:
426
+ result = _apply_caption_to_file(
427
+ md_path=md_path,
428
+ caption_item=item,
429
+ console=console,
430
+ dry_run=dry_run,
431
+ )
432
+
433
+ if result:
434
+ applied_count += 1
435
+ _display_caption_result(result, item, console, dry_run)
436
+
437
+ return applied_count
438
+
439
+
440
+ def apply_captions(
441
+ captions_path: Path,
442
+ console: Console,
443
+ dry_run: bool = False,
444
+ ) -> int:
445
+ """
446
+ Apply captions from a JSON file to markdown files.
447
+
448
+ Args:
449
+ captions_path: Path to the captions JSON file
450
+ console: Rich console for output
451
+ dry_run: If True, show what would be done without modifying files
452
+
453
+ Returns:
454
+ Number of successfully applied captions
455
+ """
456
+ captions_to_apply, unused_entries = _load_and_parse_captions(captions_path)
457
+
458
+ _display_unused_entries(unused_entries, console)
459
+
460
+ if not captions_to_apply:
461
+ console.print(
462
+ f"[yellow]No captions with 'final_alt' found in {captions_path}[/yellow]"
463
+ )
464
+ return 0
465
+
466
+ console.print(
467
+ f"[blue]Found {len(captions_to_apply)} captions to apply{' (dry run)' if dry_run else ''}[/blue]"
468
+ )
469
+
470
+ by_file = _group_captions_by_file(captions_to_apply)
471
+
472
+ applied_count = 0
473
+ for md_file, items in by_file.items():
474
+ md_path = Path(md_file)
475
+ applied_count += _process_file_captions(
476
+ md_path, items, console, dry_run
477
+ )
478
+
479
+ return applied_count
480
+
481
+
482
+ def apply_from_captions_file(
483
+ captions_file: Path, dry_run: bool = False
484
+ ) -> None:
485
+ """
486
+ Load captions from file and apply them to markdown files.
487
+
488
+ Args:
489
+ captions_file: Path to the captions JSON file
490
+ dry_run: If True, show what would be done without modifying files
491
+ """
492
+ console = Console()
493
+
494
+ if not captions_file.exists():
495
+ console.print(
496
+ f"[red]Error: Captions file not found: {captions_file}[/red]"
497
+ )
498
+ return
499
+
500
+ applied_count = apply_captions(captions_file, console, dry_run=dry_run)
501
+
502
+ # Summary
503
+ if dry_run:
504
+ console.print(
505
+ f"\n[blue]Dry run complete: {applied_count} captions would be applied[/blue]"
506
+ )
507
+ else:
508
+ console.print(
509
+ f"\n[green]Successfully applied {applied_count} captions[/green]"
510
+ )
alt_text_llm/label.py CHANGED
@@ -125,7 +125,7 @@ class DisplayManager:
125
125
  readline.parse_and_bind("set editing-mode vi")
126
126
  readline.set_startup_hook(lambda: readline.insert_text(suggestion))
127
127
  self.console.print(
128
- "\n[bold blue]Edit alt text (or press Enter to accept, 'undo' to go back):[/bold blue]"
128
+ "\n[bold blue]Edit alt text (or press Enter to accept, 'undo' to go back). Exiting will save your progress.[/bold blue]"
129
129
  )
130
130
  result = input("> ")
131
131
  readline.set_startup_hook(None)
@@ -138,7 +138,8 @@ class DisplayManager:
138
138
 
139
139
  def show_rule(self, title: str) -> None:
140
140
  """Display a separator rule."""
141
- self.console.rule(title)
141
+ self.console.print()
142
+ self.console.rule(f"[bold]Asset: {title}[/bold]")
142
143
 
143
144
  def show_error(self, error_message: str) -> None:
144
145
  """Display error message."""
@@ -296,6 +297,8 @@ def label_suggestions(
296
297
 
297
298
  try:
298
299
  _process_labeling_loop(session, display, console)
300
+ except KeyboardInterrupt:
301
+ console.print("\n[yellow]Saving progress...[/yellow]")
299
302
  finally:
300
303
  if session.processed_results:
301
304
  utils.write_output(
@@ -322,16 +325,8 @@ def label_from_suggestions_file(
322
325
 
323
326
  # Convert loaded data to AltGenerationResult, filtering out extra fields
324
327
  suggestions: list[utils.AltGenerationResult] = []
325
- for s in suggestions_from_file:
326
- filtered_data = {
327
- "markdown_file": s["markdown_file"],
328
- "asset_path": s["asset_path"],
329
- "suggested_alt": s["suggested_alt"],
330
- "model": s["model"],
331
- "context_snippet": s["context_snippet"],
332
- "line_number": int(s["line_number"]),
333
- }
334
- suggestions.append(utils.AltGenerationResult(**filtered_data))
328
+ for suggestion in suggestions_from_file:
329
+ suggestions.append(utils.AltGenerationResult(**suggestion))
335
330
 
336
331
  console.print(
337
332
  f"[green]Loaded {len(suggestions)} suggestions from {suggestions_file}[/green]"
alt_text_llm/main.py CHANGED
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
 
9
9
  from rich.console import Console
10
10
 
11
- from alt_text_llm import generate, label, scan, utils
11
+ from alt_text_llm import apply, generate, label, scan, utils
12
12
 
13
13
  _JSON_INDENT: int = 2
14
14
 
@@ -19,6 +19,7 @@ class Command(StrEnum):
19
19
  SCAN = "scan"
20
20
  GENERATE = "generate"
21
21
  LABEL = "label"
22
+ APPLY = "apply"
22
23
 
23
24
 
24
25
  def _scan_command(args: argparse.Namespace) -> None:
@@ -97,13 +98,6 @@ def _generate_command(args: argparse.Namespace) -> None:
97
98
  )
98
99
 
99
100
 
100
- def _label_command(args: argparse.Namespace) -> None:
101
- """Execute the label sub-command."""
102
- label.label_from_suggestions_file(
103
- args.suggestions_file, args.output, args.skip_existing, args.vi_mode
104
- )
105
-
106
-
107
101
  def _parse_args() -> argparse.Namespace:
108
102
  """Parse command-line arguments for all alt text workflows."""
109
103
  git_root = utils.get_git_root()
@@ -214,6 +208,25 @@ def _parse_args() -> argparse.Namespace:
214
208
  help="Enable vi keybindings for text editing (default: disabled)",
215
209
  )
216
210
 
211
+ # ---------------------------------------------------------------------------
212
+ # apply sub-command
213
+ # ---------------------------------------------------------------------------
214
+ apply_parser = subparsers.add_parser(
215
+ Command.APPLY, help="Apply labeled captions to markdown files"
216
+ )
217
+ apply_parser.add_argument(
218
+ "--captions-file",
219
+ type=Path,
220
+ default=git_root / "scripts" / "asset_captions.json",
221
+ help="Path to the captions JSON file with final_alt populated",
222
+ )
223
+ apply_parser.add_argument(
224
+ "--dry-run",
225
+ action="store_true",
226
+ default=False,
227
+ help="Show what would be changed without modifying files",
228
+ )
229
+
217
230
  return parser.parse_args()
218
231
 
219
232
 
@@ -226,7 +239,14 @@ def main() -> None:
226
239
  elif args.command == Command.GENERATE:
227
240
  _generate_command(args)
228
241
  elif args.command == Command.LABEL:
229
- _label_command(args)
242
+ label.label_from_suggestions_file(
243
+ args.suggestions_file,
244
+ args.output,
245
+ args.skip_existing,
246
+ args.vi_mode,
247
+ )
248
+ elif args.command == Command.APPLY:
249
+ apply.apply_from_captions_file(args.captions_file, args.dry_run)
230
250
  else:
231
251
  raise ValueError(f"Invalid command: {args.command}")
232
252
 
alt_text_llm/scan.py CHANGED
@@ -181,6 +181,9 @@ def _handle_html_asset(
181
181
 
182
182
  items: list[QueueItem] = []
183
183
  for src_attr, alt_text in _extract_html_img_info(token):
184
+ # In HTML, alt="" explicitly marks an image as decorative
185
+ if alt_text is not None and alt_text.strip() == "":
186
+ continue
184
187
  if _is_alt_meaningful(alt_text):
185
188
  continue
186
189
 
alt_text_llm/utils.py CHANGED
@@ -289,7 +289,7 @@ class AltGenerationResult:
289
289
  suggested_alt: str
290
290
  model: str
291
291
  context_snippet: str
292
- line_number: int
292
+ line_number: int | None = None
293
293
  final_alt: str | None = None
294
294
 
295
295
  def to_json(self) -> dict[str, object]:
@@ -465,6 +465,7 @@ def build_prompt(
465
465
  - Return only the alt text, no quotes
466
466
  - For text-heavy images: transcribe key text content, then describe visual elements
467
467
  - Don't reintroduce acronyms
468
+ - Don't use line breaks in the alt text
468
469
  - Don't describe purely visual elements unless directly relevant for
469
470
  understanding the content (e.g. don't say "the line in this scientific chart is green")
470
471
  - Describe spatial relationships and visual hierarchy when important
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alt-text-llm
3
- Version: 0.1.0
3
+ Version: 1.0
4
4
  Summary: AI-powered alt text generation and labeling tools for markdown content
5
5
  Author: TurnTrout
6
6
  License-Expression: MIT
@@ -19,18 +19,26 @@ Provides-Extra: dev
19
19
  Requires-Dist: pytest; extra == "dev"
20
20
  Requires-Dist: mypy; extra == "dev"
21
21
  Requires-Dist: types-requests; extra == "dev"
22
+ Requires-Dist: pytest-asyncio; extra == "dev"
22
23
  Dynamic: license-file
23
24
 
24
25
  # alt-text-llm
25
26
 
26
27
  AI-powered alt text generation and labeling tools for markdown content. Originally developed for [my website](https://turntrout.com/design) ([repo](https://github.com/alexander-turner/TurnTrout.com)).
27
28
 
29
+ ## Features
30
+
31
+ - **Intelligent scanning** - Detects images/videos missing meaningful alt text (ignores empty `alt=""`)
32
+ - **AI-powered generation** - Uses LLM of your choice to create context-aware alt text suggestions
33
+ - **Interactive labeling** - Manually review and edit LLM suggestions. Images display directly in your terminal
34
+ - **Automatic application** - Apply approved captions back to your markdown files
35
+
28
36
  ## Installation
29
37
 
30
- ### Quick install from GitHub
38
+ ### From PyPI
31
39
 
32
40
  ```bash
33
- pip install git+https://github.com/alexander-turner/alt-text-llm.git
41
+ pip install alt-text-llm
34
42
  ```
35
43
 
36
44
  ### Automated setup (includes system dependencies)
@@ -0,0 +1,13 @@
1
+ alt_text_llm/__init__.py,sha256=NeHM242YT29brn6QH_QN7aJ-A-MwoQS_YnWpZ9VDATU,231
2
+ alt_text_llm/apply.py,sha256=zgTob1bPQT5ZAKJtYziPPoMAiI0YeARG1qf6hwVRH6s,15168
3
+ alt_text_llm/generate.py,sha256=dYLQMzF9qS4cNoyH4v4_mIZZa2bWeqoVpXYBnw2zlu0,6550
4
+ alt_text_llm/label.py,sha256=KQ6jJnsWTnKqDJchCVUHbUURiLOpaqSW478niYaeT48,11168
5
+ alt_text_llm/main.py,sha256=klOZYcBYa8CENMBuScaQHXGWWWpq070BwVS-abkW4HQ,7911
6
+ alt_text_llm/scan.py,sha256=Uff4cru4MbWoUFyymrRRuXR1WRHXcQ6H1i9hvOh82bc,6380
7
+ alt_text_llm/utils.py,sha256=9bUm9BQ6IAyCfZKOg5Uii8zLFdYFZ8-lkfJdJmpyey8,16480
8
+ alt_text_llm-1.0.dist-info/licenses/LICENSE,sha256=VCpqtaN5u5ulLyhFHpAIKHfYLkMYubaYtpK2m1Bss6c,1085
9
+ alt_text_llm-1.0.dist-info/METADATA,sha256=XkmkCuCelWBQYEjB91nkuGI286CG-VymZMqDHQaxyXg,5373
10
+ alt_text_llm-1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ alt_text_llm-1.0.dist-info/entry_points.txt,sha256=SQyNVYF_LXPoleopqGrZOyR878rKcmGtUS9gIhNLRpY,56
12
+ alt_text_llm-1.0.dist-info/top_level.txt,sha256=SJh1xf4GM9seHJryaePMI469CUtALg30wM22vUIqnw4,13
13
+ alt_text_llm-1.0.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- alt_text_llm/__init__.py,sha256=vkNaW0Zx2C7JtXD9nG7NHFWBFYqYZ_iECgRtdJP4f5A,222
2
- alt_text_llm/generate.py,sha256=dYLQMzF9qS4cNoyH4v4_mIZZa2bWeqoVpXYBnw2zlu0,6550
3
- alt_text_llm/label.py,sha256=XvPINQfW-NFcxTbaa0rdaVKK2P6gE6UqrnIEDXV8T5k,11295
4
- alt_text_llm/main.py,sha256=CQsRnwP2u2Jca4Kdj73DBntjYND_OUd1nkKxHv4qwQs,7146
5
- alt_text_llm/scan.py,sha256=fOhfJb5rKLQejFaj1iCAu0vrqIe_bKx08jkeYXFGd-E,6233
6
- alt_text_llm/utils.py,sha256=4xMFXviMvVB4XXZdMN-VeUB1TefdjpNpWQsWVBYCWMA,16418
7
- alt_text_llm-0.1.0.dist-info/licenses/LICENSE,sha256=VCpqtaN5u5ulLyhFHpAIKHfYLkMYubaYtpK2m1Bss6c,1085
8
- alt_text_llm-0.1.0.dist-info/METADATA,sha256=MYgTZlNC_6a9br6fLi19DWcEamB-ahXIk2vkR_UVLHg,4978
9
- alt_text_llm-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
- alt_text_llm-0.1.0.dist-info/entry_points.txt,sha256=SQyNVYF_LXPoleopqGrZOyR878rKcmGtUS9gIhNLRpY,56
11
- alt_text_llm-0.1.0.dist-info/top_level.txt,sha256=SJh1xf4GM9seHJryaePMI469CUtALg30wM22vUIqnw4,13
12
- alt_text_llm-0.1.0.dist-info/RECORD,,