termpic 0.0.0 → 0.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/LICENSE +21 -0
- package/README.md +32 -0
- package/dist/index.d.mts +23 -3
- package/dist/index.mjs +256 -22
- package/package.json +2 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 finalize
|
|
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
CHANGED
|
@@ -30,6 +30,38 @@ toAnsi(grid); // そのままターミナルに貼れる
|
|
|
30
30
|
| `cellAspect` | `2` | 等幅フォント1文字の縦横比(高さ ÷ 幅) |
|
|
31
31
|
| `background` | `"dark"` | 置く背景。`light` では濃淡が反転する |
|
|
32
32
|
|
|
33
|
+
## 描き方
|
|
34
|
+
|
|
35
|
+
| モード | 1マスの分割 | 色 | 向いているもの |
|
|
36
|
+
| ----------- | ----------- | ----------- | ------------------------------ |
|
|
37
|
+
| `ascii` | 1x1 | 文字色のみ | 文字として貼りたいとき |
|
|
38
|
+
| `halfblock` | 1x2 | 上下2色 | 汎用。既定 |
|
|
39
|
+
| `quadrant` | 2x2 | 明暗2色 | 横の解像度がほしいとき |
|
|
40
|
+
| `braille` | 2x4 | 点と地の2色 | 最も細かい。輪郭がはっきり出る |
|
|
41
|
+
|
|
42
|
+
`braille` は 1 マスに 2x4 の点を持てるので `halfblock` の4倍の解像度になります。点の有無は**画像全体の平均の明度**で決めます。マスごとの中間値で切ると、一様なマスが必ず全点灯になって図地が反転するためです。点の色に加えて点が付かない側の平均も背景色として持たせているので、写真でも図地が反転しません。
|
|
43
|
+
|
|
44
|
+
## 画像から色を抽出する
|
|
45
|
+
|
|
46
|
+
決め打ちのパレットに落とすと、そこに無い色(茶色など)が破綻します。画像自身から代表色を求められます。
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { convert, extractPalette } from "termpic";
|
|
50
|
+
|
|
51
|
+
const palette = extractPalette(bitmap, 16);
|
|
52
|
+
convert(bitmap, { palette });
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
中央値分割法です。色空間の直方体を最も幅の広い軸の中央値で切る操作を、箱が指定数になるまで繰り返します。
|
|
56
|
+
|
|
57
|
+
## 輪郭を拾う
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
convert(bitmap, { mode: "ascii", edges: true });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Sobel フィルタでマス目の明度の傾きを取り、輪郭が強いマスに向きの文字を割り当てます。マスは縦長なので、画面上の角度に直すために縦方向の傾きへ `cellAspect` を掛けています。これを忘れると斜めの輪郭がすべて縦寄りに倒れます。
|
|
64
|
+
|
|
33
65
|
## 設計のポイント
|
|
34
66
|
|
|
35
67
|
**マスの縦横比は出力先で変わる。** 1マスが受け持つのは横 `blockW`・縦 `blockW * cellAspect` ピクセルです。既定の `2` は**ターミナルの比率**(行間込みで文字セルが縦2倍)で、`toAnsi` の出力にはこれが正しい値です。
|
package/dist/index.d.mts
CHANGED
|
@@ -19,7 +19,7 @@ interface Cell {
|
|
|
19
19
|
/** 背景色。ascii モードでは付かない */
|
|
20
20
|
bg?: string;
|
|
21
21
|
}
|
|
22
|
-
type Mode = "ascii" | "halfblock";
|
|
22
|
+
type Mode = "ascii" | "halfblock" | "quadrant" | "braille";
|
|
23
23
|
interface Grid {
|
|
24
24
|
cols: number;
|
|
25
25
|
rows: number;
|
|
@@ -31,7 +31,12 @@ interface Grid {
|
|
|
31
31
|
/** 薄い → 濃い の順に並べた既定の濃淡ランプ */
|
|
32
32
|
declare const DEFAULT_RAMP = " .:-=+*#%@";
|
|
33
33
|
interface ConvertOptions {
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* - `ascii` 濃淡の文字だけ
|
|
36
|
+
* - `halfblock` 上下2色のマス(既定)
|
|
37
|
+
* - `quadrant` 四分割。横の解像度が倍になる
|
|
38
|
+
* - `braille` 点字。1マスに 2x4 の点を持てるので最も細かいが、色は1マスにつき1色
|
|
39
|
+
*/
|
|
35
40
|
mode?: Mode;
|
|
36
41
|
/** 横方向のマス数。既定 60 */
|
|
37
42
|
cols?: number;
|
|
@@ -41,6 +46,11 @@ interface ConvertOptions {
|
|
|
41
46
|
palette?: readonly string[];
|
|
42
47
|
/** パレット量子化のときに誤差拡散(Floyd–Steinberg)を行う */
|
|
43
48
|
dither?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* ascii モードで輪郭を拾い、傾きに応じて `-` `/` `|` `\\` を割り当てる。
|
|
51
|
+
* 数値を渡すとその値を閾値にする(既定 0.12)。
|
|
52
|
+
*/
|
|
53
|
+
edges?: boolean | number;
|
|
44
54
|
/**
|
|
45
55
|
* 等幅フォント1文字の縦横比(高さ ÷ 幅)。既定 2。
|
|
46
56
|
* 1 にすると出力が縦に間延びするので、通常は変えない。
|
|
@@ -80,6 +90,16 @@ declare function cssVariablePalette(variables: Readonly<Record<string, string>>)
|
|
|
80
90
|
palette: string[];
|
|
81
91
|
colors: Record<string, string>;
|
|
82
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* 画像自身から代表色を求める(中央値分割法)。
|
|
95
|
+
*
|
|
96
|
+
* 決め打ちのパレットに落とすと、そこに無い色(茶色など)が破綻する。
|
|
97
|
+
* 画像を色空間の直方体として持ち、最も幅の広い軸で中央値に切る操作を
|
|
98
|
+
* 箱が `count` 個になるまで繰り返し、各箱の平均色を代表色にする。
|
|
99
|
+
*
|
|
100
|
+
* `sampleLimit` を超える画素数の画像は間引いて読む(既定 20000)。
|
|
101
|
+
*/
|
|
102
|
+
declare function extractPalette(bitmap: Bitmap, count?: number, sampleLimit?: number): string[];
|
|
83
103
|
//#endregion
|
|
84
104
|
//#region src/render.d.ts
|
|
85
105
|
/** ascii モードの出力を素のテキストにする。行末の空白は落とす */
|
|
@@ -115,4 +135,4 @@ declare function toSvg(grid: Grid, options?: SvgOptions): string;
|
|
|
115
135
|
/** そのままターミナルに貼れる ANSI エスケープ付きの文字列 */
|
|
116
136
|
declare function toAnsi(grid: Grid): string;
|
|
117
137
|
//#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 };
|
|
138
|
+
export { type Bitmap, type Cell, type ColorMapping, type ConvertOptions, DEFAULT_RAMP, type Grid, type HtmlOptions, type Mode, type Rgb, type SvgOptions, convert, cssVariablePalette, extractPalette, toAnsi, toHtml, toSvg, toText };
|
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
|
-
import { formatHex, nearestColor, toOklab } from "contrast-kit";
|
|
1
|
+
import { formatHex, nearestColor, parseHex, toOklab } from "contrast-kit";
|
|
2
2
|
//#region src/convert.ts
|
|
3
|
+
/** 1マスが受け持つ小マスの数(横 × 縦) */
|
|
4
|
+
const SUBCELLS = {
|
|
5
|
+
ascii: [1, 1],
|
|
6
|
+
halfblock: [1, 2],
|
|
7
|
+
quadrant: [2, 2],
|
|
8
|
+
braille: [2, 4]
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* 四分割ブロックの文字。添字のビットは
|
|
12
|
+
* 1=左上 / 2=右上 / 4=左下 / 8=右下 に対応する。
|
|
13
|
+
*/
|
|
14
|
+
const QUADRANTS = " ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█";
|
|
15
|
+
/**
|
|
16
|
+
* 点字の 2x4 の点と Unicode のビットの対応。
|
|
17
|
+
* 上3段は 0,1,2(左)/ 3,4,5(右)、最下段だけ 6(左)/ 7(右)と離れている。
|
|
18
|
+
*/
|
|
19
|
+
const BRAILLE_BITS = [
|
|
20
|
+
[0, 3],
|
|
21
|
+
[1, 4],
|
|
22
|
+
[2, 5],
|
|
23
|
+
[6, 7]
|
|
24
|
+
];
|
|
3
25
|
/** 薄い → 濃い の順に並べた既定の濃淡ランプ */
|
|
4
26
|
const DEFAULT_RAMP = " .:-=+*#%@";
|
|
5
|
-
/** 上半分を文字色、下半分を背景色で塗る文字 */
|
|
6
|
-
const HALF_BLOCK = "▀";
|
|
7
27
|
function clamp(value, min, max) {
|
|
8
28
|
return value < min ? min : value > max ? max : value;
|
|
9
29
|
}
|
|
@@ -97,29 +117,35 @@ function convert(bitmap, options = {}) {
|
|
|
97
117
|
const cellAspect = options.cellAspect ?? 2;
|
|
98
118
|
const ramp = options.ramp ?? " .:-=+*#%@";
|
|
99
119
|
const background = options.background ?? "dark";
|
|
120
|
+
const [subCols, subRows] = SUBCELLS[mode];
|
|
100
121
|
const blockW = bitmap.width / cols;
|
|
101
122
|
const blockH = blockW * cellAspect;
|
|
102
123
|
const rows = Math.max(1, Math.round(bitmap.height / blockH));
|
|
103
|
-
const
|
|
104
|
-
const sampleRows = rows *
|
|
105
|
-
const
|
|
124
|
+
const sampleCols = cols * subCols;
|
|
125
|
+
const sampleRows = rows * subRows;
|
|
126
|
+
const sampleW = blockW / subCols;
|
|
127
|
+
const sampleH = blockH / subRows;
|
|
106
128
|
const samples = [];
|
|
107
|
-
for (let y = 0; y < sampleRows; y++) for (let x = 0; x <
|
|
108
|
-
const colors = quantize(samples,
|
|
129
|
+
for (let y = 0; y < sampleRows; y++) for (let x = 0; x < sampleCols; x++) samples.push(averageRect(bitmap, x * sampleW, y * sampleH, (x + 1) * sampleW, (y + 1) * sampleH));
|
|
130
|
+
const colors = quantize(samples, sampleCols, sampleRows, options.palette, options.dither ?? false);
|
|
131
|
+
const at = (x, y) => colors[y * sampleCols + x];
|
|
132
|
+
const threshold = colors.reduce((total, color) => total + toOklab(color).L, 0) / Math.max(1, colors.length);
|
|
133
|
+
const edgeChars = mode === "ascii" && options.edges ? detectEdges(colors, cols, rows, cellAspect, typeof options.edges === "number" ? options.edges : .12) : void 0;
|
|
109
134
|
const cells = [];
|
|
110
|
-
for (let row = 0; row < rows; row++) for (let col = 0; col < cols; col++)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
for (let row = 0; row < rows; row++) for (let col = 0; col < cols; col++) {
|
|
136
|
+
const cell = buildCell({
|
|
137
|
+
mode,
|
|
138
|
+
ramp,
|
|
139
|
+
background,
|
|
140
|
+
at,
|
|
141
|
+
col,
|
|
142
|
+
row,
|
|
143
|
+
threshold
|
|
144
|
+
});
|
|
145
|
+
const edge = edgeChars?.[row * cols + col];
|
|
146
|
+
cells.push(edge === void 0 ? cell : {
|
|
147
|
+
...cell,
|
|
148
|
+
char: edge
|
|
123
149
|
});
|
|
124
150
|
}
|
|
125
151
|
return {
|
|
@@ -130,6 +156,90 @@ function convert(bitmap, options = {}) {
|
|
|
130
156
|
cells
|
|
131
157
|
};
|
|
132
158
|
}
|
|
159
|
+
/** 明るい側と暗い側に二分し、どの小マスが明るい側かをビットで返す */
|
|
160
|
+
function splitByLightness(colors) {
|
|
161
|
+
const levels = colors.map((color) => toOklab(color).L);
|
|
162
|
+
const middle = (Math.min(...levels) + Math.max(...levels)) / 2;
|
|
163
|
+
let bits = 0;
|
|
164
|
+
const lightGroup = [];
|
|
165
|
+
const darkGroup = [];
|
|
166
|
+
for (const [index, level] of levels.entries()) if (level >= middle) {
|
|
167
|
+
bits |= 1 << index;
|
|
168
|
+
lightGroup.push(colors[index]);
|
|
169
|
+
} else darkGroup.push(colors[index]);
|
|
170
|
+
return {
|
|
171
|
+
bits,
|
|
172
|
+
light: averageHex(lightGroup.length > 0 ? lightGroup : colors),
|
|
173
|
+
dark: averageHex(darkGroup.length > 0 ? darkGroup : colors)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function averageHex(colors) {
|
|
177
|
+
let r = 0;
|
|
178
|
+
let g = 0;
|
|
179
|
+
let b = 0;
|
|
180
|
+
for (const color of colors) {
|
|
181
|
+
const rgb = parseHex(color);
|
|
182
|
+
r += rgb.r;
|
|
183
|
+
g += rgb.g;
|
|
184
|
+
b += rgb.b;
|
|
185
|
+
}
|
|
186
|
+
const count = colors.length;
|
|
187
|
+
return formatHex({
|
|
188
|
+
r: Math.round(r / count),
|
|
189
|
+
g: Math.round(g / count),
|
|
190
|
+
b: Math.round(b / count)
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
function buildCell(context) {
|
|
194
|
+
const { mode, at, col, row } = context;
|
|
195
|
+
if (mode === "halfblock") return {
|
|
196
|
+
char: "▀",
|
|
197
|
+
fg: at(col, row * 2),
|
|
198
|
+
bg: at(col, row * 2 + 1)
|
|
199
|
+
};
|
|
200
|
+
if (mode === "quadrant") {
|
|
201
|
+
const { bits, light, dark } = splitByLightness([
|
|
202
|
+
at(col * 2, row * 2),
|
|
203
|
+
at(col * 2 + 1, row * 2),
|
|
204
|
+
at(col * 2, row * 2 + 1),
|
|
205
|
+
at(col * 2 + 1, row * 2 + 1)
|
|
206
|
+
]);
|
|
207
|
+
return {
|
|
208
|
+
char: QUADRANTS[bits],
|
|
209
|
+
fg: light,
|
|
210
|
+
bg: dark
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (mode === "braille") {
|
|
214
|
+
const dots = [];
|
|
215
|
+
for (let y = 0; y < 4; y++) dots.push(at(col * 2, row * 4 + y), at(col * 2 + 1, row * 4 + y));
|
|
216
|
+
const levels = dots.map((color) => toOklab(color).L);
|
|
217
|
+
const isLit = (level) => context.background === "dark" ? level >= context.threshold : level < context.threshold;
|
|
218
|
+
let pattern = 0;
|
|
219
|
+
const lit = [];
|
|
220
|
+
const unlit = [];
|
|
221
|
+
for (let y = 0; y < 4; y++) for (let x = 0; x < 2; x++) {
|
|
222
|
+
const index = y * 2 + x;
|
|
223
|
+
if (isLit(levels[index])) {
|
|
224
|
+
pattern |= 1 << BRAILLE_BITS[y][x];
|
|
225
|
+
lit.push(dots[index]);
|
|
226
|
+
} else unlit.push(dots[index]);
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
char: String.fromCodePoint(10240 + pattern),
|
|
230
|
+
fg: averageHex(lit.length > 0 ? lit : dots),
|
|
231
|
+
bg: averageHex(unlit.length > 0 ? unlit : dots)
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const fg = at(col, row);
|
|
235
|
+
const lightness = toOklab(fg).L;
|
|
236
|
+
const level = context.background === "dark" ? lightness : 1 - lightness;
|
|
237
|
+
const index = clamp(Math.round(level * (context.ramp.length - 1)), 0, context.ramp.length - 1);
|
|
238
|
+
return {
|
|
239
|
+
char: context.ramp[index],
|
|
240
|
+
fg
|
|
241
|
+
};
|
|
242
|
+
}
|
|
133
243
|
/**
|
|
134
244
|
* CSS カスタムプロパティの組から、量子化用のパレットと
|
|
135
245
|
* 出力用の色の対応表を作る。
|
|
@@ -161,6 +271,106 @@ function cssVariablePalette(variables) {
|
|
|
161
271
|
colors
|
|
162
272
|
};
|
|
163
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* 画像自身から代表色を求める(中央値分割法)。
|
|
276
|
+
*
|
|
277
|
+
* 決め打ちのパレットに落とすと、そこに無い色(茶色など)が破綻する。
|
|
278
|
+
* 画像を色空間の直方体として持ち、最も幅の広い軸で中央値に切る操作を
|
|
279
|
+
* 箱が `count` 個になるまで繰り返し、各箱の平均色を代表色にする。
|
|
280
|
+
*
|
|
281
|
+
* `sampleLimit` を超える画素数の画像は間引いて読む(既定 20000)。
|
|
282
|
+
*/
|
|
283
|
+
function extractPalette(bitmap, count = 16, sampleLimit = 2e4) {
|
|
284
|
+
if (count < 1) throw new TypeError("色数は 1 以上にしてください");
|
|
285
|
+
const total = bitmap.width * bitmap.height;
|
|
286
|
+
const step = Math.max(1, Math.ceil(total / sampleLimit));
|
|
287
|
+
const pixels = [];
|
|
288
|
+
for (let index = 0; index < total; index += step) {
|
|
289
|
+
const offset = index * 4;
|
|
290
|
+
if ((bitmap.data[offset + 3] ?? 255) === 0) continue;
|
|
291
|
+
pixels.push({
|
|
292
|
+
r: bitmap.data[offset] ?? 0,
|
|
293
|
+
g: bitmap.data[offset + 1] ?? 0,
|
|
294
|
+
b: bitmap.data[offset + 2] ?? 0
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (pixels.length === 0) return ["#000000"];
|
|
298
|
+
let boxes = [pixels];
|
|
299
|
+
while (boxes.length < count) {
|
|
300
|
+
let target = -1;
|
|
301
|
+
let widestSpread = 0;
|
|
302
|
+
let widestAxis = "r";
|
|
303
|
+
for (const [index, box] of boxes.entries()) {
|
|
304
|
+
if (box.length < 2) continue;
|
|
305
|
+
for (const axis of [
|
|
306
|
+
"r",
|
|
307
|
+
"g",
|
|
308
|
+
"b"
|
|
309
|
+
]) {
|
|
310
|
+
const values = box.map((pixel) => pixel[axis]);
|
|
311
|
+
const spread = Math.max(...values) - Math.min(...values);
|
|
312
|
+
if (spread > widestSpread) {
|
|
313
|
+
widestSpread = spread;
|
|
314
|
+
widestAxis = axis;
|
|
315
|
+
target = index;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (target === -1) break;
|
|
320
|
+
const box = [...boxes[target]].sort((a, b) => a[widestAxis] - b[widestAxis]);
|
|
321
|
+
const middle = Math.floor(box.length / 2);
|
|
322
|
+
boxes = [
|
|
323
|
+
...boxes.slice(0, target),
|
|
324
|
+
box.slice(0, middle),
|
|
325
|
+
box.slice(middle),
|
|
326
|
+
...boxes.slice(target + 1)
|
|
327
|
+
];
|
|
328
|
+
}
|
|
329
|
+
return boxes.filter((box) => box.length > 0).map((box) => {
|
|
330
|
+
const sum = box.reduce((total2, pixel) => ({
|
|
331
|
+
r: total2.r + pixel.r,
|
|
332
|
+
g: total2.g + pixel.g,
|
|
333
|
+
b: total2.b + pixel.b
|
|
334
|
+
}), {
|
|
335
|
+
r: 0,
|
|
336
|
+
g: 0,
|
|
337
|
+
b: 0
|
|
338
|
+
});
|
|
339
|
+
return formatHex({
|
|
340
|
+
r: Math.round(sum.r / box.length),
|
|
341
|
+
g: Math.round(sum.g / box.length),
|
|
342
|
+
b: Math.round(sum.b / box.length)
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
/** 輪郭の向きに割り当てる文字。添字は 0=横 / 1=右下がり / 2=縦 / 3=右上がり */
|
|
347
|
+
const EDGE_CHARS = [
|
|
348
|
+
"-",
|
|
349
|
+
"\\",
|
|
350
|
+
"|",
|
|
351
|
+
"/"
|
|
352
|
+
];
|
|
353
|
+
/**
|
|
354
|
+
* Sobel フィルタでマス目の明度の傾きを取り、輪郭が強いマスに向きの文字を割り当てる。
|
|
355
|
+
*
|
|
356
|
+
* マスは縦長なので、画面上の角度に直すために縦方向の傾きへ `cellAspect` を掛ける。
|
|
357
|
+
* これを忘れると斜めの輪郭がすべて縦寄りに倒れる。
|
|
358
|
+
*/
|
|
359
|
+
function detectEdges(colors, cols, rows, cellAspect, threshold) {
|
|
360
|
+
const lightness = colors.map((color) => toOklab(color).L);
|
|
361
|
+
const at = (x, y) => lightness[clamp(y, 0, rows - 1) * cols + clamp(x, 0, cols - 1)];
|
|
362
|
+
return Array.from({ length: cols * rows }, (_, index) => {
|
|
363
|
+
const col = index % cols;
|
|
364
|
+
const row = Math.floor(index / cols);
|
|
365
|
+
const gx = -at(col - 1, row - 1) + at(col + 1, row - 1) + -2 * at(col - 1, row) + 2 * at(col + 1, row) + -at(col - 1, row + 1) + at(col + 1, row + 1);
|
|
366
|
+
const gy = -at(col - 1, row - 1) - 2 * at(col, row - 1) - at(col + 1, row - 1) + at(col - 1, row + 1) + 2 * at(col, row + 1) + at(col + 1, row + 1);
|
|
367
|
+
if (Math.hypot(gx, gy) / 4 < threshold) return void 0;
|
|
368
|
+
let angle = Math.atan2(gy * cellAspect, gx) + Math.PI / 2;
|
|
369
|
+
angle = (angle % Math.PI + Math.PI) % Math.PI;
|
|
370
|
+
const bucket = Math.round(angle / (Math.PI / 4)) % 4;
|
|
371
|
+
return EDGE_CHARS[bucket];
|
|
372
|
+
});
|
|
373
|
+
}
|
|
164
374
|
//#endregion
|
|
165
375
|
//#region src/render.ts
|
|
166
376
|
const HTML_ESCAPES = {
|
|
@@ -241,6 +451,30 @@ function toSvg(grid, options = {}) {
|
|
|
241
451
|
emitRow((col) => cellAt(col).fg, row * cellH);
|
|
242
452
|
emitRow((col) => cellAt(col).bg ?? cellAt(col).fg, row * cellH + halfH);
|
|
243
453
|
}
|
|
454
|
+
} else if (grid.mode === "quadrant") {
|
|
455
|
+
const halfW = cellW / 2;
|
|
456
|
+
const halfH = cellH / 2;
|
|
457
|
+
for (let row = 0; row < grid.rows; row++) for (let col = 0; col < grid.cols; col++) {
|
|
458
|
+
const cell = grid.cells[row * grid.cols + col];
|
|
459
|
+
const bits = Math.max(0, QUADRANTS.indexOf(cell.char));
|
|
460
|
+
for (let corner = 0; corner < 4; corner++) {
|
|
461
|
+
const color = (bits & 1 << corner) === 0 ? cell.bg ?? cell.fg : cell.fg;
|
|
462
|
+
parts.push(`<rect x="${col * cellW + corner % 2 * halfW}" y="${row * cellH + Math.floor(corner / 2) * halfH}" width="${halfW}" height="${halfH}" fill="${mapColor(color, options.colors)}"/>`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} else if (grid.mode === "braille") {
|
|
466
|
+
const dotW = cellW / 2;
|
|
467
|
+
const dotH = cellH / 4;
|
|
468
|
+
const radius = Math.min(dotW, dotH) * .38;
|
|
469
|
+
for (let row = 0; row < grid.rows; row++) for (let col = 0; col < grid.cols; col++) {
|
|
470
|
+
const cell = grid.cells[row * grid.cols + col];
|
|
471
|
+
const pattern = (cell.char.codePointAt(0) ?? 10240) - 10240;
|
|
472
|
+
parts.push(`<rect x="${col * cellW}" y="${row * cellH}" width="${cellW}" height="${cellH}" fill="${mapColor(cell.bg ?? cell.fg, options.colors)}"/>`);
|
|
473
|
+
for (let y = 0; y < 4; y++) for (let x = 0; x < 2; x++) {
|
|
474
|
+
if ((pattern & 1 << BRAILLE_BITS[y][x]) === 0) continue;
|
|
475
|
+
parts.push(`<circle cx="${col * cellW + (x + .5) * dotW}" cy="${row * cellH + (y + .5) * dotH}" r="${radius}" fill="${mapColor(cell.fg, options.colors)}"/>`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
244
478
|
} else for (let row = 0; row < grid.rows; row++) {
|
|
245
479
|
const spans = runsOfRow(grid, row).map((run) => `<tspan fill="${mapColor(run.fg, options.colors)}">${escapeHtml(run.text)}</tspan>`).join("");
|
|
246
480
|
const baseline = row * cellH + cellH * .78;
|
|
@@ -267,4 +501,4 @@ function toAnsi(grid) {
|
|
|
267
501
|
return lines.join("\n");
|
|
268
502
|
}
|
|
269
503
|
//#endregion
|
|
270
|
-
export { DEFAULT_RAMP, convert, cssVariablePalette, toAnsi, toHtml, toSvg, toText };
|
|
504
|
+
export { DEFAULT_RAMP, convert, cssVariablePalette, extractPalette, toAnsi, toHtml, toSvg, toText };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termpic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Convert images into terminal art: ASCII ramps, half-block color cells, and palette quantization.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ansi",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"prepublishOnly": "vp run build"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"contrast-kit": "
|
|
43
|
+
"contrast-kit": "^0.2.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^26.1.1",
|