Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"elysia": "^1.4.29",
"elysia-rate-limit": "^4.6.2",
"file-type": "^22.0.1",
"heic-decode": "^2.1.0",
"just-bash": "3.1.0",
"logging": "^4.2.0",
"nanoid": "^6.0.1",
Expand Down Expand Up @@ -43,6 +44,7 @@
"devDependencies": {
"@biomejs/biome": "^2.5.8",
"@types/bun": "1.3.14",
"@types/heic-decode": "^2.0.0",
"@types/node-telegram-bot-api": "^0.64.15",
"@types/turndown": "^5.0.6",
"drizzle-kit": "^0.31.10",
Expand Down
2 changes: 2 additions & 0 deletions src/extensions/converter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ const MIME_TO_EXT: Record<string, string> = {
"image/gif": ".gif",
"image/bmp": ".bmp",
"image/tiff": ".tiff",
"image/heic": ".heic",
"image/heif": ".heif",
};

/**
Expand Down
100 changes: 95 additions & 5 deletions src/extensions/converter/ocr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import path from "node:path";
import decode from "heic-decode";
import { pdf } from "pdf-to-img";

/** System prompt for OCR processing. */
Expand All @@ -28,16 +29,99 @@ const MIME_TYPES: Record<string, string> = {
".gif": "image/gif",
".bmp": "image/bmp",
".tiff": "image/tiff",
".heic": "image/heic",
".heif": "image/heif",
};

/** Extensions that require HEIC decoding before processing. */
const HEIC_EXTENSIONS = new Set([".heic", ".heif"]);

/**
* Encodes raw RGBA pixel data as an uncompressed 32-bit BMP buffer.
* BMP stores rows bottom-to-top, so the row order is flipped.
*
* @param data - RGBA pixel data (4 bytes per pixel)
* @param width - Image width in pixels
* @param height - Image height in pixels
* @returns A Buffer containing a valid BMP file
*/
function rgbaToBmp(data: Uint8ClampedArray, width: number, height: number): Buffer {
const rowSize = width * 4;
const pixelDataSize = rowSize * height;
const headerSize = 14 + 108; // BMP file header (14) + BITMAPV4HEADER (108)
const fileSize = headerSize + pixelDataSize;

const buf = Buffer.alloc(fileSize);

// -- BMP File Header (14 bytes) --
buf.write("BM", 0); // Signature
buf.writeUInt32LE(fileSize, 2); // File size
buf.writeUInt32LE(0, 6); // Reserved
buf.writeUInt32LE(headerSize, 10); // Pixel data offset

// -- BITMAPV4HEADER (108 bytes) - supports alpha channel --
buf.writeUInt32LE(108, 14); // Header size
buf.writeInt32LE(width, 18); // Width
buf.writeInt32LE(-height, 22); // Height (negative = top-down row order)
buf.writeUInt16LE(1, 26); // Color planes
buf.writeUInt16LE(32, 28); // Bits per pixel
buf.writeUInt32LE(3, 30); // Compression: BI_BITFIELDS
buf.writeUInt32LE(pixelDataSize, 34); // Image size
buf.writeInt32LE(2835, 38); // X pixels per meter (~72 DPI)
buf.writeInt32LE(2835, 42); // Y pixels per meter
buf.writeUInt32LE(0, 46); // Colors used
buf.writeUInt32LE(0, 50); // Important colors

// Channel masks (RGBA): R=0x00FF0000, G=0x0000FF00, B=0x000000FF, A=0xFF000000
buf.writeUInt32LE(0x00ff0000, 54); // Red mask
buf.writeUInt32LE(0x0000ff00, 58); // Green mask
buf.writeUInt32LE(0x000000ff, 62); // Blue mask
buf.writeUInt32LE(0xff000000, 66); // Alpha mask

// Color space type: LCS_sRGB
buf.writeUInt32LE(0x73524742, 70);
// Remaining V4 fields (endpoints + gamma) are zero-filled by Buffer.alloc

// -- Pixel data (BGRA order for BMP) --
for (let i = 0; i < data.length; i += 4) {
const offset = headerSize + i;
buf[offset] = data[i + 2]!; // B
buf[offset + 1] = data[i + 1]!; // G
buf[offset + 2] = data[i]!; // R
buf[offset + 3] = data[i + 3]!; // A
}

return buf;
}

/**
* Decodes a HEIC/HEIF file to raw RGBA pixel data using heic-decode,
* then converts it to a PNG buffer via Bun.Image.
*
* @param filePath - Absolute path to the HEIC/HEIF file
* @param imageSize - Maximum dimension to resize the output to
* @returns Base64-encoded PNG string
*/
async function decodeHeicToPngBase64(filePath: string, imageSize: number): Promise<string> {
const fileBuffer = await Bun.file(filePath).arrayBuffer();
const { width, height, data } = await decode({ buffer: new Uint8Array(fileBuffer) });

// heic-decode returns raw RGBA pixel data - encode as BMP so Bun.Image can read it
const bmpBuffer = rgbaToBmp(data, width, height);
return new Bun.Image(bmpBuffer).resize(imageSize).png().toBase64();
}

/**
* Reads a file and returns base64-encoded image content parts suitable
* for injection into an {@link AgentMessage}.
*
* For PDFs, each page is converted to a PNG buffer via `pdf-to-img`.
* For images, the raw bytes are read and base64-encoded directly.
* All output is encoded as PNG (lossless) to maximize OCR accuracy.
* For PDFs, each page is rasterized via `pdf-to-img` and re-encoded as PNG.
* For HEIC/HEIF, raw pixels are decoded via heic-decode and encoded as PNG.
* For other images, the file is resized and re-encoded as PNG.
*
* @param filePath - Absolute path to the image or PDF file
* @param imageSize - Maximum dimension to resize images to
* @returns Array of image content parts
* @throws If the file cannot be read or the PDF cannot be parsed
*/
Expand All @@ -52,7 +136,7 @@ export async function buildImageParts(
const doc = await pdf(filePath);

for await (const pageBuffer of doc) {
const data = await new Bun.Image(pageBuffer).resize(imageSize).jpeg().toBase64();
const data = await new Bun.Image(pageBuffer).resize(imageSize).png().toBase64();
parts.push({
type: "image",
data,
Expand All @@ -67,6 +151,12 @@ export async function buildImageParts(
throw new Error(`Unsupported file type: ${ext}`);
}

const data = await new Bun.Image(filePath).resize(imageSize).jpeg().toBase64();
return [{ type: "image", data, mimeType }];
// HEIC/HEIF files need explicit decoding since Bun.Image cannot read them directly
if (HEIC_EXTENSIONS.has(ext)) {
const data = await decodeHeicToPngBase64(filePath, imageSize);
return [{ type: "image", data, mimeType: "image/png" }];
}

const data = await new Bun.Image(filePath).resize(imageSize).png().toBase64();
return [{ type: "image", data, mimeType: "image/png" }];
}
2 changes: 1 addition & 1 deletion src/extensions/converter/skills/converter/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The converter skill provides a `convert` shell command that transforms files int
Supported file types:

- PDF documents (each page is converted to an image and processed)
- Images: PNG, JPEG, WebP, GIF, BMP, TIFF
- Images: PNG, JPEG, WebP, GIF, BMP, TIFF, HEIC, HEIF

File types are detected by reading magic bytes from the file header, not by file extension.

Expand Down
Loading