Unverified Commit bc94dc13 authored by Hong Minhee (洪 民憙)'s avatar Hong Minhee (洪 民憙) Committed by GitHub
Browse files

Merge pull request #299 from dodok8/dodok8-fix-issue-259

parents bcad8834 685445c2
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -15,8 +15,10 @@
    "@jimp/wasm-webp": "npm:@jimp/wasm-webp@^1.6.0",
    "@poppanator/http-constants": "npm:@poppanator/http-constants@^1.1.1",
    "@std/dotenv": "jsr:@std/dotenv@^0.225.2",
    "@std/assert": "jsr:@std/assert@^1.0.0",
    "@std/semver": "jsr:@std/semver@^1.0.5",
    "cli-highlight": "npm:cli-highlight@^2.1.11",
    "fetch-mock": "npm:fetch-mock@^12.5.2",
    "hono": "jsr:@hono/hono@^4.8.3",
    "icojs": "npm:icojs@^0.19.4",
    "jimp": "npm:jimp@^1.6.0",

cli/node.test.ts

0 → 100644
+181 −0
Original line number Diff line number Diff line
import { assertEquals } from "@std/assert";
import fetchMock from "fetch-mock";
import { getAsciiArt, getFaviconUrl, Jimp, rgbTo256Color } from "./node.ts";

const HTML_WITH_SMALL_ICON = `
<!DOCTYPE html>
<html>
<head>
  <title>Test Site</title>
  <link rel="icon" href="/favicon.ico" sizes="32x32">
  <link rel="apple-touch-icon" href="/apple-touch-icon.png">
  </head>
<body>Test</body>
</html>
`;

Deno.test("getFaviconUrl - small favicon.ico and apple-touch-icon.png", async () => {
  fetchMock.spyGlobal();

  fetchMock.get("https://example.com/", {
    body: HTML_WITH_SMALL_ICON,
    headers: { "Content-Type": "text/html" },
  });

  const result = await getFaviconUrl("https://example.com/");
  assertEquals(result.href, "https://example.com/apple-touch-icon.png");

  fetchMock.hardReset();
});

const HTML_WITH_ICON = `
<!DOCTYPE html>
<html>
<head>
  <title>Test Site</title>
  <link rel="icon" href="/favicon.ico" sizes="64x64">
  <link rel="apple-touch-icon" href="/apple-touch-icon.png">
  </head>
<body>Test</body>
</html>
`;

Deno.test("getFaviconUrl - favicon.ico and apple-touch-icon.png", async () => {
  fetchMock.spyGlobal();

  fetchMock.get("https://example.com/", {
    body: HTML_WITH_ICON,
    headers: { "Content-Type": "text/html" },
  });

  const result = await getFaviconUrl("https://example.com/");
  assertEquals(result.href, "https://example.com/favicon.ico");

  fetchMock.hardReset();
});

const HTML_WITH_SVG_ONLY = `
<!DOCTYPE html>
<html>
<head>
  <title>Test Site</title>
  <link rel="icon" href="/icon.svg" type="image/svg+xml">  
  </head>
<body>Test</body>
</html>
`;

Deno.test("getFaviconUrl - svg icons only falls back to /favicon.ico", async () => {
  fetchMock.spyGlobal();

  fetchMock.get("https://example.com/", {
    body: HTML_WITH_SVG_ONLY,
    headers: { "Content-Type": "text/html" },
  });

  const result = await getFaviconUrl("https://example.com/");
  assertEquals(result.href, "https://example.com/favicon.ico");

  fetchMock.hardReset();
});

const HTML_WITHOUT_ICON = `
<!DOCTYPE html>
<html>
<head>
  <title>Test Site</title>
</head>
<body>Test</body>
</html>
`;

Deno.test("getFaviconUrl - falls back to /favicon.ico", async () => {
  fetchMock.spyGlobal();

  fetchMock.get("https://example.com/", {
    body: HTML_WITHOUT_ICON,
    headers: { "Content-Type": "text/html" },
  });

  const result = await getFaviconUrl("https://example.com/");
  assertEquals(result.href, "https://example.com/favicon.ico");

  fetchMock.hardReset();
});

Deno.test("rgbTo256Color - check RGB cube", () => {
  const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
  const colors: Array<{ r: number; g: number; b: number }> = [];

  for (let r = 0; r < 6; r++) {
    for (let g = 0; g < 6; g++) {
      for (let b = 0; b < 6; b++) {
        colors.push({
          r: CUBE_VALUES[r],
          g: CUBE_VALUES[g],
          b: CUBE_VALUES[b],
        });
      }
    }
  }

  // Expected color indices for the above colors (16-231)
  // RGB cube: 6x6x6 = 216 colors, indices 16-231
  const expected_color_idx = Array.from(
    { length: colors.length },
    (_, i) => 16 + i,
  );

  const results = colors.map((color) =>
    rgbTo256Color(color.r, color.g, color.b)
  );
  assertEquals(results, expected_color_idx);
});

Deno.test("rgbTo256Color - check grayscale", () => {
  const grayscale = Array.from({ length: 24 }).map(
    (_, idx) => ({
      r: 8 + idx * 10,
      g: 8 + idx * 10,
      b: 8 + idx * 10,
    }),
  );

  const expected_gray_idx = Array.from(
    { length: grayscale.length },
    (_, i) => 232 + i,
  );

  const results = grayscale.map((GRAY) =>
    rgbTo256Color(GRAY.r, GRAY.g, GRAY.b)
  );
  assertEquals(results, expected_gray_idx);
});

Deno.test("getAsciiArt - Darkest Letter", async () => {
  // Create black and white 1x1 images using Jimp constructor
  const blackImage = new Jimp({ width: 1, height: 1, color: 0x000000ff });
  const blackImageBuffer = await blackImage.getBuffer("image/webp");

  const blackResult = getAsciiArt(
    await Jimp.read(blackImageBuffer),
    1,
    true,
  );

  assertEquals(blackResult, "");
});

Deno.test("getAsciiArt - Brightest Letter", async () => {
  // Create black and white 1x1 images using Jimp constructor
  const whiteImage = new Jimp({ width: 1, height: 1, color: 0xffffffff });
  const whiteImageBuffer = await whiteImage.getBuffer("image/webp");

  const whiteResult = getAsciiArt(
    await Jimp.read(whiteImageBuffer),
    1,
    true,
  );

  assertEquals(whiteResult, " ");
});
+41 −16
Original line number Diff line number Diff line
@@ -221,7 +221,7 @@ const LINK_REGEXP =
  /<link((?:\s+(?:[-a-z]+)=(?:"[^"]*"|'[^']*'|[^\s]+))*)\s*\/?>/ig;
const LINK_ATTRS_REGEXP = /(?:\s+([-a-z]+)=("[^"]*"|'[^']*'|[^\s]+))/ig;

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

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

function rgbTo256Color(r: number, g: number, b: number): number {
  // Handle grayscale colors (colors 232-255)
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];

const findClosestIndex = (value: number): number => {
  let minDiff = Infinity;
  let closestIndex = 0;
  for (let idx = 0; idx < CUBE_VALUES.length; idx++) {
    const diff = Math.abs(value - CUBE_VALUES[idx]);
    if (diff < minDiff) {
      minDiff = diff;
      closestIndex = idx;
    }
  }
  return closestIndex;
};

export function rgbTo256Color(r: number, g: number, b: number): number {
  // Check if it's a grayscale color first (when all RGB values are very close)
  const gray = Math.round((r + g + b) / 3);
  if (
    Math.abs(r - gray) < 10 && Math.abs(g - gray) < 10 &&
    Math.abs(b - gray) < 10
  ) {
    if (gray < 8) return 16; // Black
    if (gray > 248) return 231; // White
    return Math.round(((gray - 8) / 240) * 23) + 232;
  const isGrayscale = Math.abs(r - gray) <= 5 && Math.abs(g - gray) <= 5 &&
    Math.abs(b - gray) <= 5;

  // Handle grayscale colors (colors 232-255) - but exclude exact cube values
  if (isGrayscale) {
    const isExactCubeValue = CUBE_VALUES.includes(r) && r === g && g === b;

    if (!isExactCubeValue) {
      if (gray < 8) return 232; // Darkest grayscale
      if (gray > 238) return 255; // Brightest grayscale

      // Map to grayscale range 232-255 (24 levels)
      // XTerm grayscale: 8, 18, 28, ..., 238 maps to 232, 233, 234, ..., 255
      const grayIndex = Math.round((gray - 8) / 10);
      return Math.max(232, Math.min(255, 232 + grayIndex));
    }
  }

  // Handle RGB colors (colors 16-231)
  // Convert to 6x6x6 cube
  const r6 = Math.round((r / 255) * 5);
  const g6 = Math.round((g / 255) * 5);
  const b6 = Math.round((b / 255) * 5);
  // XTerm 256 color cube values: [0, 95, 135, 175, 215, 255]

  const r6 = findClosestIndex(r);
  const g6 = findClosestIndex(g);
  const b6 = findClosestIndex(b);

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

function getAsciiArt(
export function getAsciiArt(
  image: Awaited<ReturnType<typeof Jimp.read>>,
  width = DEFAULT_IMAGE_WIDTH,
  trueColorSupport: boolean,