diff --git a/packages/cli/THIRD_PARTY_LICENSES.md b/packages/cli/THIRD_PARTY_LICENSES.md new file mode 100644 index 000000000..3d1e47486 --- /dev/null +++ b/packages/cli/THIRD_PARTY_LICENSES.md @@ -0,0 +1,28 @@ +# Third-Party Licenses + +## Cozette + +The Sixel dashboard includes a generated subset of Cozette bitmap glyph data, +version 1.30.0. Source: . + +MIT License + +Copyright (c) 2020, Ines + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cli/package.json b/packages/cli/package.json index 2cc24485d..594694d62 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,7 @@ "dist/node-sqlite3-wasm.wasm", "dist/vendor/symbolic_bg.wasm", "LICENSE.md", + "THIRD_PARTY_LICENSES.md", "README.md" ], "license": "FSL-1.1-Apache-2.0", diff --git a/packages/cli/src/lib/formatters/cozette-font.ts b/packages/cli/src/lib/formatters/cozette-font.ts new file mode 100644 index 000000000..e0b6781be --- /dev/null +++ b/packages/cli/src/lib/formatters/cozette-font.ts @@ -0,0 +1,83 @@ +/*! + * Cozette bitmap font subset, version 1.30.0. + * Copyright (c) 2020, Ines + * https://github.com/the-moonwitch/Cozette + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Buffer } from "node:buffer"; + +/** Cozette's native monospaced cell width. */ +export const COZETTE_CELL_WIDTH = 6; + +/** Cozette's native cell height, including descenders. */ +export const COZETTE_CELL_HEIGHT = 13; + +const FIRST_ASCII_CODE_POINT = 32; +const LAST_ASCII_CODE_POINT = 126; +const FULL_BLOCK_CODE_POINT = 0x25_88; +const BLACK_SQUARE_CODE_POINT = 0x25_a0; +const FULL_BLOCK_INDEX = LAST_ASCII_CODE_POINT - FIRST_ASCII_CODE_POINT + 1; +const BLACK_SQUARE_INDEX = FULL_BLOCK_INDEX + 1; + +/** Visible fallback for a Unicode glyph outside the embedded Cozette subset. */ +const UNKNOWN_GLYPH = new Uint8Array([ + 63, 33, 45, 45, 45, 45, 45, 45, 45, 45, 33, 63, 0, +]); + +/** + * Printable ASCII, full block, and black square raster rows generated from + * Cozette's v1.30.0 BDF release. Each glyph has 13 rows packed into the low + * six bits of a byte, with bit 5 as the leftmost pixel. Source SHA-256: + * 8d740166af3a14053773ac7a8846bf288c8818a44f73ab28d4725d91b03a5639. + */ +const GLYPH_ROWS = Uint8Array.from( + Buffer.from( + "AAAAAAAAAAAAAAAAAAAABAQEBAQEAAQAAAAACgoKAAAAAAAAAAAAAAAKCh8KCh8KCgAAAAAEDhUUDgUFFQ4EAAAACBQJAgQIEgUCAAAAAAQKCgQNEhISDQAAAAAEBAQAAAAAAAAAAAAAAgQECAgICAgEBAIAAAgEBAICAgICBAQIAAAAAAAKBB8ECgAAAAAAAAAABAQfBAQAAAAAAAAAAAAAAAAMDAQIAAAAAAAAAB8AAAAAAAAAAAAAAAAAAAwMAAAAAAEBAgIEBAgIEBAAAAAADhERFRUREQ4AAAAAAAQMFAQEBAQfAAAAAAAOEQECBAgQHwAAAAAADhEBBgEBEQ4AAAAAAAEDBQkRHwEBAAAAAAAfEBAeAQERDgAAAAAABggQHhEREQ4AAAAAAB8BAgIEBAgIAAAAAAAOEREOERERDgAAAAAADhEREQ8BAgwAAAAAAAAADAwAAAwMAAAAAAAAAAwMAAAMDAQIAAAAAAECBAgEAgEAAAAAAAAAAB8AHwAAAAAAAAAAEAgEAgQIEAAAAAAADhEBAgQEAAQAAAAAAA4RERcVFxAPAAAAAAAOERERHxEREQAAAAAAHhERHhERER4AAAAAAA4REBAQEBEOAAAAAAAcEhERERESHAAAAAAAHxAQHhAQEB8AAAAAAB8QEB4QEBAQAAAAAAAOERAQExERDgAAAAAAERERHxEREREAAAAAAA4EBAQEBAQOAAAAAAAHAQEBARERDgAAAAAAERIUHBISEREAAAAAABAQEBAQEBAfAAAAAAARGxUVEREREQAAAAAAERkZFRUTExEAAAAAAA4REREREREOAAAAAAAeERERHhAQEAAAAAAADhEREREREg0BAAAAAB4RER4SERERAAAAAAAOERAOAQERDgAAAAAAHwQEBAQEBAQAAAAAABEREREREREOAAAAAAAREREKCgoEBAAAAAAAERERFRUOCgoAAAAAABERCgQEChERAAAAAAAREREKBAQEBAAAAAAAHwIEBAgIEB8AAAAADggICAgICAgICA4AABAQCAgEBAICAQEAAAAOAgICAgICAgICDgAEChEAAAAAAAAAAAAAAAAAAAAAAAAAAB8AAAAIBAAAAAAAAAAAAAAAAAAADxERERMNAAAAABAQEB4RERERHgAAAAAAAAAOERAQEQ4AAAAAAQEBDxEREREPAAAAAAAAAA4RHxARDgAAAAAHCAgeCAgICAgAAAAAAAAADxEREREPAQEOABAQEB4REREREQAAAAAABAAMBAQEBAMAAAAAAAIABgICAgICCgQAABAQEBESFBwSEQAAAAAMBAQEBAQEBAYAAAAAAAAAGhUVFRUVAAAAAAAAAB4REREREQAAAAAAAAAOEREREQ4AAAAAAAAAHhEREREeEBAQAAAAAA8RERERDwEBAQAAAAAeERAQEBAAAAAAAAAADxAOAQEeAAAAAAAICB4ICAgIBwAAAAAAAAAREREREQ8AAAAAAAAAEREKCgQEAAAAAAAAABERFRUKCgAAAAAAAAARCgQEChEAAAAAAAAAEREREREPAQEOAAAAAB8CBAgQHwAAAAADBAQEBBgEBAQEAwAABAQEBAQEBAQEBAQAABgEBAQEAwQEBAQYAAAAAAAACRUSAAAAAAA/Pz8/Pz8/Pz8/Pz8/AAAAPz8/Pz8/PwAAAA==", + "base64" + ) +); + +/** Look up a Cozette glyph by character without parsing or rasterizing a font. */ +export function getCozetteGlyph(character: string): Uint8Array { + const codePoint = character.codePointAt(0); + const index = getGlyphIndex(codePoint); + if (index === undefined) { + return UNKNOWN_GLYPH; + } + const start = index * COZETTE_CELL_HEIGHT; + return GLYPH_ROWS.subarray(start, start + COZETTE_CELL_HEIGHT); +} + +function getGlyphIndex(codePoint: number | undefined): number | undefined { + if ( + codePoint !== undefined && + codePoint >= FIRST_ASCII_CODE_POINT && + codePoint <= LAST_ASCII_CODE_POINT + ) { + return codePoint - FIRST_ASCII_CODE_POINT; + } + if (codePoint === FULL_BLOCK_CODE_POINT) { + return FULL_BLOCK_INDEX; + } + if (codePoint === BLACK_SQUARE_CODE_POINT) { + return BLACK_SQUARE_INDEX; + } +} diff --git a/packages/cli/src/lib/formatters/pixel-canvas.ts b/packages/cli/src/lib/formatters/pixel-canvas.ts index e7fd83799..6dc081ba5 100644 --- a/packages/cli/src/lib/formatters/pixel-canvas.ts +++ b/packages/cli/src/lib/formatters/pixel-canvas.ts @@ -8,6 +8,11 @@ */ import type { DecodedImage } from "../sixel-image.js"; +import { + COZETTE_CELL_HEIGHT, + COZETTE_CELL_WIDTH, + getCozetteGlyph, +} from "./cozette-font.js"; /** An RGB color tuple with one 0-255 value per channel. */ export type Rgb = [number, number, number]; @@ -147,130 +152,95 @@ function copyOpaquePixel( destination.data[targetOffset + 3] = source.data[sourceOffset + 3] ?? 255; } -/** 5x7 glyphs for dashboard labels and table content. */ -const FONT: Record = { - A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], - B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"], - C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], - D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], - E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], - F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"], - G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"], - H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], - I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], - J: ["00111", "00010", "00010", "00010", "10010", "10010", "01100"], - K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"], - L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], - M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"], - N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"], - O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], - P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], - Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"], - R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], - S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], - T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], - U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], - V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"], - W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"], - X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"], - Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"], - Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"], - "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"], - "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"], - "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"], - "3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"], - "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"], - "5": ["11111", "10000", "10000", "11110", "00001", "00001", "11110"], - "6": ["01110", "10000", "10000", "11110", "10001", "10001", "01110"], - "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"], - "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"], - "9": ["01110", "10001", "10001", "01111", "00001", "00001", "01110"], - " ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"], - ".": ["00000", "00000", "00000", "00000", "00000", "00110", "00110"], - ",": ["00000", "00000", "00000", "00000", "00110", "00110", "00100"], - ":": ["00000", "00110", "00110", "00000", "00110", "00110", "00000"], - ";": ["00000", "00110", "00110", "00000", "00110", "00110", "00100"], - "-": ["00000", "00000", "00000", "01110", "00000", "00000", "00000"], - _: ["00000", "00000", "00000", "00000", "00000", "00000", "11111"], - "/": ["00001", "00010", "00100", "01000", "10000", "00000", "00000"], - "\\": ["10000", "01000", "00100", "00010", "00001", "00000", "00000"], - "(": ["00010", "00100", "01000", "01000", "01000", "00100", "00010"], - ")": ["01000", "00100", "00010", "00010", "00010", "00100", "01000"], - "[": ["01110", "01000", "01000", "01000", "01000", "01000", "01110"], - "]": ["01110", "00010", "00010", "00010", "00010", "00010", "01110"], - "=": ["00000", "11111", "00000", "11111", "00000", "00000", "00000"], - "+": ["00000", "00100", "00100", "11111", "00100", "00100", "00000"], - "?": ["01110", "10001", "00001", "00010", "00100", "00000", "00100"], - "!": ["00100", "00100", "00100", "00100", "00100", "00000", "00100"], - "#": ["01010", "11111", "01010", "01010", "11111", "01010", "00000"], - "%": ["11001", "11010", "00100", "01000", "10110", "00110", "00000"], - "@": ["01110", "10001", "10111", "10101", "10111", "10000", "01111"], - "*": ["00000", "10101", "01110", "11111", "01110", "10101", "00000"], - "|": ["00100", "00100", "00100", "00100", "00100", "00100", "00100"], - "<": ["00010", "00100", "01000", "10000", "01000", "00100", "00010"], - ">": ["01000", "00100", "00010", "00001", "00010", "00100", "01000"], - "&": ["01100", "10010", "10100", "01000", "10101", "10010", "01101"], - "'": ["00100", "00100", "00010", "00000", "00000", "00000", "00000"], - '"': ["01010", "01010", "00100", "00000", "00000", "00000", "00000"], - "█": ["11111", "11111", "11111", "11111", "11111", "11111", "11111"], - "■": ["01110", "11111", "11111", "11111", "11111", "11111", "01110"], -}; - -/** Visible fallback for a Unicode glyph outside the compact bitmap font. */ -const UNKNOWN_GLYPH = [ - "11111", - "10001", - "10101", - "10101", - "10101", - "10001", - "11111", -]; - -/** Draw a single line of compact bitmap text, clipped to its terminal cells. */ +/** Draw a Cozette text line, scaled and clipped to its terminal cells. */ export function drawPixelText( image: DecodedImage, text: string, options: PixelTextOptions ): void { - const scale = Math.max( - 1, - Math.min( - Math.floor((options.cellWidth - 2) / 5), - Math.floor((options.cellHeight - 2) / 7) - ) - ); + const cellWidth = Math.max(1, Math.floor(options.cellWidth)); + const cellHeight = Math.max(1, Math.floor(options.cellHeight)); let column = 0; for (const rawChar of text) { if (column >= options.maxColumns) { break; } - const glyph = FONT[rawChar.toUpperCase()] ?? UNKNOWN_GLYPH; - const glyphWidth = 5 * scale; - const glyphHeight = 7 * scale; - const glyphX = - options.x + - column * options.cellWidth + - Math.max(0, Math.floor((options.cellWidth - glyphWidth) / 2)); - const glyphY = - options.y + - Math.max(0, Math.floor((options.cellHeight - glyphHeight) / 2)); + drawCozetteGlyph(image, getCozetteGlyph(rawChar), { + x: options.x + column * cellWidth, + y: options.y, + cellWidth, + cellHeight, + color: options.color, + }); + column += 1; + } +} - for (let row = 0; row < glyph.length; row += 1) { - const pattern = glyph[row] ?? "00000"; - for (let pixel = 0; pixel < pattern.length; pixel += 1) { - if (pattern[pixel] === "1") { - drawPixelRect(image, { - x: glyphX + pixel * scale, - y: glyphY + row * scale, - width: scale, - height: scale, - color: options.color, - }); - } +type CozetteGlyphOptions = { + x: number; + y: number; + cellWidth: number; + cellHeight: number; + color: Rgb; +}; + +function drawCozetteGlyph( + image: DecodedImage, + glyph: Uint8Array, + options: CozetteGlyphOptions +): void { + for (let row = 0; row < COZETTE_CELL_HEIGHT; row += 1) { + const pattern = glyph[row] ?? 0; + for (let column = 0; column < COZETTE_CELL_WIDTH; column += 1) { + if (!isCozettePixelSet(pattern, column)) { + continue; } + drawCozettePixel(image, { ...options, row, column }); } - column += 1; } } + +function isCozettePixelSet(pattern: number, column: number): boolean { + const divisor = 2 ** (COZETTE_CELL_WIDTH - 1 - column); + return Math.floor(pattern / divisor) % 2 === 1; +} + +type CozettePixelOptions = CozetteGlyphOptions & { + row: number; + column: number; +}; + +function drawCozettePixel( + image: DecodedImage, + options: CozettePixelOptions +): void { + const x = + options.x + + scaleCoordinate(options.column, options.cellWidth, COZETTE_CELL_WIDTH); + const y = + options.y + + scaleCoordinate(options.row, options.cellHeight, COZETTE_CELL_HEIGHT); + const right = + options.x + + scaleCoordinate(options.column + 1, options.cellWidth, COZETTE_CELL_WIDTH); + const bottom = + options.y + + scaleCoordinate(options.row + 1, options.cellHeight, COZETTE_CELL_HEIGHT); + drawPixelRect(image, { + x, + y, + width: right - x, + height: bottom - y, + color: options.color, + }); +} + +/** Scale a Cozette bitmap coordinate to its terminal-cell boundary. */ +function scaleCoordinate( + coordinate: number, + cellSize: number, + glyphSize: number +): number { + return Math.floor((coordinate * cellSize) / glyphSize); +} diff --git a/packages/cli/test/lib/formatters/pixel-canvas.test.ts b/packages/cli/test/lib/formatters/pixel-canvas.test.ts new file mode 100644 index 000000000..ddca1d59b --- /dev/null +++ b/packages/cli/test/lib/formatters/pixel-canvas.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "vitest"; +import { getCozetteGlyph } from "../../../src/lib/formatters/cozette-font.js"; +import { + createPixelCanvas, + drawPixelText, +} from "../../../src/lib/formatters/pixel-canvas.js"; + +function opaquePixelCount(image: { data: Uint8Array }): number { + let count = 0; + for (let index = 3; index < image.data.length; index += 4) { + if (image.data[index] === 255) { + count += 1; + } + } + return count; +} + +describe("drawPixelText", () => { + test("uses Cozette's native 6x13 raster at terminal cell size", () => { + const image = createPixelCanvas({ width: 6, height: 13 }); + + drawPixelText(image, "A", { + x: 0, + y: 0, + cellWidth: 6, + cellHeight: 13, + maxColumns: 1, + color: [255, 255, 255], + }); + + expect(opaquePixelCount(image)).toBe(20); + expect(image.data[(2 * image.width + 2) * 4 + 3]).toBe(255); + expect(image.data[(0 * image.width + 1) * 4 + 3]).toBe(0); + }); + + test("packs Cozette rows with bit five as the leftmost pixel", () => { + expect(getCozetteGlyph("A")[2]).toBe(0b00_1110); + }); + + test("fills non-integer terminal cells with proportional bitmap scaling", () => { + const image = createPixelCanvas({ width: 9, height: 18 }); + + drawPixelText(image, "A", { + x: 0, + y: 0, + cellWidth: 9, + cellHeight: 18, + maxColumns: 1, + color: [255, 255, 255], + }); + + expect(opaquePixelCount(image)).toBe(48); + expect(image.data[(2 * image.width + 3) * 4 + 3]).toBe(255); + }); + + test("uses proportional scaling for larger cells", () => { + const image = createPixelCanvas({ width: 12, height: 26 }); + + drawPixelText(image, "A", { + x: 0, + y: 0, + cellWidth: 12, + cellHeight: 26, + maxColumns: 1, + color: [255, 255, 255], + }); + + expect(opaquePixelCount(image)).toBe(20 * 4); + }); + + test("never draws outside an undersized terminal cell", () => { + const image = createPixelCanvas({ width: 16, height: 16 }); + + drawPixelText(image, "AA", { + x: 4, + y: 4, + cellWidth: 4, + cellHeight: 8, + maxColumns: 2, + color: [255, 255, 255], + }); + + for (let y = 0; y < image.height; y += 1) { + for (let x = 0; x < image.width; x += 1) { + const opaque = image.data[(y * image.width + x) * 4 + 3] === 255; + if (opaque) { + expect(x).toBeGreaterThanOrEqual(4); + expect(x).toBeLessThan(12); + expect(y).toBeGreaterThanOrEqual(4); + expect(y).toBeLessThan(12); + } + } + } + }); +});