github-search-cli 9.7.1 → 9.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/README.md +79 -24
- package/completions/ghfind.bash +5 -1
- package/completions/ghfind.fish +6 -1
- package/completions/ghfind.zsh +14 -1
- package/dist/cli.js +1506 -264
- package/package.json +7 -2
- package/release-notes/9.8.0.md +55 -0
- package/release-notes/9.8.1.md +74 -0
- package/src/agent-skill.ts +14 -0
- package/src/cli.ts +413 -1
- package/src/help.ts +2 -1
- package/src/image-loader.ts +277 -0
- package/src/image-render.ts +209 -0
- package/src/markdown-images.ts +123 -0
- package/src/markdown-view.ts +316 -0
- package/src/mcp-server.ts +60 -0
- package/src/png.ts +260 -0
- package/src/readme.ts +54 -0
- package/src/skill-finder.ts +445 -0
- package/src/tui.ts +72 -50
- package/src/markdown-render.ts +0 -211
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full markdown view for OpenTUI.
|
|
3
|
+
*
|
|
4
|
+
* A real `MarkdownRenderable` with a theme-derived syntax style, themed
|
|
5
|
+
* headings, and inline images drawn as half blocks. Used by the README
|
|
6
|
+
* viewer and by the update modal's release notes.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
BoxRenderable,
|
|
10
|
+
MarkdownRenderable,
|
|
11
|
+
RGBA,
|
|
12
|
+
StyledText,
|
|
13
|
+
SyntaxStyle,
|
|
14
|
+
TextRenderable,
|
|
15
|
+
createTextAttributes,
|
|
16
|
+
type CliRenderer,
|
|
17
|
+
type RenderNodeContext,
|
|
18
|
+
type Renderable,
|
|
19
|
+
type TextChunk,
|
|
20
|
+
} from "@opentui/core";
|
|
21
|
+
import {
|
|
22
|
+
rasterizeImage,
|
|
23
|
+
toImageRuns,
|
|
24
|
+
type HalfBlockImage,
|
|
25
|
+
type Rgb,
|
|
26
|
+
} from "./image-render";
|
|
27
|
+
import { resolveReadmeImageUrl } from "./image-loader";
|
|
28
|
+
import {
|
|
29
|
+
extractImageRefs,
|
|
30
|
+
inlinePlainText,
|
|
31
|
+
type BlockTokenLike,
|
|
32
|
+
type ImageRef,
|
|
33
|
+
} from "./markdown-images";
|
|
34
|
+
|
|
35
|
+
/** Tallest inline image, in terminal cells. */
|
|
36
|
+
export const MAX_IMAGE_ROWS = 24;
|
|
37
|
+
|
|
38
|
+
/** Cap on memoised rasters — a resize session re-keys on width, so this
|
|
39
|
+
* would otherwise grow without bound. */
|
|
40
|
+
const RASTER_CACHE_LIMIT = 32;
|
|
41
|
+
|
|
42
|
+
export interface MarkdownViewOptions {
|
|
43
|
+
renderer: CliRenderer;
|
|
44
|
+
/** Theme palette (see src/themes.ts). */
|
|
45
|
+
colors: Record<string, string>;
|
|
46
|
+
/** URL of the markdown document, used to resolve relative image paths. */
|
|
47
|
+
imageBase?: string;
|
|
48
|
+
/** Panel background, painted behind text, captions and images. Default: colors.bg. */
|
|
49
|
+
background?: string;
|
|
50
|
+
/** Render inline images. Default: true. */
|
|
51
|
+
images?: boolean;
|
|
52
|
+
/** Fetches bytes for a resolved image URL. */
|
|
53
|
+
loadImage?: (url: string) => Promise<Uint8Array | null>;
|
|
54
|
+
/** Width available for an image, in cells. */
|
|
55
|
+
getImageWidth?: () => number;
|
|
56
|
+
/** Height cap for one image, in cells. Default: MAX_IMAGE_ROWS. */
|
|
57
|
+
maxImageRows?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MarkdownView {
|
|
61
|
+
readonly renderable: MarkdownRenderable;
|
|
62
|
+
readonly imagesEnabled: boolean;
|
|
63
|
+
/** Replace the document; relative images resolve against `imageBase`. */
|
|
64
|
+
setContent(markdown: string, imageBase?: string): void;
|
|
65
|
+
/** Turn inline images on/off and rebuild the document. */
|
|
66
|
+
setImagesEnabled(enabled: boolean): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Syntax style for the markdown grammar's highlight groups. */
|
|
70
|
+
export function createMarkdownStyle(
|
|
71
|
+
colors: Record<string, string>,
|
|
72
|
+
): SyntaxStyle {
|
|
73
|
+
const surface = colors.surface ?? colors.bg;
|
|
74
|
+
return SyntaxStyle.fromStyles({
|
|
75
|
+
default: { fg: colors.text ?? "#ffffff" },
|
|
76
|
+
conceal: { fg: colors.muted ?? "#888888" },
|
|
77
|
+
"markup.heading": { fg: colors.accent ?? colors.blue, bold: true },
|
|
78
|
+
"markup.strong": { fg: colors.text ?? "#ffffff", bold: true },
|
|
79
|
+
"markup.italic": { fg: colors.text ?? "#ffffff", italic: true },
|
|
80
|
+
"markup.strikethrough": { fg: colors.muted ?? "#888888", dim: true },
|
|
81
|
+
"markup.raw": { fg: colors.green ?? "#a6e3a1", bg: surface },
|
|
82
|
+
"markup.raw.block": { fg: colors.green ?? "#a6e3a1" },
|
|
83
|
+
"markup.link": { fg: colors.accent ?? colors.blue },
|
|
84
|
+
"markup.link.label": {
|
|
85
|
+
fg: colors.accent ?? colors.blue,
|
|
86
|
+
underline: true,
|
|
87
|
+
},
|
|
88
|
+
"markup.link.url": { fg: colors.muted ?? "#888888", underline: true },
|
|
89
|
+
"markup.quote": { fg: colors.muted ?? "#888888", italic: true },
|
|
90
|
+
"markup.list": { fg: colors.blue ?? colors.accent },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hexToRgb(hex: string | undefined): Rgb {
|
|
95
|
+
const fallback: Rgb = [0, 0, 0];
|
|
96
|
+
if (!hex) return fallback;
|
|
97
|
+
const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
98
|
+
if (!match) return fallback;
|
|
99
|
+
const value = Number.parseInt(match[1], 16);
|
|
100
|
+
return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function rgba(color: Rgb): RGBA {
|
|
104
|
+
return RGBA.fromValues(color[0], color[1], color[2], 255);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Half-block rows as one styled-text block (one line per image row). */
|
|
108
|
+
function imageStyledText(image: HalfBlockImage): StyledText {
|
|
109
|
+
const chunks: TextChunk[] = [];
|
|
110
|
+
toImageRuns(image).forEach((runs, rowIndex) => {
|
|
111
|
+
if (rowIndex > 0) chunks.push({ __isChunk: true, text: "\n" });
|
|
112
|
+
for (const run of runs) {
|
|
113
|
+
chunks.push({
|
|
114
|
+
__isChunk: true,
|
|
115
|
+
text: run.text,
|
|
116
|
+
fg: rgba(run.top),
|
|
117
|
+
bg: rgba(run.bottom),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
return new StyledText(chunks);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build the markdown renderable and its block renderers.
|
|
126
|
+
*
|
|
127
|
+
* Two OpenTUI quirks shape the options below:
|
|
128
|
+
*
|
|
129
|
+
* - `streaming`: without a loaded tree-sitter markdown grammar a finished
|
|
130
|
+
* block has no highlights to draw and renders as blank lines, so the
|
|
131
|
+
* styled initial text has to be kept alive instead.
|
|
132
|
+
* - `internalBlockMode: "top-level"`: in the default coalesced mode every
|
|
133
|
+
* style refresh re-renders blocks with the built-in renderers, which
|
|
134
|
+
* throws away the custom heading/image renderables created here.
|
|
135
|
+
*/
|
|
136
|
+
export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
|
|
137
|
+
const { renderer, colors } = opts;
|
|
138
|
+
const maxImageRows = opts.maxImageRows ?? MAX_IMAGE_ROWS;
|
|
139
|
+
const rasterCache = new Map<string, Promise<HalfBlockImage | null>>();
|
|
140
|
+
let imagesEnabled = opts.images ?? true;
|
|
141
|
+
let imageBase = opts.imageBase;
|
|
142
|
+
|
|
143
|
+
const text = () => colors.text ?? "#ffffff";
|
|
144
|
+
const bg = () => opts.background ?? colors.bg ?? "#000000";
|
|
145
|
+
const muted = () => colors.muted ?? "#888888";
|
|
146
|
+
|
|
147
|
+
const renderable = new MarkdownRenderable(renderer, {
|
|
148
|
+
content: "",
|
|
149
|
+
syntaxStyle: createMarkdownStyle(colors),
|
|
150
|
+
fg: text(),
|
|
151
|
+
bg: bg(),
|
|
152
|
+
conceal: true,
|
|
153
|
+
streaming: true,
|
|
154
|
+
internalBlockMode: "top-level",
|
|
155
|
+
// Borderless columns are the top-level default; boxed tables read better
|
|
156
|
+
// for the multi-column tables READMEs use.
|
|
157
|
+
tableOptions: { style: "grid" },
|
|
158
|
+
width: "100%",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const caption = (label: string, color = muted()) =>
|
|
162
|
+
new TextRenderable(renderer, {
|
|
163
|
+
content: ` ${label}`,
|
|
164
|
+
fg: color,
|
|
165
|
+
bg: bg(),
|
|
166
|
+
wrapMode: "word",
|
|
167
|
+
flexShrink: 0,
|
|
168
|
+
width: "100%",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const rasterize = (url: string): Promise<HalfBlockImage | null> => {
|
|
172
|
+
// Size is part of the key: a resized terminal needs fresh cells.
|
|
173
|
+
const maxCols = Math.max(8, Math.floor(opts.getImageWidth?.() ?? 100));
|
|
174
|
+
const key = `${maxCols}|${url}`;
|
|
175
|
+
const cached = rasterCache.get(key);
|
|
176
|
+
if (cached) return cached;
|
|
177
|
+
const pending = (async () => {
|
|
178
|
+
const loader = opts.loadImage;
|
|
179
|
+
if (!loader) return null;
|
|
180
|
+
const bytes = await loader(url);
|
|
181
|
+
if (!bytes) return null;
|
|
182
|
+
return rasterizeImage(bytes, {
|
|
183
|
+
maxCols,
|
|
184
|
+
maxRows: maxImageRows,
|
|
185
|
+
background: hexToRgb(bg()),
|
|
186
|
+
});
|
|
187
|
+
})();
|
|
188
|
+
rasterCache.set(key, pending);
|
|
189
|
+
// Map iteration order is insertion order, so the first key is the oldest.
|
|
190
|
+
// Unbounded growth would re-rasterize every image on every resize.
|
|
191
|
+
while (rasterCache.size > RASTER_CACHE_LIMIT) {
|
|
192
|
+
const oldest = rasterCache.keys().next().value;
|
|
193
|
+
if (oldest === undefined) break;
|
|
194
|
+
rasterCache.delete(oldest);
|
|
195
|
+
}
|
|
196
|
+
return pending;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const imageNode = (image: ImageRef): Renderable => {
|
|
200
|
+
const label = `🖼 ${image.alt.trim() || "image"}`;
|
|
201
|
+
const url = imageBase ? resolveReadmeImageUrl(image.href, imageBase) : null;
|
|
202
|
+
if (!imagesEnabled || !url || !opts.loadImage) return caption(label);
|
|
203
|
+
|
|
204
|
+
const container = new BoxRenderable(renderer, {
|
|
205
|
+
flexDirection: "column",
|
|
206
|
+
flexShrink: 0,
|
|
207
|
+
width: "100%",
|
|
208
|
+
});
|
|
209
|
+
const placeholder = caption(`${label} (loading…)`);
|
|
210
|
+
container.add(placeholder);
|
|
211
|
+
|
|
212
|
+
void rasterize(url).then(
|
|
213
|
+
(cells) => {
|
|
214
|
+
// The document may have been replaced (or the overlay closed) while
|
|
215
|
+
// the image was downloading.
|
|
216
|
+
if (container.isDestroyed || !container.parent) return;
|
|
217
|
+
container.remove(placeholder);
|
|
218
|
+
placeholder.destroy();
|
|
219
|
+
container.add(caption(label));
|
|
220
|
+
if (cells) {
|
|
221
|
+
container.add(
|
|
222
|
+
new TextRenderable(renderer, {
|
|
223
|
+
content: imageStyledText(cells),
|
|
224
|
+
bg: bg(),
|
|
225
|
+
wrapMode: "none",
|
|
226
|
+
selectable: false,
|
|
227
|
+
flexShrink: 0,
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
renderable.requestRender();
|
|
232
|
+
},
|
|
233
|
+
(err) => {
|
|
234
|
+
// Swallow: a failed image should not crash the view.
|
|
235
|
+
void err;
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
return container;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const headingNode = (token: BlockTokenLike): Renderable | undefined => {
|
|
242
|
+
const heading = inlinePlainText(token.tokens).trim();
|
|
243
|
+
if (!heading) return undefined;
|
|
244
|
+
const depth = token.depth ?? 1;
|
|
245
|
+
const color = depth <= 2 ? colors.accent : (colors.teal ?? colors.accent);
|
|
246
|
+
return new TextRenderable(renderer, {
|
|
247
|
+
content: new StyledText([
|
|
248
|
+
{
|
|
249
|
+
__isChunk: true,
|
|
250
|
+
text: heading,
|
|
251
|
+
fg: RGBA.fromHex(color ?? "#ffffff"),
|
|
252
|
+
attributes: createTextAttributes({
|
|
253
|
+
bold: true,
|
|
254
|
+
underline: depth === 1,
|
|
255
|
+
}),
|
|
256
|
+
},
|
|
257
|
+
]),
|
|
258
|
+
bg: bg(),
|
|
259
|
+
wrapMode: "word",
|
|
260
|
+
flexShrink: 0,
|
|
261
|
+
width: "100%",
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const buildRenderNode =
|
|
266
|
+
() =>
|
|
267
|
+
(
|
|
268
|
+
token: BlockTokenLike,
|
|
269
|
+
_context: RenderNodeContext,
|
|
270
|
+
): Renderable | undefined => {
|
|
271
|
+
if (token.type === "heading") return headingNode(token);
|
|
272
|
+
const images = extractImageRefs(token);
|
|
273
|
+
if (images && images.length > 0) {
|
|
274
|
+
const box = new BoxRenderable(renderer, {
|
|
275
|
+
flexDirection: "column",
|
|
276
|
+
flexShrink: 0,
|
|
277
|
+
width: "100%",
|
|
278
|
+
marginTop: 1,
|
|
279
|
+
});
|
|
280
|
+
for (const image of images) box.add(imageNode(image));
|
|
281
|
+
return box;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
renderable.renderNode = buildRenderNode();
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
renderable,
|
|
290
|
+
get imagesEnabled() {
|
|
291
|
+
return imagesEnabled;
|
|
292
|
+
},
|
|
293
|
+
setContent(markdown: string, base?: string) {
|
|
294
|
+
if (base !== undefined) imageBase = base;
|
|
295
|
+
// Rebuild the style so theme switches (which mutate `colors`) apply.
|
|
296
|
+
renderable.syntaxStyle = createMarkdownStyle(colors);
|
|
297
|
+
renderable.fg = text();
|
|
298
|
+
renderable.bg = bg();
|
|
299
|
+
renderable.content = markdown;
|
|
300
|
+
// Finalize streaming so the last tokens are no longer marked unstable.
|
|
301
|
+
// Without this, OpenTUI keeps the trailing 2 blocks perpetually
|
|
302
|
+
// unstable in streaming mode, which recreates image render nodes (and
|
|
303
|
+
// their pending fetch promises) on every render cycle — the promise
|
|
304
|
+
// resolves after the loop idles and never settles.
|
|
305
|
+
renderable.streaming = false;
|
|
306
|
+
renderable.requestRender();
|
|
307
|
+
},
|
|
308
|
+
setImagesEnabled(enabled: boolean) {
|
|
309
|
+
if (imagesEnabled === enabled) return;
|
|
310
|
+
imagesEnabled = enabled;
|
|
311
|
+
// A fresh renderNode identity forces a full block rebuild.
|
|
312
|
+
renderable.renderNode = buildRenderNode();
|
|
313
|
+
renderable.requestRender();
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
package/src/mcp-server.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* ghfind_deep_dive deepdive.ts
|
|
18
18
|
* ghfind_bookmarked_releases releases.ts + bookmarks.ts (read-only)
|
|
19
19
|
* ghfind_skill agent-skill.ts
|
|
20
|
+
* ghfind_skill_search skill-finder.ts (local scan | skills.sh HTTP)
|
|
20
21
|
*
|
|
21
22
|
* Only the tools capability is declared (no prompts/resources yet), so those
|
|
22
23
|
* methods fall through to "Method not found".
|
|
@@ -47,6 +48,12 @@ import { getBookmarks } from "./bookmarks";
|
|
|
47
48
|
import { appendHistory } from "./history";
|
|
48
49
|
import { loadConfig } from "./config";
|
|
49
50
|
import { ensureBuiltinSkill, getSkill } from "./agent-skill";
|
|
51
|
+
import {
|
|
52
|
+
formatSkillSearchText,
|
|
53
|
+
searchSkills,
|
|
54
|
+
type SkillSearchResult,
|
|
55
|
+
type SkillSource,
|
|
56
|
+
} from "./skill-finder";
|
|
50
57
|
import { getVersion } from "./version";
|
|
51
58
|
|
|
52
59
|
// ─── Protocol constants ────────────────────────────────────────────────
|
|
@@ -212,6 +219,8 @@ const TRENDING_QUERY = { keywords: [], qualifiers: [], raw: "trending" };
|
|
|
212
219
|
// ─── Tool registry ─────────────────────────────────────────────────────
|
|
213
220
|
|
|
214
221
|
const REPO_SORTS = ["best-match", "stars", "updated", "forks"] as const;
|
|
222
|
+
/** Skill search sources: this machine, or the public skills.sh ecosystem. */
|
|
223
|
+
const SKILL_SOURCES = ["local", "registry"] as const;
|
|
215
224
|
const TRENDING_SINCE = ["daily", "weekly", "monthly"] as const;
|
|
216
225
|
const PKG_SORTS = ["best-match", "score", "downloads", "name"] as const;
|
|
217
226
|
|
|
@@ -599,6 +608,56 @@ const TOOLS: ToolDefinition[] = [
|
|
|
599
608
|
};
|
|
600
609
|
},
|
|
601
610
|
},
|
|
611
|
+
{
|
|
612
|
+
name: "ghfind_skill_search",
|
|
613
|
+
title: "Search agent skills",
|
|
614
|
+
description:
|
|
615
|
+
"Search installed agent skills (SKILL.md files under the user's skill directories) or the public skills.sh ecosystem by keyword, and return matching skills with their descriptions. Read-only: it never installs anything - registry hits include the `npx skills add` command for the user to run deliberately. Use when the user asks to find a skill for a task, or asks what skills are available.",
|
|
616
|
+
inputSchema: {
|
|
617
|
+
type: "object",
|
|
618
|
+
properties: {
|
|
619
|
+
query: {
|
|
620
|
+
type: "string",
|
|
621
|
+
description:
|
|
622
|
+
'Keyword to match against skill names and descriptions, e.g. "changelog" or "testing" (2+ characters).',
|
|
623
|
+
},
|
|
624
|
+
source: {
|
|
625
|
+
type: "string",
|
|
626
|
+
description:
|
|
627
|
+
"Where to search: 'local' for skills installed on this machine (default), 'registry' for the public skills.sh ecosystem.",
|
|
628
|
+
enum: [...SKILL_SOURCES],
|
|
629
|
+
},
|
|
630
|
+
limit: {
|
|
631
|
+
type: "number",
|
|
632
|
+
description: "Max results, 1-100 (default 20).",
|
|
633
|
+
minimum: 1,
|
|
634
|
+
maximum: 100,
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
required: ["query"],
|
|
638
|
+
},
|
|
639
|
+
async run(args) {
|
|
640
|
+
const source = coerceEnum<SkillSource>(
|
|
641
|
+
args.source,
|
|
642
|
+
SKILL_SOURCES,
|
|
643
|
+
"local",
|
|
644
|
+
);
|
|
645
|
+
const limit = coerceInt(args.limit, 20, 1, 100);
|
|
646
|
+
const result: SkillSearchResult = await searchSkills(
|
|
647
|
+
String(args.query ?? ""),
|
|
648
|
+
{ source, limit },
|
|
649
|
+
);
|
|
650
|
+
const where =
|
|
651
|
+
result.source === "local"
|
|
652
|
+
? "installed skills on this machine"
|
|
653
|
+
: "the public skills.sh ecosystem";
|
|
654
|
+
const header = `${result.count} match(es) for "${result.query}" in ${where}:`;
|
|
655
|
+
return {
|
|
656
|
+
text: `${header}\n\n${formatSkillSearchText(result)}`,
|
|
657
|
+
data: { ...result },
|
|
658
|
+
};
|
|
659
|
+
},
|
|
660
|
+
},
|
|
602
661
|
];
|
|
603
662
|
|
|
604
663
|
/** Tools/list payload (name, title, description, inputSchema only). */
|
|
@@ -737,6 +796,7 @@ export async function processServerLine(
|
|
|
737
796
|
},
|
|
738
797
|
instructions:
|
|
739
798
|
"ghfind gives agents GitHub search: repositories, trending, npm packages, org/user profiles, repo comparison and deep-dives. " +
|
|
799
|
+
"ghfind_skill_search finds agent skills, either installed on this machine or in the public skills.sh ecosystem. " +
|
|
740
800
|
"Call ghfind_skill for the full CLI guide. Unauthenticated requests are rate-limited to 60/hr; pass a token or set GITHUB_TOKEN for 5,000/hr.",
|
|
741
801
|
}),
|
|
742
802
|
);
|
package/src/png.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal PNG decoder — pure TypeScript, no native deps.
|
|
3
|
+
*
|
|
4
|
+
* Only used to turn an image into cells we can print, so it covers the
|
|
5
|
+
* subset every encoder emits: no interlace, all five colour types, bit
|
|
6
|
+
* depths 1–16, tRNS transparency. Palette + tRNS are expanded to RGBA so
|
|
7
|
+
* callers only ever see 8-bit RGBA.
|
|
8
|
+
*
|
|
9
|
+
* Sources that aren't PNG (JPEG, WebP, GIF, AVIF, …) are normalised
|
|
10
|
+
* upstream by `Bun.Image` before they get here.
|
|
11
|
+
*/
|
|
12
|
+
import { inflateSync } from "node:zlib";
|
|
13
|
+
|
|
14
|
+
export interface DecodedImage {
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
/** Row-major RGBA, 4 bytes per pixel. */
|
|
18
|
+
data: Uint8Array;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
22
|
+
|
|
23
|
+
/** Number of samples per pixel for each PNG colour type. */
|
|
24
|
+
function channelsForColorType(colorType: number): number {
|
|
25
|
+
switch (colorType) {
|
|
26
|
+
case 0:
|
|
27
|
+
return 1; // greyscale
|
|
28
|
+
case 2:
|
|
29
|
+
return 3; // truecolour
|
|
30
|
+
case 3:
|
|
31
|
+
return 1; // palette index
|
|
32
|
+
case 4:
|
|
33
|
+
return 2; // greyscale + alpha
|
|
34
|
+
case 6:
|
|
35
|
+
return 4; // truecolour + alpha
|
|
36
|
+
default:
|
|
37
|
+
throw new Error(`Unsupported PNG colour type ${colorType}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Paeth predictor, per the PNG spec (§9.4). */
|
|
42
|
+
function paeth(a: number, b: number, c: number): number {
|
|
43
|
+
const p = a + b - c;
|
|
44
|
+
const pa = Math.abs(p - a);
|
|
45
|
+
const pb = Math.abs(p - b);
|
|
46
|
+
const pc = Math.abs(p - c);
|
|
47
|
+
if (pa <= pb && pa <= pc) return a;
|
|
48
|
+
if (pb <= pc) return b;
|
|
49
|
+
return c;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Reverse the per-scanline filters applied by the encoder. */
|
|
53
|
+
function unfilter(
|
|
54
|
+
raw: Uint8Array,
|
|
55
|
+
height: number,
|
|
56
|
+
bytesPerRow: number,
|
|
57
|
+
bytesPerPixel: number,
|
|
58
|
+
): Uint8Array {
|
|
59
|
+
const out = new Uint8Array(height * bytesPerRow);
|
|
60
|
+
let src = 0;
|
|
61
|
+
for (let y = 0; y < height; y++) {
|
|
62
|
+
const filter = raw[src++];
|
|
63
|
+
const rowStart = y * bytesPerRow;
|
|
64
|
+
const prevStart = rowStart - bytesPerRow;
|
|
65
|
+
for (let x = 0; x < bytesPerRow; x++) {
|
|
66
|
+
const value = raw[src + x];
|
|
67
|
+
const left = x >= bytesPerPixel ? out[rowStart + x - bytesPerPixel] : 0;
|
|
68
|
+
const up = y > 0 ? out[prevStart + x] : 0;
|
|
69
|
+
const upLeft =
|
|
70
|
+
y > 0 && x >= bytesPerPixel ? out[prevStart + x - bytesPerPixel] : 0;
|
|
71
|
+
let result: number;
|
|
72
|
+
switch (filter) {
|
|
73
|
+
case 0:
|
|
74
|
+
result = value;
|
|
75
|
+
break;
|
|
76
|
+
case 1:
|
|
77
|
+
result = value + left;
|
|
78
|
+
break;
|
|
79
|
+
case 2:
|
|
80
|
+
result = value + up;
|
|
81
|
+
break;
|
|
82
|
+
case 3:
|
|
83
|
+
result = value + ((left + up) >> 1);
|
|
84
|
+
break;
|
|
85
|
+
case 4:
|
|
86
|
+
result = value + paeth(left, up, upLeft);
|
|
87
|
+
break;
|
|
88
|
+
default:
|
|
89
|
+
throw new Error(`Unsupported PNG filter type ${filter}`);
|
|
90
|
+
}
|
|
91
|
+
out[rowStart + x] = result & 0xff;
|
|
92
|
+
}
|
|
93
|
+
src += bytesPerRow;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read the `index`-th sample of a row for sub-byte bit depths. */
|
|
99
|
+
function sampleAt(
|
|
100
|
+
row: Uint8Array,
|
|
101
|
+
offsetInRow: number,
|
|
102
|
+
index: number,
|
|
103
|
+
bitDepth: number,
|
|
104
|
+
): number {
|
|
105
|
+
if (bitDepth === 8) return row[offsetInRow + index];
|
|
106
|
+
if (bitDepth === 16) return row[offsetInRow + index * 2];
|
|
107
|
+
const bitOffset = index * bitDepth;
|
|
108
|
+
const byte = row[offsetInRow + (bitOffset >> 3)];
|
|
109
|
+
const shift = 8 - bitDepth - (bitOffset & 7);
|
|
110
|
+
return (byte >> shift) & ((1 << bitDepth) - 1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Decode PNG bytes into 8-bit RGBA. Throws on unsupported variants. */
|
|
114
|
+
export function decodePng(bytes: Uint8Array): DecodedImage {
|
|
115
|
+
if (bytes.length < 8) throw new Error("PNG too short");
|
|
116
|
+
for (let i = 0; i < PNG_SIGNATURE.length; i++) {
|
|
117
|
+
if (bytes[i] !== PNG_SIGNATURE[i]) throw new Error("Not a PNG file");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let width = 0;
|
|
121
|
+
let height = 0;
|
|
122
|
+
let bitDepth = 8;
|
|
123
|
+
let colorType = 6;
|
|
124
|
+
let interlace = 0;
|
|
125
|
+
let palette: Uint8Array | null = null;
|
|
126
|
+
let transparency: Uint8Array | null = null;
|
|
127
|
+
const idat: Uint8Array[] = [];
|
|
128
|
+
|
|
129
|
+
let offset = 8;
|
|
130
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
131
|
+
while (offset + 8 <= bytes.length) {
|
|
132
|
+
const length = view.getUint32(offset);
|
|
133
|
+
const type = String.fromCharCode(
|
|
134
|
+
bytes[offset + 4],
|
|
135
|
+
bytes[offset + 5],
|
|
136
|
+
bytes[offset + 6],
|
|
137
|
+
bytes[offset + 7],
|
|
138
|
+
);
|
|
139
|
+
const dataStart = offset + 8;
|
|
140
|
+
const dataEnd = dataStart + length;
|
|
141
|
+
if (dataEnd > bytes.length) throw new Error("Truncated PNG chunk");
|
|
142
|
+
|
|
143
|
+
switch (type) {
|
|
144
|
+
case "IHDR": {
|
|
145
|
+
width = view.getUint32(dataStart);
|
|
146
|
+
height = view.getUint32(dataStart + 4);
|
|
147
|
+
bitDepth = bytes[dataStart + 8];
|
|
148
|
+
colorType = bytes[dataStart + 9];
|
|
149
|
+
interlace = bytes[dataStart + 12];
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "PLTE":
|
|
153
|
+
palette = bytes.subarray(dataStart, dataEnd);
|
|
154
|
+
break;
|
|
155
|
+
case "tRNS":
|
|
156
|
+
transparency = bytes.subarray(dataStart, dataEnd);
|
|
157
|
+
break;
|
|
158
|
+
case "IDAT":
|
|
159
|
+
idat.push(bytes.subarray(dataStart, dataEnd));
|
|
160
|
+
break;
|
|
161
|
+
default:
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
offset = dataEnd + 4; // skip CRC
|
|
165
|
+
if (type === "IEND") break;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (width === 0 || height === 0) throw new Error("PNG has no IHDR");
|
|
169
|
+
if (interlace !== 0) throw new Error("Interlaced PNG is not supported");
|
|
170
|
+
|
|
171
|
+
const channels = channelsForColorType(colorType);
|
|
172
|
+
const bitsPerPixel = channels * bitDepth;
|
|
173
|
+
const bytesPerRow = Math.ceil((width * bitsPerPixel) / 8);
|
|
174
|
+
const bytesPerPixel = Math.max(1, Math.ceil(bitsPerPixel / 8));
|
|
175
|
+
|
|
176
|
+
const compressed = idat.length === 1 ? idat[0] : concat(idat);
|
|
177
|
+
const raw = new Uint8Array(inflateSync(compressed));
|
|
178
|
+
// One filter byte per scanline plus the samples themselves.
|
|
179
|
+
if (raw.length < height * (bytesPerRow + 1)) {
|
|
180
|
+
throw new Error("Truncated PNG pixel data");
|
|
181
|
+
}
|
|
182
|
+
const rows = unfilter(raw, height, bytesPerRow, bytesPerPixel);
|
|
183
|
+
|
|
184
|
+
const out = new Uint8Array(width * height * 4);
|
|
185
|
+
const maxSample = (1 << Math.min(bitDepth, 8)) - 1;
|
|
186
|
+
const scale = bitDepth === 16 ? 1 / 257 : 255 / maxSample;
|
|
187
|
+
|
|
188
|
+
for (let y = 0; y < height; y++) {
|
|
189
|
+
const row = rows.subarray(y * bytesPerRow, (y + 1) * bytesPerRow);
|
|
190
|
+
for (let x = 0; x < width; x++) {
|
|
191
|
+
const to8 = (sample: number) =>
|
|
192
|
+
bitDepth === 8 || bitDepth === 16 ? sample : Math.round(sample * scale);
|
|
193
|
+
let r: number;
|
|
194
|
+
let g: number;
|
|
195
|
+
let b: number;
|
|
196
|
+
let a = 255;
|
|
197
|
+
switch (colorType) {
|
|
198
|
+
case 0: {
|
|
199
|
+
const grey = to8(sampleAt(row, 0, x, bitDepth));
|
|
200
|
+
r = g = b = grey;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case 2:
|
|
204
|
+
r = to8(sampleAt(row, 0, x * 3, bitDepth));
|
|
205
|
+
g = to8(sampleAt(row, 0, x * 3 + 1, bitDepth));
|
|
206
|
+
b = to8(sampleAt(row, 0, x * 3 + 2, bitDepth));
|
|
207
|
+
break;
|
|
208
|
+
case 3: {
|
|
209
|
+
const index = sampleAt(row, 0, x, bitDepth);
|
|
210
|
+
const p = index * 3;
|
|
211
|
+
if (!palette || p + 2 >= palette.length)
|
|
212
|
+
throw new Error("PNG palette entry missing");
|
|
213
|
+
r = palette[p];
|
|
214
|
+
g = palette[p + 1];
|
|
215
|
+
b = palette[p + 2];
|
|
216
|
+
if (transparency && index < transparency.length)
|
|
217
|
+
a = transparency[index];
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case 4: {
|
|
221
|
+
const grey = to8(sampleAt(row, 0, x * 2, bitDepth));
|
|
222
|
+
r = g = b = grey;
|
|
223
|
+
a = to8(sampleAt(row, 0, x * 2 + 1, bitDepth));
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
default: {
|
|
227
|
+
r = to8(sampleAt(row, 0, x * 4, bitDepth));
|
|
228
|
+
g = to8(sampleAt(row, 0, x * 4 + 1, bitDepth));
|
|
229
|
+
b = to8(sampleAt(row, 0, x * 4 + 2, bitDepth));
|
|
230
|
+
a = to8(sampleAt(row, 0, x * 4 + 3, bitDepth));
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (colorType === 0 && transparency && transparency.length >= 2) {
|
|
235
|
+
const key = (transparency[0] << 8) | transparency[1];
|
|
236
|
+
const sample = sampleAt(row, 0, x, bitDepth);
|
|
237
|
+
if (sample === key) a = 0;
|
|
238
|
+
}
|
|
239
|
+
const o = (y * width + x) * 4;
|
|
240
|
+
out[o] = r;
|
|
241
|
+
out[o + 1] = g;
|
|
242
|
+
out[o + 2] = b;
|
|
243
|
+
out[o + 3] = a;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { width, height, data: out };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function concat(chunks: Uint8Array[]): Uint8Array {
|
|
251
|
+
let total = 0;
|
|
252
|
+
for (const c of chunks) total += c.length;
|
|
253
|
+
const merged = new Uint8Array(total);
|
|
254
|
+
let offset = 0;
|
|
255
|
+
for (const c of chunks) {
|
|
256
|
+
merged.set(c, offset);
|
|
257
|
+
offset += c.length;
|
|
258
|
+
}
|
|
259
|
+
return merged;
|
|
260
|
+
}
|