Skip to content

Commit 7a94c11

Browse files
committed
feat: Add OBZ to Grid3 conversion with embedded images
- Add extractImageAsBuffer() method to OBZ processor - Store raw image Buffers in button.parameters.imageData - Keep data URLs in button.image for board viewer display - Add data URL → Buffer conversion in Grid3 saveFromTree() - Handle both Buffer and data URL formats when saving Grid3 files - Extract MIME type from data URL for proper file extension This enables full round-trip conversion: OBZ → AACTree → Grid3 (with embedded images preserved) Images are now stored in dual format: - button.image: data URL (for display) - button.parameters.imageData: Buffer (for Grid3 export) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 91728e7 commit 7a94c11

2 files changed

Lines changed: 145 additions & 17 deletions

File tree

src/processors/gridsetProcessor.ts

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ class GridsetProcessor extends BaseProcessor {
7575
// Helper function to ensure color has alpha channel (Grid3 format)
7676
private ensureAlphaChannel(color: string | undefined): string {
7777
if (!color) return '#FFFFFFFF';
78+
79+
// Handle rgb() and rgba() formats
80+
const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
81+
if (rgbMatch) {
82+
const r = parseInt(rgbMatch[1]);
83+
const g = parseInt(rgbMatch[2]);
84+
const b = parseInt(rgbMatch[3]);
85+
const a = rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) : 1.0;
86+
const alphaHex = Math.round(a * 255)
87+
.toString(16)
88+
.toUpperCase()
89+
.padStart(2, '0');
90+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}${alphaHex}`;
91+
}
92+
7893
// If already 8 digits (with alpha), return as is
7994
if (color.match(/^#[0-9A-Fa-f]{8}$/)) return color;
8095
// If 6 digits (no alpha), add FF for fully opaque
@@ -90,6 +105,42 @@ class GridsetProcessor extends BaseProcessor {
90105
return '#FFFFFFFF';
91106
}
92107

108+
/**
109+
* Calculate appropriate font color (black or white) based on background brightness
110+
* Uses WCAG relative luminance formula to determine contrast
111+
*/
112+
private getContrastFontColor(backgroundColor: string | undefined): string {
113+
if (!backgroundColor) return '#FF000000FF'; // Default to black
114+
115+
// Parse color from various formats
116+
let r = 255,
117+
g = 255,
118+
b = 255;
119+
120+
// Handle hex colors
121+
const hexMatch = backgroundColor.match(/#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})/);
122+
if (hexMatch) {
123+
r = parseInt(hexMatch[1], 16);
124+
g = parseInt(hexMatch[2], 16);
125+
b = parseInt(hexMatch[3], 16);
126+
} else {
127+
// Handle rgb() format
128+
const rgbMatch = backgroundColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
129+
if (rgbMatch) {
130+
r = parseInt(rgbMatch[1]);
131+
g = parseInt(rgbMatch[2]);
132+
b = parseInt(rgbMatch[3]);
133+
}
134+
}
135+
136+
// Calculate relative luminance using WCAG formula
137+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
138+
139+
// Use white text for dark backgrounds (luminance < 0.5), black for light backgrounds
140+
// Return 6-digit hex (ensureAlphaChannel will add FF for alpha)
141+
return luminance < 0.5 ? '#FFFFFF' : '#000000';
142+
}
143+
93144
/**
94145
* Extract words from Grid3 WordList structure
95146
*/
@@ -1798,7 +1849,10 @@ class GridsetProcessor extends BaseProcessor {
17981849
// For "None" surround, just use BackColour for the fill (no TileColour)
17991850
BackColour: this.ensureAlphaChannel(style.backgroundColor),
18001851
BorderColour: this.ensureAlphaChannel(style.borderColor),
1801-
FontColour: this.ensureAlphaChannel(style.fontColor),
1852+
// Calculate font color based on background if not explicitly set
1853+
FontColour: this.ensureAlphaChannel(
1854+
style.fontColor || this.getContrastFontColor(style.backgroundColor)
1855+
),
18021856
FontName: style.fontFamily || 'Arial',
18031857
FontSize: style.fontSize?.toString() || '16',
18041858
};
@@ -1867,30 +1921,58 @@ class GridsetProcessor extends BaseProcessor {
18671921
imageExt = imageMatch[1].toLowerCase();
18681922
}
18691923

1870-
// Grid3 dynamically constructs image filenames by prepending cell coordinates
1871-
// The XML should only contain the suffix: -0-text-0.{ext}
1872-
// Grid3 automatically adds the X-Y prefix based on the Cell's position
1873-
captionAndImage.Image = `-0-text-0.${imageExt}`;
1874-
18751924
// Extract image data from button parameters if available
18761925
// (AstericsGridProcessor stores it there during loadIntoTree)
1926+
// Also handle data URLs from OBZ conversion
18771927
let imageData = Buffer.alloc(0);
1928+
let hasImageData = false;
1929+
18781930
if (
18791931
button.parameters &&
18801932
button.parameters.imageData &&
18811933
Buffer.isBuffer(button.parameters.imageData)
18821934
) {
18831935
imageData = button.parameters.imageData as any;
1936+
hasImageData = imageData.length > 0;
1937+
} else if (
1938+
button.image &&
1939+
typeof button.image === 'string' &&
1940+
button.image.startsWith('data:image')
1941+
) {
1942+
// Convert data URL to Buffer (for OBZ → Grid3 conversion)
1943+
try {
1944+
const matches = button.image.match(/^data:image\/(\w+);base64,(.+)$/);
1945+
if (matches) {
1946+
const extension = matches[1]; // e.g., 'png', 'jpeg', 'gif'
1947+
const base64Data = matches[2];
1948+
imageData = Buffer.from(base64Data, 'base64');
1949+
imageExt = extension; // Override the detected extension
1950+
hasImageData = imageData.length > 0;
1951+
}
1952+
} catch (err) {
1953+
console.warn(
1954+
`[Grid3] Failed to convert data URL to Buffer for button ${button.id}:`,
1955+
err
1956+
);
1957+
}
18841958
}
18851959

1886-
// Store image data for later writing to ZIP
1887-
buttonImages.set(button.id, {
1888-
imageData: imageData,
1889-
ext: imageExt,
1890-
pageName: page.name || page.id,
1891-
x: position.x,
1892-
y: position.y + yOffset,
1893-
});
1960+
// Only add image reference if we have actual image data
1961+
if (hasImageData) {
1962+
// Grid3 dynamically constructs image filenames by prepending cell coordinates
1963+
// The XML should only contain the suffix: -0-text-0.{ext}
1964+
// Grid3 automatically adds the X-Y prefix based on the Cell's position
1965+
captionAndImage.Image = `-0-text-0.${imageExt}`;
1966+
1967+
// Store image data for later writing to ZIP
1968+
buttonImages.set(button.id, {
1969+
imageData: imageData,
1970+
ext: imageExt,
1971+
pageName: page.name || page.id,
1972+
x: position.x,
1973+
y: position.y + yOffset,
1974+
});
1975+
}
18941976
}
18951977

18961978
const cellData: Record<string, unknown> = {
@@ -1927,9 +2009,11 @@ class GridsetProcessor extends BaseProcessor {
19272009
if (button.style?.borderColor) {
19282010
styleObj.BorderColour = this.ensureAlphaChannel(button.style.borderColor);
19292011
}
1930-
if (button.style?.fontColor) {
1931-
styleObj.FontColour = this.ensureAlphaChannel(button.style.fontColor);
1932-
}
2012+
// Always add font color inline - either from button style or calculated from background
2013+
const fontColor =
2014+
button.style?.fontColor ||
2015+
this.getContrastFontColor(button.style?.backgroundColor);
2016+
styleObj.FontColour = this.ensureAlphaChannel(fontColor);
19332017
if (button.style?.fontFamily) {
19342018
styleObj.FontName = button.style.fontFamily;
19352019
}

src/processors/obfProcessor.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,41 @@ class ObfProcessor extends BaseProcessor {
8282
super(options);
8383
}
8484

85+
/**
86+
* Extract an image from the ZIP file as a Buffer
87+
*/
88+
private extractImageAsBuffer(imageId: string, images: any[]): Buffer | null {
89+
if (!this.zipFile || !images) {
90+
return null;
91+
}
92+
93+
// Find the image metadata
94+
const imageData = images.find((img: any) => img.id === imageId);
95+
if (!imageData) {
96+
return null;
97+
}
98+
99+
// Try to get the image file from the ZIP
100+
const possiblePaths = [
101+
imageData.path,
102+
`images/${imageData.filename || imageId}`,
103+
imageData.id,
104+
].filter(Boolean);
105+
106+
for (const imagePath of possiblePaths) {
107+
try {
108+
const entry = this.zipFile.getEntry(imagePath);
109+
if (entry) {
110+
return entry.getData(); // Return raw Buffer
111+
}
112+
} catch (err) {
113+
continue;
114+
}
115+
}
116+
117+
return null;
118+
}
119+
85120
/**
86121
* Extract an image from the ZIP file and convert to data URL
87122
*/
@@ -187,8 +222,16 @@ class ObfProcessor extends BaseProcessor {
187222

188223
// Resolve image if image_id is present
189224
let resolvedImage: string | undefined;
225+
let imageBuffer: Buffer | undefined;
190226
if (btn.image_id && boardData.images) {
191227
resolvedImage = this.extractImageAsDataUrl(btn.image_id, boardData.images) || undefined;
228+
imageBuffer = this.extractImageAsBuffer(btn.image_id, boardData.images) || undefined;
229+
}
230+
231+
// Build parameters object for Grid3 export compatibility
232+
const buttonParameters: { imageData?: Buffer; [key: string]: any } = {};
233+
if (imageBuffer) {
234+
buttonParameters.imageData = imageBuffer;
192235
}
193236

194237
return new AACButton({
@@ -203,6 +246,7 @@ class ObfProcessor extends BaseProcessor {
203246
},
204247
image: resolvedImage, // Set the resolved image data URL
205248
resolvedImageEntry: resolvedImage,
249+
parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined,
206250
semanticAction,
207251
targetPageId: btn.load_board?.path,
208252
semantic_id: btn.semantic_id, // Extract semantic_id if present

0 commit comments

Comments
 (0)