alt-text-llm 0.1.0__tar.gz → 1.0__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.
Potentially problematic release.
This version of alt-text-llm might be problematic. Click here for more details.
- {alt_text_llm-0.1.0/alt_text_llm.egg-info → alt_text_llm-1.0}/PKG-INFO +11 -3
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/README.md +9 -2
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/alt_text_llm/__init__.py +2 -1
- alt_text_llm-1.0/alt_text_llm/apply.py +510 -0
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/alt_text_llm/label.py +7 -12
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/alt_text_llm/main.py +29 -9
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/alt_text_llm/scan.py +3 -0
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/alt_text_llm/utils.py +2 -1
- {alt_text_llm-0.1.0 → alt_text_llm-1.0/alt_text_llm.egg-info}/PKG-INFO +11 -3
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/alt_text_llm.egg-info/SOURCES.txt +3 -0
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/alt_text_llm.egg-info/requires.txt +1 -0
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/pyproject.toml +10 -2
- alt_text_llm-1.0/tests/test_apply.py +940 -0
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/tests/test_generate.py +24 -29
- alt_text_llm-1.0/tests/test_helpers.py +89 -0
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/tests/test_label.py +101 -76
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/tests/test_scan.py +14 -27
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/tests/test_utils.py +111 -141
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/LICENSE +0 -0
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/MANIFEST.in +0 -0
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/alt_text_llm/generate.py +0 -0
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/alt_text_llm.egg-info/dependency_links.txt +0 -0
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/alt_text_llm.egg-info/entry_points.txt +0 -0
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/alt_text_llm.egg-info/top_level.txt +0 -0
- {alt_text_llm-0.1.0 → alt_text_llm-1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alt-text-llm
|
|
3
|
-
Version:
|
|
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
|
-
###
|
|
38
|
+
### From PyPI
|
|
31
39
|
|
|
32
40
|
```bash
|
|
33
|
-
pip install
|
|
41
|
+
pip install alt-text-llm
|
|
34
42
|
```
|
|
35
43
|
|
|
36
44
|
### Automated setup (includes system dependencies)
|
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
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)).
|
|
4
4
|
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Intelligent scanning** - Detects images/videos missing meaningful alt text (ignores empty `alt=""`)
|
|
8
|
+
- **AI-powered generation** - Uses LLM of your choice to create context-aware alt text suggestions
|
|
9
|
+
- **Interactive labeling** - Manually review and edit LLM suggestions. Images display directly in your terminal
|
|
10
|
+
- **Automatic application** - Apply approved captions back to your markdown files
|
|
11
|
+
|
|
5
12
|
## Installation
|
|
6
13
|
|
|
7
|
-
###
|
|
14
|
+
### From PyPI
|
|
8
15
|
|
|
9
16
|
```bash
|
|
10
|
-
pip install
|
|
17
|
+
pip install alt-text-llm
|
|
11
18
|
```
|
|
12
19
|
|
|
13
20
|
### Automated setup (includes system dependencies)
|
|
@@ -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("<", "<")
|
|
44
|
+
alt_text = alt_text.replace(">", ">")
|
|
45
|
+
alt_text = alt_text.replace('"', """)
|
|
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: 
|
|
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"", 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
|
+
)
|
|
@@ -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)
|
|
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.
|
|
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
|
|
326
|
-
|
|
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]"
|
|
@@ -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
|
-
|
|
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
|
|