Skip to content

Commit bc94dc1

Browse files
authored
Merge pull request #299 from dodok8/dodok8-fix-issue-259
2 parents bcad883 + 685445c commit bc94dc1

3 files changed

Lines changed: 224 additions & 16 deletions

File tree

cli/deno.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
"@jimp/wasm-webp": "npm:@jimp/wasm-webp@^1.6.0",
1616
"@poppanator/http-constants": "npm:@poppanator/http-constants@^1.1.1",
1717
"@std/dotenv": "jsr:@std/dotenv@^0.225.2",
18+
"@std/assert": "jsr:@std/assert@^1.0.0",
1819
"@std/semver": "jsr:@std/semver@^1.0.5",
1920
"cli-highlight": "npm:cli-highlight@^2.1.11",
21+
"fetch-mock": "npm:fetch-mock@^12.5.2",
2022
"hono": "jsr:@hono/hono@^4.8.3",
2123
"icojs": "npm:icojs@^0.19.4",
2224
"jimp": "npm:jimp@^1.6.0",

cli/node.test.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { assertEquals } from "@std/assert";
2+
import fetchMock from "fetch-mock";
3+
import { getAsciiArt, getFaviconUrl, Jimp, rgbTo256Color } from "./node.ts";
4+
5+
const HTML_WITH_SMALL_ICON = `
6+
<!DOCTYPE html>
7+
<html>
8+
<head>
9+
<title>Test Site</title>
10+
<link rel="icon" href="/favicon.ico" sizes="32x32">
11+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
12+
</head>
13+
<body>Test</body>
14+
</html>
15+
`;
16+
17+
Deno.test("getFaviconUrl - small favicon.ico and apple-touch-icon.png", async () => {
18+
fetchMock.spyGlobal();
19+
20+
fetchMock.get("https://example.com/", {
21+
body: HTML_WITH_SMALL_ICON,
22+
headers: { "Content-Type": "text/html" },
23+
});
24+
25+
const result = await getFaviconUrl("https://example.com/");
26+
assertEquals(result.href, "https://example.com/apple-touch-icon.png");
27+
28+
fetchMock.hardReset();
29+
});
30+
31+
const HTML_WITH_ICON = `
32+
<!DOCTYPE html>
33+
<html>
34+
<head>
35+
<title>Test Site</title>
36+
<link rel="icon" href="/favicon.ico" sizes="64x64">
37+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
38+
</head>
39+
<body>Test</body>
40+
</html>
41+
`;
42+
43+
Deno.test("getFaviconUrl - favicon.ico and apple-touch-icon.png", async () => {
44+
fetchMock.spyGlobal();
45+
46+
fetchMock.get("https://example.com/", {
47+
body: HTML_WITH_ICON,
48+
headers: { "Content-Type": "text/html" },
49+
});
50+
51+
const result = await getFaviconUrl("https://example.com/");
52+
assertEquals(result.href, "https://example.com/favicon.ico");
53+
54+
fetchMock.hardReset();
55+
});
56+
57+
const HTML_WITH_SVG_ONLY = `
58+
<!DOCTYPE html>
59+
<html>
60+
<head>
61+
<title>Test Site</title>
62+
<link rel="icon" href="/icon.svg" type="image/svg+xml">
63+
</head>
64+
<body>Test</body>
65+
</html>
66+
`;
67+
68+
Deno.test("getFaviconUrl - svg icons only falls back to /favicon.ico", async () => {
69+
fetchMock.spyGlobal();
70+
71+
fetchMock.get("https://example.com/", {
72+
body: HTML_WITH_SVG_ONLY,
73+
headers: { "Content-Type": "text/html" },
74+
});
75+
76+
const result = await getFaviconUrl("https://example.com/");
77+
assertEquals(result.href, "https://example.com/favicon.ico");
78+
79+
fetchMock.hardReset();
80+
});
81+
82+
const HTML_WITHOUT_ICON = `
83+
<!DOCTYPE html>
84+
<html>
85+
<head>
86+
<title>Test Site</title>
87+
</head>
88+
<body>Test</body>
89+
</html>
90+
`;
91+
92+
Deno.test("getFaviconUrl - falls back to /favicon.ico", async () => {
93+
fetchMock.spyGlobal();
94+
95+
fetchMock.get("https://example.com/", {
96+
body: HTML_WITHOUT_ICON,
97+
headers: { "Content-Type": "text/html" },
98+
});
99+
100+
const result = await getFaviconUrl("https://example.com/");
101+
assertEquals(result.href, "https://example.com/favicon.ico");
102+
103+
fetchMock.hardReset();
104+
});
105+
106+
Deno.test("rgbTo256Color - check RGB cube", () => {
107+
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
108+
const colors: Array<{ r: number; g: number; b: number }> = [];
109+
110+
for (let r = 0; r < 6; r++) {
111+
for (let g = 0; g < 6; g++) {
112+
for (let b = 0; b < 6; b++) {
113+
colors.push({
114+
r: CUBE_VALUES[r],
115+
g: CUBE_VALUES[g],
116+
b: CUBE_VALUES[b],
117+
});
118+
}
119+
}
120+
}
121+
122+
// Expected color indices for the above colors (16-231)
123+
// RGB cube: 6x6x6 = 216 colors, indices 16-231
124+
const expected_color_idx = Array.from(
125+
{ length: colors.length },
126+
(_, i) => 16 + i,
127+
);
128+
129+
const results = colors.map((color) =>
130+
rgbTo256Color(color.r, color.g, color.b)
131+
);
132+
assertEquals(results, expected_color_idx);
133+
});
134+
135+
Deno.test("rgbTo256Color - check grayscale", () => {
136+
const grayscale = Array.from({ length: 24 }).map(
137+
(_, idx) => ({
138+
r: 8 + idx * 10,
139+
g: 8 + idx * 10,
140+
b: 8 + idx * 10,
141+
}),
142+
);
143+
144+
const expected_gray_idx = Array.from(
145+
{ length: grayscale.length },
146+
(_, i) => 232 + i,
147+
);
148+
149+
const results = grayscale.map((GRAY) =>
150+
rgbTo256Color(GRAY.r, GRAY.g, GRAY.b)
151+
);
152+
assertEquals(results, expected_gray_idx);
153+
});
154+
155+
Deno.test("getAsciiArt - Darkest Letter", async () => {
156+
// Create black and white 1x1 images using Jimp constructor
157+
const blackImage = new Jimp({ width: 1, height: 1, color: 0x000000ff });
158+
const blackImageBuffer = await blackImage.getBuffer("image/webp");
159+
160+
const blackResult = getAsciiArt(
161+
await Jimp.read(blackImageBuffer),
162+
1,
163+
true,
164+
);
165+
166+
assertEquals(blackResult, "█");
167+
});
168+
169+
Deno.test("getAsciiArt - Brightest Letter", async () => {
170+
// Create black and white 1x1 images using Jimp constructor
171+
const whiteImage = new Jimp({ width: 1, height: 1, color: 0xffffffff });
172+
const whiteImageBuffer = await whiteImage.getBuffer("image/webp");
173+
174+
const whiteResult = getAsciiArt(
175+
await Jimp.read(whiteImageBuffer),
176+
1,
177+
true,
178+
);
179+
180+
assertEquals(whiteResult, " ");
181+
});

cli/node.ts

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ const LINK_REGEXP =
221221
/<link((?:\s+(?:[-a-z]+)=(?:"[^"]*"|'[^']*'|[^\s]+))*)\s*\/?>/ig;
222222
const LINK_ATTRS_REGEXP = /(?:\s+([-a-z]+)=("[^"]*"|'[^']*'|[^\s]+))/ig;
223223

224-
async function getFaviconUrl(
224+
export async function getFaviconUrl(
225225
url: string | URL,
226226
userAgent?: string,
227227
): Promise<URL> {
@@ -253,7 +253,7 @@ async function getFaviconUrl(
253253
return new URL("/favicon.ico", response.url);
254254
}
255255

256-
const Jimp = createJimp({
256+
export const Jimp = createJimp({
257257
formats: [...defaultFormats, webp],
258258
plugins: defaultPlugins,
259259
});
@@ -277,28 +277,53 @@ const ASCII_CHARS =
277277
"█▓▒░@#B8&WM%*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. ";
278278
// cSpell: enable
279279

280-
function rgbTo256Color(r: number, g: number, b: number): number {
281-
// Handle grayscale colors (colors 232-255)
280+
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
281+
282+
const findClosestIndex = (value: number): number => {
283+
let minDiff = Infinity;
284+
let closestIndex = 0;
285+
for (let idx = 0; idx < CUBE_VALUES.length; idx++) {
286+
const diff = Math.abs(value - CUBE_VALUES[idx]);
287+
if (diff < minDiff) {
288+
minDiff = diff;
289+
closestIndex = idx;
290+
}
291+
}
292+
return closestIndex;
293+
};
294+
295+
export function rgbTo256Color(r: number, g: number, b: number): number {
296+
// Check if it's a grayscale color first (when all RGB values are very close)
282297
const gray = Math.round((r + g + b) / 3);
283-
if (
284-
Math.abs(r - gray) < 10 && Math.abs(g - gray) < 10 &&
285-
Math.abs(b - gray) < 10
286-
) {
287-
if (gray < 8) return 16; // Black
288-
if (gray > 248) return 231; // White
289-
return Math.round(((gray - 8) / 240) * 23) + 232;
298+
const isGrayscale = Math.abs(r - gray) <= 5 && Math.abs(g - gray) <= 5 &&
299+
Math.abs(b - gray) <= 5;
300+
301+
// Handle grayscale colors (colors 232-255) - but exclude exact cube values
302+
if (isGrayscale) {
303+
const isExactCubeValue = CUBE_VALUES.includes(r) && r === g && g === b;
304+
305+
if (!isExactCubeValue) {
306+
if (gray < 8) return 232; // Darkest grayscale
307+
if (gray > 238) return 255; // Brightest grayscale
308+
309+
// Map to grayscale range 232-255 (24 levels)
310+
// XTerm grayscale: 8, 18, 28, ..., 238 maps to 232, 233, 234, ..., 255
311+
const grayIndex = Math.round((gray - 8) / 10);
312+
return Math.max(232, Math.min(255, 232 + grayIndex));
313+
}
290314
}
291315

292316
// Handle RGB colors (colors 16-231)
293-
// Convert to 6x6x6 cube
294-
const r6 = Math.round((r / 255) * 5);
295-
const g6 = Math.round((g / 255) * 5);
296-
const b6 = Math.round((b / 255) * 5);
317+
// XTerm 256 color cube values: [0, 95, 135, 175, 215, 255]
318+
319+
const r6 = findClosestIndex(r);
320+
const g6 = findClosestIndex(g);
321+
const b6 = findClosestIndex(b);
297322

298323
return 16 + (36 * r6) + (6 * g6) + b6;
299324
}
300325

301-
function getAsciiArt(
326+
export function getAsciiArt(
302327
image: Awaited<ReturnType<typeof Jimp.read>>,
303328
width = DEFAULT_IMAGE_WIDTH,
304329
trueColorSupport: boolean,

0 commit comments

Comments
 (0)