Skip to content

Commit 52087b5

Browse files
willwadeclaude
andcommitted
feat: Add image debugging tools and fix Grid3 coordinate-prefixed image names
- Fix image resolution for Grid3 files that use coordinate-prefixed image names (e.g., "-0-text-0.png" should resolve to "1-4-0-text-0.png") - Add auditGridsetImages() utility for debugging image issues in gridsets - Add formatImageAuditSummary() for human-readable audit reports - Export image debugging utilities from Gridset namespace This fixes the issue where images were showing as broken in aac-board-viewer for gridsets that store partial image names without coordinate prefixes. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 4cab83f commit 52087b5

3 files changed

Lines changed: 272 additions & 0 deletions

File tree

src/gridset.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,11 @@ export {
153153
resolveGridsetPassword,
154154
resolveGridsetPasswordFromEnv,
155155
} from './processors/gridset/password';
156+
157+
// === Image Debugging ===
158+
export {
159+
auditGridsetImages,
160+
formatImageAuditSummary,
161+
type ImageAuditResult,
162+
type ImageIssue,
163+
} from './processors/gridset/imageDebug';
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
/**
2+
* Image Debugging Utilities for Grid3 Files
3+
*
4+
* These utilities help developers understand why images might not be resolving
5+
* correctly in Grid3 gridsets.
6+
*/
7+
8+
import type { ZipEntry } from './password';
9+
import { openZipFromInput } from '../../utils/zip';
10+
import { getZipEntriesFromAdapter } from './password';
11+
import { resolveGridsetPasswordFromEnv } from './password';
12+
import { XMLParser } from 'fast-xml-parser';
13+
import { decodeText } from '../../utils/io';
14+
15+
export interface ImageIssue {
16+
gridName: string;
17+
cellX: number;
18+
cellY: number;
19+
declaredImage: string | undefined;
20+
expectedPaths: string[];
21+
issue: 'not_found' | 'symbol_library' | 'external_reference';
22+
suggestion: string;
23+
}
24+
25+
export interface ImageAuditResult {
26+
totalCells: number;
27+
cellsWithImages: number;
28+
resolvedImages: number;
29+
unresolvedImages: number;
30+
issues: ImageIssue[];
31+
availableImages: string[];
32+
}
33+
34+
/**
35+
* Audit a gridset file to find image resolution issues
36+
*
37+
* @param gridsetBuffer - The gridset file as a Buffer
38+
* @returns Detailed audit report of image issues
39+
*
40+
* @example
41+
* const audit = await auditGridsetImages(gridsetBuffer);
42+
* console.log(`Found ${audit.unresolvedImages} unresolved images`);
43+
* audit.issues.forEach(issue => {
44+
* console.log(`Cell (${issue.cellX}, ${issue.cellY}): ${issue.suggestion}`);
45+
* });
46+
*/
47+
export async function auditGridsetImages(
48+
gridsetBuffer: Uint8Array,
49+
password = resolveGridsetPasswordFromEnv()
50+
): Promise<ImageAuditResult> {
51+
const issues: ImageIssue[] = [];
52+
const availableImages = new Set<string>();
53+
let totalCells = 0;
54+
let cellsWithImages = 0;
55+
let resolvedImages = 0;
56+
let unresolvedImages = 0;
57+
58+
try {
59+
const { zip } = await openZipFromInput(gridsetBuffer);
60+
const entries = getZipEntriesFromAdapter(zip, password);
61+
const parser = new XMLParser();
62+
63+
// Collect all image files in the gridset
64+
const imageExtensions = ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.emf', '.wmf'];
65+
for (const entry of entries) {
66+
const name = entry.entryName.toLowerCase();
67+
if (imageExtensions.some(ext => name.endsWith(ext))) {
68+
availableImages.add(entry.entryName);
69+
}
70+
}
71+
72+
// Process each grid file
73+
for (const entry of entries) {
74+
if (!entry.entryName.startsWith('Grids/') || !entry.entryName.endsWith('grid.xml')) {
75+
continue;
76+
}
77+
78+
try {
79+
const xmlContent = decodeText(await entry.getData());
80+
const data = parser.parse(xmlContent);
81+
const grid = data.Grid || data.grid;
82+
if (!grid) continue;
83+
84+
const gridNameMatch = entry.entryName.match(/^Grids\/([^/]+)\//);
85+
const gridName = gridNameMatch ? gridNameMatch[1] : entry.entryName;
86+
87+
const gridEntryPath = entry.entryName.replace(/\\/g, '/');
88+
const baseDir = gridEntryPath.replace(/\/grid\.xml$/, '/');
89+
90+
// Check for FileMap.xml
91+
const fileMapEntry = entries.find((e) =>
92+
e.entryName === baseDir + 'FileMap.xml'
93+
);
94+
95+
const dynamicFilesMap = new Map<string, string[]>();
96+
if (fileMapEntry) {
97+
try {
98+
const fmXml = decodeText(await fileMapEntry.getData());
99+
const fmData = parser.parse(fmXml);
100+
const fileEntries = fmData?.FileMap?.Entries?.Entry || fmData?.fileMap?.entries?.entry;
101+
if (fileEntries) {
102+
const arr = Array.isArray(fileEntries) ? fileEntries : [fileEntries];
103+
for (const ent of arr) {
104+
const rawStaticFile = ent['@_StaticFile'] || ent.StaticFile || ent.staticFile;
105+
const staticFile = typeof rawStaticFile === 'string' ? rawStaticFile.replace(/\\/g, '/') : '';
106+
if (!staticFile) continue;
107+
const df = ent.DynamicFiles || ent.dynamicFiles;
108+
const candidates = df?.File || df?.file || df?.Files || df?.files;
109+
const list = Array.isArray(candidates) ? candidates : candidates ? [candidates] : [];
110+
dynamicFilesMap.set(staticFile, list);
111+
}
112+
}
113+
} catch (e) {
114+
// FileMap parsing failed, continue without it
115+
}
116+
}
117+
118+
// Process cells
119+
const cells = grid.Cells?.Cell || grid.cells?.cell;
120+
if (!cells) continue;
121+
122+
const cellArr = Array.isArray(cells) ? cells : [cells];
123+
124+
for (const cell of cellArr) {
125+
totalCells++;
126+
const content = cell.Content;
127+
if (!content) continue;
128+
129+
const captionAndImage = content.CaptionAndImage || content.captionAndImage;
130+
const imageCandidate =
131+
captionAndImage?.Image ||
132+
captionAndImage?.image ||
133+
captionAndImage?.ImageName ||
134+
captionAndImage?.imageName;
135+
136+
if (!imageCandidate) continue;
137+
138+
cellsWithImages++;
139+
140+
const cellX = Math.max(0, parseInt(String(cell['@_X'] || '1'), 10) - 1);
141+
const cellY = Math.max(0, parseInt(String(cell['@_Y'] || '1'), 10) - 1);
142+
143+
// Try to resolve the image
144+
const imageName = String(imageCandidate).trim();
145+
const imageFound = availableImages.has(`${baseDir}${imageName}`) ||
146+
availableImages.has(`${baseDir}Images/${imageName}`);
147+
148+
if (imageFound) {
149+
resolvedImages++;
150+
} else {
151+
unresolvedImages++;
152+
153+
// Determine the issue
154+
const expectedPaths = [
155+
`${baseDir}${imageName}`,
156+
`${baseDir}Images/${imageName}`,
157+
`${baseDir}${cellX + 1}-${cellY + 1}-0-text-0.png`,
158+
`${baseDir}${cellX + 1}-${cellY + 1}.png`,
159+
];
160+
161+
let issue: ImageIssue['issue'];
162+
let suggestion: string;
163+
164+
if (imageName.startsWith('[')) {
165+
// Check if it's a symbol library reference
166+
if (imageName.includes('widgit') || imageName.includes('Widgit')) {
167+
issue = 'symbol_library';
168+
suggestion = 'This is a Widgit symbol library reference. These symbols are not stored in the gridset - they require the Widgit Symbols to be installed on the system.';
169+
} else if (imageName.includes('grid3x') || imageName.includes('Grid3')) {
170+
issue = 'external_reference';
171+
suggestion = 'This is a built-in Grid3 resource reference. These images are not included in the gridset file.';
172+
} else {
173+
issue = 'symbol_library';
174+
suggestion = `External symbol library reference: ${imageName}. Symbol libraries are not embedded in gridset files.`;
175+
}
176+
} else {
177+
issue = 'not_found';
178+
const similarImages = Array.from(availableImages).filter(img =>
179+
img.toLowerCase().includes(imageName.toLowerCase().substring(0, 10))
180+
);
181+
if (similarImages.length > 0) {
182+
suggestion = `Image not found. Did you mean one of these?\n ${similarImages.slice(0, 3).join('\n ')}`;
183+
} else {
184+
suggestion = `Image file not found in gridset. The file may have been excluded or the path is incorrect.`;
185+
}
186+
}
187+
188+
issues.push({
189+
gridName,
190+
cellX: cellX + 1,
191+
cellY: cellY + 1,
192+
declaredImage: imageName,
193+
expectedPaths,
194+
issue,
195+
suggestion,
196+
});
197+
}
198+
}
199+
} catch (e) {
200+
// Skip grids that can't be processed
201+
continue;
202+
}
203+
}
204+
205+
return {
206+
totalCells,
207+
cellsWithImages,
208+
resolvedImages,
209+
unresolvedImages,
210+
issues,
211+
availableImages: Array.from(availableImages).sort(),
212+
};
213+
} catch (error: any) {
214+
throw new Error(`Failed to audit gridset images: ${error.message}`);
215+
}
216+
}
217+
218+
/**
219+
* Get a human-readable summary of image audit results
220+
*/
221+
export function formatImageAuditSummary(audit: ImageAuditResult): string {
222+
const lines: string[] = [];
223+
224+
lines.push('=== Grid3 Image Audit Summary ===');
225+
lines.push(`Total cells: ${audit.totalCells}`);
226+
lines.push(`Cells with images: ${audit.cellsWithImages}`);
227+
lines.push(`Resolved images: ${audit.resolvedImages}`);
228+
lines.push(`Unresolved images: ${audit.unresolvedImages}`);
229+
lines.push(`Available image files: ${audit.availableImages.length}`);
230+
lines.push('');
231+
232+
if (audit.issues.length > 0) {
233+
lines.push('=== Image Issues ===');
234+
235+
// Group by issue type
236+
const byType = new Map<ImageIssue['issue'], ImageIssue[]>();
237+
for (const issue of audit.issues) {
238+
const list = byType.get(issue.issue) || [];
239+
list.push(issue);
240+
byType.set(issue.issue, list);
241+
}
242+
243+
for (const [type, issues] of byType) {
244+
lines.push(`\n${type.toUpperCase()} (${issues.length} occurrences):`);
245+
for (const issue of issues.slice(0, 5)) { // Show first 5 of each type
246+
lines.push(` [${issue.gridName}] Cell (${issue.cellX}, ${issue.cellY}): ${issue.declaredImage}`);
247+
lines.push(` → ${issue.suggestion}`);
248+
}
249+
if (issues.length > 5) {
250+
lines.push(` ... and ${issues.length - 5} more`);
251+
}
252+
}
253+
}
254+
255+
return lines.join('\n');
256+
}

src/processors/gridset/resolver.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ export function resolveGrid3CellImage(
8282

8383
// Direct declared file
8484
if (imageName) {
85+
// Check for partial image names that start with '-' (common in Grid3)
86+
// These are coordinate-based suffixes like "-0-text-0.png" that need
87+
// to be prefixed with the cell coordinates
88+
if (imageName.startsWith('-') && x != null && y != null) {
89+
const coordPrefixed = joinBaseDir(baseDir, `${x}-${y}${imageName}`);
90+
if (has(coordPrefixed)) return coordPrefixed;
91+
}
92+
8593
const p1 = joinBaseDir(baseDir, imageName);
8694
if (has(p1)) return p1;
8795
const p2 = joinBaseDir(baseDir, `Images/${imageName}`);

0 commit comments

Comments
 (0)