@prjct.app/pi-clipboard 0.1.1 → 0.1.4
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 +14 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +7 -7
- package/docs/package.md +6 -2
- package/docs/releases.md +29 -0
- package/package.json +18 -10
- package/src/index.ts +92 -172
- package/src/png.ts +0 -214
- package/src/raster.ts +0 -116
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
|
+
## [0.1.4](https://github.com/prjct-app/pi-clipboard/compare/v0.1.3...v0.1.4) (2026-09-10)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* delete previewed clipboard temp files on session shutdown ([a64a2b4](https://github.com/prjct-app/pi-clipboard/commit/a64a2b446565fc7867395186097a63b62779e3f4))
|
|
6
|
+
* render clipboard previews with Pi's native image components ([312295b](https://github.com/prjct-app/pi-clipboard/commit/312295b422d2c29bf5708be88c3254cb4bd17e45))
|
|
7
|
+
|
|
1
8
|
# Changelog
|
|
2
9
|
|
|
10
|
+
## 0.1.3
|
|
11
|
+
|
|
12
|
+
- Clarify the package description and add focused discovery keywords.
|
|
13
|
+
- Declare the cover image for the official Pi package gallery.
|
|
14
|
+
|
|
15
|
+
- Align repository, documentation, and cover URLs with the npm package name.
|
|
16
|
+
|
|
3
17
|
## 0.1.1
|
|
4
18
|
|
|
5
19
|
- Add a dedicated cover to the GitHub and npm README.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -12,3 +12,7 @@
|
|
|
12
12
|
## Package documentation
|
|
13
13
|
|
|
14
14
|
Follow [docs/package.md](docs/package.md) and its versioned official references. Keep README examples consistent with registered commands, distinguish tested behavior from unverified compatibility, and verify `npm run check:package` before release.
|
|
15
|
+
|
|
16
|
+
## Releases
|
|
17
|
+
|
|
18
|
+
Merging a releasable change into `main` automatically publishes to npm. Use conventional commit messages and read [Automatic releases](docs/releases.md) before merging. The workflow manages versions and authenticates with npm through OIDC.
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# pi-clipboard
|
|
2
2
|
|
|
3
|
-
[](https://pi.dev)
|
|
4
4
|
|
|
5
|
-
Preview pasted images
|
|
5
|
+
Preview pasted images in PI Agent before sending them, with inline thumbnails and a compact attachment gallery.
|
|
6
6
|
|
|
7
7
|
`@prjct.app/pi-clipboard` · Clipboard image previews; one extension.
|
|
8
8
|
|
|
@@ -24,9 +24,9 @@ For project-only installation, add `-l`: `pi install -l npm:@prjct.app/pi-clipbo
|
|
|
24
24
|
2. Paste an image using Pi's native clipboard flow.
|
|
25
25
|
3. Inspect the preview above the editor, then submit your message normally.
|
|
26
26
|
|
|
27
|
-
Previews appear automatically; there is no slash command to enable them. Removing an attachment path from the editor removes its preview.
|
|
27
|
+
Previews appear automatically; there is no slash command to enable them. Removing an attachment path from the editor removes its preview. Each attachment renders as its own preview.
|
|
28
28
|
|
|
29
|
-
Only Pi-created temporary clipboard attachments are eligible. Arbitrary image paths do not produce previews. Supported filenames end in PNG, JPEG, WebP, or GIF; individual files over 50 MiB and symbolic links are rejected.
|
|
29
|
+
Only Pi-created temporary clipboard attachments are eligible. Arbitrary image paths do not produce previews. Supported filenames end in PNG, JPEG, WebP, or GIF; individual files over 50 MiB and symbolic links are rejected. Clipboard images stay on disk as the temporary files Pi created; the files this session previewed are deleted when the Pi session ends. Previews render through Pi's native terminal image support; terminals without it show Pi's native `[Image: ...]` text fallback. The extension itself makes no network requests. Submitting an image still follows Pi's normal model-provider attachment flow.
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
## Manage the package
|
|
@@ -41,17 +41,17 @@ pi remove npm:@prjct.app/pi-clipboard
|
|
|
41
41
|
|
|
42
42
|
Use `pi config` to enable or disable individual resources. Use `pi config -l` for project settings and add `-l` to removal when you installed locally.
|
|
43
43
|
|
|
44
|
-
To pin version 0.1.
|
|
44
|
+
To pin version 0.1.3, use `pi install npm:@prjct.app/pi-clipboard@0.1.3`. Pi skips pinned npm versions during package updates. For a Git installation, update or remove using the same `git:github.com/prjct-app/pi-clipboard` source instead of the npm source.
|
|
45
45
|
|
|
46
46
|
When switching from GitHub to npm, remove the Git installation first, then install the npm package and restart Pi.
|
|
47
47
|
|
|
48
48
|
## Troubleshooting
|
|
49
49
|
|
|
50
|
-
If no preview appears, confirm you are in interactive TUI mode and pasted through Pi.
|
|
50
|
+
If no preview appears, confirm you are in interactive TUI mode and pasted through Pi. The `[Image: ...]` text line is the expected fallback when inline images are unavailable.
|
|
51
51
|
|
|
52
52
|
## Package and API documentation
|
|
53
53
|
|
|
54
|
-
Uses `session_start`, `session_shutdown`, `ctx.ui.getEditorText()`, `ctx.ui.setWidget()`, and Pi TUI
|
|
54
|
+
Uses `session_start`, `session_shutdown`, `ctx.ui.getEditorText()`, `ctx.ui.setWidget()`, and Pi TUI `Image`/`Container` components. Kitty placements receive PNG payloads through Pi's native `convertToPng`. Clipboard files previewed by the session are deleted on `session_shutdown` after revalidating that they are still Pi-owned temporary files. It retains the native editor and attachment submission flow.
|
|
55
55
|
|
|
56
56
|
See [Package structure and compatibility](docs/package.md) for the manifest, dependency policy, shipped resources, and official references. This package follows the [official Pi package guide](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/packages.md) and [extension API guide](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/extensions.md) for the tested version.
|
|
57
57
|
|
package/docs/package.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
- npm name: `@prjct.app/pi-clipboard`.
|
|
6
6
|
- Initial version: `0.1.0`.
|
|
7
|
-
- Source repository: [prjct-app/pi-
|
|
7
|
+
- Source repository: [prjct-app/pi-clipboard](https://github.com/prjct-app/pi-clipboard).
|
|
8
8
|
- Tested host: Pi `0.85.1`; Node.js `22.19+`.
|
|
9
9
|
|
|
10
10
|
The npm name and repository name may differ. Repository URLs remain unchanged. Existing runtime command names, event names, persisted entry types, and settings keys are unchanged by the package rename.
|
|
@@ -35,7 +35,7 @@ Third-party runtime dependencies belong in `dependencies`. Companion extensions
|
|
|
35
35
|
|
|
36
36
|
## Public interfaces
|
|
37
37
|
|
|
38
|
-
Uses `session_start`, `session_shutdown`, `ctx.ui.getEditorText()`, `ctx.ui.setWidget()`, and Pi TUI
|
|
38
|
+
Uses `session_start`, `session_shutdown`, `ctx.ui.getEditorText()`, `ctx.ui.setWidget()`, and Pi TUI `Image`/`Container` components. Kitty placements receive PNG payloads through Pi's native `convertToPng`. Clipboard files previewed by the session are deleted on `session_shutdown` after revalidating that they are still Pi-owned temporary files. It retains the native editor and attachment submission flow.
|
|
39
39
|
|
|
40
40
|
## Published contents
|
|
41
41
|
|
|
@@ -52,3 +52,7 @@ These links are pinned to the tested Pi version rather than the moving main bran
|
|
|
52
52
|
- [TUI: components, rendering, terminal widths, and image support](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/tui.md).
|
|
53
53
|
|
|
54
54
|
The installed `@earendil-works/pi-coding-agent@0.85.1` package ships the same guides under `docs/`. The [current official guide](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) may describe changes beyond this tested baseline.
|
|
55
|
+
|
|
56
|
+
## Discovery metadata
|
|
57
|
+
|
|
58
|
+
The `pi-package` keyword identifies this package for the official Pi gallery. Focused keywords describe its actual features. The `pi.image` field points to its public cover, following the [official gallery metadata format](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/packages.md#gallery-metadata).
|
package/docs/releases.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Automatic releases
|
|
2
|
+
|
|
3
|
+
Merging a releasable change into `main` starts the **Release** workflow. It checks TypeScript, runs the tests, and checks the package contents before publishing.
|
|
4
|
+
|
|
5
|
+
The workflow uses semantic-release to calculate the version, update `package.json`, `package-lock.json` and `CHANGELOG.md`, create a `vX.Y.Z` tag, publish to npm, and create a GitHub release. Release-tool dependencies are locked separately under `.github/release/` and are not installed with the extension.
|
|
6
|
+
|
|
7
|
+
## Commit messages
|
|
8
|
+
|
|
9
|
+
- `fix:` and `perf:` publish a patch version.
|
|
10
|
+
- `feat:` publishes a minor version.
|
|
11
|
+
- A `BREAKING CHANGE:` footer or a conventional `!` marker publishes a major version.
|
|
12
|
+
- `docs:`, `refactor:`, `build:`, `ci(release):`, and `chore(deps):` publish a patch version.
|
|
13
|
+
- Other changes, such as tests alone, do not publish a version.
|
|
14
|
+
|
|
15
|
+
Use these prefixes on commits. When squash merging, use a conventional prefix in the pull request title. Let the workflow manage release versions instead of editing the version by hand.
|
|
16
|
+
|
|
17
|
+
## Authentication
|
|
18
|
+
|
|
19
|
+
npm trusts this repository's `.github/workflows/release.yml` through GitHub Actions OIDC. No npm token or interactive one-time password is needed for each release. The workflow is restricted to `main`; it uses GitHub's short-lived repository token to write the version commit, tag, and release. Private repositories do not produce npm provenance attestations.
|
|
20
|
+
|
|
21
|
+
The corresponding npm trusted publisher must use organization `prjct-app`, this repository's name, workflow filename `release.yml`, no environment name, and permission to publish directly with `npm publish`.
|
|
22
|
+
|
|
23
|
+
## Preview and recovery
|
|
24
|
+
|
|
25
|
+
Run **Release** from the Actions tab on `main` with `dry_run` enabled to preview the next version and release notes. No version commit, tag, npm publication, or GitHub release is created by a dry run.
|
|
26
|
+
|
|
27
|
+
Runs are serialized and an outdated checkout is skipped. Never cancel a run during publication. If a run fails, inspect its logs and the existing npm version and GitHub tag before retrying: publication is not a transaction across both services. Do not delete a published version or move an existing release tag to recover.
|
|
28
|
+
|
|
29
|
+
References: [semantic-release](https://semantic-release.gitbook.io/semantic-release/), [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/).
|
package/package.json
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prjct.app/pi-clipboard",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Preview pasted images
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Preview pasted images in PI Agent before sending them, with inline thumbnails and a compact attachment gallery.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
|
8
8
|
"pi",
|
|
9
|
+
"pi-agent",
|
|
10
|
+
"pi-coding-agent",
|
|
11
|
+
"pi-extension",
|
|
12
|
+
"ai-coding",
|
|
13
|
+
"developer-tools",
|
|
9
14
|
"clipboard",
|
|
10
|
-
"image",
|
|
11
|
-
"
|
|
12
|
-
"terminal"
|
|
15
|
+
"image-preview",
|
|
16
|
+
"screenshots",
|
|
17
|
+
"terminal",
|
|
18
|
+
"tui"
|
|
13
19
|
],
|
|
14
20
|
"pi": {
|
|
15
21
|
"extensions": [
|
|
16
22
|
"./index.ts"
|
|
17
|
-
]
|
|
23
|
+
],
|
|
24
|
+
"image": "https://raw.githubusercontent.com/prjct-app/pi-clipboard/main/docs/cover.png"
|
|
18
25
|
},
|
|
19
26
|
"scripts": {
|
|
20
27
|
"check": "tsc --noEmit",
|
|
@@ -38,11 +45,11 @@
|
|
|
38
45
|
"license": "MIT",
|
|
39
46
|
"repository": {
|
|
40
47
|
"type": "git",
|
|
41
|
-
"url": "https://github.com/prjct-app/pi-
|
|
48
|
+
"url": "git+https://github.com/prjct-app/pi-clipboard.git"
|
|
42
49
|
},
|
|
43
|
-
"homepage": "https://github.com/prjct-app/pi-
|
|
50
|
+
"homepage": "https://github.com/prjct-app/pi-clipboard#readme",
|
|
44
51
|
"bugs": {
|
|
45
|
-
"url": "https://github.com/prjct-app/pi-
|
|
52
|
+
"url": "https://github.com/prjct-app/pi-clipboard/issues"
|
|
46
53
|
},
|
|
47
54
|
"publishConfig": {
|
|
48
55
|
"access": "public"
|
|
@@ -54,6 +61,7 @@
|
|
|
54
61
|
"LICENSE",
|
|
55
62
|
"CONTRIBUTING.md",
|
|
56
63
|
"CHANGELOG.md",
|
|
57
|
-
"docs/package.md"
|
|
64
|
+
"docs/package.md",
|
|
65
|
+
"docs/releases.md"
|
|
58
66
|
]
|
|
59
67
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,29 +1,24 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import { lstat, open } from "node:fs/promises";
|
|
2
|
+
import { lstat, open, unlink } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { basename, dirname, extname, resolve } from "node:path";
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
convertToPng,
|
|
8
|
-
resizeImage,
|
|
9
8
|
type ExtensionAPI,
|
|
10
9
|
type ExtensionContext,
|
|
11
10
|
} from "@earendil-works/pi-coding-agent";
|
|
12
11
|
import {
|
|
12
|
+
Container,
|
|
13
13
|
getCapabilities,
|
|
14
14
|
getImageDimensions,
|
|
15
|
-
imageFallback,
|
|
16
15
|
Image,
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
imageFallback,
|
|
17
|
+
Text,
|
|
19
18
|
type Component,
|
|
20
19
|
type ImageDimensions,
|
|
21
|
-
type TUI,
|
|
22
20
|
} from "@earendil-works/pi-tui";
|
|
23
21
|
|
|
24
|
-
import { decodePng, encodePng, type RgbaImage } from "./png.ts";
|
|
25
|
-
import { blit, createImage, fillRoundedRect, fitWithin, strokeRoundedRect, type Rgba } from "./raster.ts";
|
|
26
|
-
|
|
27
22
|
const CLIPBOARD_IMAGE_NAME_SOURCE = "pi-clipboard-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.(?:png|jpe?g|webp|gif)";
|
|
28
23
|
const CLIPBOARD_IMAGE_FILENAME = new RegExp(CLIPBOARD_IMAGE_NAME_SOURCE, "gi");
|
|
29
24
|
const CLIPBOARD_IMAGE_BASENAME = new RegExp(`^${CLIPBOARD_IMAGE_NAME_SOURCE}$`, "i");
|
|
@@ -38,39 +33,16 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
|
|
|
38
33
|
const WIDGET_KEY = "pi-image-preview";
|
|
39
34
|
const DEFAULT_POLL_INTERVAL_MS = 150;
|
|
40
35
|
const MAX_PREVIEW_FILE_BYTES = 50 * 1024 * 1024;
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const CARD_GAP_CELLS = 1;
|
|
44
|
-
const MAX_CARDS_PER_ROW = 6;
|
|
45
|
-
const CARD_WIDTH_PX = 160;
|
|
46
|
-
const CARD_HEIGHT_PX = 112;
|
|
47
|
-
const CARD_GAP_PX = 8;
|
|
48
|
-
const THUMBNAIL_INSET_PX = 8;
|
|
49
|
-
const THUMBNAIL_WIDTH_PX = CARD_WIDTH_PX - THUMBNAIL_INSET_PX * 2;
|
|
50
|
-
const THUMBNAIL_HEIGHT_PX = CARD_HEIGHT_PX - THUMBNAIL_INSET_PX * 2;
|
|
51
|
-
const CARD_RADIUS_PX = 16;
|
|
52
|
-
const CARD_BORDER_PX = 2;
|
|
53
|
-
const THUMBNAIL_RADIUS_PX = 12;
|
|
54
|
-
const CARD_FILL: Rgba = [0x11, 0x18, 0x27, Math.round(0.92 * 255)];
|
|
55
|
-
const CARD_BORDER: Rgba = [0x9c, 0xa3, 0xaf, 255];
|
|
36
|
+
const PREVIEW_MAX_WIDTH_CELLS = 60;
|
|
37
|
+
const PREVIEW_MAX_HEIGHT_CELLS = 12;
|
|
56
38
|
|
|
57
39
|
interface PreviewSource {
|
|
58
40
|
filePath: string;
|
|
59
|
-
|
|
41
|
+
base64: string;
|
|
60
42
|
mimeType: string;
|
|
61
43
|
dimensions: ImageDimensions;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
interface GalleryLayout {
|
|
65
|
-
columns: number;
|
|
66
|
-
rows: number;
|
|
67
|
-
displayWidth: number;
|
|
68
|
-
displayHeight: number;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
interface RenderedGallery {
|
|
72
|
-
signature: string;
|
|
73
|
-
image: Image;
|
|
44
|
+
/** PNG variant for Kitty placements (f=100 only decodes PNG). Undefined while unconverted. */
|
|
45
|
+
kittyBase64?: string;
|
|
74
46
|
}
|
|
75
47
|
|
|
76
48
|
interface Scheduler {
|
|
@@ -122,11 +94,12 @@ async function readPreviewSource(filePath: string): Promise<PreviewSource | unde
|
|
|
122
94
|
) return undefined;
|
|
123
95
|
|
|
124
96
|
const data = await handle.readFile();
|
|
97
|
+
const base64 = data.toString("base64");
|
|
125
98
|
return {
|
|
126
99
|
filePath,
|
|
127
|
-
|
|
100
|
+
base64,
|
|
128
101
|
mimeType,
|
|
129
|
-
dimensions: getImageDimensions(
|
|
102
|
+
dimensions: getImageDimensions(base64, mimeType) ?? { widthPx: 800, heightPx: 600 },
|
|
130
103
|
};
|
|
131
104
|
} finally {
|
|
132
105
|
await handle.close();
|
|
@@ -136,161 +109,99 @@ async function readPreviewSource(filePath: string): Promise<PreviewSource | unde
|
|
|
136
109
|
}
|
|
137
110
|
}
|
|
138
111
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
columns,
|
|
149
|
-
rows,
|
|
150
|
-
displayWidth: Math.min(available, columns * CARD_WIDTH_CELLS + (columns - 1) * CARD_GAP_CELLS),
|
|
151
|
-
displayHeight: rows * 7 + Math.max(0, rows - 1),
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function padLine(text: string, width: number): string {
|
|
156
|
-
const truncated = truncateToWidth(text, width);
|
|
157
|
-
return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async function thumbnailPixels(source: PreviewSource): Promise<RgbaImage | undefined> {
|
|
161
|
-
let png: Uint8Array | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* Delete a clipboard image previously previewed by this session. Revalidates
|
|
114
|
+
* the same containment rules used for reads before unlinking, so only real
|
|
115
|
+
* Pi-owned temporary clipboard files are removed; anything else stays put.
|
|
116
|
+
*/
|
|
117
|
+
async function deleteClipboardImage(filePath: string): Promise<void> {
|
|
118
|
+
if (dirname(resolve(filePath)) !== resolve(tmpdir())) return;
|
|
119
|
+
if (!CLIPBOARD_IMAGE_BASENAME.test(basename(filePath))) return;
|
|
162
120
|
try {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
});
|
|
167
|
-
const converted = resized ? await convertToPng(resized.data, resized.mimeType) : null;
|
|
168
|
-
if (converted) png = Buffer.from(converted.data, "base64");
|
|
121
|
+
const info = await lstat(filePath);
|
|
122
|
+
if (!info.isFile() || info.isSymbolicLink()) return;
|
|
123
|
+
await unlink(filePath);
|
|
169
124
|
} catch {
|
|
170
|
-
|
|
125
|
+
// Already removed or unreadable; nothing to clean up.
|
|
171
126
|
}
|
|
172
|
-
if (!png && source.mimeType === "image/png") png = source.data;
|
|
173
|
-
const decoded = png ? decodePng(png) : null;
|
|
174
|
-
return decoded ? fitWithin(decoded, THUMBNAIL_WIDTH_PX, THUMBNAIL_HEIGHT_PX) : undefined;
|
|
175
127
|
}
|
|
176
128
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const top = box.y + Math.floor((box.height - thumbnail.height) / 2);
|
|
193
|
-
blit(card, thumbnail, left, top, { rect: box, radius: THUMBNAIL_RADIUS_PX });
|
|
129
|
+
/**
|
|
130
|
+
* Kitty graphics placements (f=100) only decode PNG, so non-PNG sources need a
|
|
131
|
+
* PNG variant. Conversion uses Pi's native `convertToPng` (bundled WASM, no
|
|
132
|
+
* external tools) and runs once per source, off the render path.
|
|
133
|
+
*/
|
|
134
|
+
async function prepareKittyVariant(source: PreviewSource): Promise<void> {
|
|
135
|
+
if (source.mimeType === "image/png") {
|
|
136
|
+
source.kittyBase64 = source.base64;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const converted = await convertToPng(source.base64, source.mimeType);
|
|
141
|
+
source.kittyBase64 = converted?.data;
|
|
142
|
+
} catch {
|
|
143
|
+
source.kittyBase64 = undefined;
|
|
194
144
|
}
|
|
195
|
-
return card;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function buildGallery(sources: PreviewSource[], layout: GalleryLayout): Promise<Buffer> {
|
|
199
|
-
const cards = await Promise.all(sources.map(roundedCard));
|
|
200
|
-
const width = layout.columns * CARD_WIDTH_PX + (layout.columns - 1) * CARD_GAP_PX;
|
|
201
|
-
const height = layout.rows * CARD_HEIGHT_PX + (layout.rows - 1) * CARD_GAP_PX;
|
|
202
|
-
const gallery = createImage(width, height);
|
|
203
|
-
cards.forEach((card, index) => {
|
|
204
|
-
blit(
|
|
205
|
-
gallery,
|
|
206
|
-
card,
|
|
207
|
-
(index % layout.columns) * (CARD_WIDTH_PX + CARD_GAP_PX),
|
|
208
|
-
Math.floor(index / layout.columns) * (CARD_HEIGHT_PX + CARD_GAP_PX),
|
|
209
|
-
);
|
|
210
|
-
});
|
|
211
|
-
return encodePng(gallery);
|
|
212
145
|
}
|
|
213
146
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Renders one native Pi `Image` per clipboard attachment inside a `Container`.
|
|
149
|
+
* The `Image` component owns Kitty/iTerm2 placement and the `[Image: ...]`
|
|
150
|
+
* text fallback for terminals without inline-image support.
|
|
151
|
+
*/
|
|
152
|
+
class ClipboardPreviewGallery implements Component {
|
|
153
|
+
private container?: Container;
|
|
154
|
+
private containerKey?: string;
|
|
218
155
|
|
|
219
156
|
constructor(
|
|
220
|
-
private readonly tui: TUI,
|
|
221
157
|
private readonly sources: PreviewSource[],
|
|
222
158
|
private readonly fallbackColor: (text: string) => string,
|
|
223
159
|
) {}
|
|
224
160
|
|
|
225
|
-
private
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const cards = row.map((source) => {
|
|
234
|
-
const label = imageFallback(source.mimeType, source.dimensions);
|
|
235
|
-
return [
|
|
236
|
-
`╭${"─".repeat(innerWidth)}╮`,
|
|
237
|
-
`│${padLine(this.fallbackColor(label), innerWidth)}│`,
|
|
238
|
-
`╰${"─".repeat(innerWidth)}╯`,
|
|
239
|
-
];
|
|
240
|
-
});
|
|
241
|
-
for (let line = 0; line < 3; line += 1) {
|
|
242
|
-
lines.push(cards.map((card) => card[line]).join(" "));
|
|
161
|
+
private build(): Container {
|
|
162
|
+
const kitty = getCapabilities().images === "kitty";
|
|
163
|
+
const container = new Container();
|
|
164
|
+
for (const source of this.sources) {
|
|
165
|
+
if (kitty && !source.kittyBase64) {
|
|
166
|
+
const label = imageFallback(source.mimeType, source.dimensions, source.filePath);
|
|
167
|
+
container.addChild(new Text(this.fallbackColor(label), 1, 0));
|
|
168
|
+
continue;
|
|
243
169
|
}
|
|
170
|
+
container.addChild(new Image(
|
|
171
|
+
kitty ? source.kittyBase64! : source.base64,
|
|
172
|
+
kitty ? "image/png" : source.mimeType,
|
|
173
|
+
{ fallbackColor: this.fallbackColor },
|
|
174
|
+
{
|
|
175
|
+
maxWidthCells: PREVIEW_MAX_WIDTH_CELLS,
|
|
176
|
+
maxHeightCells: PREVIEW_MAX_HEIGHT_CELLS,
|
|
177
|
+
filename: source.filePath,
|
|
178
|
+
},
|
|
179
|
+
source.dimensions,
|
|
180
|
+
));
|
|
244
181
|
}
|
|
245
|
-
|
|
246
|
-
return lines;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
private requestGallery(layout: GalleryLayout, signature: string): void {
|
|
250
|
-
if (this.pendingSignature === signature) return;
|
|
251
|
-
this.pendingSignature = signature;
|
|
252
|
-
const generation = ++this.buildGeneration;
|
|
253
|
-
|
|
254
|
-
void buildGallery(this.sources, layout).then((data) => {
|
|
255
|
-
if (generation !== this.buildGeneration) return;
|
|
256
|
-
this.gallery = {
|
|
257
|
-
signature,
|
|
258
|
-
image: new Image(data.toString("base64"), "image/png", {
|
|
259
|
-
fallbackColor: this.fallbackColor,
|
|
260
|
-
}, {
|
|
261
|
-
maxWidthCells: layout.displayWidth,
|
|
262
|
-
maxHeightCells: layout.displayHeight,
|
|
263
|
-
}),
|
|
264
|
-
};
|
|
265
|
-
this.pendingSignature = undefined;
|
|
266
|
-
this.tui.requestRender();
|
|
267
|
-
}).catch(() => {
|
|
268
|
-
if (generation !== this.buildGeneration) return;
|
|
269
|
-
this.pendingSignature = undefined;
|
|
270
|
-
this.tui.requestRender();
|
|
271
|
-
});
|
|
182
|
+
return container;
|
|
272
183
|
}
|
|
273
184
|
|
|
274
185
|
render(width: number): string[] {
|
|
275
186
|
if (this.sources.length === 0) return [];
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (this.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
187
|
+
const key = [
|
|
188
|
+
getCapabilities().images ?? "none",
|
|
189
|
+
...this.sources.map((source) => `${source.filePath} ${source.kittyBase64 ? "png" : "raw"}`),
|
|
190
|
+
].join("\0");
|
|
191
|
+
if (!this.container || this.containerKey !== key) {
|
|
192
|
+
this.container = this.build();
|
|
193
|
+
this.containerKey = key;
|
|
194
|
+
}
|
|
195
|
+
return this.container.render(Math.max(1, width));
|
|
284
196
|
}
|
|
285
197
|
|
|
286
198
|
invalidate(): void {
|
|
287
|
-
this.
|
|
199
|
+
this.container?.invalidate();
|
|
288
200
|
}
|
|
289
201
|
|
|
290
202
|
dispose(): void {
|
|
291
|
-
this.
|
|
292
|
-
this.
|
|
293
|
-
this.gallery = undefined;
|
|
203
|
+
this.container = undefined;
|
|
204
|
+
this.containerKey = undefined;
|
|
294
205
|
}
|
|
295
206
|
}
|
|
296
207
|
|
|
@@ -301,8 +212,8 @@ function renderPreviewWidget(ctx: ExtensionContext, sources: PreviewSource[]): v
|
|
|
301
212
|
}
|
|
302
213
|
|
|
303
214
|
const snapshot = [...sources];
|
|
304
|
-
ctx.ui.setWidget(WIDGET_KEY, (
|
|
305
|
-
new
|
|
215
|
+
ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) =>
|
|
216
|
+
new ClipboardPreviewGallery(snapshot, (text) => theme.fg("muted", text)));
|
|
306
217
|
}
|
|
307
218
|
|
|
308
219
|
export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
|
|
@@ -314,6 +225,7 @@ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
|
|
|
314
225
|
let generation = 0;
|
|
315
226
|
let activeContext: ExtensionContext | undefined;
|
|
316
227
|
let sources = new Map<string, PreviewSource>();
|
|
228
|
+
let trackedFiles = new Set<string>();
|
|
317
229
|
let syncInFlight = false;
|
|
318
230
|
let syncAgain = false;
|
|
319
231
|
|
|
@@ -323,6 +235,7 @@ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
|
|
|
323
235
|
timer = undefined;
|
|
324
236
|
activeContext = undefined;
|
|
325
237
|
sources = new Map();
|
|
238
|
+
trackedFiles = new Set();
|
|
326
239
|
syncInFlight = false;
|
|
327
240
|
syncAgain = false;
|
|
328
241
|
if (ctx?.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
@@ -349,12 +262,17 @@ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
|
|
|
349
262
|
|
|
350
263
|
const additions = await Promise.all(paths
|
|
351
264
|
.filter((path) => !sources.has(path))
|
|
352
|
-
.map(async (path) =>
|
|
265
|
+
.map(async (path) => {
|
|
266
|
+
const source = await readPreviewSource(path);
|
|
267
|
+
if (source) await prepareKittyVariant(source);
|
|
268
|
+
return [path, source] as const;
|
|
269
|
+
}));
|
|
353
270
|
if (expectedGeneration !== generation || ctx !== activeContext) return;
|
|
354
271
|
|
|
355
272
|
for (const [path, source] of additions) {
|
|
356
273
|
if (source && !sources.has(path)) {
|
|
357
274
|
sources.set(path, source);
|
|
275
|
+
trackedFiles.add(path);
|
|
358
276
|
changed = true;
|
|
359
277
|
}
|
|
360
278
|
}
|
|
@@ -393,8 +311,10 @@ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
|
|
|
393
311
|
timer.unref?.();
|
|
394
312
|
});
|
|
395
313
|
|
|
396
|
-
pi.on("session_shutdown", (_event, ctx) => {
|
|
314
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
315
|
+
const tracked = [...trackedFiles];
|
|
397
316
|
stop(ctx);
|
|
317
|
+
await Promise.all(tracked.map(deleteClipboardImage));
|
|
398
318
|
});
|
|
399
319
|
};
|
|
400
320
|
}
|
package/src/png.ts
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
import { deflateSync, inflateSync } from "node:zlib";
|
|
2
|
-
|
|
3
|
-
/** Straight-alpha RGBA8 raster. */
|
|
4
|
-
export interface RgbaImage {
|
|
5
|
-
width: number;
|
|
6
|
-
height: number;
|
|
7
|
-
data: Uint8Array;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
11
|
-
const CHANNELS_BY_COLOR_TYPE: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
12
|
-
|
|
13
|
-
const CRC_TABLE = (() => {
|
|
14
|
-
const table = new Uint32Array(256);
|
|
15
|
-
for (let n = 0; n < 256; n += 1) {
|
|
16
|
-
let c = n;
|
|
17
|
-
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
18
|
-
table[n] = c >>> 0;
|
|
19
|
-
}
|
|
20
|
-
return table;
|
|
21
|
-
})();
|
|
22
|
-
|
|
23
|
-
function crc32(...parts: Uint8Array[]): number {
|
|
24
|
-
let crc = 0xffffffff;
|
|
25
|
-
for (const part of parts) {
|
|
26
|
-
for (const byte of part) crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
|
|
27
|
-
}
|
|
28
|
-
return (crc ^ 0xffffffff) >>> 0;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function paeth(a: number, b: number, c: number): number {
|
|
32
|
-
const p = a + b - c;
|
|
33
|
-
const pa = Math.abs(p - a);
|
|
34
|
-
const pb = Math.abs(p - b);
|
|
35
|
-
const pc = Math.abs(p - c);
|
|
36
|
-
if (pa <= pb && pa <= pc) return a;
|
|
37
|
-
return pb <= pc ? b : c;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function unfilter(raw: Uint8Array, stride: number, height: number, bytesPerPixel: number): Uint8Array | null {
|
|
41
|
-
const out = new Uint8Array(stride * height);
|
|
42
|
-
let offset = 0;
|
|
43
|
-
for (let y = 0; y < height; y += 1) {
|
|
44
|
-
const filter = raw[offset];
|
|
45
|
-
offset += 1;
|
|
46
|
-
const row = out.subarray(y * stride, (y + 1) * stride);
|
|
47
|
-
const prev = y > 0 ? out.subarray((y - 1) * stride, y * stride) : undefined;
|
|
48
|
-
for (let x = 0; x < stride; x += 1) {
|
|
49
|
-
const value = raw[offset + x]!;
|
|
50
|
-
const left = x >= bytesPerPixel ? row[x - bytesPerPixel]! : 0;
|
|
51
|
-
const up = prev ? prev[x]! : 0;
|
|
52
|
-
const upLeft = prev && x >= bytesPerPixel ? prev[x - bytesPerPixel]! : 0;
|
|
53
|
-
let decoded: number;
|
|
54
|
-
switch (filter) {
|
|
55
|
-
case 0: decoded = value; break;
|
|
56
|
-
case 1: decoded = value + left; break;
|
|
57
|
-
case 2: decoded = value + up; break;
|
|
58
|
-
case 3: decoded = value + ((left + up) >> 1); break;
|
|
59
|
-
case 4: decoded = value + paeth(left, up, upLeft); break;
|
|
60
|
-
default: return null;
|
|
61
|
-
}
|
|
62
|
-
row[x] = decoded & 0xff;
|
|
63
|
-
}
|
|
64
|
-
offset += stride;
|
|
65
|
-
}
|
|
66
|
-
return out;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Decode a non-interlaced PNG (any color type, bit depths 1-16) into RGBA8.
|
|
71
|
-
* Returns null for anything it cannot represent so callers can fall back.
|
|
72
|
-
*/
|
|
73
|
-
export function decodePng(bytes: Uint8Array): RgbaImage | null {
|
|
74
|
-
if (bytes.length < 8 || SIGNATURE.some((byte, index) => bytes[index] !== byte)) return null;
|
|
75
|
-
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
76
|
-
|
|
77
|
-
let width = 0;
|
|
78
|
-
let height = 0;
|
|
79
|
-
let bitDepth = 0;
|
|
80
|
-
let colorType = 0;
|
|
81
|
-
let interlace = 0;
|
|
82
|
-
let palette: Uint8Array | undefined;
|
|
83
|
-
let transparency: Uint8Array | undefined;
|
|
84
|
-
const idat: Uint8Array[] = [];
|
|
85
|
-
|
|
86
|
-
let offset = 8;
|
|
87
|
-
while (offset + 8 <= bytes.length) {
|
|
88
|
-
const length = view.getUint32(offset);
|
|
89
|
-
const type = String.fromCharCode(bytes[offset + 4]!, bytes[offset + 5]!, bytes[offset + 6]!, bytes[offset + 7]!);
|
|
90
|
-
const start = offset + 8;
|
|
91
|
-
const end = start + length;
|
|
92
|
-
if (end + 4 > bytes.length) return null;
|
|
93
|
-
const chunk = bytes.subarray(start, end);
|
|
94
|
-
if (type === "IHDR") {
|
|
95
|
-
width = view.getUint32(start);
|
|
96
|
-
height = view.getUint32(start + 4);
|
|
97
|
-
bitDepth = bytes[start + 8]!;
|
|
98
|
-
colorType = bytes[start + 9]!;
|
|
99
|
-
interlace = bytes[start + 12]!;
|
|
100
|
-
} else if (type === "PLTE") {
|
|
101
|
-
palette = chunk;
|
|
102
|
-
} else if (type === "tRNS") {
|
|
103
|
-
transparency = chunk;
|
|
104
|
-
} else if (type === "IDAT") {
|
|
105
|
-
idat.push(chunk);
|
|
106
|
-
} else if (type === "IEND") {
|
|
107
|
-
break;
|
|
108
|
-
}
|
|
109
|
-
offset = end + 4;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const channels = CHANNELS_BY_COLOR_TYPE[colorType];
|
|
113
|
-
if (!width || !height || interlace !== 0 || idat.length === 0 || !channels) return null;
|
|
114
|
-
if (![1, 2, 4, 8, 16].includes(bitDepth)) return null;
|
|
115
|
-
if (colorType === 3 && !palette) return null;
|
|
116
|
-
|
|
117
|
-
const bitsPerPixel = channels * bitDepth;
|
|
118
|
-
const bytesPerPixel = Math.max(1, bitsPerPixel >> 3);
|
|
119
|
-
const stride = Math.ceil((width * bitsPerPixel) / 8);
|
|
120
|
-
|
|
121
|
-
let raw: Uint8Array;
|
|
122
|
-
try {
|
|
123
|
-
raw = inflateSync(Buffer.concat(idat));
|
|
124
|
-
} catch {
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
if (raw.length < (stride + 1) * height) return null;
|
|
128
|
-
const scanlines = unfilter(raw, stride, height, bytesPerPixel);
|
|
129
|
-
if (!scanlines) return null;
|
|
130
|
-
|
|
131
|
-
const maxSample = (1 << bitDepth) - 1;
|
|
132
|
-
const readSample = (row: Uint8Array, index: number): number => {
|
|
133
|
-
if (bitDepth === 8) return row[index]!;
|
|
134
|
-
if (bitDepth === 16) return row[index * 2]!;
|
|
135
|
-
const bitOffset = index * bitDepth;
|
|
136
|
-
const byte = row[bitOffset >> 3]!;
|
|
137
|
-
return (byte >> (8 - bitDepth - (bitOffset & 7))) & maxSample;
|
|
138
|
-
};
|
|
139
|
-
const scale = (sample: number): number => (bitDepth === 8 || bitDepth === 16 ? sample : Math.round((sample * 255) / maxSample));
|
|
140
|
-
|
|
141
|
-
const data = new Uint8Array(width * height * 4);
|
|
142
|
-
for (let y = 0; y < height; y += 1) {
|
|
143
|
-
const row = scanlines.subarray(y * stride, (y + 1) * stride);
|
|
144
|
-
for (let x = 0; x < width; x += 1) {
|
|
145
|
-
const out = (y * width + x) * 4;
|
|
146
|
-
const base = x * channels;
|
|
147
|
-
switch (colorType) {
|
|
148
|
-
case 0: {
|
|
149
|
-
const gray = scale(readSample(row, base));
|
|
150
|
-
data[out] = gray; data[out + 1] = gray; data[out + 2] = gray; data[out + 3] = 255;
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
case 2:
|
|
154
|
-
data[out] = scale(readSample(row, base));
|
|
155
|
-
data[out + 1] = scale(readSample(row, base + 1));
|
|
156
|
-
data[out + 2] = scale(readSample(row, base + 2));
|
|
157
|
-
data[out + 3] = 255;
|
|
158
|
-
break;
|
|
159
|
-
case 3: {
|
|
160
|
-
const index = readSample(row, base);
|
|
161
|
-
data[out] = palette![index * 3] ?? 0;
|
|
162
|
-
data[out + 1] = palette![index * 3 + 1] ?? 0;
|
|
163
|
-
data[out + 2] = palette![index * 3 + 2] ?? 0;
|
|
164
|
-
data[out + 3] = transparency?.[index] ?? 255;
|
|
165
|
-
break;
|
|
166
|
-
}
|
|
167
|
-
case 4: {
|
|
168
|
-
const gray = scale(readSample(row, base));
|
|
169
|
-
data[out] = gray; data[out + 1] = gray; data[out + 2] = gray;
|
|
170
|
-
data[out + 3] = scale(readSample(row, base + 1));
|
|
171
|
-
break;
|
|
172
|
-
}
|
|
173
|
-
default:
|
|
174
|
-
data[out] = scale(readSample(row, base));
|
|
175
|
-
data[out + 1] = scale(readSample(row, base + 1));
|
|
176
|
-
data[out + 2] = scale(readSample(row, base + 2));
|
|
177
|
-
data[out + 3] = scale(readSample(row, base + 3));
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return { width, height, data };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function chunk(type: string, payload: Uint8Array): Buffer {
|
|
186
|
-
const header = Buffer.alloc(8);
|
|
187
|
-
header.writeUInt32BE(payload.length, 0);
|
|
188
|
-
header.write(type, 4, "ascii");
|
|
189
|
-
const crc = Buffer.alloc(4);
|
|
190
|
-
crc.writeUInt32BE(crc32(header.subarray(4), payload), 0);
|
|
191
|
-
return Buffer.concat([header, payload, crc]);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Encode an RGBA8 raster as a non-interlaced 8-bit RGBA PNG. */
|
|
195
|
-
export function encodePng(image: RgbaImage): Buffer {
|
|
196
|
-
const stride = image.width * 4;
|
|
197
|
-
const raw = Buffer.alloc((stride + 1) * image.height);
|
|
198
|
-
for (let y = 0; y < image.height; y += 1) {
|
|
199
|
-
raw[y * (stride + 1)] = 0;
|
|
200
|
-
raw.set(image.data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
|
201
|
-
}
|
|
202
|
-
const ihdr = Buffer.alloc(13);
|
|
203
|
-
ihdr.writeUInt32BE(image.width, 0);
|
|
204
|
-
ihdr.writeUInt32BE(image.height, 4);
|
|
205
|
-
ihdr[8] = 8; // bit depth
|
|
206
|
-
ihdr[9] = 6; // RGBA
|
|
207
|
-
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
|
208
|
-
return Buffer.concat([
|
|
209
|
-
Buffer.from(SIGNATURE),
|
|
210
|
-
chunk("IHDR", ihdr),
|
|
211
|
-
chunk("IDAT", deflateSync(raw)),
|
|
212
|
-
chunk("IEND", new Uint8Array(0)),
|
|
213
|
-
]);
|
|
214
|
-
}
|
package/src/raster.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import type { RgbaImage } from "./png.ts";
|
|
2
|
-
|
|
3
|
-
export type Rgba = readonly [r: number, g: number, b: number, a: number];
|
|
4
|
-
|
|
5
|
-
export interface Rect {
|
|
6
|
-
x: number;
|
|
7
|
-
y: number;
|
|
8
|
-
width: number;
|
|
9
|
-
height: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function createImage(width: number, height: number): RgbaImage {
|
|
13
|
-
return { width, height, data: new Uint8Array(width * height * 4) };
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Box-filter resample (area average when shrinking, nearest when enlarging). */
|
|
17
|
-
export function resample(image: RgbaImage, width: number, height: number): RgbaImage {
|
|
18
|
-
if (width === image.width && height === image.height) return image;
|
|
19
|
-
const out = createImage(width, height);
|
|
20
|
-
const scaleX = image.width / width;
|
|
21
|
-
const scaleY = image.height / height;
|
|
22
|
-
for (let y = 0; y < height; y += 1) {
|
|
23
|
-
const y0 = Math.floor(y * scaleY);
|
|
24
|
-
const y1 = Math.max(y0 + 1, Math.min(image.height, Math.ceil((y + 1) * scaleY)));
|
|
25
|
-
for (let x = 0; x < width; x += 1) {
|
|
26
|
-
const x0 = Math.floor(x * scaleX);
|
|
27
|
-
const x1 = Math.max(x0 + 1, Math.min(image.width, Math.ceil((x + 1) * scaleX)));
|
|
28
|
-
let r = 0; let g = 0; let b = 0; let a = 0; let count = 0;
|
|
29
|
-
for (let sy = y0; sy < y1; sy += 1) {
|
|
30
|
-
for (let sx = x0; sx < x1; sx += 1) {
|
|
31
|
-
const i = (sy * image.width + sx) * 4;
|
|
32
|
-
const alpha = image.data[i + 3]!;
|
|
33
|
-
r += image.data[i]! * alpha;
|
|
34
|
-
g += image.data[i + 1]! * alpha;
|
|
35
|
-
b += image.data[i + 2]! * alpha;
|
|
36
|
-
a += alpha;
|
|
37
|
-
count += 1;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
const o = (y * width + x) * 4;
|
|
41
|
-
if (a > 0) {
|
|
42
|
-
out.data[o] = Math.round(r / a);
|
|
43
|
-
out.data[o + 1] = Math.round(g / a);
|
|
44
|
-
out.data[o + 2] = Math.round(b / a);
|
|
45
|
-
}
|
|
46
|
-
out.data[o + 3] = Math.round(a / count);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return out;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Scale to fit inside the box while preserving aspect ratio (enlarging allowed). */
|
|
53
|
-
export function fitWithin(image: RgbaImage, maxWidth: number, maxHeight: number): RgbaImage {
|
|
54
|
-
const scale = Math.min(maxWidth / image.width, maxHeight / image.height);
|
|
55
|
-
const width = Math.max(1, Math.round(image.width * scale));
|
|
56
|
-
const height = Math.max(1, Math.round(image.height * scale));
|
|
57
|
-
return resample(image, width, height);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Anti-aliased coverage (0..1) of the pixel at (px, py) by a rounded rectangle. */
|
|
61
|
-
export function roundedRectCoverage(px: number, py: number, rect: Rect, radius: number): number {
|
|
62
|
-
const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
|
|
63
|
-
const dx = Math.abs(px + 0.5 - (rect.x + rect.width / 2)) - (rect.width / 2 - r);
|
|
64
|
-
const dy = Math.abs(py + 0.5 - (rect.y + rect.height / 2)) - (rect.height / 2 - r);
|
|
65
|
-
const distance = Math.hypot(Math.max(dx, 0), Math.max(dy, 0)) + Math.min(Math.max(dx, dy), 0) - r;
|
|
66
|
-
return Math.min(1, Math.max(0, 0.5 - distance));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function blendPixel(canvas: RgbaImage, x: number, y: number, color: Rgba, coverage: number): void {
|
|
70
|
-
if (coverage <= 0 || x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return;
|
|
71
|
-
const i = (y * canvas.width + x) * 4;
|
|
72
|
-
const sa = (color[3] / 255) * coverage;
|
|
73
|
-
if (sa <= 0) return;
|
|
74
|
-
const da = canvas.data[i + 3]! / 255;
|
|
75
|
-
const outA = sa + da * (1 - sa);
|
|
76
|
-
for (let c = 0; c < 3; c += 1) {
|
|
77
|
-
canvas.data[i + c] = Math.round((color[c]! * sa + canvas.data[i + c]! * da * (1 - sa)) / outA);
|
|
78
|
-
}
|
|
79
|
-
canvas.data[i + 3] = Math.round(outA * 255);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function forEachPixel(rect: Rect, canvas: RgbaImage, visit: (x: number, y: number) => void): void {
|
|
83
|
-
const x0 = Math.max(0, Math.floor(rect.x));
|
|
84
|
-
const y0 = Math.max(0, Math.floor(rect.y));
|
|
85
|
-
const x1 = Math.min(canvas.width, Math.ceil(rect.x + rect.width));
|
|
86
|
-
const y1 = Math.min(canvas.height, Math.ceil(rect.y + rect.height));
|
|
87
|
-
for (let y = y0; y < y1; y += 1) {
|
|
88
|
-
for (let x = x0; x < x1; x += 1) visit(x, y);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function fillRoundedRect(canvas: RgbaImage, rect: Rect, radius: number, color: Rgba): void {
|
|
93
|
-
forEachPixel(rect, canvas, (x, y) => blendPixel(canvas, x, y, color, roundedRectCoverage(x, y, rect, radius)));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Stroke drawn inside `rect`, `width` pixels wide, following its rounded outline. */
|
|
97
|
-
export function strokeRoundedRect(canvas: RgbaImage, rect: Rect, radius: number, width: number, color: Rgba): void {
|
|
98
|
-
const inner: Rect = { x: rect.x + width, y: rect.y + width, width: rect.width - width * 2, height: rect.height - width * 2 };
|
|
99
|
-
forEachPixel(rect, canvas, (x, y) => {
|
|
100
|
-
const coverage = roundedRectCoverage(x, y, rect, radius) - roundedRectCoverage(x, y, inner, radius - width);
|
|
101
|
-
blendPixel(canvas, x, y, color, coverage);
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Source-over blit; when `mask` is given, source alpha is clipped to that rounded rectangle. */
|
|
106
|
-
export function blit(canvas: RgbaImage, image: RgbaImage, left: number, top: number, mask?: { rect: Rect; radius: number }): void {
|
|
107
|
-
for (let y = 0; y < image.height; y += 1) {
|
|
108
|
-
for (let x = 0; x < image.width; x += 1) {
|
|
109
|
-
const cx = left + x;
|
|
110
|
-
const cy = top + y;
|
|
111
|
-
const coverage = mask ? roundedRectCoverage(cx, cy, mask.rect, mask.radius) : 1;
|
|
112
|
-
const i = (y * image.width + x) * 4;
|
|
113
|
-
blendPixel(canvas, cx, cy, [image.data[i]!, image.data[i + 1]!, image.data[i + 2]!, image.data[i + 3]!], coverage);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|