@narumitw/pi-jupyter 0.1.4 → 0.33.0
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/LICENSE +21 -0
- package/README.md +129 -118
- package/package.json +44 -34
- package/src/index.ts +1 -0
- package/src/jupyter-command.ts +138 -0
- package/src/jupyter-menu.ts +310 -0
- package/src/jupyter-preview.ts +579 -0
- package/src/notebook-panel.ts +204 -0
- package/src/notebook.ts +237 -0
- package/src/png-thumbnail.ts +237 -0
- package/extensions/index.ts +0 -882
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { basename, relative } from "node:path";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
type Component,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
type OverlayOptions,
|
|
8
|
+
type TUI,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
visibleWidth,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import {
|
|
13
|
+
type LoadedNotebook,
|
|
14
|
+
type Notebook,
|
|
15
|
+
renderNotebookBody,
|
|
16
|
+
sanitizeTerminalText,
|
|
17
|
+
wrapBoxLines,
|
|
18
|
+
} from "./notebook.js";
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_PANEL_WIDTH_PERCENT = 42;
|
|
21
|
+
export const MIN_PANEL_WIDTH = 42;
|
|
22
|
+
export const MIN_EDITOR_WIDTH = 24;
|
|
23
|
+
export const RIGHT_MARGIN = 1;
|
|
24
|
+
|
|
25
|
+
export type PreviewState = {
|
|
26
|
+
path?: string;
|
|
27
|
+
cwd: string;
|
|
28
|
+
visible: boolean;
|
|
29
|
+
focused: boolean;
|
|
30
|
+
scroll: number;
|
|
31
|
+
lastLoadedAt?: Date;
|
|
32
|
+
lastMtime?: Date;
|
|
33
|
+
lastError?: string;
|
|
34
|
+
model?: Notebook;
|
|
35
|
+
panelWidth?: number;
|
|
36
|
+
resizing?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function applyLoadedNotebook(state: PreviewState, loaded: LoadedNotebook): void {
|
|
40
|
+
state.model = loaded.model;
|
|
41
|
+
state.lastMtime = loaded.lastMtime;
|
|
42
|
+
state.lastLoadedAt = loaded.lastLoadedAt;
|
|
43
|
+
state.lastError = undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class NotebookPreviewPanel implements Component {
|
|
47
|
+
private readonly tui: TUI;
|
|
48
|
+
private readonly theme: Theme;
|
|
49
|
+
private readonly state: PreviewState;
|
|
50
|
+
private readonly releaseFocus: () => void;
|
|
51
|
+
|
|
52
|
+
constructor(tui: TUI, theme: Theme, state: PreviewState, releaseFocus: () => void) {
|
|
53
|
+
this.tui = tui;
|
|
54
|
+
this.theme = theme;
|
|
55
|
+
this.state = state;
|
|
56
|
+
this.releaseFocus = releaseFocus;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
handleInput(data: string): void {
|
|
60
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, "f8")) {
|
|
61
|
+
this.releaseFocus();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const page = 10;
|
|
65
|
+
if (matchesKey(data, Key.up) || data === "k")
|
|
66
|
+
this.state.scroll = Math.max(0, this.state.scroll - 1);
|
|
67
|
+
else if (matchesKey(data, Key.down) || data === "j") this.state.scroll += 1;
|
|
68
|
+
else if (matchesKey(data, Key.home) || data === "g") this.state.scroll = 0;
|
|
69
|
+
else if (matchesKey(data, Key.pageUp) || data === "u") {
|
|
70
|
+
this.state.scroll = Math.max(0, this.state.scroll - page);
|
|
71
|
+
} else if (matchesKey(data, Key.pageDown) || data === "d") this.state.scroll += page;
|
|
72
|
+
else return;
|
|
73
|
+
this.tui.requestRender();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
render(width: number): string[] {
|
|
77
|
+
const inner = Math.max(1, width - 2);
|
|
78
|
+
const border = (text: string) => this.theme.fg("border", text);
|
|
79
|
+
const accent = (text: string) => this.theme.fg("accent", text);
|
|
80
|
+
const dim = (text: string) => this.theme.fg("dim", text);
|
|
81
|
+
const error = (text: string) => this.theme.fg("error", text);
|
|
82
|
+
const pad = (text = "") => {
|
|
83
|
+
const truncated = truncateToWidth(text, inner, "…", true);
|
|
84
|
+
return `${border("│")}${truncated}${" ".repeat(Math.max(0, inner - visibleWidth(truncated)))}${border("│")}`;
|
|
85
|
+
};
|
|
86
|
+
const pathLabel = this.state.path
|
|
87
|
+
? sanitizeTerminalText(relative(this.state.cwd, this.state.path) || basename(this.state.path))
|
|
88
|
+
: "no notebook";
|
|
89
|
+
const title = `${this.state.resizing ? "↔ " : this.state.focused ? "● " : ""}Jupyter Preview`;
|
|
90
|
+
const lines = [border(`╭${"─".repeat(inner)}╮`), pad(` ${accent(title)} ${dim(pathLabel)}`)];
|
|
91
|
+
lines.push(`${border("├")}${border("─".repeat(inner))}${border("┤")}`);
|
|
92
|
+
|
|
93
|
+
if (!this.state.path) {
|
|
94
|
+
lines.push(pad(" No notebook selected."), pad(dim(" Run /jupyter to choose one.")));
|
|
95
|
+
} else if (this.state.lastError && !this.state.model) {
|
|
96
|
+
lines.push(
|
|
97
|
+
...wrapBoxLines(error(sanitizeTerminalText(this.state.lastError)), inner).map(pad),
|
|
98
|
+
);
|
|
99
|
+
} else if (!this.state.model) lines.push(pad(dim(" Loading…")));
|
|
100
|
+
else {
|
|
101
|
+
if (this.state.lastError) {
|
|
102
|
+
lines.push(
|
|
103
|
+
...wrapBoxLines(
|
|
104
|
+
error(
|
|
105
|
+
` Refresh failed; showing last valid version: ${sanitizeTerminalText(this.state.lastError)}`,
|
|
106
|
+
),
|
|
107
|
+
inner,
|
|
108
|
+
).map(pad),
|
|
109
|
+
pad(),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const body = renderNotebookBody(this.state, inner, this.theme);
|
|
113
|
+
const maximumScroll = Math.max(0, body.length - 3);
|
|
114
|
+
this.state.scroll = Math.min(this.state.scroll, maximumScroll);
|
|
115
|
+
lines.push(...body.slice(this.state.scroll).map(pad));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
lines.push(`${border("├")}${border("─".repeat(inner))}${border("┤")}`);
|
|
119
|
+
lines.push(pad(dim(previewFooter(this.state, inner))), border(`╰${"─".repeat(inner)}╯`));
|
|
120
|
+
return lines;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
invalidate(): void {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type MouseEvent = { button: number; x: number; y: number; released: boolean };
|
|
127
|
+
|
|
128
|
+
export function installMouseResize(
|
|
129
|
+
tui: TUI,
|
|
130
|
+
state: PreviewState,
|
|
131
|
+
overlayOptions: OverlayOptions,
|
|
132
|
+
requestRender: () => void,
|
|
133
|
+
): () => void {
|
|
134
|
+
const terminal = tui.terminal;
|
|
135
|
+
terminal.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h");
|
|
136
|
+
const removeListener = tui.addInputListener((data) => {
|
|
137
|
+
const event = parseSgrMouseEvent(data);
|
|
138
|
+
if (!event) return undefined;
|
|
139
|
+
const isPrimaryButton = (event.button & 3) === 0;
|
|
140
|
+
const isMotion = (event.button & 32) !== 0;
|
|
141
|
+
const handleX = getPanelLeftBorderX(terminal.columns, state);
|
|
142
|
+
if (event.released && state.resizing) {
|
|
143
|
+
state.resizing = false;
|
|
144
|
+
requestRender();
|
|
145
|
+
return { consume: true };
|
|
146
|
+
}
|
|
147
|
+
if (!state.resizing && isPrimaryButton && Math.abs(event.x - handleX) <= 1)
|
|
148
|
+
state.resizing = true;
|
|
149
|
+
if (!state.resizing || !isPrimaryButton) return undefined;
|
|
150
|
+
if (isMotion || event.x !== handleX) {
|
|
151
|
+
const width = clampPanelWidth(
|
|
152
|
+
terminal.columns - RIGHT_MARGIN - (event.x - 1),
|
|
153
|
+
terminal.columns,
|
|
154
|
+
);
|
|
155
|
+
state.panelWidth = width;
|
|
156
|
+
overlayOptions.width = width;
|
|
157
|
+
requestRender();
|
|
158
|
+
}
|
|
159
|
+
return { consume: true };
|
|
160
|
+
});
|
|
161
|
+
return () => {
|
|
162
|
+
state.resizing = false;
|
|
163
|
+
removeListener();
|
|
164
|
+
terminal.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l");
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function previewFooter(state: PreviewState, width: number): string {
|
|
169
|
+
if (state.resizing) return " Drag to resize width";
|
|
170
|
+
if (state.focused) {
|
|
171
|
+
return width >= 58
|
|
172
|
+
? " ↑↓ PgUp/PgDn or j/k/u/d scroll • Esc/F8 return"
|
|
173
|
+
: " ↑↓ scroll • Esc/F8 return";
|
|
174
|
+
}
|
|
175
|
+
return width >= 58
|
|
176
|
+
? " Drag left border resize • Ctrl+Alt+j/k scroll • Shift+F8 focus"
|
|
177
|
+
: " Shift+F8 focus • F8 close";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function parseSgrMouseEvent(data: string): MouseEvent | undefined {
|
|
181
|
+
const prefix = "\x1b[<";
|
|
182
|
+
if (!data.startsWith(prefix)) return undefined;
|
|
183
|
+
const suffix = data.at(-1);
|
|
184
|
+
if (suffix !== "M" && suffix !== "m") return undefined;
|
|
185
|
+
const parts = data.slice(prefix.length, -1).split(";");
|
|
186
|
+
if (parts.length !== 3) return undefined;
|
|
187
|
+
const [button, x, y] = parts.map((part) => Number.parseInt(part, 10));
|
|
188
|
+
if (button === undefined || x === undefined || y === undefined) return undefined;
|
|
189
|
+
if (![button, x, y].every(Number.isFinite)) return undefined;
|
|
190
|
+
return { button, x, y, released: suffix === "m" };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function getPanelLeftBorderX(termWidth: number, state: PreviewState): number {
|
|
194
|
+
const width = clampPanelWidth(
|
|
195
|
+
state.panelWidth ?? Math.floor((termWidth * DEFAULT_PANEL_WIDTH_PERCENT) / 100),
|
|
196
|
+
termWidth,
|
|
197
|
+
);
|
|
198
|
+
return termWidth - RIGHT_MARGIN - width + 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function clampPanelWidth(width: number, termWidth: number): number {
|
|
202
|
+
const maxWidth = Math.max(MIN_PANEL_WIDTH, termWidth - RIGHT_MARGIN - MIN_EDITOR_WIDTH);
|
|
203
|
+
return Math.max(MIN_PANEL_WIDTH, Math.min(maxWidth, Math.round(width)));
|
|
204
|
+
}
|
package/src/notebook.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { type FileHandle, open } from "node:fs/promises";
|
|
3
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Image, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
5
|
+
import { decodePng, renderPngThumbnail } from "./png-thumbnail.js";
|
|
6
|
+
|
|
7
|
+
export const MAX_NOTEBOOK_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export type NotebookCell = {
|
|
10
|
+
cell_type?: string;
|
|
11
|
+
execution_count?: number | null;
|
|
12
|
+
source?: string | string[];
|
|
13
|
+
outputs?: Array<Record<string, unknown>>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type Notebook = {
|
|
17
|
+
cells?: NotebookCell[];
|
|
18
|
+
metadata?: Record<string, unknown>;
|
|
19
|
+
nbformat?: number;
|
|
20
|
+
nbformat_minor?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type LoadedNotebook = {
|
|
24
|
+
model: Notebook;
|
|
25
|
+
lastMtime: Date;
|
|
26
|
+
lastLoadedAt: Date;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type NotebookRenderState = Partial<LoadedNotebook> & {
|
|
30
|
+
model?: Notebook;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function loadNotebook(path: string, signal?: AbortSignal): Promise<LoadedNotebook> {
|
|
34
|
+
signal?.throwIfAborted();
|
|
35
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
36
|
+
try {
|
|
37
|
+
signal?.throwIfAborted();
|
|
38
|
+
const info = await file.stat();
|
|
39
|
+
if (!info.isFile()) throw new Error("notebook path is not a regular file");
|
|
40
|
+
if (info.size > MAX_NOTEBOOK_BYTES) {
|
|
41
|
+
throw new Error(`notebook exceeds the ${MAX_NOTEBOOK_BYTES / 1024 / 1024} MB preview limit`);
|
|
42
|
+
}
|
|
43
|
+
const model = JSON.parse(await readBoundedUtf8(file, signal)) as Notebook;
|
|
44
|
+
validateNotebook(model);
|
|
45
|
+
return { model, lastMtime: info.mtime, lastLoadedAt: new Date() };
|
|
46
|
+
} finally {
|
|
47
|
+
await file.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readBoundedUtf8(file: FileHandle, signal?: AbortSignal): Promise<string> {
|
|
52
|
+
const chunks: Buffer[] = [];
|
|
53
|
+
let total = 0;
|
|
54
|
+
while (total <= MAX_NOTEBOOK_BYTES) {
|
|
55
|
+
signal?.throwIfAborted();
|
|
56
|
+
const buffer = Buffer.alloc(Math.min(64 * 1024, MAX_NOTEBOOK_BYTES + 1 - total));
|
|
57
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, null);
|
|
58
|
+
if (bytesRead === 0) break;
|
|
59
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
60
|
+
total += bytesRead;
|
|
61
|
+
}
|
|
62
|
+
if (total > MAX_NOTEBOOK_BYTES) {
|
|
63
|
+
throw new Error(`notebook exceeds the ${MAX_NOTEBOOK_BYTES / 1024 / 1024} MB preview limit`);
|
|
64
|
+
}
|
|
65
|
+
return Buffer.concat(chunks, total).toString("utf8");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateNotebook(value: unknown): asserts value is Notebook {
|
|
69
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
70
|
+
throw new Error("notebook root must be an object");
|
|
71
|
+
}
|
|
72
|
+
const notebook = value as Notebook;
|
|
73
|
+
if (notebook.cells !== undefined && !Array.isArray(notebook.cells)) {
|
|
74
|
+
throw new Error("notebook cells must be an array");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function renderNotebookBody(
|
|
79
|
+
state: NotebookRenderState,
|
|
80
|
+
width: number,
|
|
81
|
+
theme: Theme,
|
|
82
|
+
): string[] {
|
|
83
|
+
const notebook = state.model;
|
|
84
|
+
if (!notebook) return [];
|
|
85
|
+
const cells = Array.isArray(notebook.cells) ? notebook.cells : [];
|
|
86
|
+
const dim = (text: string) => theme.fg("dim", text);
|
|
87
|
+
const accent = (text: string) => theme.fg("accent", text);
|
|
88
|
+
const success = (text: string) => theme.fg("success", text);
|
|
89
|
+
const warning = (text: string) => theme.fg("warning", text);
|
|
90
|
+
const error = (text: string) => theme.fg("error", text);
|
|
91
|
+
const loaded = state.lastLoadedAt?.toLocaleTimeString() ?? "unknown";
|
|
92
|
+
const mtime = state.lastMtime?.toLocaleTimeString() ?? "unknown";
|
|
93
|
+
const lines = [
|
|
94
|
+
` ${success("✓")} ${cells.length} cells ${dim(`loaded ${loaded}, mtime ${mtime}`)}`,
|
|
95
|
+
"",
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
cells.forEach((cell, index) => {
|
|
99
|
+
const type = sanitizeTerminalText(cell.cell_type ?? "unknown");
|
|
100
|
+
const execution = cell.execution_count == null ? "" : ` In [${cell.execution_count}]`;
|
|
101
|
+
const color = type === "markdown" ? accent : type === "code" ? success : warning;
|
|
102
|
+
lines.push(color(` ${index + 1}. ${type}${execution}`));
|
|
103
|
+
|
|
104
|
+
const source = sanitizeTerminalText(normalizeSource(cell.source)).trimEnd();
|
|
105
|
+
const sourceLines = source.length > 0 ? source.split("\n") : [dim("(empty)")];
|
|
106
|
+
for (const line of sourceLines.slice(0, 12)) lines.push(...wrapBoxLines(` ${line}`, width));
|
|
107
|
+
if (sourceLines.length > 12)
|
|
108
|
+
lines.push(dim(` … ${sourceLines.length - 12} more source lines`));
|
|
109
|
+
|
|
110
|
+
if (type === "code" && Array.isArray(cell.outputs) && cell.outputs.length > 0) {
|
|
111
|
+
const outputLines = renderOutputs(cell.outputs, width, theme);
|
|
112
|
+
if (outputLines.length > 0) {
|
|
113
|
+
lines.push(dim(" output:"));
|
|
114
|
+
for (const outputLine of outputLines.slice(0, 24)) {
|
|
115
|
+
const styled = outputLine.startsWith("Error:") ? error(outputLine) : outputLine;
|
|
116
|
+
lines.push(...wrapBoxLines(` ${styled}`, width));
|
|
117
|
+
}
|
|
118
|
+
if (outputLines.length > 24) {
|
|
119
|
+
lines.push(dim(` … ${outputLines.length - 24} more output lines`));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
lines.push("");
|
|
124
|
+
});
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renderOutputs(
|
|
129
|
+
outputs: Array<Record<string, unknown>>,
|
|
130
|
+
width: number,
|
|
131
|
+
theme: Theme,
|
|
132
|
+
): string[] {
|
|
133
|
+
const lines: string[] = [];
|
|
134
|
+
const dim = (text: string) => theme.fg("dim", text);
|
|
135
|
+
for (const output of outputs) {
|
|
136
|
+
const outputType = sanitizeTerminalText(String(output.output_type ?? "output"));
|
|
137
|
+
if (outputType === "stream") {
|
|
138
|
+
lines.push(
|
|
139
|
+
...sanitizeTerminalText(normalizeSource(output.text as string | string[] | undefined))
|
|
140
|
+
.split("\n")
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.map(dim),
|
|
143
|
+
);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (outputType === "error") {
|
|
147
|
+
const name = sanitizeTerminalText(String(output.ename ?? "Error"));
|
|
148
|
+
const value = sanitizeTerminalText(String(output.evalue ?? ""));
|
|
149
|
+
lines.push(`Error: ${name}${value ? `: ${value}` : ""}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const data = output.data as Record<string, unknown> | undefined;
|
|
154
|
+
if (data) {
|
|
155
|
+
const imageMime = Object.keys(data).find((key) => key.startsWith("image/"));
|
|
156
|
+
if (imageMime) {
|
|
157
|
+
lines.push(dim(`${sanitizeTerminalText(imageMime)}:`));
|
|
158
|
+
lines.push(
|
|
159
|
+
...renderInlineImage(
|
|
160
|
+
normalizeSource(data[imageMime] as string | string[]),
|
|
161
|
+
imageMime,
|
|
162
|
+
width,
|
|
163
|
+
theme,
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const text = data["text/plain"] ?? data["text/markdown"];
|
|
168
|
+
if (typeof text === "string" || Array.isArray(text)) {
|
|
169
|
+
lines.push(
|
|
170
|
+
...sanitizeTerminalText(normalizeSource(text as string | string[]))
|
|
171
|
+
.split("\n")
|
|
172
|
+
.filter(Boolean)
|
|
173
|
+
.map(dim),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (imageMime || typeof text === "string" || Array.isArray(text)) continue;
|
|
177
|
+
}
|
|
178
|
+
lines.push(dim(`[${outputType}]`));
|
|
179
|
+
}
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderInlineImage(
|
|
184
|
+
base64Data: string,
|
|
185
|
+
mimeType: string,
|
|
186
|
+
width: number,
|
|
187
|
+
theme: Theme,
|
|
188
|
+
): string[] {
|
|
189
|
+
const cleanBase64 = base64Data.replace(/\s+/g, "");
|
|
190
|
+
if (!cleanBase64) return [theme.fg("warning", `[empty ${mimeType} output]`)];
|
|
191
|
+
if (mimeType === "image/png") {
|
|
192
|
+
try {
|
|
193
|
+
return renderPngThumbnail(decodePng(cleanBase64), Math.max(8, Math.min(60, width - 8)), 16);
|
|
194
|
+
} catch (cause) {
|
|
195
|
+
return [theme.fg("warning", `[${mimeType} thumbnail failed: ${errorMessage(cause)}]`)];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const image = new Image(
|
|
200
|
+
cleanBase64,
|
|
201
|
+
mimeType,
|
|
202
|
+
{ fallbackColor: (text: string) => theme.fg("muted", text) },
|
|
203
|
+
{ maxWidthCells: Math.max(8, Math.min(60, width - 8)), maxHeightCells: 16 },
|
|
204
|
+
);
|
|
205
|
+
return image.render(Math.max(10, width - 8));
|
|
206
|
+
} catch (cause) {
|
|
207
|
+
return [theme.fg("warning", `[${mimeType} output: ${errorMessage(cause)}]`)];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function sanitizeTerminalText(text: string): string {
|
|
212
|
+
let sanitized = "";
|
|
213
|
+
for (const character of text) {
|
|
214
|
+
const code = character.codePointAt(0) ?? 0;
|
|
215
|
+
const isUnsafe =
|
|
216
|
+
(code < 0x20 && code !== 0x09 && code !== 0x0a) || (code >= 0x7f && code <= 0x9f);
|
|
217
|
+
sanitized += isUnsafe ? `\\x${code.toString(16).padStart(2, "0")}` : character;
|
|
218
|
+
}
|
|
219
|
+
return sanitized;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function errorMessage(cause: unknown): string {
|
|
223
|
+
return sanitizeTerminalText(cause instanceof Error ? cause.message : String(cause));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function normalizeSource(source: string | string[] | undefined): string {
|
|
227
|
+
if (Array.isArray(source)) return source.join("");
|
|
228
|
+
return typeof source === "string" ? source : "";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function wrapBoxLines(text: string, width: number): string[] {
|
|
232
|
+
const max = Math.max(1, width - 1);
|
|
233
|
+
return wrapTextWithAnsi(text, max).map((line) => {
|
|
234
|
+
if (visibleWidth(line) <= max) return line;
|
|
235
|
+
return truncateToWidth(line, max, "…", true);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { inflateSync } from "node:zlib";
|
|
2
|
+
|
|
3
|
+
export type DecodedPng = {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
pixels: Uint8ClampedArray;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const MAX_PNG_PIXELS = 16_000_000;
|
|
10
|
+
const MAX_INFLATED_BYTES = MAX_PNG_PIXELS * 4 + 4096;
|
|
11
|
+
|
|
12
|
+
export function decodePng(base64Data: string): DecodedPng {
|
|
13
|
+
const bytes = Buffer.from(base64Data, "base64");
|
|
14
|
+
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
15
|
+
if (bytes.length < signature.length || !bytes.subarray(0, signature.length).equals(signature)) {
|
|
16
|
+
throw new Error("not a PNG");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let offset = 8;
|
|
20
|
+
let width = 0;
|
|
21
|
+
let height = 0;
|
|
22
|
+
let bitDepth = 0;
|
|
23
|
+
let colorType = 0;
|
|
24
|
+
let palette: Buffer | undefined;
|
|
25
|
+
let transparency: Buffer | undefined;
|
|
26
|
+
const idat: Buffer[] = [];
|
|
27
|
+
|
|
28
|
+
while (offset + 12 <= bytes.length) {
|
|
29
|
+
const length = bytes.readUInt32BE(offset);
|
|
30
|
+
const chunkEnd = offset + 12 + length;
|
|
31
|
+
if (chunkEnd > bytes.length) throw new Error("truncated PNG chunk");
|
|
32
|
+
const type = bytes.subarray(offset + 4, offset + 8).toString("ascii");
|
|
33
|
+
const data = bytes.subarray(offset + 8, offset + 8 + length);
|
|
34
|
+
offset = chunkEnd;
|
|
35
|
+
|
|
36
|
+
if (type === "IHDR") {
|
|
37
|
+
if (data.length !== 13) throw new Error("invalid PNG header");
|
|
38
|
+
width = data.readUInt32BE(0);
|
|
39
|
+
height = data.readUInt32BE(4);
|
|
40
|
+
bitDepth = data[8] ?? 0;
|
|
41
|
+
colorType = data[9] ?? 0;
|
|
42
|
+
if (data[10] !== 0 || data[11] !== 0 || data[12] !== 0) {
|
|
43
|
+
throw new Error("unsupported PNG format");
|
|
44
|
+
}
|
|
45
|
+
} else if (type === "PLTE") palette = Buffer.from(data);
|
|
46
|
+
else if (type === "tRNS") transparency = Buffer.from(data);
|
|
47
|
+
else if (type === "IDAT") idat.push(Buffer.from(data));
|
|
48
|
+
else if (type === "IEND") break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!width || !height || idat.length === 0) throw new Error("missing PNG data");
|
|
52
|
+
if (width * height > MAX_PNG_PIXELS) throw new Error("PNG dimensions are too large");
|
|
53
|
+
if (bitDepth !== 8) throw new Error(`unsupported PNG bit depth ${bitDepth}`);
|
|
54
|
+
|
|
55
|
+
const channels = pngChannels(colorType);
|
|
56
|
+
const stride = width * channels;
|
|
57
|
+
const expectedBytes = height * (stride + 1);
|
|
58
|
+
if (expectedBytes > MAX_INFLATED_BYTES) throw new Error("PNG output is too large");
|
|
59
|
+
const inflated = inflateSync(Buffer.concat(idat), { maxOutputLength: expectedBytes });
|
|
60
|
+
if (inflated.length !== expectedBytes) throw new Error("invalid PNG scanline data");
|
|
61
|
+
|
|
62
|
+
const raw = Buffer.alloc(height * stride);
|
|
63
|
+
let inOffset = 0;
|
|
64
|
+
let outOffset = 0;
|
|
65
|
+
let previous = Buffer.alloc(stride);
|
|
66
|
+
for (let y = 0; y < height; y++) {
|
|
67
|
+
const filter = inflated[inOffset++];
|
|
68
|
+
const scanline = inflated.subarray(inOffset, inOffset + stride);
|
|
69
|
+
inOffset += stride;
|
|
70
|
+
const recon = Buffer.alloc(stride);
|
|
71
|
+
for (let x = 0; x < stride; x++) {
|
|
72
|
+
const left = x >= channels ? (recon[x - channels] ?? 0) : 0;
|
|
73
|
+
const up = previous[x] ?? 0;
|
|
74
|
+
const upLeft = x >= channels ? (previous[x - channels] ?? 0) : 0;
|
|
75
|
+
const value = scanline[x] ?? 0;
|
|
76
|
+
switch (filter) {
|
|
77
|
+
case 0:
|
|
78
|
+
recon[x] = value;
|
|
79
|
+
break;
|
|
80
|
+
case 1:
|
|
81
|
+
recon[x] = (value + left) & 0xff;
|
|
82
|
+
break;
|
|
83
|
+
case 2:
|
|
84
|
+
recon[x] = (value + up) & 0xff;
|
|
85
|
+
break;
|
|
86
|
+
case 3:
|
|
87
|
+
recon[x] = (value + Math.floor((left + up) / 2)) & 0xff;
|
|
88
|
+
break;
|
|
89
|
+
case 4:
|
|
90
|
+
recon[x] = (value + paeth(left, up, upLeft)) & 0xff;
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
throw new Error(`unsupported PNG filter ${filter}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
recon.copy(raw, outOffset);
|
|
97
|
+
outOffset += stride;
|
|
98
|
+
previous = recon;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
width,
|
|
103
|
+
height,
|
|
104
|
+
pixels: convertToRgba(raw, width, height, colorType, palette, transparency),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function convertToRgba(
|
|
109
|
+
raw: Buffer,
|
|
110
|
+
width: number,
|
|
111
|
+
height: number,
|
|
112
|
+
colorType: number,
|
|
113
|
+
palette?: Buffer,
|
|
114
|
+
transparency?: Buffer,
|
|
115
|
+
): Uint8ClampedArray {
|
|
116
|
+
const pixels = new Uint8ClampedArray(width * height * 4);
|
|
117
|
+
for (let i = 0, p = 0; i < raw.length; p++) {
|
|
118
|
+
let r = 0;
|
|
119
|
+
let g = 0;
|
|
120
|
+
let b = 0;
|
|
121
|
+
let a = 255;
|
|
122
|
+
if (colorType === 0) {
|
|
123
|
+
r = g = b = raw[i++] ?? 0;
|
|
124
|
+
if (transparency?.length === 2 && r === transparency.readUInt16BE(0)) a = 0;
|
|
125
|
+
} else if (colorType === 2) {
|
|
126
|
+
r = raw[i++] ?? 0;
|
|
127
|
+
g = raw[i++] ?? 0;
|
|
128
|
+
b = raw[i++] ?? 0;
|
|
129
|
+
if (
|
|
130
|
+
transparency?.length === 6 &&
|
|
131
|
+
r === transparency.readUInt16BE(0) &&
|
|
132
|
+
g === transparency.readUInt16BE(2) &&
|
|
133
|
+
b === transparency.readUInt16BE(4)
|
|
134
|
+
)
|
|
135
|
+
a = 0;
|
|
136
|
+
} else if (colorType === 3) {
|
|
137
|
+
const index = raw[i++] ?? 0;
|
|
138
|
+
if (!palette || index * 3 + 2 >= palette.length) throw new Error("invalid PNG palette");
|
|
139
|
+
r = palette[index * 3] ?? 0;
|
|
140
|
+
g = palette[index * 3 + 1] ?? 0;
|
|
141
|
+
b = palette[index * 3 + 2] ?? 0;
|
|
142
|
+
a = transparency?.[index] ?? 255;
|
|
143
|
+
} else if (colorType === 4) {
|
|
144
|
+
r = g = b = raw[i++] ?? 0;
|
|
145
|
+
a = raw[i++] ?? 0;
|
|
146
|
+
} else {
|
|
147
|
+
r = raw[i++] ?? 0;
|
|
148
|
+
g = raw[i++] ?? 0;
|
|
149
|
+
b = raw[i++] ?? 0;
|
|
150
|
+
a = raw[i++] ?? 0;
|
|
151
|
+
}
|
|
152
|
+
const output = p * 4;
|
|
153
|
+
pixels[output] = r;
|
|
154
|
+
pixels[output + 1] = g;
|
|
155
|
+
pixels[output + 2] = b;
|
|
156
|
+
pixels[output + 3] = a;
|
|
157
|
+
}
|
|
158
|
+
return pixels;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function pngChannels(colorType: number): number {
|
|
162
|
+
switch (colorType) {
|
|
163
|
+
case 0:
|
|
164
|
+
case 3:
|
|
165
|
+
return 1;
|
|
166
|
+
case 2:
|
|
167
|
+
return 3;
|
|
168
|
+
case 4:
|
|
169
|
+
return 2;
|
|
170
|
+
case 6:
|
|
171
|
+
return 4;
|
|
172
|
+
default:
|
|
173
|
+
throw new Error(`unsupported PNG color type ${colorType}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function paeth(a: number, b: number, c: number): number {
|
|
178
|
+
const p = a + b - c;
|
|
179
|
+
const pa = Math.abs(p - a);
|
|
180
|
+
const pb = Math.abs(p - b);
|
|
181
|
+
const pc = Math.abs(p - c);
|
|
182
|
+
if (pa <= pb && pa <= pc) return a;
|
|
183
|
+
return pb <= pc ? b : c;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function renderPngThumbnail(
|
|
187
|
+
png: DecodedPng,
|
|
188
|
+
maxWidthCells: number,
|
|
189
|
+
maxHeightCells: number,
|
|
190
|
+
): string[] {
|
|
191
|
+
let targetWidth = Math.max(1, Math.min(maxWidthCells, png.width));
|
|
192
|
+
let targetPixelHeight = Math.max(1, Math.round((png.height / png.width) * targetWidth));
|
|
193
|
+
if (Math.ceil(targetPixelHeight / 2) > maxHeightCells) {
|
|
194
|
+
targetPixelHeight = maxHeightCells * 2;
|
|
195
|
+
targetWidth = Math.max(
|
|
196
|
+
1,
|
|
197
|
+
Math.min(maxWidthCells, Math.round((png.width / png.height) * targetPixelHeight)),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const lines: string[] = [];
|
|
202
|
+
for (let row = 0; row < Math.ceil(targetPixelHeight / 2); row++) {
|
|
203
|
+
let line = "";
|
|
204
|
+
for (let x = 0; x < targetWidth; x++) {
|
|
205
|
+
const upper = samplePngPixel(png, x, row * 2, targetWidth, targetPixelHeight);
|
|
206
|
+
const lower =
|
|
207
|
+
row * 2 + 1 < targetPixelHeight
|
|
208
|
+
? samplePngPixel(png, x, row * 2 + 1, targetWidth, targetPixelHeight)
|
|
209
|
+
: ([255, 255, 255] as const);
|
|
210
|
+
line += `\x1b[38;2;${upper[0]};${upper[1]};${upper[2]}m\x1b[48;2;${lower[0]};${lower[1]};${lower[2]}m▀`;
|
|
211
|
+
}
|
|
212
|
+
lines.push(`${line}\x1b[0m`);
|
|
213
|
+
}
|
|
214
|
+
return lines;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function samplePngPixel(
|
|
218
|
+
png: DecodedPng,
|
|
219
|
+
x: number,
|
|
220
|
+
y: number,
|
|
221
|
+
targetWidth: number,
|
|
222
|
+
targetHeight: number,
|
|
223
|
+
): readonly [number, number, number] {
|
|
224
|
+
const sx = Math.min(
|
|
225
|
+
png.width - 1,
|
|
226
|
+
Math.max(0, Math.floor(((x + 0.5) / targetWidth) * png.width)),
|
|
227
|
+
);
|
|
228
|
+
const sy = Math.min(
|
|
229
|
+
png.height - 1,
|
|
230
|
+
Math.max(0, Math.floor(((y + 0.5) / targetHeight) * png.height)),
|
|
231
|
+
);
|
|
232
|
+
const offset = (sy * png.width + sx) * 4;
|
|
233
|
+
const alpha = (png.pixels[offset + 3] ?? 0) / 255;
|
|
234
|
+
const blend = (channel: number) =>
|
|
235
|
+
Math.round((png.pixels[offset + channel] ?? 0) * alpha + 255 * (1 - alpha));
|
|
236
|
+
return [blend(0), blend(1), blend(2)];
|
|
237
|
+
}
|