termpic 0.0.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/README.md +85 -0
- package/dist/index.d.mts +118 -0
- package/dist/index.mjs +270 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# termpic
|
|
2
|
+
|
|
3
|
+
画像をターミナルの絵に変換する。ASCII の濃淡、半ブロックの色マス、パレット量子化に対応。依存は [contrast-kit](https://www.npmjs.com/package/contrast-kit) のみ。
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i termpic
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { convert, toHtml, toSvg, toText, toAnsi } from "termpic";
|
|
11
|
+
|
|
12
|
+
// bitmap は { width, height, data }(ブラウザの ImageData と同じ形)
|
|
13
|
+
const grid = convert(bitmap, { mode: "halfblock", cols: 60 });
|
|
14
|
+
|
|
15
|
+
toHtml(grid, { alt: "自画像" }); // <pre> と <span>
|
|
16
|
+
toSvg(grid, { cellSize: 8 }); // 矩形だけの SVG(フォント非依存)
|
|
17
|
+
toText(grid); // 素のテキスト(ascii モード向け)
|
|
18
|
+
toAnsi(grid); // そのままターミナルに貼れる
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## オプション
|
|
22
|
+
|
|
23
|
+
| | 既定 | 説明 |
|
|
24
|
+
| ------------ | -------------- | -------------------------------------------------- |
|
|
25
|
+
| `mode` | `"halfblock"` | `ascii` は濃淡の文字、`halfblock` は1マスに上下2色 |
|
|
26
|
+
| `cols` | `60` | 横のマス数 |
|
|
27
|
+
| `ramp` | `" .:-=+*#%@"` | ascii の濃淡ランプ(薄い → 濃い) |
|
|
28
|
+
| `palette` | なし | 渡すとその色だけに量子化する |
|
|
29
|
+
| `dither` | `false` | 量子化時に誤差拡散(Floyd–Steinberg) |
|
|
30
|
+
| `cellAspect` | `2` | 等幅フォント1文字の縦横比(高さ ÷ 幅) |
|
|
31
|
+
| `background` | `"dark"` | 置く背景。`light` では濃淡が反転する |
|
|
32
|
+
|
|
33
|
+
## 設計のポイント
|
|
34
|
+
|
|
35
|
+
**マスの縦横比は出力先で変わる。** 1マスが受け持つのは横 `blockW`・縦 `blockW * cellAspect` ピクセルです。既定の `2` は**ターミナルの比率**(行間込みで文字セルが縦2倍)で、`toAnsi` の出力にはこれが正しい値です。
|
|
36
|
+
|
|
37
|
+
ところが HTML として文字で描く場合、実際の文字セルはフォントと `line-height` で決まります。Chromium で実測すると:
|
|
38
|
+
|
|
39
|
+
| | 1文字の大きさ | 縦横比 |
|
|
40
|
+
| ------------------ | -------------- | -------- |
|
|
41
|
+
| `line-height: 1` | 9.63 × 16.00px | **1.66** |
|
|
42
|
+
| `line-height: 1.2` | 9.63 × 19.19px | 1.99 |
|
|
43
|
+
|
|
44
|
+
半ブロックは行間があるとマスのあいだに隙間が出るので `line-height: 1` が必要で、そのとき実際の比率は 1.66 です。ここを 2 のままにすると、絵は本来の 83% の高さで描かれて横に潰れます。**HTML に出すときは、描画に使うフォントの実測値を渡してください。**
|
|
45
|
+
|
|
46
|
+
SVG は矩形を `cellAspect` 倍の高さで描くので自己完結していて、どの値でも比率は保たれます(マス目の粗さが変わるだけ)。
|
|
47
|
+
|
|
48
|
+
円が円のまま出ることは、合成画像を使ったテストで固定しています。
|
|
49
|
+
|
|
50
|
+
**濃淡の割り当てには知覚的な明度を使う。** WCAG の相対輝度は線形光なので、そのまま等間隔の文字ランプに割り当てると暗部に偏ります(中間グレーが 0.216)。contrast-kit の `toOklab().L`(中間グレーで 0.60)を使っています。
|
|
51
|
+
|
|
52
|
+
**HTML は同じ色が続く区間をまとめる。** SVG も横に続く同色の矩形を1つにまとめるので、平坦な部分が多い画像では出力が10分の1近くまで縮みます。
|
|
53
|
+
|
|
54
|
+
## テーマに追従させる
|
|
55
|
+
|
|
56
|
+
出力する色を CSS 変数に差し替えられます。役割(変数名)で色を持つので、ライト/ダークでトークンの値が入れ替わるサイトなら、絵もテーマの切り替えに追従します。画像ではできない振る舞いです。
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { convert, cssVariablePalette, toSvg } from "termpic";
|
|
60
|
+
|
|
61
|
+
const { palette, colors } = cssVariablePalette({
|
|
62
|
+
"--bg": "#0b0e0f",
|
|
63
|
+
"--fg": "#cfd8d3",
|
|
64
|
+
"--accent": "#7ee787",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const grid = convert(bitmap, { palette, dither: true });
|
|
68
|
+
toSvg(grid, { colors }); // fill="var(--accent)" が出る
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`toAnsi` は実際の色の数値が要るので差し替えません。
|
|
72
|
+
|
|
73
|
+
## 貼り付け先に必要な CSS
|
|
74
|
+
|
|
75
|
+
```css
|
|
76
|
+
.termpic {
|
|
77
|
+
margin: 0;
|
|
78
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
79
|
+
line-height: 1;
|
|
80
|
+
letter-spacing: 0;
|
|
81
|
+
white-space: pre;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`line-height: 1` を入れないと、半ブロックのマスのあいだに隙間が出ます。
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
//#region src/convert.d.ts
|
|
2
|
+
/** 変換元の画像。ブラウザの `ImageData` と同じ形 */
|
|
3
|
+
interface Bitmap {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
/** RGBA が 4 バイトずつ並んだ配列 */
|
|
7
|
+
data: Uint8ClampedArray | readonly number[];
|
|
8
|
+
}
|
|
9
|
+
interface Rgb {
|
|
10
|
+
r: number;
|
|
11
|
+
g: number;
|
|
12
|
+
b: number;
|
|
13
|
+
}
|
|
14
|
+
/** 出力1マスぶん */
|
|
15
|
+
interface Cell {
|
|
16
|
+
char: string;
|
|
17
|
+
/** 文字色(#rrggbb) */
|
|
18
|
+
fg: string;
|
|
19
|
+
/** 背景色。ascii モードでは付かない */
|
|
20
|
+
bg?: string;
|
|
21
|
+
}
|
|
22
|
+
type Mode = "ascii" | "halfblock";
|
|
23
|
+
interface Grid {
|
|
24
|
+
cols: number;
|
|
25
|
+
rows: number;
|
|
26
|
+
mode: Mode;
|
|
27
|
+
/** セルの縦横比(高さ ÷ 幅)。SVG に落とすときに必要 */
|
|
28
|
+
cellAspect: number;
|
|
29
|
+
cells: Cell[];
|
|
30
|
+
}
|
|
31
|
+
/** 薄い → 濃い の順に並べた既定の濃淡ランプ */
|
|
32
|
+
declare const DEFAULT_RAMP = " .:-=+*#%@";
|
|
33
|
+
interface ConvertOptions {
|
|
34
|
+
/** ascii = 濃淡の文字だけ / halfblock = 上下2色のマス(既定) */
|
|
35
|
+
mode?: Mode;
|
|
36
|
+
/** 横方向のマス数。既定 60 */
|
|
37
|
+
cols?: number;
|
|
38
|
+
/** ascii モードの濃淡ランプ(薄い → 濃い) */
|
|
39
|
+
ramp?: string;
|
|
40
|
+
/** 量子化先のパレット。省略すると元の色をそのまま使う */
|
|
41
|
+
palette?: readonly string[];
|
|
42
|
+
/** パレット量子化のときに誤差拡散(Floyd–Steinberg)を行う */
|
|
43
|
+
dither?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* 等幅フォント1文字の縦横比(高さ ÷ 幅)。既定 2。
|
|
46
|
+
* 1 にすると出力が縦に間延びするので、通常は変えない。
|
|
47
|
+
*/
|
|
48
|
+
cellAspect?: number;
|
|
49
|
+
/**
|
|
50
|
+
* 出力を置く背景。`dark` では明るい画素ほど濃い文字を使う
|
|
51
|
+
* (濃い文字ほど地の暗さを覆って明るく見えるため)。既定 `dark`
|
|
52
|
+
*/
|
|
53
|
+
background?: "dark" | "light";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 画像をターミナル表現のマス目に変換する。
|
|
57
|
+
*
|
|
58
|
+
* `cellAspect`(既定 2)は等幅フォント1文字の縦横比。1マスは横 `blockW`・
|
|
59
|
+
* 縦 `blockW * cellAspect` ピクセルぶんを受け持つので、丸は丸のまま出る。
|
|
60
|
+
*/
|
|
61
|
+
declare function convert(bitmap: Bitmap, options?: ConvertOptions): Grid;
|
|
62
|
+
/**
|
|
63
|
+
* CSS カスタムプロパティの組から、量子化用のパレットと
|
|
64
|
+
* 出力用の色の対応表を作る。
|
|
65
|
+
*
|
|
66
|
+
* ```ts
|
|
67
|
+
* const { palette, colors } = cssVariablePalette({
|
|
68
|
+
* "--bg": "#0b0e0f",
|
|
69
|
+
* "--accent": "#7ee787",
|
|
70
|
+
* });
|
|
71
|
+
* const grid = convert(bitmap, { palette });
|
|
72
|
+
* toHtml(grid, { colors }); // 色が var(--accent) で出る
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* 役割(変数名)で色を持つので、ライト/ダークでトークンの値が入れ替わる
|
|
76
|
+
* サイトなら、出力した絵もテーマの切り替えに追従する。
|
|
77
|
+
* 同じ色が複数の名前に割り当てられている場合は、先に現れた名前を使う。
|
|
78
|
+
*/
|
|
79
|
+
declare function cssVariablePalette(variables: Readonly<Record<string, string>>): {
|
|
80
|
+
palette: string[];
|
|
81
|
+
colors: Record<string, string>;
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/render.d.ts
|
|
85
|
+
/** ascii モードの出力を素のテキストにする。行末の空白は落とす */
|
|
86
|
+
declare function toText(grid: Grid): string;
|
|
87
|
+
/** 出力する色を差し替える対応表。`#7ee787` → `var(--accent)` のように使う */
|
|
88
|
+
interface ColorMapping {
|
|
89
|
+
colors?: Readonly<Record<string, string>>;
|
|
90
|
+
}
|
|
91
|
+
interface HtmlOptions extends ColorMapping {
|
|
92
|
+
/** <pre> に付けるクラス名。既定 "termpic" */
|
|
93
|
+
className?: string;
|
|
94
|
+
/** 読み上げ用の説明。指定すると role="img" と aria-label が付く */
|
|
95
|
+
alt?: string;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* `<pre>` にまとめた HTML を返す。
|
|
99
|
+
* 同じ色が続くマスは1つの `<span>` にまとめるので、出力はマス数よりずっと小さくなる。
|
|
100
|
+
*/
|
|
101
|
+
declare function toHtml(grid: Grid, options?: HtmlOptions): string;
|
|
102
|
+
interface SvgOptions extends ColorMapping {
|
|
103
|
+
/** 1マスの幅(px)。高さは cellAspect 倍になる。既定 8 */
|
|
104
|
+
cellSize?: number;
|
|
105
|
+
alt?: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* SVG を返す。
|
|
109
|
+
*
|
|
110
|
+
* halfblock モードは矩形だけで描くのでフォントに依存しない。
|
|
111
|
+
* ascii モードは文字を使うため等幅フォントを指定し、行ごとに `textLength` で
|
|
112
|
+
* 幅を固定して、環境によるフォントの差で崩れないようにしている。
|
|
113
|
+
*/
|
|
114
|
+
declare function toSvg(grid: Grid, options?: SvgOptions): string;
|
|
115
|
+
/** そのままターミナルに貼れる ANSI エスケープ付きの文字列 */
|
|
116
|
+
declare function toAnsi(grid: Grid): string;
|
|
117
|
+
//#endregion
|
|
118
|
+
export { type Bitmap, type Cell, type ColorMapping, type ConvertOptions, DEFAULT_RAMP, type Grid, type HtmlOptions, type Mode, type Rgb, type SvgOptions, convert, cssVariablePalette, toAnsi, toHtml, toSvg, toText };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { formatHex, nearestColor, toOklab } from "contrast-kit";
|
|
2
|
+
//#region src/convert.ts
|
|
3
|
+
/** 薄い → 濃い の順に並べた既定の濃淡ランプ */
|
|
4
|
+
const DEFAULT_RAMP = " .:-=+*#%@";
|
|
5
|
+
/** 上半分を文字色、下半分を背景色で塗る文字 */
|
|
6
|
+
const HALF_BLOCK = "▀";
|
|
7
|
+
function clamp(value, min, max) {
|
|
8
|
+
return value < min ? min : value > max ? max : value;
|
|
9
|
+
}
|
|
10
|
+
/** 指定した矩形の平均色。範囲外は切り詰める */
|
|
11
|
+
function averageRect(bitmap, left, top, right, bottom) {
|
|
12
|
+
const x0 = clamp(Math.floor(left), 0, bitmap.width);
|
|
13
|
+
const x1 = clamp(Math.ceil(right), x0 + 1, bitmap.width);
|
|
14
|
+
const y0 = clamp(Math.floor(top), 0, bitmap.height);
|
|
15
|
+
const y1 = clamp(Math.ceil(bottom), y0 + 1, bitmap.height);
|
|
16
|
+
let r = 0;
|
|
17
|
+
let g = 0;
|
|
18
|
+
let b = 0;
|
|
19
|
+
let count = 0;
|
|
20
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
|
21
|
+
const index = (y * bitmap.width + x) * 4;
|
|
22
|
+
const alpha = (bitmap.data[index + 3] ?? 255) / 255;
|
|
23
|
+
r += (bitmap.data[index] ?? 0) * alpha;
|
|
24
|
+
g += (bitmap.data[index + 1] ?? 0) * alpha;
|
|
25
|
+
b += (bitmap.data[index + 2] ?? 0) * alpha;
|
|
26
|
+
count++;
|
|
27
|
+
}
|
|
28
|
+
return count === 0 ? {
|
|
29
|
+
r: 0,
|
|
30
|
+
g: 0,
|
|
31
|
+
b: 0
|
|
32
|
+
} : {
|
|
33
|
+
r: r / count,
|
|
34
|
+
g: g / count,
|
|
35
|
+
b: b / count
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** 誤差拡散つきでパレットに量子化する。palette が無ければそのまま返す */
|
|
39
|
+
function quantize(samples, cols, rows, palette, dither) {
|
|
40
|
+
if (!palette || palette.length === 0) return samples.map((sample) => formatHex({
|
|
41
|
+
r: Math.round(clamp(sample.r, 0, 255)),
|
|
42
|
+
g: Math.round(clamp(sample.g, 0, 255)),
|
|
43
|
+
b: Math.round(clamp(sample.b, 0, 255))
|
|
44
|
+
}));
|
|
45
|
+
const working = samples.map((sample) => ({ ...sample }));
|
|
46
|
+
const result = [];
|
|
47
|
+
const spread = (index, error, factor) => {
|
|
48
|
+
const target = working[index];
|
|
49
|
+
if (!target) return;
|
|
50
|
+
target.r += error.r * factor;
|
|
51
|
+
target.g += error.g * factor;
|
|
52
|
+
target.b += error.b * factor;
|
|
53
|
+
};
|
|
54
|
+
for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) {
|
|
55
|
+
const index = y * cols + x;
|
|
56
|
+
const current = working[index];
|
|
57
|
+
const rounded = {
|
|
58
|
+
r: Math.round(clamp(current.r, 0, 255)),
|
|
59
|
+
g: Math.round(clamp(current.g, 0, 255)),
|
|
60
|
+
b: Math.round(clamp(current.b, 0, 255))
|
|
61
|
+
};
|
|
62
|
+
const picked = nearestColor(rounded, palette);
|
|
63
|
+
result.push(picked);
|
|
64
|
+
if (!dither) continue;
|
|
65
|
+
const chosen = hexToRgb(picked);
|
|
66
|
+
const error = {
|
|
67
|
+
r: current.r - chosen.r,
|
|
68
|
+
g: current.g - chosen.g,
|
|
69
|
+
b: current.b - chosen.b
|
|
70
|
+
};
|
|
71
|
+
if (x + 1 < cols) spread(index + 1, error, 7 / 16);
|
|
72
|
+
if (y + 1 < rows) {
|
|
73
|
+
if (x > 0) spread(index + cols - 1, error, 3 / 16);
|
|
74
|
+
spread(index + cols, error, 5 / 16);
|
|
75
|
+
if (x + 1 < cols) spread(index + cols + 1, error, 1 / 16);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
function hexToRgb(hex) {
|
|
81
|
+
return {
|
|
82
|
+
r: Number.parseInt(hex.slice(1, 3), 16),
|
|
83
|
+
g: Number.parseInt(hex.slice(3, 5), 16),
|
|
84
|
+
b: Number.parseInt(hex.slice(5, 7), 16)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 画像をターミナル表現のマス目に変換する。
|
|
89
|
+
*
|
|
90
|
+
* `cellAspect`(既定 2)は等幅フォント1文字の縦横比。1マスは横 `blockW`・
|
|
91
|
+
* 縦 `blockW * cellAspect` ピクセルぶんを受け持つので、丸は丸のまま出る。
|
|
92
|
+
*/
|
|
93
|
+
function convert(bitmap, options = {}) {
|
|
94
|
+
if (bitmap.width <= 0 || bitmap.height <= 0) throw new TypeError("画像の大きさが 0 です");
|
|
95
|
+
const mode = options.mode ?? "halfblock";
|
|
96
|
+
const cols = Math.max(1, Math.floor(options.cols ?? 60));
|
|
97
|
+
const cellAspect = options.cellAspect ?? 2;
|
|
98
|
+
const ramp = options.ramp ?? " .:-=+*#%@";
|
|
99
|
+
const background = options.background ?? "dark";
|
|
100
|
+
const blockW = bitmap.width / cols;
|
|
101
|
+
const blockH = blockW * cellAspect;
|
|
102
|
+
const rows = Math.max(1, Math.round(bitmap.height / blockH));
|
|
103
|
+
const perCell = mode === "halfblock" ? 2 : 1;
|
|
104
|
+
const sampleRows = rows * perCell;
|
|
105
|
+
const sampleH = blockH / perCell;
|
|
106
|
+
const samples = [];
|
|
107
|
+
for (let y = 0; y < sampleRows; y++) for (let x = 0; x < cols; x++) samples.push(averageRect(bitmap, x * blockW, y * sampleH, (x + 1) * blockW, (y + 1) * sampleH));
|
|
108
|
+
const colors = quantize(samples, cols, sampleRows, options.palette, options.dither ?? false);
|
|
109
|
+
const cells = [];
|
|
110
|
+
for (let row = 0; row < rows; row++) for (let col = 0; col < cols; col++) if (mode === "halfblock") cells.push({
|
|
111
|
+
char: HALF_BLOCK,
|
|
112
|
+
fg: colors[row * 2 * cols + col],
|
|
113
|
+
bg: colors[(row * 2 + 1) * cols + col]
|
|
114
|
+
});
|
|
115
|
+
else {
|
|
116
|
+
const fg = colors[row * cols + col];
|
|
117
|
+
const lightness = toOklab(fg).L;
|
|
118
|
+
const level = background === "dark" ? lightness : 1 - lightness;
|
|
119
|
+
const index = clamp(Math.round(level * (ramp.length - 1)), 0, ramp.length - 1);
|
|
120
|
+
cells.push({
|
|
121
|
+
char: ramp[index],
|
|
122
|
+
fg
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
cols,
|
|
127
|
+
rows,
|
|
128
|
+
mode,
|
|
129
|
+
cellAspect,
|
|
130
|
+
cells
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* CSS カスタムプロパティの組から、量子化用のパレットと
|
|
135
|
+
* 出力用の色の対応表を作る。
|
|
136
|
+
*
|
|
137
|
+
* ```ts
|
|
138
|
+
* const { palette, colors } = cssVariablePalette({
|
|
139
|
+
* "--bg": "#0b0e0f",
|
|
140
|
+
* "--accent": "#7ee787",
|
|
141
|
+
* });
|
|
142
|
+
* const grid = convert(bitmap, { palette });
|
|
143
|
+
* toHtml(grid, { colors }); // 色が var(--accent) で出る
|
|
144
|
+
* ```
|
|
145
|
+
*
|
|
146
|
+
* 役割(変数名)で色を持つので、ライト/ダークでトークンの値が入れ替わる
|
|
147
|
+
* サイトなら、出力した絵もテーマの切り替えに追従する。
|
|
148
|
+
* 同じ色が複数の名前に割り当てられている場合は、先に現れた名前を使う。
|
|
149
|
+
*/
|
|
150
|
+
function cssVariablePalette(variables) {
|
|
151
|
+
const palette = [];
|
|
152
|
+
const colors = {};
|
|
153
|
+
for (const [name, value] of Object.entries(variables)) {
|
|
154
|
+
const hex = formatHex(value.trim());
|
|
155
|
+
if (colors[hex] !== void 0) continue;
|
|
156
|
+
palette.push(hex);
|
|
157
|
+
colors[hex] = `var(${name})`;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
palette,
|
|
161
|
+
colors
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/render.ts
|
|
166
|
+
const HTML_ESCAPES = {
|
|
167
|
+
"&": "&",
|
|
168
|
+
"<": "<",
|
|
169
|
+
">": ">",
|
|
170
|
+
"\"": """
|
|
171
|
+
};
|
|
172
|
+
function escapeHtml(value) {
|
|
173
|
+
return value.replace(/[&<>"]/g, (char) => HTML_ESCAPES[char]);
|
|
174
|
+
}
|
|
175
|
+
function runsOfRow(grid, row) {
|
|
176
|
+
const runs = [];
|
|
177
|
+
for (let col = 0; col < grid.cols; col++) {
|
|
178
|
+
const cell = grid.cells[row * grid.cols + col];
|
|
179
|
+
const last = runs.at(-1);
|
|
180
|
+
if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.char;
|
|
181
|
+
else runs.push({
|
|
182
|
+
text: cell.char,
|
|
183
|
+
fg: cell.fg,
|
|
184
|
+
bg: cell.bg
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return runs;
|
|
188
|
+
}
|
|
189
|
+
/** ascii モードの出力を素のテキストにする。行末の空白は落とす */
|
|
190
|
+
function toText(grid) {
|
|
191
|
+
const lines = [];
|
|
192
|
+
for (let row = 0; row < grid.rows; row++) {
|
|
193
|
+
let line = "";
|
|
194
|
+
for (let col = 0; col < grid.cols; col++) line += grid.cells[row * grid.cols + col].char;
|
|
195
|
+
lines.push(line.replace(/\s+$/u, ""));
|
|
196
|
+
}
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
function mapColor(color, colors) {
|
|
200
|
+
return colors?.[color] ?? color;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* `<pre>` にまとめた HTML を返す。
|
|
204
|
+
* 同じ色が続くマスは1つの `<span>` にまとめるので、出力はマス数よりずっと小さくなる。
|
|
205
|
+
*/
|
|
206
|
+
function toHtml(grid, options = {}) {
|
|
207
|
+
const body = Array.from({ length: grid.rows }, (_, row) => runsOfRow(grid, row).map((run) => {
|
|
208
|
+
const fg = mapColor(run.fg, options.colors);
|
|
209
|
+
return `<span style="${run.bg === void 0 ? `color:${fg}` : `color:${fg};background:${mapColor(run.bg, options.colors)}`}">${escapeHtml(run.text)}</span>`;
|
|
210
|
+
}).join("")).join("\n");
|
|
211
|
+
const attributes = [`class="${escapeHtml(options.className ?? "termpic")}"`];
|
|
212
|
+
if (options.alt !== void 0) attributes.push(`role="img"`, `aria-label="${escapeHtml(options.alt)}"`);
|
|
213
|
+
return `<pre ${attributes.join(" ")}>${body}</pre>`;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* SVG を返す。
|
|
217
|
+
*
|
|
218
|
+
* halfblock モードは矩形だけで描くのでフォントに依存しない。
|
|
219
|
+
* ascii モードは文字を使うため等幅フォントを指定し、行ごとに `textLength` で
|
|
220
|
+
* 幅を固定して、環境によるフォントの差で崩れないようにしている。
|
|
221
|
+
*/
|
|
222
|
+
function toSvg(grid, options = {}) {
|
|
223
|
+
const cellW = options.cellSize ?? 8;
|
|
224
|
+
const cellH = cellW * grid.cellAspect;
|
|
225
|
+
const width = grid.cols * cellW;
|
|
226
|
+
const height = grid.rows * cellH;
|
|
227
|
+
const parts = [];
|
|
228
|
+
if (grid.mode === "halfblock") {
|
|
229
|
+
const halfH = cellH / 2;
|
|
230
|
+
const emitRow = (colorAt, y) => {
|
|
231
|
+
let start = 0;
|
|
232
|
+
for (let col = 1; col <= grid.cols; col++) {
|
|
233
|
+
if (col < grid.cols && colorAt(col) === colorAt(start)) continue;
|
|
234
|
+
const width = (col - start) * cellW;
|
|
235
|
+
parts.push(`<rect x="${start * cellW}" y="${y}" width="${width}" height="${halfH}" fill="${mapColor(colorAt(start), options.colors)}"/>`);
|
|
236
|
+
start = col;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
for (let row = 0; row < grid.rows; row++) {
|
|
240
|
+
const cellAt = (col) => grid.cells[row * grid.cols + col];
|
|
241
|
+
emitRow((col) => cellAt(col).fg, row * cellH);
|
|
242
|
+
emitRow((col) => cellAt(col).bg ?? cellAt(col).fg, row * cellH + halfH);
|
|
243
|
+
}
|
|
244
|
+
} else for (let row = 0; row < grid.rows; row++) {
|
|
245
|
+
const spans = runsOfRow(grid, row).map((run) => `<tspan fill="${mapColor(run.fg, options.colors)}">${escapeHtml(run.text)}</tspan>`).join("");
|
|
246
|
+
const baseline = row * cellH + cellH * .78;
|
|
247
|
+
parts.push(`<text x="0" y="${baseline}" textLength="${width}" lengthAdjust="spacingAndGlyphs" xml:space="preserve">${spans}</text>`);
|
|
248
|
+
}
|
|
249
|
+
const label = options.alt === void 0 ? "" : `<title>${escapeHtml(options.alt)}</title>`;
|
|
250
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img"${grid.mode === "ascii" ? ` font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="${cellH * .9}"` : ""}>${label}${parts.join("")}</svg>`;
|
|
251
|
+
}
|
|
252
|
+
function ansiColor(hex, layer) {
|
|
253
|
+
return `\u001b[${layer};2;${Number.parseInt(hex.slice(1, 3), 16)};${Number.parseInt(hex.slice(3, 5), 16)};${Number.parseInt(hex.slice(5, 7), 16)}m`;
|
|
254
|
+
}
|
|
255
|
+
/** そのままターミナルに貼れる ANSI エスケープ付きの文字列 */
|
|
256
|
+
function toAnsi(grid) {
|
|
257
|
+
const lines = [];
|
|
258
|
+
for (let row = 0; row < grid.rows; row++) {
|
|
259
|
+
let line = "";
|
|
260
|
+
for (const run of runsOfRow(grid, row)) {
|
|
261
|
+
line += ansiColor(run.fg, 38);
|
|
262
|
+
if (run.bg !== void 0) line += ansiColor(run.bg, 48);
|
|
263
|
+
line += run.text;
|
|
264
|
+
}
|
|
265
|
+
lines.push(`${line}\u001b[0m`);
|
|
266
|
+
}
|
|
267
|
+
return lines.join("\n");
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
export { DEFAULT_RAMP, convert, cssVariablePalette, toAnsi, toHtml, toSvg, toText };
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "termpic",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Convert images into terminal art: ASCII ramps, half-block color cells, and palette quantization.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ansi",
|
|
7
|
+
"ascii-art",
|
|
8
|
+
"dithering",
|
|
9
|
+
"image",
|
|
10
|
+
"pixel-art",
|
|
11
|
+
"terminal"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/finalize/termpic#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/finalize/termpic/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "finalize",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/finalize/termpic.git",
|
|
22
|
+
"directory": "packages/termpic"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./dist/index.mjs",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "vp pack",
|
|
37
|
+
"dev": "vp pack --watch",
|
|
38
|
+
"test": "vp test",
|
|
39
|
+
"check": "vp check",
|
|
40
|
+
"prepublishOnly": "vp run build"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"contrast-kit": "link:../../../contrast-kit/packages/contrast"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^26.1.1",
|
|
47
|
+
"bumpp": "^11.1.0",
|
|
48
|
+
"typescript": "^7.0.2",
|
|
49
|
+
"vite": "catalog:",
|
|
50
|
+
"vite-plus": "catalog:"
|
|
51
|
+
}
|
|
52
|
+
}
|