feat: snip a region into chat, and make links clickable (#14)
This commit was merged in pull request #14.
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { Attachment, PendingAttachment } from "./types";
|
||||
|
||||
/**
|
||||
* Reading attachments off disk, and describing them to the renderer.
|
||||
*
|
||||
* Kept separate from `capture.ts` because the two answer different questions:
|
||||
* capture is about getting an image *onto* the disk, this is about getting it
|
||||
* back off again and telling Vicinae how to draw it.
|
||||
*
|
||||
* Imports no Vicinae module, so it unit tests with no runtime.
|
||||
*/
|
||||
|
||||
/** Extensions Ollama accepts. A snip is always PNG; the rest are here for a future picker. */
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
};
|
||||
|
||||
/**
|
||||
* The box a preview is fitted into.
|
||||
*
|
||||
* The height is not a preference: `MdImage.qml` clamps rendered height to 200px
|
||||
* whatever is asked for (`Math.min(_rawH, 200, maxImageHeight)`), so a taller
|
||||
* request is silently ignored. The width is here to keep a very wide capture
|
||||
* from being scaled up past the detail pane it sits in.
|
||||
*/
|
||||
const PREVIEW_MAX_WIDTH = 400;
|
||||
const PREVIEW_MAX_HEIGHT = 200;
|
||||
|
||||
export function mimeForPath(path: string): string {
|
||||
return MIME_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/** The eight bytes every PNG starts with. */
|
||||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
|
||||
/**
|
||||
* Pixel dimensions from a PNG's `IHDR` chunk, or `undefined` for anything else.
|
||||
*
|
||||
* Only PNG is handled, because that is the only thing a snip produces and
|
||||
* guessing at other formats would be a lie. The header is 24 bytes, so this
|
||||
* costs nothing on top of the read that is already happening.
|
||||
*/
|
||||
export function pngSize(bytes: Buffer): { width: number; height: number } | undefined {
|
||||
if (bytes.length < 24) return undefined;
|
||||
if (!PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)) return undefined;
|
||||
if (bytes.toString("ascii", 12, 16) !== "IHDR") return undefined;
|
||||
|
||||
const width = bytes.readUInt32BE(16);
|
||||
const height = bytes.readUInt32BE(20);
|
||||
|
||||
return width > 0 && height > 0 ? { width, height } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact box to ask the renderer for, or `undefined` when the size is unknown.
|
||||
*
|
||||
* This is the whole point of reading the header. With no hint, `MdImage.qml`
|
||||
* sizes its container to the pane width and 200px tall and then aspect-fits the
|
||||
* image inside it, so a wide screenshot sits in a letterbox with dead space
|
||||
* above and below it. Asking for a box that already matches the image's aspect
|
||||
* ratio leaves none.
|
||||
*
|
||||
* The scale is capped at 1 so a small capture is not blown up into a blurry one.
|
||||
*/
|
||||
export function previewBox(
|
||||
width: number | undefined,
|
||||
height: number | undefined,
|
||||
): { width: number; height: number } | undefined {
|
||||
if (!width || !height || width <= 0 || height <= 0) return undefined;
|
||||
|
||||
const scale = Math.min(1, PREVIEW_MAX_WIDTH / width, PREVIEW_MAX_HEIGHT / height);
|
||||
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A markdown image source for a local file.
|
||||
*
|
||||
* A `file://` URL rather than a bare path, because Vicinae's markdown renderer
|
||||
* only resolves a scheme it recognises: `imageProviderUrl` in
|
||||
* `markdown-model.cpp` routes `file://`, and a scheme-less string, through
|
||||
* `ImageURL::local`, and anything it does not recognise it returns as empty.
|
||||
* `pathToFileURL` does the escaping, so a capture under a directory with a space
|
||||
* in its name still resolves.
|
||||
*
|
||||
* The query is read from the URL before it is resolved to a path, so the size
|
||||
* hints travel here without ever reaching the file name. They are spelled the
|
||||
* way Raycast spelled them, which is what the parser looks for.
|
||||
*/
|
||||
export function imageSource(attachment: Pick<Attachment, "path" | "width" | "height">): string {
|
||||
const url = pathToFileURL(attachment.path);
|
||||
const box = previewBox(attachment.width, attachment.height);
|
||||
|
||||
if (box) {
|
||||
url.searchParams.set("raycast-width", String(box.width));
|
||||
url.searchParams.set("raycast-height", String(box.height));
|
||||
}
|
||||
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an image into the shape a request needs.
|
||||
*
|
||||
* Ollama wants base64 with no `data:` URL prefix, which is why the payload is
|
||||
* built here rather than handed straight to the renderer. The dimensions are
|
||||
* kept for the same reason the path is: the transcript needs them long after
|
||||
* this call is over, and the preview layout depends on them.
|
||||
*/
|
||||
export async function readAttachment(path: string): Promise<PendingAttachment> {
|
||||
const bytes = await readFile(path);
|
||||
const size = pngSize(bytes);
|
||||
|
||||
return {
|
||||
path,
|
||||
mime: mimeForPath(path),
|
||||
base64: bytes.toString("base64"),
|
||||
width: size?.width,
|
||||
height: size?.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** The persistable half of a pending attachment: everything except the base64 payload. */
|
||||
export function toStoredAttachment(attachment: PendingAttachment): Attachment {
|
||||
const { path, mime, width, height } = attachment;
|
||||
return { path, mime, width, height };
|
||||
}
|
||||
Reference in New Issue
Block a user