pi-image-tools 1.0.11 → 1.2.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/CHANGELOG.md +28 -0
- package/README.md +17 -4
- package/package.json +11 -8
- package/src/clipboard.ts +13 -62
- package/src/commands.ts +1 -1
- package/src/config.ts +1 -1
- package/src/debug-logger.ts +54 -20
- package/src/errors.ts +11 -0
- package/src/image-mime.ts +60 -0
- package/src/image-preview.ts +260 -160
- package/src/image-size.ts +63 -0
- package/src/index.ts +13 -19
- package/src/inline-user-preview.ts +71 -37
- package/src/keybindings.ts +2 -2
- package/src/powershell.ts +222 -0
- package/src/recent-images.ts +14 -49
- package/src/terminal-image-width.ts +4 -2
- package/src/types.ts +20 -20
- package/src/temp-file.ts +0 -82
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.0] - 2026-05-22
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Added Linux Sixel previews through `img2sixel` detection and conversion when the converter is available on `PATH`.
|
|
7
|
+
- Added asynchronous image preview conversion so user-message previews can fall back without blocking the TUI.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Updated Pi peer dependencies and runtime imports to the `@earendil-works` scope.
|
|
11
|
+
- Improved preview setup diagnostics so missing Sixel converters report actionable fallback warnings.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Hardened debug logging with redaction and asynchronous file writes while keeping logging disabled by default.
|
|
15
|
+
|
|
16
|
+
## [1.1.0] - 2026-05-03
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- Added centralized image MIME, image-size, PowerShell, and error utilities used by clipboard, preview, attachment, and recent-image flows.
|
|
20
|
+
- Added size-limit enforcement for image attachments, recent-cache writes, recent-image loads, and Sixel preview conversion through `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES`.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- Reworked recent-image caching to use an extension-owned cache directory with safe pruning that preserves user files.
|
|
24
|
+
- Reworked Sixel preview rendering to use an existing PowerShell `Sixel` module only, normalize converter output into complete terminal sequences, and fall back to native previews with actionable warnings.
|
|
25
|
+
- Reworked terminal image width resolution to honor Pi project/global `terminal.imageWidthCells` settings with a documented default fallback.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- Prevented extension debug logging from writing terminal output; debug events now remain file-based and disabled by default.
|
|
29
|
+
- Preserved Sixel, Kitty, and iTerm inline image protocol rows during preview width fitting.
|
|
30
|
+
|
|
3
31
|
## [1.0.11] - 2026-04-25
|
|
4
32
|
|
|
5
33
|
### Changed
|
package/README.md
CHANGED
|
@@ -114,6 +114,7 @@ Pi's built-in image paste shortcut is not overridden by default. To keep the pre
|
|
|
114
114
|
|----------|-------------|---------|
|
|
115
115
|
| `PI_IMAGE_TOOLS_RECENT_DIRS` | Semicolon-separated directories to search for recent images | Platform defaults listed below |
|
|
116
116
|
| `PI_IMAGE_TOOLS_RECENT_CACHE_DIR` | Custom cache directory for clipboard-pasted images | OS temp dir + `pi-image-tools/recent-cache` |
|
|
117
|
+
| `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES` | Maximum accepted image payload size before attachment, recent-cache writes, and preview conversion | `20971520` (20 MB) |
|
|
117
118
|
|
|
118
119
|
Example:
|
|
119
120
|
|
|
@@ -254,8 +255,10 @@ When you queue one or more images, the extension renders an inline preview insid
|
|
|
254
255
|
|
|
255
256
|
Preview behavior:
|
|
256
257
|
- up to **3 images** are previewed per message
|
|
257
|
-
- Sixel rendering is attempted on Windows when available
|
|
258
|
+
- Sixel rendering is attempted on Windows when the PowerShell `Sixel` module is already installed and on Linux when `img2sixel` is available on `PATH`
|
|
259
|
+
- no PowerShell modules or Linux packages are installed automatically at runtime
|
|
258
260
|
- native TUI image rendering is used as the fallback
|
|
261
|
+
- image payloads over `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES` are rejected before attachment, recent-cache writes, or Sixel conversion
|
|
259
262
|
- inline width fitting now preserves Sixel, Kitty, and iTerm image protocol rows instead of truncating them like plain text
|
|
260
263
|
|
|
261
264
|
### Clipboard readers
|
|
@@ -285,11 +288,16 @@ pi-image-tools/
|
|
|
285
288
|
│ ├── clipboard.ts # Cross-platform clipboard image reading
|
|
286
289
|
│ ├── config.ts # Runtime config loading and validation
|
|
287
290
|
│ ├── debug-logger.ts # File-based debug logging
|
|
291
|
+
│ ├── errors.ts # Shared error normalization
|
|
292
|
+
│ ├── image-mime.ts # Shared image MIME and extension mapping
|
|
293
|
+
│ ├── image-size.ts # Shared image byte-size limits
|
|
288
294
|
│ ├── recent-images.ts # Recent image discovery and cache management
|
|
289
295
|
│ ├── image-preview.ts # Preview building and Sixel/native rendering
|
|
290
296
|
│ ├── inline-user-preview.ts # Inline preview patching for user messages
|
|
291
297
|
│ ├── keybindings.ts # Keyboard shortcut registration
|
|
292
|
-
│ ├──
|
|
298
|
+
│ ├── powershell.ts # Shared PowerShell command runner
|
|
299
|
+
│ ├── sixel-protocol.ts # Sixel protocol normalization and render lines
|
|
300
|
+
│ ├── terminal-image-width.ts # Terminal image width settings resolution
|
|
293
301
|
│ └── types.ts # Shared TypeScript types
|
|
294
302
|
└── config/
|
|
295
303
|
└── config.example.json # Starter runtime config
|
|
@@ -303,9 +311,14 @@ pi-image-tools/
|
|
|
303
311
|
| Linux clipboard paste fails | Make sure you are in a graphical session and install `wl-clipboard` or `xclip` |
|
|
304
312
|
| Recent picker is empty | Add directories via `PI_IMAGE_TOOLS_RECENT_DIRS` or paste images from clipboard first so they enter the recent cache |
|
|
305
313
|
| `/paste-image recent` says it requires interactive mode | Run Pi in interactive TUI mode |
|
|
306
|
-
| Sixel preview warning appears | On Windows, install the PowerShell `Sixel` module and restart Pi |
|
|
314
|
+
| Sixel preview warning appears | On Linux, install `img2sixel` (for example from `libsixel-bin`); on Windows, install the PowerShell `Sixel` module and restart Pi |
|
|
307
315
|
|
|
308
|
-
Manual Sixel installation:
|
|
316
|
+
Manual Sixel installation examples:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
# Debian/Ubuntu
|
|
320
|
+
sudo apt install libsixel-bin
|
|
321
|
+
```
|
|
309
322
|
|
|
310
323
|
```powershell
|
|
311
324
|
Install-Module -Name Sixel -Scope CurrentUser -Force -AllowClobber
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-image-tools",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Image attachment and rendering extension for Pi TUI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
"LICENSE"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"
|
|
20
|
-
"
|
|
19
|
+
"typecheck": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noEmit",
|
|
20
|
+
"build": "npm run typecheck",
|
|
21
|
+
"lint": "npm run typecheck",
|
|
21
22
|
"test": "node --test",
|
|
22
|
-
"check": "npm run
|
|
23
|
+
"check": "npm run build && npm run test"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|
|
25
26
|
"pi-package",
|
|
@@ -33,7 +34,9 @@
|
|
|
33
34
|
"image-preview",
|
|
34
35
|
"clipboard",
|
|
35
36
|
"preview",
|
|
36
|
-
"windows"
|
|
37
|
+
"windows",
|
|
38
|
+
"linux",
|
|
39
|
+
"cross-platform"
|
|
37
40
|
],
|
|
38
41
|
"author": "MasuRii",
|
|
39
42
|
"license": "MIT",
|
|
@@ -57,10 +60,10 @@
|
|
|
57
60
|
]
|
|
58
61
|
},
|
|
59
62
|
"peerDependencies": {
|
|
60
|
-
"@
|
|
61
|
-
"@
|
|
63
|
+
"@earendil-works/pi-coding-agent": "^0.75.4",
|
|
64
|
+
"@earendil-works/pi-tui": "^0.75.4"
|
|
62
65
|
},
|
|
63
66
|
"optionalDependencies": {
|
|
64
|
-
"@mariozechner/clipboard": "^0.3.
|
|
67
|
+
"@mariozechner/clipboard": "^0.3.6"
|
|
65
68
|
}
|
|
66
69
|
}
|
package/src/clipboard.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
|
|
4
|
+
import { isErrnoException } from "./errors.js";
|
|
5
|
+
import { normalizeMimeType, selectPreferredImageMimeType, SUPPORTED_IMAGE_MIME_TYPES } from "./image-mime.js";
|
|
6
|
+
import { runPowerShellCommand } from "./powershell.js";
|
|
4
7
|
import type { ClipboardImage, ClipboardModule } from "./types.js";
|
|
5
8
|
|
|
6
9
|
const require = createRequire(import.meta.url);
|
|
@@ -8,14 +11,6 @@ const require = createRequire(import.meta.url);
|
|
|
8
11
|
const LIST_TYPES_TIMEOUT_MS = 1000;
|
|
9
12
|
const READ_TIMEOUT_MS = 5000;
|
|
10
13
|
const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
11
|
-
const SUPPORTED_IMAGE_MIME_TYPES = [
|
|
12
|
-
"image/png",
|
|
13
|
-
"image/jpeg",
|
|
14
|
-
"image/webp",
|
|
15
|
-
"image/gif",
|
|
16
|
-
"image/bmp",
|
|
17
|
-
] as const;
|
|
18
|
-
|
|
19
14
|
let cachedClipboardModule: ClipboardModule | null | undefined;
|
|
20
15
|
|
|
21
16
|
interface CommandResult {
|
|
@@ -29,11 +24,7 @@ interface ClipboardReadResult {
|
|
|
29
24
|
image: ClipboardImage | null;
|
|
30
25
|
}
|
|
31
26
|
|
|
32
|
-
function
|
|
33
|
-
return "code" in error;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function hasGraphicalSession(platform: NodeJS.Platform, environment: NodeJS.ProcessEnv): boolean {
|
|
27
|
+
export function hasGraphicalSession(platform: NodeJS.Platform, environment: NodeJS.ProcessEnv): boolean {
|
|
37
28
|
return platform !== "linux" || Boolean(environment.DISPLAY || environment.WAYLAND_DISPLAY);
|
|
38
29
|
}
|
|
39
30
|
|
|
@@ -41,27 +32,6 @@ function isWaylandSession(environment: NodeJS.ProcessEnv): boolean {
|
|
|
41
32
|
return Boolean(environment.WAYLAND_DISPLAY) || environment.XDG_SESSION_TYPE === "wayland";
|
|
42
33
|
}
|
|
43
34
|
|
|
44
|
-
function normalizeMimeType(mimeType: string): string {
|
|
45
|
-
return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | null {
|
|
49
|
-
const normalized = mimeTypes
|
|
50
|
-
.map((mimeType) => mimeType.trim())
|
|
51
|
-
.filter((mimeType) => mimeType.length > 0)
|
|
52
|
-
.map((mimeType) => ({ raw: mimeType, normalized: normalizeMimeType(mimeType) }));
|
|
53
|
-
|
|
54
|
-
for (const preferredMimeType of SUPPORTED_IMAGE_MIME_TYPES) {
|
|
55
|
-
const match = normalized.find((mimeType) => mimeType.normalized === preferredMimeType);
|
|
56
|
-
if (match) {
|
|
57
|
-
return match.raw;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const firstImage = normalized.find((mimeType) => mimeType.normalized.startsWith("image/"));
|
|
62
|
-
return firstImage?.raw ?? null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
35
|
function loadClipboardModule(
|
|
66
36
|
platform: NodeJS.Platform = process.platform,
|
|
67
37
|
environment: NodeJS.ProcessEnv = process.env,
|
|
@@ -141,10 +111,6 @@ function runCommand(
|
|
|
141
111
|
};
|
|
142
112
|
}
|
|
143
113
|
|
|
144
|
-
function encodePowerShell(script: string): string {
|
|
145
|
-
return Buffer.from(script, "utf16le").toString("base64");
|
|
146
|
-
}
|
|
147
|
-
|
|
148
114
|
function readClipboardImageViaPowerShell(): ClipboardReadResult {
|
|
149
115
|
const script = `
|
|
150
116
|
$ErrorActionPreference = 'Stop'
|
|
@@ -170,33 +136,18 @@ try {
|
|
|
170
136
|
}
|
|
171
137
|
`;
|
|
172
138
|
|
|
173
|
-
const result =
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
"Bypass",
|
|
180
|
-
"-STA",
|
|
181
|
-
"-EncodedCommand",
|
|
182
|
-
encodePowerShell(script),
|
|
183
|
-
],
|
|
184
|
-
{
|
|
185
|
-
encoding: "utf8",
|
|
186
|
-
timeout: READ_TIMEOUT_MS,
|
|
187
|
-
maxBuffer: MAX_BUFFER_BYTES,
|
|
188
|
-
windowsHide: true,
|
|
189
|
-
},
|
|
190
|
-
);
|
|
139
|
+
const result = runPowerShellCommand(script, {
|
|
140
|
+
encoded: true,
|
|
141
|
+
sta: true,
|
|
142
|
+
timeout: READ_TIMEOUT_MS,
|
|
143
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
144
|
+
});
|
|
191
145
|
|
|
192
|
-
if (result.
|
|
193
|
-
return {
|
|
194
|
-
available: !isErrnoException(result.error) || result.error.code !== "ENOENT",
|
|
195
|
-
image: null,
|
|
196
|
-
};
|
|
146
|
+
if (result.missingCommand) {
|
|
147
|
+
return { available: false, image: null };
|
|
197
148
|
}
|
|
198
149
|
|
|
199
|
-
if (result.
|
|
150
|
+
if (!result.ok) {
|
|
200
151
|
return { available: true, image: null };
|
|
201
152
|
}
|
|
202
153
|
|
package/src/commands.ts
CHANGED
package/src/config.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
|
-
import type { KeyId } from "@
|
|
5
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
6
6
|
|
|
7
7
|
const CONFIG_FILE_NAME = "config.json";
|
|
8
8
|
const EXTENSION_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
package/src/debug-logger.ts
CHANGED
|
@@ -1,30 +1,43 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { appendFile } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
|
|
4
5
|
import { getExtensionRoot, type ImageToolsConfig } from "./config.js";
|
|
5
|
-
|
|
6
6
|
const DEBUG_DIRECTORY_NAME = "debug";
|
|
7
7
|
const DEBUG_LOG_FILE_NAME = "debug.log";
|
|
8
|
+
const SECRET_KEYS = /api[_-]?key|authorization|token|secret|password/i;
|
|
8
9
|
|
|
9
10
|
type DebugFields = Record<string, unknown>;
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
12
|
+
interface DebugLoggerCreateOptions {
|
|
13
|
+
extensionRoot?: string;
|
|
14
|
+
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
function redactFields(value: unknown): unknown {
|
|
17
|
+
if (Array.isArray(value)) return value.map((entry) => redactFields(entry));
|
|
18
|
+
if (value && typeof value === "object") {
|
|
19
|
+
const output: Record<string, unknown> = {};
|
|
20
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
21
|
+
output[key] = SECRET_KEYS.test(key) ? "[REDACTED]" : redactFields(nestedValue);
|
|
22
|
+
}
|
|
23
|
+
return output;
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
export class DebugLogger {
|
|
29
|
+
private readonly debugDirectory: string | undefined;
|
|
20
30
|
private readonly logPath: string | undefined;
|
|
31
|
+
private debugDirectoryReady = false;
|
|
32
|
+
private writeQueue: Promise<void> = Promise.resolve();
|
|
21
33
|
|
|
22
|
-
private constructor(private readonly enabled: boolean) {
|
|
23
|
-
this.
|
|
34
|
+
private constructor(private readonly enabled: boolean, extensionRoot = getExtensionRoot()) {
|
|
35
|
+
this.debugDirectory = enabled ? join(extensionRoot, DEBUG_DIRECTORY_NAME) : undefined;
|
|
36
|
+
this.logPath = this.debugDirectory ? join(this.debugDirectory, DEBUG_LOG_FILE_NAME) : undefined;
|
|
24
37
|
}
|
|
25
38
|
|
|
26
|
-
static create(config: ImageToolsConfig): DebugLogger {
|
|
27
|
-
return new DebugLogger(config.debug);
|
|
39
|
+
static create(config: ImageToolsConfig, options: DebugLoggerCreateOptions = {}): DebugLogger {
|
|
40
|
+
return new DebugLogger(config.debug, options.extensionRoot);
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
log(event: string, fields: DebugFields = {}): void {
|
|
@@ -32,17 +45,38 @@ export class DebugLogger {
|
|
|
32
45
|
return;
|
|
33
46
|
}
|
|
34
47
|
|
|
35
|
-
const debugDirectory = join(getExtensionRoot(), DEBUG_DIRECTORY_NAME);
|
|
36
|
-
|
|
37
48
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
const redactedFields = redactFields(fields) as DebugFields;
|
|
50
|
+
const line = `${JSON.stringify({ timestamp: new Date().toISOString(), event, ...redactedFields })}\n`;
|
|
51
|
+
this.writeQueue = this.writeQueue.then(
|
|
52
|
+
() => this.appendLine(line),
|
|
53
|
+
() => this.appendLine(line),
|
|
43
54
|
);
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
void this.writeQueue.catch(() => undefined);
|
|
56
|
+
} catch {
|
|
57
|
+
// Debug logging must never affect extension behavior.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
flush(): Promise<void> {
|
|
62
|
+
return this.writeQueue.catch(() => undefined);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async appendLine(line: string): Promise<void> {
|
|
66
|
+
if (!this.logPath) {
|
|
67
|
+
return;
|
|
46
68
|
}
|
|
69
|
+
|
|
70
|
+
this.ensureDebugDirectory();
|
|
71
|
+
await appendFile(this.logPath, line, "utf-8");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private ensureDebugDirectory(): void {
|
|
75
|
+
if (this.debugDirectoryReady || !this.debugDirectory) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
mkdirSync(this.debugDirectory, { recursive: true });
|
|
80
|
+
this.debugDirectoryReady = true;
|
|
47
81
|
}
|
|
48
82
|
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function getErrorMessage(error: unknown): string {
|
|
2
|
+
if (error instanceof Error && error.message.trim().length > 0) {
|
|
3
|
+
return error.message;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return "Unknown error";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
|
10
|
+
return error instanceof Error && "code" in error;
|
|
11
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const PREFERRED_IMAGE_MIME_TYPES = [
|
|
2
|
+
"image/png",
|
|
3
|
+
"image/jpeg",
|
|
4
|
+
"image/webp",
|
|
5
|
+
"image/gif",
|
|
6
|
+
"image/bmp",
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
const MIME_TYPE_TO_EXTENSION = new Map<string, string>([
|
|
10
|
+
["image/png", "png"],
|
|
11
|
+
["image/jpeg", "jpg"],
|
|
12
|
+
["image/webp", "webp"],
|
|
13
|
+
["image/gif", "gif"],
|
|
14
|
+
["image/bmp", "bmp"],
|
|
15
|
+
["image/tiff", "tiff"],
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const EXTENSION_TO_MIME_TYPE = new Map<string, string>([
|
|
19
|
+
[".png", "image/png"],
|
|
20
|
+
[".jpg", "image/jpeg"],
|
|
21
|
+
[".jpeg", "image/jpeg"],
|
|
22
|
+
[".webp", "image/webp"],
|
|
23
|
+
[".gif", "image/gif"],
|
|
24
|
+
[".bmp", "image/bmp"],
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export const SUPPORTED_IMAGE_MIME_TYPES: readonly string[] = PREFERRED_IMAGE_MIME_TYPES;
|
|
28
|
+
|
|
29
|
+
export function normalizeMimeType(mimeType: string): string {
|
|
30
|
+
return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | null {
|
|
34
|
+
const normalized = mimeTypes
|
|
35
|
+
.map((mimeType) => mimeType.trim())
|
|
36
|
+
.filter((mimeType) => mimeType.length > 0)
|
|
37
|
+
.map((mimeType) => ({ raw: mimeType, normalized: normalizeMimeType(mimeType) }));
|
|
38
|
+
|
|
39
|
+
for (const preferredMimeType of SUPPORTED_IMAGE_MIME_TYPES) {
|
|
40
|
+
const match = normalized.find((mimeType) => mimeType.normalized === preferredMimeType);
|
|
41
|
+
if (match) {
|
|
42
|
+
return match.raw;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const firstImage = normalized.find((mimeType) => mimeType.normalized.startsWith("image/"));
|
|
47
|
+
return firstImage?.raw ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function mimeTypeToExtension(mimeType: string, fallbackExtension = "png"): string {
|
|
51
|
+
return MIME_TYPE_TO_EXTENSION.get(normalizeMimeType(mimeType)) ?? fallbackExtension;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function extensionToMimeType(fileNameOrExtension: string): string | null {
|
|
55
|
+
const extension = fileNameOrExtension.startsWith(".")
|
|
56
|
+
? fileNameOrExtension.toLowerCase()
|
|
57
|
+
: `.${fileNameOrExtension.toLowerCase()}`;
|
|
58
|
+
|
|
59
|
+
return EXTENSION_TO_MIME_TYPE.get(extension) ?? null;
|
|
60
|
+
}
|