Skip to content

Commit aafc365

Browse files
Read ALT metadata from clipboard instead of generating captions (#101)
- update Image Alt Text Extractor to surface existing ALT metadata from pasted or clipboard images - remove on-device caption generation and report when no ALT text is present - improve status messages and clipboard paste handling while keeping image preview and copy actions ------ [Codex Task](https://chatgpt.com/codex/tasks/task_e_694912ed0aac8325942edd57067bf29a)
1 parent 863a06b commit aafc365

2 files changed

Lines changed: 367 additions & 0 deletions

File tree

image-alt-text-extractor.docs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Surface the original ALT text that came with an image you paste, drop, or grab directly from your clipboard, and let you know when no ALT metadata is available.

image-alt-text-extractor.html

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
<title>Image Alt Text Extractor</title>
8+
<link rel="stylesheet" href="styles.css">
9+
<style>
10+
body {
11+
max-width: 960px;
12+
margin: 0 auto;
13+
padding: 24px 20px 48px;
14+
}
15+
16+
.page-header {
17+
display: flex;
18+
flex-direction: column;
19+
gap: 0.5rem;
20+
}
21+
22+
.site-link {
23+
font-weight: 600;
24+
color: var(--foreground-subtle);
25+
text-decoration: none;
26+
}
27+
28+
.site-link:hover,
29+
.site-link:focus-visible {
30+
color: var(--foreground);
31+
}
32+
33+
main {
34+
display: grid;
35+
gap: 1.25rem;
36+
}
37+
38+
.panel {
39+
padding: clamp(1.25rem, 3vw, 2rem);
40+
}
41+
42+
.input-grid {
43+
display: grid;
44+
gap: 1rem;
45+
}
46+
47+
.drop-zone {
48+
border: 2px dashed var(--color-border, #d0d7de);
49+
border-radius: 12px;
50+
padding: 28px 18px;
51+
text-align: center;
52+
cursor: pointer;
53+
transition: border-color 0.2s ease, background-color 0.2s ease;
54+
}
55+
56+
.drop-zone.dragover {
57+
border-color: var(--color-accent, #0070f3);
58+
background-color: rgba(0, 112, 243, 0.08);
59+
}
60+
61+
.drop-zone:focus-visible {
62+
outline: 2px solid var(--color-accent, #0070f3);
63+
outline-offset: 2px;
64+
}
65+
66+
.drop-zone input[type="file"] {
67+
display: none;
68+
}
69+
70+
.status-text {
71+
color: var(--color-muted, #57606a);
72+
min-height: 1.25rem;
73+
}
74+
75+
.status-text.error {
76+
color: #b42318;
77+
}
78+
79+
.preview-wrapper {
80+
display: grid;
81+
gap: 0.75rem;
82+
}
83+
84+
.preview-wrapper img {
85+
max-width: 100%;
86+
border-radius: 10px;
87+
background: #fff;
88+
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.08);
89+
}
90+
91+
.alt-text-area {
92+
width: 100%;
93+
min-height: 120px;
94+
resize: vertical;
95+
}
96+
97+
.badge {
98+
display: inline-flex;
99+
align-items: center;
100+
gap: 0.35rem;
101+
padding: 0.25rem 0.6rem;
102+
border-radius: 999px;
103+
background: var(--background-elevated, #f6f8fa);
104+
color: var(--foreground-muted, #57606a);
105+
font-size: 0.95rem;
106+
width: fit-content;
107+
}
108+
109+
.info-row {
110+
display: flex;
111+
flex-wrap: wrap;
112+
gap: 0.75rem;
113+
align-items: center;
114+
}
115+
116+
.tool-actions {
117+
display: flex;
118+
flex-wrap: wrap;
119+
gap: 0.75rem;
120+
}
121+
122+
@media (max-width: 720px) {
123+
body {
124+
padding: 20px 16px 40px;
125+
}
126+
}
127+
</style>
128+
</head>
129+
130+
<body>
131+
<header class="page-header">
132+
<a class="site-link" href="https://tools.mathspp.com/" aria-label="Back to tools.mathspp.com">← tools.mathspp.com</a>
133+
<h1>Image Alt Text Extractor</h1>
134+
<p class="lead">Paste or drop an image and we will surface any ALT text that came with it. If none is present, we will tell you.</p>
135+
</header>
136+
137+
<main>
138+
<section class="surface panel">
139+
<div class="input-grid">
140+
<label class="drop-zone" id="drop-zone" tabindex="0" aria-label="Paste or drop an image here">
141+
<strong>Paste, drop, or select an image</strong>
142+
<p class="status-text" id="drop-hint">Drop a file, press Ctrl/Cmd+V, or use the buttons below.</p>
143+
<input type="file" id="file-input" accept="image/*">
144+
</label>
145+
<div class="tool-actions">
146+
<button type="button" id="clipboard-button">Paste image from clipboard</button>
147+
<button type="button" id="file-button">Choose an image</button>
148+
</div>
149+
<p class="status-text" id="status" aria-live="polite">No image loaded yet.</p>
150+
</div>
151+
</section>
152+
153+
<section class="surface panel" aria-live="polite">
154+
<div class="info-row" aria-hidden="true" id="info-row" hidden>
155+
<span class="badge" id="image-info"></span>
156+
</div>
157+
<div class="preview-wrapper">
158+
<img id="preview" alt="Preview of the pasted image" hidden>
159+
<div class="form-group">
160+
<label for="alt-text">ALT text found</label>
161+
<textarea id="alt-text" class="alt-text-area" placeholder="ALT text will appear here" spellcheck="true"></textarea>
162+
</div>
163+
<div class="tool-actions">
164+
<button type="button" id="copy-button">Copy ALT text</button>
165+
<button type="button" id="clear-button">Clear</button>
166+
</div>
167+
</div>
168+
</section>
169+
</main>
170+
171+
<script>
172+
const dropZone = document.getElementById('drop-zone');
173+
const fileInput = document.getElementById('file-input');
174+
const clipboardButton = document.getElementById('clipboard-button');
175+
const fileButton = document.getElementById('file-button');
176+
const statusEl = document.getElementById('status');
177+
const previewEl = document.getElementById('preview');
178+
const altTextEl = document.getElementById('alt-text');
179+
const copyButton = document.getElementById('copy-button');
180+
const clearButton = document.getElementById('clear-button');
181+
const dropHint = document.getElementById('drop-hint');
182+
const infoRow = document.getElementById('info-row');
183+
const imageInfo = document.getElementById('image-info');
184+
185+
let lastObjectUrl = null;
186+
187+
function setStatus(message, isError = false) {
188+
statusEl.textContent = message;
189+
statusEl.classList.toggle('error', isError);
190+
}
191+
192+
function resetPreview() {
193+
previewEl.src = '';
194+
previewEl.hidden = true;
195+
altTextEl.value = '';
196+
infoRow.hidden = true;
197+
if (lastObjectUrl) {
198+
URL.revokeObjectURL(lastObjectUrl);
199+
lastObjectUrl = null;
200+
}
201+
setStatus('No image loaded yet.');
202+
}
203+
204+
function describeImage(file) {
205+
const sizeKb = file.size / 1024;
206+
const kbDisplay = sizeKb >= 1024 ? `${(sizeKb / 1024).toFixed(1)} MB` : `${sizeKb.toFixed(0)} kB`;
207+
imageInfo.textContent = `${file.name || 'clipboard image'}${kbDisplay}`;
208+
infoRow.hidden = false;
209+
}
210+
211+
function extractAltFromHtml(html) {
212+
if (!html) return '';
213+
try {
214+
const parser = new DOMParser();
215+
const doc = parser.parseFromString(html, 'text/html');
216+
const img = doc.querySelector('img[alt]');
217+
return img?.getAttribute('alt')?.trim() || '';
218+
} catch (error) {
219+
console.error('Failed to parse HTML clipboard content', error);
220+
return '';
221+
}
222+
}
223+
224+
function handleImage(file, altText, sourceDescription) {
225+
describeImage(file);
226+
const objectUrl = URL.createObjectURL(file);
227+
if (lastObjectUrl) {
228+
URL.revokeObjectURL(lastObjectUrl);
229+
}
230+
lastObjectUrl = objectUrl;
231+
previewEl.src = objectUrl;
232+
previewEl.hidden = false;
233+
altTextEl.value = altText || '';
234+
if (altText) {
235+
setStatus(`ALT text found from ${sourceDescription}.`);
236+
} else {
237+
setStatus('No ALT text found for this image.', true);
238+
}
239+
}
240+
241+
async function handleClipboardRead() {
242+
if (!navigator.clipboard?.read) {
243+
setStatus('Clipboard image reading is not supported in this browser.', true);
244+
return;
245+
}
246+
try {
247+
setStatus('Reading clipboard…');
248+
const items = await navigator.clipboard.read();
249+
250+
let html = '';
251+
let imageBlob = null;
252+
253+
for (const item of items) {
254+
if (!html && item.types.includes('text/html')) {
255+
const blob = await item.getType('text/html');
256+
html = await blob.text();
257+
}
258+
const imageType = item.types.find(type => type.startsWith('image/'));
259+
if (!imageBlob && imageType) {
260+
imageBlob = await item.getType(imageType);
261+
imageBlob.name = 'clipboard-image';
262+
}
263+
}
264+
265+
if (!imageBlob) {
266+
setStatus('No image found in clipboard.', true);
267+
return;
268+
}
269+
270+
const altText = extractAltFromHtml(html);
271+
handleImage(imageBlob, altText, 'clipboard metadata');
272+
} catch (error) {
273+
console.error(error);
274+
setStatus('Failed to read clipboard image or metadata. The browser may have blocked access.', true);
275+
}
276+
}
277+
278+
function handlePasteEvent(event) {
279+
const file = [...event.clipboardData.files].find(item => item.type.startsWith('image/'));
280+
const html = event.clipboardData.getData('text/html');
281+
const altText = extractAltFromHtml(html);
282+
283+
if (file) {
284+
event.preventDefault();
285+
handleImage(file, altText, 'clipboard metadata');
286+
} else if (altText) {
287+
altTextEl.value = altText;
288+
setStatus('ALT text found in clipboard HTML, but no image was included.');
289+
} else {
290+
setStatus('Clipboard does not contain an image or ALT metadata.', true);
291+
}
292+
}
293+
294+
function handleFileInput(file, sourceDescription) {
295+
if (!file || !file.type.startsWith('image/')) {
296+
setStatus('Please provide an image file.', true);
297+
return;
298+
}
299+
handleImage(file, '', sourceDescription);
300+
}
301+
302+
dropZone.addEventListener('dragover', (event) => {
303+
event.preventDefault();
304+
dropZone.classList.add('dragover');
305+
});
306+
307+
dropZone.addEventListener('dragleave', () => {
308+
dropZone.classList.remove('dragover');
309+
});
310+
311+
dropZone.addEventListener('drop', (event) => {
312+
event.preventDefault();
313+
dropZone.classList.remove('dragover');
314+
const [file] = event.dataTransfer.files;
315+
handleFileInput(file, 'drag and drop');
316+
});
317+
318+
dropZone.addEventListener('click', () => fileInput.click());
319+
320+
dropZone.addEventListener('keydown', (event) => {
321+
if (event.key === 'Enter' || event.key === ' ') {
322+
event.preventDefault();
323+
fileInput.click();
324+
}
325+
});
326+
327+
dropZone.addEventListener('paste', handlePasteEvent);
328+
329+
fileInput.addEventListener('change', (event) => {
330+
const [file] = event.target.files;
331+
handleFileInput(file, 'file picker');
332+
fileInput.value = '';
333+
});
334+
335+
fileButton.addEventListener('click', () => fileInput.click());
336+
337+
clipboardButton.addEventListener('click', handleClipboardRead);
338+
339+
copyButton.addEventListener('click', async () => {
340+
if (!altTextEl.value.trim()) {
341+
setStatus('Nothing to copy yet.', true);
342+
return;
343+
}
344+
try {
345+
await navigator.clipboard.writeText(altTextEl.value.trim());
346+
setStatus('ALT text copied to clipboard.');
347+
} catch (error) {
348+
console.error(error);
349+
setStatus('Unable to copy ALT text.', true);
350+
}
351+
});
352+
353+
clearButton.addEventListener('click', () => {
354+
resetPreview();
355+
});
356+
357+
dropHint.textContent = navigator.clipboard?.read ? 'Drop a file, press Ctrl/Cmd+V, or use the buttons below.' : 'Drop a file or use the choose image button.';
358+
setStatus('No image loaded yet.');
359+
</script>
360+
361+
<footer class="page-footer">
362+
<p>Built with ❤️, 🤖, and 🐍, by <a href="https://mathspp.com/">Rodrigo Girão Serrão</a></p>
363+
</footer>
364+
</body>
365+
366+
</html>

0 commit comments

Comments
 (0)