Skip to content

Commit 91728e7

Browse files
committed
feat: Add embedded image support for OBZ files
- Add image_id field to ObfButton interface - Extract images from OBZ ZIP archives and convert to base64 data URLs - Populate button.image and resolvedImageEntry with resolved image data - Add image caching to avoid duplicate extractions - Support multiple image path resolution strategies - Fallback to external URLs for images not embedded in ZIP - Update test to verify data URL format This enables proper display of images in OBZ files (like vocal-flair-60.obz) in the board viewer, which previously couldn't show embedded images. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 8ec7f9c commit 91728e7

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

src/processors/obfProcessor.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ interface ObfButton {
4040
border_color?: string;
4141
semantic_id?: string; // Optional semantic identifier for motor planning
4242
hidden?: boolean; // OBF uses boolean hidden field
43+
image_id?: string; // Reference to image in the images array
4344
}
4445

4546
/**
@@ -74,9 +75,84 @@ interface ObfBoard {
7475
}
7576

7677
class ObfProcessor extends BaseProcessor {
78+
private zipFile?: AdmZip;
79+
private imageCache: Map<string, string> = new Map(); // Cache for data URLs
80+
7781
constructor(options?: ProcessorOptions) {
7882
super(options);
7983
}
84+
85+
/**
86+
* Extract an image from the ZIP file and convert to data URL
87+
*/
88+
private extractImageAsDataUrl(imageId: string, images: any[]): string | null {
89+
// Check cache first
90+
if (this.imageCache.has(imageId)) {
91+
return this.imageCache.get(imageId)!;
92+
}
93+
94+
if (!this.zipFile || !images) {
95+
return null;
96+
}
97+
98+
// Find the image metadata
99+
const imageData = images.find((img: any) => img.id === imageId);
100+
if (!imageData) {
101+
return null;
102+
}
103+
104+
// Try to get the image file from the ZIP
105+
// Images are typically stored in an 'images' folder or root
106+
const possiblePaths = [
107+
imageData.path, // Explicit path if provided
108+
`images/${imageData.filename || imageId}`, // Standard images folder
109+
imageData.id, // Just the ID
110+
].filter(Boolean);
111+
112+
for (const imagePath of possiblePaths) {
113+
try {
114+
const entry = this.zipFile.getEntry(imagePath);
115+
if (entry) {
116+
const buffer = entry.getData();
117+
const contentType = imageData.content_type || this.getMimeTypeFromFilename(imagePath);
118+
const dataUrl = `data:${contentType};base64,${buffer.toString('base64')}`;
119+
this.imageCache.set(imageId, dataUrl);
120+
return dataUrl;
121+
}
122+
} catch (err) {
123+
// Continue to next path
124+
continue;
125+
}
126+
}
127+
128+
// If image has a URL, use that as fallback
129+
if (imageData.url) {
130+
this.imageCache.set(imageId, imageData.url);
131+
return imageData.url;
132+
}
133+
134+
return null;
135+
}
136+
137+
private getMimeTypeFromFilename(filename: string): string {
138+
const ext = filename.toLowerCase().split('.').pop();
139+
switch (ext) {
140+
case 'png':
141+
return 'image/png';
142+
case 'jpg':
143+
case 'jpeg':
144+
return 'image/jpeg';
145+
case 'gif':
146+
return 'image/gif';
147+
case 'svg':
148+
return 'image/svg+xml';
149+
case 'webp':
150+
return 'image/webp';
151+
default:
152+
return 'image/png';
153+
}
154+
}
155+
80156
private processBoard(boardData: ObfBoard, _boardPath: string): AACPage {
81157
const sourceButtons = boardData.buttons || [];
82158

@@ -109,6 +185,12 @@ class ObfProcessor extends BaseProcessor {
109185
},
110186
};
111187

188+
// Resolve image if image_id is present
189+
let resolvedImage: string | undefined;
190+
if (btn.image_id && boardData.images) {
191+
resolvedImage = this.extractImageAsDataUrl(btn.image_id, boardData.images) || undefined;
192+
}
193+
112194
return new AACButton({
113195
// Make button ID unique by combining page ID and button ID
114196
id: `${pageId}::${btn?.id || ''}`,
@@ -119,6 +201,8 @@ class ObfProcessor extends BaseProcessor {
119201
backgroundColor: btn.background_color,
120202
borderColor: btn.border_color,
121203
},
204+
image: resolvedImage, // Set the resolved image data URL
205+
resolvedImageEntry: resolvedImage,
122206
semanticAction,
123207
targetPageId: btn.load_board?.path,
124208
semantic_id: btn.semantic_id, // Extract semantic_id if present
@@ -345,6 +429,11 @@ class ObfProcessor extends BaseProcessor {
345429
console.error('[OBF] Error instantiating AdmZip with input:', err);
346430
throw err;
347431
}
432+
433+
// Store the ZIP file reference for image extraction
434+
this.zipFile = zip;
435+
this.imageCache.clear(); // Clear cache for new file
436+
348437
console.log('[OBF] Detected zip archive, extracting .obf files');
349438
zip.getEntries().forEach((entry) => {
350439
if (entry.entryName.endsWith('.obf')) {

test/obfProcessor.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ describe('OBFProcessor', () => {
2727
if (rootPage) {
2828
const imgBtn = rootPage.buttons.find((b: any) => b.image);
2929
if (imgBtn) {
30-
expect((imgBtn as any).image).toHaveProperty('id');
30+
// Image should now be a data URL string (from embedded OBZ images)
31+
expect(typeof imgBtn.image).toBe('string');
32+
expect(imgBtn.image).toMatch(/^data:image\//);
33+
// resolvedImageEntry should also be set
34+
expect(imgBtn.resolvedImageEntry).toBe(imgBtn.image);
3135
}
3236
}
3337
});

0 commit comments

Comments
 (0)