kittyhtml 0.1.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 +94 -0
- package/assets/fonts/NotoSans-Bold.ttf +0 -0
- package/assets/fonts/NotoSans-BoldItalic.ttf +0 -0
- package/assets/fonts/NotoSans-Italic.ttf +0 -0
- package/assets/fonts/NotoSans-Regular.ttf +0 -0
- package/assets/fonts/NotoSansMono-Bold.ttf +0 -0
- package/assets/fonts/NotoSansMono-Regular.ttf +0 -0
- package/examples/demo.html +35 -0
- package/package.json +56 -0
- package/skill/kittyhtml/SKILL.md +62 -0
- package/src/cli.js +126 -0
- package/src/index.js +2 -0
- package/src/protocols.js +74 -0
- package/src/render.js +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kyle Kukshtel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# kittyhtml
|
|
2
|
+
|
|
3
|
+
Render HTML to an image and display it inline in a graphics-capable terminal (Kitty, WezTerm, Ghostty, iTerm2).
|
|
4
|
+
|
|
5
|
+
This is **not** a headless browser. It's a thin CLI that pipes HTML through [DropFlow](https://github.com/chearon/dropflow) (a real CSS layout engine, no JS/no Chromium) to a PNG, then emits the Kitty graphics protocol or iTerm2 inline-image protocol on stdout.
|
|
6
|
+
|
|
7
|
+
Built for AI agents that have something nice to show you — a styled report, a small table, a card — without taking over your screen with a browser.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
# from this directory, until published
|
|
13
|
+
npm install
|
|
14
|
+
npm link # exposes the `kittyhtml` binary on your PATH
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Node 20+. Pulls in [`canvas`](https://www.npmjs.com/package/canvas) (native build) and `dropflow`.
|
|
18
|
+
|
|
19
|
+
## Use
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
kittyhtml --demo # bundled demo page
|
|
23
|
+
echo '<h1>hi</h1>' | kittyhtml --width 400
|
|
24
|
+
kittyhtml report.html --scale 2 -o report.png # write PNG to file
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Options
|
|
28
|
+
|
|
29
|
+
| flag | default | description |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `--width N` | `800` | viewport width in CSS px |
|
|
32
|
+
| `--height N` | *auto-fit* | fixed canvas height |
|
|
33
|
+
| `--scale N` | `1` | pixel ratio (try `2` for retina-sharp text) |
|
|
34
|
+
| `--background CSS` | — | fill canvas before painting, e.g. `#fff` |
|
|
35
|
+
| `--format auto\|kitty\|iterm2` | `auto` | output protocol; auto-detect from `$TERM`/`$TERM_PROGRAM` |
|
|
36
|
+
| `--out, -o PATH` | — | write PNG to file (use `-` for raw PNG on stdout) |
|
|
37
|
+
| `--demo` | — | render the bundled demo page |
|
|
38
|
+
|
|
39
|
+
### As a library
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
import { renderHtml, encode } from 'kittyhtml';
|
|
43
|
+
|
|
44
|
+
const png = await renderHtml('<h1>hello</h1>', { width: 400, scale: 2 });
|
|
45
|
+
process.stdout.write(encode(png, 'kitty'));
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Releasing
|
|
49
|
+
|
|
50
|
+
Releases publish via GitHub Actions using npm trusted publishing (OIDC, no long-lived token). To cut a release:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npm version patch # or minor / major — bumps package.json and tags
|
|
54
|
+
git push --follow-tags
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The `Publish to npm` workflow fires on the `v*` tag, exchanges a GitHub OIDC token with npm for a one-shot publish token, and publishes with `--provenance` so each release carries a Sigstore attestation linking it back to the source commit.
|
|
58
|
+
|
|
59
|
+
## CSS caveats
|
|
60
|
+
|
|
61
|
+
DropFlow implements a serious subset of CSS but isn't a browser. Things to know when writing HTML for it (as of DropFlow 0.6.x):
|
|
62
|
+
|
|
63
|
+
- Use the longhand `background-color`, not the `background` shorthand.
|
|
64
|
+
- `max-width` / `min-width` aren't supported yet — use `width`.
|
|
65
|
+
- `list-style` markers don't render; use `•` or numbers inline.
|
|
66
|
+
- `border-radius`, `box-shadow`, `transform`, and `position: absolute/fixed` aren't supported yet.
|
|
67
|
+
- Body background doesn't propagate to the canvas — set the background on a wrapper element, or pass `--background <css>` to fill the canvas.
|
|
68
|
+
|
|
69
|
+
See the [DropFlow README](https://github.com/chearon/dropflow#supported-css-rules) for the full support matrix.
|
|
70
|
+
|
|
71
|
+
## Fonts
|
|
72
|
+
|
|
73
|
+
First run fetches Noto fonts from a CDN via DropFlow's bundled `register-noto-fonts.js`. Subsequent renders reuse what was loaded. Bundled offline fonts are on the roadmap.
|
|
74
|
+
|
|
75
|
+
## Claude Code skill
|
|
76
|
+
|
|
77
|
+
A bundled skill lets Claude Code render output as a styled inline image when you ask for it as "kittyhtml" or "khtml":
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
mkdir -p ~/.claude/skills
|
|
81
|
+
cp -r skill/kittyhtml ~/.claude/skills/kittyhtml
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then in any Claude Code session: *"give me this report as kittyhtml"* — the agent will generate DropFlow-compatible HTML and pipe it through this CLI. The skill is narrow on purpose; it only triggers on those keywords.
|
|
85
|
+
|
|
86
|
+
## How agents should use it
|
|
87
|
+
|
|
88
|
+
If you're an AI agent on a host with `kittyhtml` installed and the user is on a graphics-capable terminal, pipe your HTML through it instead of dumping markup as text:
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
echo "$HTML" | kittyhtml --width 700 --scale 2
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The image is one frame in the scrollback — no popups, no new windows.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<body style="font-family: 'Noto Sans', sans-serif; margin: 0; padding: 0; background-color: #f4f4f5; color: #18181b;">
|
|
4
|
+
<div style="background-color: #f4f4f5; padding: 32px 0;">
|
|
5
|
+
<div style="width: 720px; margin: 0 auto;">
|
|
6
|
+
|
|
7
|
+
<div style="background-color: #ffffff; border: 1px solid #e4e4e7; padding: 28px 32px;">
|
|
8
|
+
<div style="font-size: 11px; color: #71717a; font-weight: 700;">KITTYHTML · DEMO</div>
|
|
9
|
+
<div style="font-size: 26px; font-weight: 700; padding-top: 6px;">HTML, in your terminal.</div>
|
|
10
|
+
<div style="font-size: 14px; color: #52525b; padding-top: 4px;">Rendered by DropFlow, displayed via the Kitty graphics protocol.</div>
|
|
11
|
+
|
|
12
|
+
<div style="padding-top: 18px; font-size: 14px; line-height: 1.6;">
|
|
13
|
+
No Chromium, no headless browser. Just CSS layout to a PNG, base64-streamed into your terminal as one inline image. Useful when an AI agent has something to show you that reads better as a page than as plain text.
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<div style="font-size: 14px; font-weight: 700; padding-top: 22px; padding-bottom: 6px;">What this is good for</div>
|
|
17
|
+
<div style="font-size: 14px; line-height: 1.7; padding-left: 4px;">
|
|
18
|
+
<div>• Inline reports, tables, and summaries from agents.</div>
|
|
19
|
+
<div>• Quick previews of email or marketing copy.</div>
|
|
20
|
+
<div>• Lightweight diagrams that compose better in HTML than in ASCII.</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div style="margin-top: 20px; background-color: #18181b; color: #e4e4e7; padding: 14px 16px; font-family: 'Noto Sans Mono', monospace; font-size: 13px;">
|
|
24
|
+
$ echo '<h1>hi</h1>' | kittyhtml
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div style="font-size: 12px; color: #71717a; padding-top: 18px;">
|
|
28
|
+
Tip: try <span style="font-family: 'Noto Sans Mono', monospace; color: #18181b;">--scale 2</span> for sharper text on high-DPI displays.
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</body>
|
|
35
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kittyhtml",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Render HTML to an image and display it inline in Kitty/iTerm2-capable terminals. No browser — CSS layout via DropFlow.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kittyhtml": "./src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js",
|
|
12
|
+
"./render": "./src/render.js",
|
|
13
|
+
"./protocols": "./src/protocols.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"assets/fonts",
|
|
18
|
+
"skill",
|
|
19
|
+
"examples/demo.html",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"demo": "node src/cli.js --demo"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"canvas": "^2.11.2",
|
|
30
|
+
"dropflow": "^0.6.1"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"kitty",
|
|
34
|
+
"iterm2",
|
|
35
|
+
"terminal",
|
|
36
|
+
"graphics",
|
|
37
|
+
"html",
|
|
38
|
+
"css",
|
|
39
|
+
"render",
|
|
40
|
+
"dropflow",
|
|
41
|
+
"image",
|
|
42
|
+
"cli",
|
|
43
|
+
"ai-agent",
|
|
44
|
+
"claude-code"
|
|
45
|
+
],
|
|
46
|
+
"author": "Kyle Kukshtel <kyle.kukshtel@gmail.com>",
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/kkukshtel/kittyhtml.git"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/kkukshtel/kittyhtml/issues"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/kkukshtel/kittyhtml#readme"
|
|
56
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kittyhtml
|
|
3
|
+
description: Render output as an inline terminal image using the `kittyhtml` CLI. ONLY use when the user explicitly asks for output as "kittyhtml" or "khtml" (e.g. "show me this as kittyhtml", "as khtml please", "render in kittyhtml"). Do NOT use for general HTML, web, or browser-related tasks.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# kittyhtml output format
|
|
7
|
+
|
|
8
|
+
The user has asked for output as **kittyhtml** or **khtml** — they want the result rendered as a styled HTML page and displayed inline in their terminal as an image, via the `kittyhtml` CLI.
|
|
9
|
+
|
|
10
|
+
## What to do
|
|
11
|
+
|
|
12
|
+
1. **Verify the tool is installed**: `command -v kittyhtml` (one-time check per session). If missing, tell the user to run `npm install -g kittyhtml` and stop.
|
|
13
|
+
|
|
14
|
+
2. **Generate HTML** that represents the content the user asked about — a styled page, card, table, summary, or whatever fits the request. Keep it focused; the rendered image should fit on one screen.
|
|
15
|
+
|
|
16
|
+
3. **Pipe it through the CLI**:
|
|
17
|
+
```sh
|
|
18
|
+
cat <<'HTML' | kittyhtml --width 700 --scale 2
|
|
19
|
+
<!DOCTYPE html>
|
|
20
|
+
<html><body>...</body></html>
|
|
21
|
+
HTML
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Use `--scale 2` for crisp text on retina/HiDPI. Use `--width 600`–`800` for content that should feel "page-sized." Use a smaller width (`--width 400`) for compact card-like output.
|
|
25
|
+
|
|
26
|
+
4. After piping, write a one-line confirmation (e.g. "Rendered above."). The image is the deliverable; don't restate its contents in text.
|
|
27
|
+
|
|
28
|
+
## CSS rules — DropFlow subset
|
|
29
|
+
|
|
30
|
+
DropFlow is a real CSS layout engine but it's not a browser. Stick to this subset:
|
|
31
|
+
|
|
32
|
+
- **Use `background-color`, NOT the `background` shorthand.** The shorthand is silently dropped.
|
|
33
|
+
- **Use `width: Npx`, NOT `max-width`.** `max-width` / `min-width` aren't implemented.
|
|
34
|
+
- **No `list-style` markers.** Don't use `<ul><li>` and expect bullets. Use `<div>• item</div>` or numbered prefixes.
|
|
35
|
+
- **No `border-radius`, `box-shadow`, `transform`, `position: absolute/fixed`.** Square corners only. Use `border: 1px solid #color;` for definition.
|
|
36
|
+
- **`<body>` background does NOT propagate to the canvas.** Wrap content in an outer `<div style="background-color: #fff; padding: ...">` to fill the image.
|
|
37
|
+
- **Only inline `style` attributes work** — no `<style>` blocks, no classes.
|
|
38
|
+
- **Fonts available**: `Noto Sans` (default), `Noto Sans Mono` for code.
|
|
39
|
+
|
|
40
|
+
## Template that's known to render well
|
|
41
|
+
|
|
42
|
+
```html
|
|
43
|
+
<!DOCTYPE html>
|
|
44
|
+
<html>
|
|
45
|
+
<body style="font-family: 'Noto Sans', sans-serif; margin: 0; padding: 0; color: #18181b;">
|
|
46
|
+
<div style="background-color: #f4f4f5; padding: 24px 0;">
|
|
47
|
+
<div style="width: 640px; margin: 0 auto;">
|
|
48
|
+
<div style="background-color: #ffffff; border: 1px solid #e4e4e7; padding: 24px 28px;">
|
|
49
|
+
<!-- content here -->
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</body>
|
|
54
|
+
</html>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## When NOT to use this skill
|
|
58
|
+
|
|
59
|
+
- The user wants real HTML to save to a file or open in a browser → write HTML normally.
|
|
60
|
+
- The user is asking how `kittyhtml` works → answer the question directly.
|
|
61
|
+
- The user wants a screenshot of a real web page → this isn't a browser; suggest a headless-Chrome tool instead.
|
|
62
|
+
- The user hasn't said "kittyhtml" or "khtml" → do not invoke; produce regular output.
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { renderHtml } from './render.js';
|
|
6
|
+
import { encode, detectTerminal } from './protocols.js';
|
|
7
|
+
|
|
8
|
+
const HELP = `kittyhtml — render HTML to an image and display it inline in the terminal.
|
|
9
|
+
|
|
10
|
+
USAGE
|
|
11
|
+
kittyhtml [FILE] [options]
|
|
12
|
+
cat page.html | kittyhtml [options]
|
|
13
|
+
kittyhtml --demo
|
|
14
|
+
|
|
15
|
+
OPTIONS
|
|
16
|
+
--width N Viewport width in CSS px (default 800).
|
|
17
|
+
--height N Fixed canvas height; omit to auto-fit content.
|
|
18
|
+
--scale N Pixel ratio for crisper output (default 1; try 2).
|
|
19
|
+
--background CSS Fill canvas background before painting (e.g. "#fff").
|
|
20
|
+
--format FMT Output protocol: auto | kitty | iterm2 (default auto).
|
|
21
|
+
--out, -o PATH Write PNG to PATH instead of stdout. ("-" = stdout PNG)
|
|
22
|
+
--demo Render a bundled demo page.
|
|
23
|
+
--help, -h Show this help.
|
|
24
|
+
|
|
25
|
+
EXAMPLES
|
|
26
|
+
kittyhtml --demo
|
|
27
|
+
echo '<h1>hi</h1>' | kittyhtml --width 400
|
|
28
|
+
kittyhtml report.html --scale 2 -o report.png
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const opts = {
|
|
33
|
+
width: 800,
|
|
34
|
+
height: null,
|
|
35
|
+
scale: 1,
|
|
36
|
+
background: null,
|
|
37
|
+
format: 'auto',
|
|
38
|
+
out: null,
|
|
39
|
+
demo: false,
|
|
40
|
+
file: null,
|
|
41
|
+
};
|
|
42
|
+
const positional = [];
|
|
43
|
+
for (let i = 0; i < argv.length; i++) {
|
|
44
|
+
const a = argv[i];
|
|
45
|
+
const next = () => {
|
|
46
|
+
const v = argv[++i];
|
|
47
|
+
if (v === undefined) {
|
|
48
|
+
process.stderr.write(`kittyhtml: missing value for ${a}\n`);
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
return v;
|
|
52
|
+
};
|
|
53
|
+
switch (a) {
|
|
54
|
+
case '--demo': opts.demo = true; break;
|
|
55
|
+
case '--width': opts.width = Number(next()); break;
|
|
56
|
+
case '--height': opts.height = Number(next()); break;
|
|
57
|
+
case '--scale': opts.scale = Number(next()); break;
|
|
58
|
+
case '--background': case '--bg': opts.background = next(); break;
|
|
59
|
+
case '--format': opts.format = next(); break;
|
|
60
|
+
case '--out': case '-o': opts.out = next(); break;
|
|
61
|
+
case '--help': case '-h': process.stdout.write(HELP); process.exit(0);
|
|
62
|
+
default:
|
|
63
|
+
if (a.startsWith('-')) {
|
|
64
|
+
process.stderr.write(`kittyhtml: unknown option ${a}\n`);
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
positional.push(a);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (positional.length > 1) {
|
|
71
|
+
process.stderr.write(`kittyhtml: too many positional arguments\n`);
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
74
|
+
if (positional[0]) opts.file = positional[0];
|
|
75
|
+
return opts;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function loadDemoHtml() {
|
|
79
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
80
|
+
return readFileSync(join(here, '..', 'examples', 'demo.html'), 'utf8');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function main() {
|
|
84
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
85
|
+
|
|
86
|
+
let html;
|
|
87
|
+
if (opts.demo) {
|
|
88
|
+
html = loadDemoHtml();
|
|
89
|
+
} else if (opts.file) {
|
|
90
|
+
html = readFileSync(opts.file, 'utf8');
|
|
91
|
+
} else if (!process.stdin.isTTY) {
|
|
92
|
+
html = readFileSync(0, 'utf8');
|
|
93
|
+
} else {
|
|
94
|
+
process.stdout.write(HELP);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const png = await renderHtml(html, opts);
|
|
99
|
+
|
|
100
|
+
if (opts.out) {
|
|
101
|
+
if (opts.out === '-') {
|
|
102
|
+
process.stdout.write(png);
|
|
103
|
+
} else {
|
|
104
|
+
writeFileSync(opts.out, png);
|
|
105
|
+
process.stderr.write(`kittyhtml: wrote ${opts.out} (${png.length} bytes)\n`);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const encoded = encode(png, opts.format);
|
|
111
|
+
if (encoded == null) {
|
|
112
|
+
const term = process.env.TERM || '';
|
|
113
|
+
const tp = process.env.TERM_PROGRAM || '';
|
|
114
|
+
process.stderr.write(
|
|
115
|
+
`kittyhtml: no graphics-capable terminal detected (TERM=${term}, TERM_PROGRAM=${tp}).\n` +
|
|
116
|
+
` Use --format kitty|iterm2 to force, or --out FILE to write a PNG.\n`,
|
|
117
|
+
);
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
|
120
|
+
process.stdout.write(encoded);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
main().catch(err => {
|
|
124
|
+
process.stderr.write(`kittyhtml: ${err.stack || err.message}\n`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
});
|
package/src/index.js
ADDED
package/src/protocols.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const ESC = '\x1b';
|
|
2
|
+
const ST = '\x1b\\';
|
|
3
|
+
const BEL = '\x07';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect a terminal graphics protocol from the environment.
|
|
7
|
+
* Returns 'kitty' | 'iterm2' | null.
|
|
8
|
+
*/
|
|
9
|
+
export function detectTerminal(env = process.env) {
|
|
10
|
+
if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') return 'kitty';
|
|
11
|
+
if (env.TERM_PROGRAM === 'WezTerm') return 'kitty';
|
|
12
|
+
if (env.TERM === 'xterm-ghostty' || env.TERM_PROGRAM === 'ghostty') return 'kitty';
|
|
13
|
+
if (env.TERM_PROGRAM === 'iTerm.app' || env.LC_TERMINAL === 'iTerm2') return 'iterm2';
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Encode a PNG buffer for the Kitty graphics protocol.
|
|
19
|
+
* https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
|
20
|
+
*
|
|
21
|
+
* The payload is base64-encoded and split into chunks of at most 4096 bytes.
|
|
22
|
+
* Only the first chunk carries the action/format keys; subsequent chunks carry
|
|
23
|
+
* only `m=1` (more) or `m=0` (last).
|
|
24
|
+
*/
|
|
25
|
+
export function encodeKitty(pngBuffer) {
|
|
26
|
+
const b64 = pngBuffer.toString('base64');
|
|
27
|
+
const chunkSize = 4096;
|
|
28
|
+
|
|
29
|
+
if (b64.length <= chunkSize) {
|
|
30
|
+
return `${ESC}_Ga=T,f=100;${b64}${ST}\n`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let out = '';
|
|
34
|
+
let i = 0;
|
|
35
|
+
let first = true;
|
|
36
|
+
while (i < b64.length) {
|
|
37
|
+
const slice = b64.slice(i, i + chunkSize);
|
|
38
|
+
i += chunkSize;
|
|
39
|
+
const more = i < b64.length ? 1 : 0;
|
|
40
|
+
if (first) {
|
|
41
|
+
out += `${ESC}_Ga=T,f=100,m=${more};${slice}${ST}`;
|
|
42
|
+
first = false;
|
|
43
|
+
} else {
|
|
44
|
+
out += `${ESC}_Gm=${more};${slice}${ST}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out + '\n';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Encode a PNG buffer for the iTerm2 inline image protocol.
|
|
52
|
+
* https://iterm2.com/documentation-images.html
|
|
53
|
+
*/
|
|
54
|
+
export function encodeIterm2(pngBuffer) {
|
|
55
|
+
const b64 = pngBuffer.toString('base64');
|
|
56
|
+
return `${ESC}]1337;File=inline=1;size=${pngBuffer.length}:${b64}${BEL}\n`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Encode a PNG for the chosen format. 'auto' uses detectTerminal().
|
|
61
|
+
* Returns null if format is 'auto' and no graphics-capable terminal was detected.
|
|
62
|
+
*/
|
|
63
|
+
export function encode(pngBuffer, format = 'auto') {
|
|
64
|
+
let fmt = format;
|
|
65
|
+
if (fmt === 'auto') {
|
|
66
|
+
fmt = detectTerminal();
|
|
67
|
+
if (!fmt) return null;
|
|
68
|
+
}
|
|
69
|
+
switch (fmt) {
|
|
70
|
+
case 'kitty': return encodeKitty(pngBuffer);
|
|
71
|
+
case 'iterm2': return encodeIterm2(pngBuffer);
|
|
72
|
+
default: throw new Error(`Unknown terminal format: ${fmt}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as flow from 'dropflow';
|
|
2
|
+
import parse from 'dropflow/parse.js';
|
|
3
|
+
import { createCanvas } from 'canvas';
|
|
4
|
+
|
|
5
|
+
const FONTS_DIR = new URL('../assets/fonts/', import.meta.url);
|
|
6
|
+
const BUNDLED_FONTS = [
|
|
7
|
+
'NotoSans-Regular.ttf',
|
|
8
|
+
'NotoSans-Bold.ttf',
|
|
9
|
+
'NotoSans-Italic.ttf',
|
|
10
|
+
'NotoSans-BoldItalic.ttf',
|
|
11
|
+
'NotoSansMono-Regular.ttf',
|
|
12
|
+
'NotoSansMono-Bold.ttf',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
let fontsReady = false;
|
|
16
|
+
function ensureFonts() {
|
|
17
|
+
if (fontsReady) return;
|
|
18
|
+
for (const file of BUNDLED_FONTS) {
|
|
19
|
+
flow.fonts.add(flow.createFaceFromTablesSync(new URL(file, FONTS_DIR)));
|
|
20
|
+
}
|
|
21
|
+
fontsReady = true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render an HTML string to a PNG buffer using DropFlow.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} html
|
|
28
|
+
* @param {object} [opts]
|
|
29
|
+
* @param {number} [opts.width=800] Viewport width in CSS px before scaling.
|
|
30
|
+
* @param {number|null} [opts.height] Fixed canvas height; if null, auto-fit to content.
|
|
31
|
+
* @param {number} [opts.scale=1] Pixel ratio (2 = retina-sharp).
|
|
32
|
+
* @param {string|null} [opts.background] Optional canvas background fill (CSS color).
|
|
33
|
+
* @returns {Promise<Buffer>} PNG image bytes.
|
|
34
|
+
*/
|
|
35
|
+
export async function renderHtml(html, opts = {}) {
|
|
36
|
+
const { width = 800, height = null, scale = 1, background = null } = opts;
|
|
37
|
+
ensureFonts();
|
|
38
|
+
|
|
39
|
+
const root = parse(html);
|
|
40
|
+
await flow.load(root);
|
|
41
|
+
|
|
42
|
+
const pxWidth = Math.max(1, Math.round(width * scale));
|
|
43
|
+
const layout = flow.generate(root);
|
|
44
|
+
|
|
45
|
+
let pxHeight;
|
|
46
|
+
if (height != null) {
|
|
47
|
+
pxHeight = Math.max(1, Math.round(height * scale));
|
|
48
|
+
flow.layout(layout, pxWidth, pxHeight);
|
|
49
|
+
} else {
|
|
50
|
+
flow.layout(layout, pxWidth, 1_000_000);
|
|
51
|
+
const measured = layout.getBorderArea().height;
|
|
52
|
+
pxHeight = Math.max(1, Math.ceil(measured));
|
|
53
|
+
flow.layout(layout, pxWidth, pxHeight);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const canvas = createCanvas(pxWidth, pxHeight);
|
|
57
|
+
const ctx = canvas.getContext('2d');
|
|
58
|
+
if (background) {
|
|
59
|
+
ctx.fillStyle = background;
|
|
60
|
+
ctx.fillRect(0, 0, pxWidth, pxHeight);
|
|
61
|
+
}
|
|
62
|
+
flow.paintToCanvas(layout, ctx);
|
|
63
|
+
return canvas.toBuffer('image/png');
|
|
64
|
+
}
|