diff --git a/assets/fonts/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..711830e Binary files /dev/null and b/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/bun.lock b/bun.lock index f07ed92..d468c59 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "beautiful-mermaid": "^1.1.3", "hast-util-to-string": "^3.0.1", "image-dimensions": "^2.5.1", + "opentype.js": "^2.0.0", "refractor": "^5.0.0", "rehype-raw": "^7.0.0", "remark-abbr": "^1.4.2", @@ -22,6 +23,7 @@ }, "devDependencies": { "@types/bun": "latest", + "@types/opentype.js": "^1.3.10", "@types/react": "^19.2.16", "typescript": "^5", }, @@ -63,6 +65,8 @@ "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/opentype.js": ["@types/opentype.js@1.3.10", "", {}, "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ=="], + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], "@types/react": ["@types/react@19.2.16", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w=="], @@ -273,6 +277,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "opentype.js": ["opentype.js@2.0.0", "", { "bin": { "ot": "bin/ot" } }, "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], diff --git a/package.json b/package.json index ce36e01..3a05b5b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "beautiful-mermaid": "^1.1.3", "hast-util-to-string": "^3.0.1", "image-dimensions": "^2.5.1", + "opentype.js": "^2.0.0", "refractor": "^5.0.0", "rehype-raw": "^7.0.0", "remark-abbr": "^1.4.2", @@ -40,6 +41,7 @@ }, "devDependencies": { "@types/bun": "latest", + "@types/opentype.js": "^1.3.10", "@types/react": "^19.2.16", "typescript": "^5" }, diff --git a/scripts/downloadFont.ts b/scripts/downloadFont.ts new file mode 100644 index 0000000..633254c --- /dev/null +++ b/scripts/downloadFont.ts @@ -0,0 +1,24 @@ +import fs from "fs"; +import path from "path"; + +const fontUrl = "https://raw.githubusercontent.com/JetBrains/JetBrainsMono/master/fonts/ttf/JetBrainsMono-Regular.ttf"; +const outputDir = "/home/tammy/.claude-gateway/workspaces/default/dev/tui-md/assets/fonts"; +const outputPath = path.join(outputDir, "JetBrainsMono-Regular.ttf"); + +async function download() { + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + console.log(`Downloading font from: ${fontUrl}...`); + const response = await fetch(fontUrl); + if (!response.ok) { + throw new Error(`Failed to download font: ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + fs.writeFileSync(outputPath, Buffer.from(arrayBuffer)); + console.log(`Successfully downloaded font to: ${outputPath}`); +} + +download().catch(console.error); diff --git a/scripts/generateFontArt.ts b/scripts/generateFontArt.ts new file mode 100644 index 0000000..c992689 --- /dev/null +++ b/scripts/generateFontArt.ts @@ -0,0 +1,511 @@ +import fs from "fs"; +import path from "path"; +import opentype from "opentype.js"; + +// Paths +const refsDir = "/home/tammy/.claude-gateway/workspaces/default/dev/tui-md/refs"; +const fontPath = "/home/tammy/.claude-gateway/workspaces/default/dev/tui-md/assets/fonts/JetBrainsMono-Regular.ttf"; +const outputPath = "/home/tammy/.claude-gateway/workspaces/default/dev/tui-md/src/texicode/jetbrains_mono_art.ts"; + +interface UnicodeChar { + codePoint: string; + char: string; + name: string; +} + +interface Candidate { + char: string; + bitmap: number[][]; // 4x4 +} + +// Load scraped characters +function loadCandidates(): Candidate[] { + const candidates: Candidate[] = []; + + // Add standard space + candidates.push({ + char: " ", + bitmap: Array.from({ length: 4 }, () => Array(4).fill(0)) + }); + + const files = ["U+2500.json", "U+2580.json", "U+25A0.json"]; + for (const file of files) { + const filePath = path.join(refsDir, file); + if (!fs.existsSync(filePath)) { + console.warn(`Warning: file ${filePath} not found. Skipping.`); + continue; + } + const data: UnicodeChar[] = JSON.parse(fs.readFileSync(filePath, "utf8")); + for (const item of data) { + let bitmap: number[][] | null = null; + if (file === "U+2500.json") { + bitmap = getBoxDrawingBitmap(item.name); + } else if (file === "U+2580.json") { + bitmap = getBlockElementBitmap(item.name); + } else if (file === "U+25A0.json") { + bitmap = getGeometricShapeBitmap(item.name); + } + + if (bitmap) { + candidates.push({ char: item.char, bitmap }); + } + } + } + + return candidates; +} + +// Procedural bitmap generators +function getBoxDrawingBitmap(name: string): number[][] { + const grid = Array.from({ length: 4 }, () => Array(4).fill(0)); + const isHeavy = name.includes("Heavy"); + const isDouble = name.includes("Double"); + + // Directions: + const hasLeft = name.includes("Left") || name.includes("Horizontal") || name.includes("Cross"); + const hasRight = name.includes("Right") || name.includes("Horizontal") || name.includes("Cross"); + const hasUp = name.includes("Up") || name.includes("Vertical") || name.includes("Cross"); + const hasDown = name.includes("Down") || name.includes("Vertical") || name.includes("Cross"); + + // Diagonals + if (name.includes("Diagonal")) { + if (name.includes("Upper Right to Lower Left")) { + grid[0][3] = grid[1][2] = grid[2][1] = grid[3][0] = 1; + } + if (name.includes("Upper Left to Lower Right")) { + grid[0][0] = grid[1][1] = grid[2][2] = grid[3][3] = 1; + } + if (name.includes("Cross")) { + grid[0][3] = grid[1][2] = grid[2][1] = grid[3][0] = 1; + grid[0][0] = grid[1][1] = grid[2][2] = grid[3][3] = 1; + } + return grid; + } + + // Draw lines + if (hasLeft) { + if (isHeavy || isDouble) { + grid[1][0] = grid[1][1] = grid[2][0] = grid[2][1] = 1; + } else { + grid[1][0] = grid[1][1] = 1; + } + } + if (hasRight) { + if (isHeavy || isDouble) { + grid[1][2] = grid[1][3] = grid[2][2] = grid[2][3] = 1; + } else { + grid[1][2] = grid[1][3] = 1; + } + } + if (hasUp) { + if (isHeavy || isDouble) { + grid[0][1] = grid[1][1] = grid[0][2] = grid[1][2] = 1; + } else { + grid[0][1] = grid[1][1] = 1; + } + } + if (hasDown) { + if (isHeavy || isDouble) { + grid[2][1] = grid[3][1] = grid[2][2] = grid[3][2] = 1; + } else { + grid[2][2] = grid[3][2] = 1; + } + } + + // Fill center intersection + if ((hasLeft || hasRight) && (hasUp || hasDown)) { + grid[1][1] = grid[1][2] = grid[2][1] = grid[2][2] = 1; + } + + return grid; +} + +function getBlockElementBitmap(name: string): number[][] { + const grid = Array.from({ length: 4 }, () => Array(4).fill(0)); + const lowerName = name.toLowerCase(); + + if (lowerName.includes("full block")) { + return Array.from({ length: 4 }, () => Array(4).fill(1)); + } + + if (lowerName.includes("upper half")) { + for (let r = 0; r < 2; r++) grid[r].fill(1); + return grid; + } + if (lowerName.includes("lower half")) { + for (let r = 2; r < 4; r++) grid[r].fill(1); + return grid; + } + if (lowerName.includes("left half")) { + for (let r = 0; r < 4; r++) { + grid[r][0] = grid[r][1] = 1; + } + return grid; + } + if (lowerName.includes("right half")) { + for (let r = 0; r < 4; r++) { + grid[r][2] = grid[r][3] = 1; + } + return grid; + } + + // Fractional blocks + if (lowerName.includes("lower one eighth") || lowerName.includes("lower one quarter")) { + grid[3].fill(1); + return grid; + } + if (lowerName.includes("lower three eighths") || lowerName.includes("lower half")) { + grid[2].fill(1); grid[3].fill(1); + return grid; + } + if (lowerName.includes("lower five eighths") || lowerName.includes("lower three quarters") || lowerName.includes("lower seven eighths")) { + grid[1].fill(1); grid[2].fill(1); grid[3].fill(1); + return grid; + } + + if (lowerName.includes("left one eighth") || lowerName.includes("left one quarter")) { + for (let r = 0; r < 4; r++) grid[r][0] = 1; + return grid; + } + if (lowerName.includes("left three eighths") || lowerName.includes("left half")) { + for (let r = 0; r < 4; r++) grid[r][0] = grid[r][1] = 1; + return grid; + } + if (lowerName.includes("left five eighths") || lowerName.includes("left three quarters") || lowerName.includes("left seven eighths")) { + for (let r = 0; r < 4; r++) grid[r][0] = grid[r][1] = grid[r][2] = 1; + return grid; + } + + if (lowerName.includes("upper one eighth")) { + grid[0].fill(1); + return grid; + } + if (lowerName.includes("right one eighth")) { + for (let r = 0; r < 4; r++) grid[r][3] = 1; + return grid; + } + + // Quadrants + const hasUL = lowerName.includes("upper left"); + const hasUR = lowerName.includes("upper right"); + const hasLL = lowerName.includes("lower left"); + const hasLR = lowerName.includes("lower right"); + + if (hasUL) { + grid[0][0] = grid[0][1] = grid[1][0] = grid[1][1] = 1; + } + if (hasUR) { + grid[0][2] = grid[0][3] = grid[1][2] = grid[1][3] = 1; + } + if (hasLL) { + grid[2][0] = grid[2][1] = grid[3][0] = grid[3][1] = 1; + } + if (hasLR) { + grid[2][2] = grid[2][3] = grid[3][2] = grid[3][3] = 1; + } + + return grid; +} + +function getGeometricShapeBitmap(name: string): number[][] { + const grid = Array.from({ length: 4 }, () => Array(4).fill(0)); + const lowerName = name.toLowerCase(); + + if (lowerName.includes("black square") || lowerName.includes("solid square")) { + return Array.from({ length: 4 }, () => Array(4).fill(1)); + } + if (lowerName.includes("white square")) { + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 4; c++) { + if (r === 0 || r === 3 || c === 0 || c === 3) grid[r][c] = 1; + } + } + return grid; + } + if (lowerName.includes("up-pointing triangle")) { + grid[0][1] = grid[0][2] = 1; + grid[1][1] = grid[1][2] = 1; + grid[2].fill(1); + grid[3].fill(1); + return grid; + } + if (lowerName.includes("down-pointing triangle")) { + grid[0].fill(1); + grid[1].fill(1); + grid[2][1] = grid[2][2] = 1; + grid[3][1] = grid[3][2] = 1; + return grid; + } + if (lowerName.includes("left-pointing triangle")) { + grid[0][0] = grid[1][0] = grid[2][0] = grid[3][0] = 1; + grid[1][1] = grid[2][1] = 1; + grid[1][2] = grid[2][2] = 1; + return grid; + } + if (lowerName.includes("right-pointing triangle")) { + grid[0][3] = grid[1][3] = grid[2][3] = grid[3][3] = 1; + grid[1][1] = grid[2][1] = 1; + grid[1][2] = grid[2][2] = 1; + return grid; + } + if (lowerName.includes("circle")) { + grid[0][1] = grid[0][2] = 1; + grid[1][0] = grid[1][1] = grid[1][2] = grid[1][3] = 1; + grid[2][0] = grid[2][1] = grid[2][2] = grid[2][3] = 1; + grid[3][1] = grid[3][2] = 1; + return grid; + } + if (lowerName.includes("diamond")) { + grid[0][1] = grid[0][2] = 1; + grid[1].fill(1); + grid[2].fill(1); + grid[3][1] = grid[3][2] = 1; + return grid; + } + if (lowerName.includes("small square")) { + grid[1][1] = grid[1][2] = 1; + grid[2][1] = grid[2][2] = 1; + return grid; + } + + if (lowerName.includes("black") || lowerName.includes("filled")) { + return Array.from({ length: 4 }, () => Array(4).fill(1)); + } + return grid; +} + +interface Segment { + p0: { x: number; y: number }; + p1: { x: number; y: number }; +} + +function flattenGlyphPath(commands: opentype.PathCommand[], scale: number, baseline: number): Segment[] { + const segments: Segment[] = []; + let currX = 0; + let currY = 0; + let startX = 0; + let startY = 0; + + const toGridX = (x: number) => x * scale; + const toGridY = (y: number) => baseline - y * scale; + + for (const cmd of commands) { + switch (cmd.type) { + case "M": + if (cmd.x !== undefined && cmd.y !== undefined) { + currX = toGridX(cmd.x); + currY = toGridY(cmd.y); + startX = currX; + startY = currY; + } + break; + case "L": + if (cmd.x !== undefined && cmd.y !== undefined) { + const nextX = toGridX(cmd.x); + const nextY = toGridY(cmd.y); + segments.push({ p0: { x: currX, y: currY }, p1: { x: nextX, y: nextY } }); + currX = nextX; + currY = nextY; + } + break; + case "Q": + if (cmd.x !== undefined && cmd.y !== undefined && cmd.x1 !== undefined && cmd.y1 !== undefined) { + const cx = toGridX(cmd.x1); + const cy = toGridY(cmd.y1); + const nextX = toGridX(cmd.x); + const nextY = toGridY(cmd.y); + const steps = 8; + let prevX = currX; + let prevY = currY; + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const mt = 1 - t; + const tx = mt * mt * currX + 2 * mt * t * cx + t * t * nextX; + const ty = mt * mt * currY + 2 * mt * t * cy + t * t * nextY; + segments.push({ p0: { x: prevX, y: prevY }, p1: { x: tx, y: ty } }); + prevX = tx; + prevY = ty; + } + currX = nextX; + currY = nextY; + } + break; + case "C": + if (cmd.x !== undefined && cmd.y !== undefined && cmd.x1 !== undefined && cmd.y1 !== undefined && cmd.x2 !== undefined && cmd.y2 !== undefined) { + const c1x = toGridX(cmd.x1); + const c1y = toGridY(cmd.y1); + const c2x = toGridX(cmd.x2); + const c2y = toGridY(cmd.y2); + const nextX = toGridX(cmd.x); + const nextY = toGridY(cmd.y); + const steps = 8; + let prevX = currX; + let prevY = currY; + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const mt = 1 - t; + const tx = mt * mt * mt * currX + 3 * mt * mt * t * c1x + 3 * mt * t * t * c2x + t * t * t * nextX; + const ty = mt * mt * mt * currY + 3 * mt * mt * t * c1y + 3 * mt * t * t * c2y + t * t * t * nextY; + segments.push({ p0: { x: prevX, y: prevY }, p1: { x: tx, y: ty } }); + prevX = tx; + prevY = ty; + } + currX = nextX; + currY = nextY; + } + break; + case "Z": + if (currX !== startX || currY !== startY) { + segments.push({ p0: { x: currX, y: currY }, p1: { x: startX, y: startY } }); + currX = startX; + currY = startY; + } + break; + } + } + return segments; +} + +function isPointInGlyph(px: number, py: number, segments: Segment[]): boolean { + let inside = false; + for (const seg of segments) { + const { p0, p1 } = seg; + const intersects = ((p0.y > py) !== (p1.y > py)) && + (px < (p1.x - p0.x) * (py - p0.y) / (p1.y - p0.y) + p0.x); + if (intersects) { + inside = !inside; + } + } + return inside; +} + +// Find closest character using weighted score +function findBestChar(subgrid: number[][], candidates: Candidate[]): string { + let bestChar = " "; + let maxScore = -999; + + for (const cand of candidates) { + let score = 0; + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 4; c++) { + const valB = subgrid[r][c]; + const valM = cand.bitmap[r][c]; + if (valB === 1 && valM === 1) { + score += 3; + } else if (valB === 0 && valM === 0) { + score += 1; + } else if (valB === 1 && valM === 0) { + score -= 2; + } else { + score -= 1; + } + } + } + + if (score > maxScore) { + maxScore = score; + bestChar = cand.char; + } + } + + return bestChar; +} + +async function run() { + const candidates = loadCandidates(); + console.log(`Loaded ${candidates.length} candidates.`); + + if (!fs.existsSync(fontPath)) { + throw new Error(`Font file not found at: ${fontPath}`); + } + + const fontBuffer = fs.readFileSync(fontPath); + const font = opentype.parse(new Uint8Array(fontBuffer).buffer); + + // Font metrics details + // Cap-height is 730 + const capHeight = 730; + + const sizes = [ + { rows: 2, heightPixels: 8, baselinePixel: 6 }, + { rows: 3, heightPixels: 12, baselinePixel: 9 }, + { rows: 4, heightPixels: 16, baselinePixel: 12 } + ]; + + const targetChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"; + + const result: Record> = { + 2: {}, + 3: {}, + 4: {} + }; + + for (const size of sizes) { + const H = size.heightPixels; + // Scale is determined by fitting cap-height into baselinePixel height + const scale = size.baselinePixel / capHeight; + + // Width of monospace char is 600, scale it to pixels and round to multiple of 4 + const W_pixels = Math.round((600 * scale) / 4) * 4 || 4; + const W_cells = W_pixels / 4; + + console.log(`Processing size: ${size.rows} rows (Height: ${H}px, Width: ${W_pixels}px, Cells: ${W_cells}x${size.rows}, Scale: ${scale})`); + + for (const char of targetChars) { + if (char === " ") { + result[size.rows][" "] = Array.from({ length: size.rows }, () => " ".repeat(W_cells)); + continue; + } + + const glyph = font.charToGlyph(char); + const segments = flattenGlyphPath(glyph.path.commands, scale, size.baselinePixel); + + // Render to pixel grid + const pixelGrid = Array.from({ length: H }, () => Array(W_pixels).fill(0)); + for (let r = 0; r < H; r++) { + for (let c = 0; c < W_pixels; c++) { + const px = c + 0.5; + const py = r + 0.5; + if (isPointInGlyph(px, py, segments)) { + pixelGrid[r][c] = 1; + } + } + } + + // Convert to cell grid + const cellGrid: string[][] = Array.from({ length: size.rows }, () => Array(W_cells).fill(" ")); + for (let cr = 0; cr < size.rows; cr++) { + for (let cc = 0; cc < W_cells; cc++) { + const subgrid = Array.from({ length: 4 }, () => Array(4).fill(0)); + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 4; c++) { + // Clamp indices just in case + const pr = Math.min(H - 1, cr * 4 + r); + const pc = Math.min(W_pixels - 1, cc * 4 + c); + subgrid[r][c] = pixelGrid[pr][pc]; + } + } + cellGrid[cr][cc] = findBestChar(subgrid, candidates); + } + } + + result[size.rows][char] = cellGrid.map(row => row.join("")); + } + } + + // Generate output TypeScript file + const tsContent = `// Auto-generated from JetBrainsMono-Regular.ttf and Compart Unicode blocks +// Contains rasterized block art for printable ASCII characters at heights of 2, 3, and 4 terminal rows. + +export interface FontArtMap { + [char: string]: string[]; +} + +export const jetbrainsMonoArt: Record<2 | 3 | 4, FontArtMap> = ${JSON.stringify(result, null, 2)}; +`; + + fs.writeFileSync(outputPath, tsContent, "utf8"); + console.log(`Successfully generated font art at ${outputPath}`); +} + +run().catch(console.error); diff --git a/scripts/scrapeCompart.ts b/scripts/scrapeCompart.ts new file mode 100644 index 0000000..e4453a1 --- /dev/null +++ b/scripts/scrapeCompart.ts @@ -0,0 +1,65 @@ +import fs from "fs"; +import path from "path"; + +const urls = [ + "https://www.compart.com/en/unicode/block/U+2500", + "https://www.compart.com/en/unicode/block/U+2580", + "https://www.compart.com/en/unicode/block/U+25A0" +]; + +const refsDir = "/home/tammy/.claude-gateway/workspaces/default/dev/tui-md/refs"; + +interface UnicodeChar { + codePoint: string; + char: string; + name: string; +} + +async function scrape() { + if (!fs.existsSync(refsDir)) { + fs.mkdirSync(refsDir, { recursive: true }); + } + + for (const url of urls) { + const blockId = url.split("/").pop()!; + console.log(`Scraping ${url}...`); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.statusText}`); + } + + const html = await response.text(); + + // Pattern to match: + //

U+2500

Box Drawings Light Horizontal

+ // We should be careful about whitespace, quotes, and newlines. + const regex = /class="content-item card"[^>]*>\s*

\s*(U\+[0-9A-F]+)\s*<\/p>\s*

\s*([\s\S]*?)\s*<\/div>\s*
\s*

\s*([\s\S]*?)\s*<\/p>/gi; + + const chars: UnicodeChar[] = []; + let match; + while ((match = regex.exec(html)) !== null) { + const codePoint = match[1].trim(); + const char = match[2].trim(); + const name = match[3].trim(); + chars.push({ codePoint, char, name }); + } + + if (chars.length === 0) { + console.warn(`Warning: No characters parsed for block ${blockId}. Checking alternative parsing pattern.`); + // Let's print a small chunk of the html to see if classes differ + const index = html.indexOf("class=\"content-item card\""); + if (index !== -1) { + console.log("Found card class. Snippet:", html.substring(index, index + 300)); + } else { + console.log("Could not find class='content-item card' in HTML."); + } + } else { + console.log(`Scraped ${chars.length} characters for block ${blockId}`); + const outputPath = path.join(refsDir, `${blockId}.json`); + fs.writeFileSync(outputPath, JSON.stringify(chars, null, 2), "utf8"); + console.log(`Saved to ${outputPath}`); + } + } +} + +scrape().catch(console.error); diff --git a/scripts/testGlyph.ts b/scripts/testGlyph.ts new file mode 100644 index 0000000..a0fb970 --- /dev/null +++ b/scripts/testGlyph.ts @@ -0,0 +1,14 @@ +import fs from "fs"; +import opentype from "opentype.js"; + +const fontPath = "/home/tammy/.claude-gateway/workspaces/default/dev/tui-md/assets/fonts/JetBrainsMono-Regular.ttf"; +const fontBuffer = fs.readFileSync(fontPath); +const font = opentype.parse(new Uint8Array(fontBuffer).buffer); + +console.log("ascender:", font.ascender); +console.log("descender:", font.descender); +console.log("unitsPerEm:", font.unitsPerEm); + +const glyph = font.charToGlyph("A"); +console.log("A bbox:", glyph.getMetrics()); +console.log("A path commands (first 5):", glyph.path.commands.slice(0, 5)); diff --git a/src/nodes/blocks.tsx b/src/nodes/blocks.tsx index 021b0a4..a1b3ec9 100644 --- a/src/nodes/blocks.tsx +++ b/src/nodes/blocks.tsx @@ -12,6 +12,7 @@ import { CodeBlock } from "./code"; import { TableBlock } from "./table"; import { MathBlock } from "./math"; import { DetailsBlock } from "./details"; +import { HeadingBlock } from "./heading"; type DefListData = { type: "defList"; children: DefListChild[] }; type DefListChild = @@ -27,8 +28,7 @@ interface BlockProps { codeOptions?: CodeOptions; } -const HEADING_FG_KEYS = ["text", "h1", "h2", "h3", "h4", "h5", "h6"] as const; -const HEADING_PREFIXES = ["", "# ", "## ", "### ", "#### ", "##### ", "###### "]; + function ClickableLinkText({ node, theme, onLinkClick }: { node: any; theme: TuiMdTheme; onLinkClick?: TuiMdLinkHandler }) { const [isHovered, setIsHovered] = useState(false); @@ -145,21 +145,8 @@ export function BlockNode({ node, theme, depth = 0, onLinkClick, streaming, code ); } - case "heading": { - const h = node as Heading; - const level = Math.min(h.depth, 6) as 1|2|3|4|5|6; - const fg = theme[HEADING_FG_KEYS[level]] ?? theme.accent; - return ( - - - {HEADING_PREFIXES[level]} - {h.children.map((c, i) => ( - - ))} - - - ); - } + case "heading": + return ; case "yaml" as any: { const yamlStr = (node as any).value || ""; diff --git a/src/nodes/heading.tsx b/src/nodes/heading.tsx new file mode 100644 index 0000000..4e62c13 --- /dev/null +++ b/src/nodes/heading.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import type { Heading } from "mdast"; +import { TextAttributes } from "@opentui/core"; +import type { TuiMdTheme } from "../theme"; +import { InlineNode } from "./inline"; +import { baseAttrs, mergeAttrs } from "../utils/attrs"; +import { toUnicodeHeading } from "../utils/unicode-heading"; + +const HEADING_FG_KEYS = ["text", "h1", "h2", "h3", "h4", "h5", "h6"] as const; + +interface HeadingBlockProps { + node: Heading; + theme: TuiMdTheme; +} + +/** + * Walk a heading's children and transform any plain text nodes into the + * Unicode Mathematical font variant for this heading level. + * Non-text inline nodes (code, emphasis, etc.) pass through untouched. + */ +function transformChildren(children: any[], level: number): any[] { + return children.map((child) => { + if (child.type === "text") { + return { ...child, value: toUnicodeHeading(child.value, level) }; + } + if (child.children) { + return { ...child, children: transformChildren(child.children, level) }; + } + return child; + }); +} + +export function HeadingBlock({ node, theme }: HeadingBlockProps) { + const level = Math.min(node.depth, 6) as 1 | 2 | 3 | 4 | 5 | 6; + const fg = theme[HEADING_FG_KEYS[level]] ?? theme.accent; + const attrs = mergeAttrs(baseAttrs(), { bold: true, fg }); + const transformedChildren = transformChildren(node.children as any[], level); + + return ( + + + {transformedChildren.map((c, i) => ( + + ))} + + + ); +} diff --git a/src/texicode/jetbrains_mono_art.ts b/src/texicode/jetbrains_mono_art.ts new file mode 100644 index 0000000..8ca8ead --- /dev/null +++ b/src/texicode/jetbrains_mono_art.ts @@ -0,0 +1,1440 @@ +// Auto-generated from JetBrainsMono-Regular.ttf and Compart Unicode blocks +// Contains rasterized block art for printable ASCII characters at heights of 2, 3, and 4 terminal rows. + +export interface FontArtMap { + [char: string]: string[]; +} + +export const jetbrainsMonoArt: Record<2 | 3 | 4, FontArtMap> = { + "2": { + "0": [ + "▔", + "─" + ], + "1": [ + "┃", + "─" + ], + "2": [ + "▐", + "└" + ], + "3": [ + "▐", + "─" + ], + "4": [ + "╱", + "▔" + ], + "5": [ + "┕", + "─" + ], + "6": [ + "╻", + "─" + ], + "7": [ + "▔", + "╹" + ], + "8": [ + "▟", + "─" + ], + "9": [ + "□", + "╹" + ], + " ": [ + " ", + " " + ], + "!": [ + "┃", + "╶" + ], + "\"": [ + "▀", + " " + ], + "#": [ + "▕", + "╵" + ], + "$": [ + "┃", + "┕" + ], + "%": [ + "┴", + "┴" + ], + "&": [ + "│", + "▀" + ], + "'": [ + "╹", + " " + ], + "(": [ + " ", + "╺" + ], + ")": [ + " ", + "╱" + ], + "*": [ + "┭", + "▔" + ], + "+": [ + "╻", + " " + ], + ",": [ + " ", + "╺" + ], + "-": [ + "▁", + " " + ], + ".": [ + " ", + "╶" + ], + "/": [ + "╷", + "╸" + ], + ":": [ + "╺", + "╶" + ], + ";": [ + "╺", + "╺" + ], + "<": [ + "╻", + "▔" + ], + "=": [ + "╺", + "▔" + ], + ">": [ + "╲", + "╹" + ], + "?": [ + "┕", + "─" + ], + "@": [ + "▙", + "▀" + ], + "A": [ + "▃", + "▔" + ], + "B": [ + "▕", + "─" + ], + "C": [ + "╹", + "└" + ], + "D": [ + "▕", + "─" + ], + "E": [ + "┃", + "└" + ], + "F": [ + "┃", + "╵" + ], + "G": [ + "▔", + "─" + ], + "H": [ + "▶", + "▀" + ], + "I": [ + "┃", + "─" + ], + "J": [ + "▕", + "▀" + ], + "K": [ + "▐", + " " + ], + "L": [ + "┃", + "└" + ], + "M": [ + "│", + " " + ], + "N": [ + "▐", + "╶" + ], + "O": [ + "▕", + "─" + ], + "P": [ + "▔", + " " + ], + "Q": [ + "▔", + "─" + ], + "R": [ + "▔", + "▕" + ], + "S": [ + "┕", + "─" + ], + "T": [ + "▜", + "╹" + ], + "U": [ + "▕", + "─" + ], + "V": [ + "┍", + "╶" + ], + "W": [ + "▎", + "╵" + ], + "X": [ + "├", + "▔" + ], + "Y": [ + "┌", + "╹" + ], + "Z": [ + "▐", + "└" + ], + "[": [ + " ", + "╺" + ], + "\\": [ + "│", + "╺" + ], + "]": [ + " ", + "╻" + ], + "^": [ + "┕", + " " + ], + "_": [ + " ", + "━" + ], + "`": [ + " ", + " " + ], + "a": [ + "┍", + "─" + ], + "b": [ + "▶", + "▝" + ], + "c": [ + "╱", + "─" + ], + "d": [ + "▶", + "─" + ], + "e": [ + "┍", + "─" + ], + "f": [ + "▟", + "╹" + ], + "g": [ + "▶", + "┕" + ], + "h": [ + "▶", + "▕" + ], + "i": [ + "┃", + "─" + ], + "j": [ + "▶", + "▕" + ], + "k": [ + "┝", + "▀" + ], + "l": [ + "┃", + "▝" + ], + "m": [ + "▅", + "▀" + ], + "n": [ + "▶", + "▕" + ], + "o": [ + "╱", + "─" + ], + "p": [ + "▶", + "▝" + ], + "q": [ + "▶", + "▶" + ], + "r": [ + "┍", + "╵" + ], + "s": [ + "┍", + "─" + ], + "t": [ + "─", + "╶" + ], + "u": [ + "▕", + "─" + ], + "v": [ + "▃", + "╹" + ], + "w": [ + "─", + "─" + ], + "x": [ + "╻", + "╵" + ], + "y": [ + "▃", + "╺" + ], + "z": [ + "┍", + "└" + ], + "{": [ + "┃", + "╹" + ], + "|": [ + "┃", + "╹" + ], + "}": [ + "┃", + "╱" + ], + "~": [ + "╻", + " " + ] + }, + "3": { + "0": [ + "┃▌", + "┃┃", + "▔▔" + ], + "1": [ + "╹▎", + " ▎", + "▔▔" + ], + "2": [ + "╱▌", + "╺▘", + "▔▔" + ], + "3": [ + "▔▌", + " ▌", + "▔ " + ], + "4": [ + "╺ ", + "┕▌", + " ╵" + ], + "5": [ + "┃▎", + " ▌", + "▔▔" + ], + "6": [ + "▐ ", + "┃┃", + "▔▔" + ], + "7": [ + "╹┃", + "▕▎", + " " + ], + "8": [ + "┃▌", + "┃╲", + "▔▔" + ], + "9": [ + "┃┃", + "╹▌", + " " + ], + " ": [ + " ", + " ", + " " + ], + "!": [ + "▕ ", + "▕ ", + " " + ], + "\"": [ + "┃▌", + " ", + " " + ], + "#": [ + "╷┃", + "┃▎", + "╵ " + ], + "$": [ + "▟▘", + "▕╲", + "▕▔" + ], + "%": [ + "┽╱", + "╻┑", + " ▔" + ], + "&": [ + "┃╲", + "▛▞", + "▔ " + ], + "'": [ + "▕ ", + " ", + " " + ], + "(": [ + "▐ ", + "▐ ", + " ╴" + ], + ")": [ + " ▎", + " ▎", + "─ " + ], + "*": [ + "▕╻", + "▝▌", + " " + ], + "+": [ + "▕ ", + "▕▔", + " " + ], + ",": [ + " ", + " ", + "▕ " + ], + "-": [ + " ", + "▔▔", + " " + ], + ".": [ + " ", + " ", + " " + ], + "/": [ + " ▎", + "▐ ", + "╹ " + ], + ":": [ + "▕▎", + " ", + " " + ], + ";": [ + "▕▎", + " ", + "▕ " + ], + "<": [ + " ╱", + "╹▖", + " " + ], + "=": [ + "▁▁", + "╺╸", + " " + ], + ">": [ + "╲ ", + "╺▘", + " " + ], + "?": [ + "▔▌", + "▕ ", + "▔ " + ], + "@": [ + "┃▉", + "▶┃", + "│ " + ], + "A": [ + "▐▎", + "┝▌", + "╵ " + ], + "B": [ + "┃▌", + "┃╲", + "▔▔" + ], + "C": [ + "┃▘", + "┃ ", + "▔▔" + ], + "D": [ + "┃▌", + "┃┃", + "▔ " + ], + "E": [ + "┃▔", + "┃▔", + "▔▔" + ], + "F": [ + "┃▔", + "┃▔", + "╵ " + ], + "G": [ + "┃▘", + "┃▌", + "▔▔" + ], + "H": [ + "┃┃", + "┃▌", + "╵╵" + ], + "I": [ + "▕▔", + "▕ ", + "▔▔" + ], + "J": [ + " ┃", + "╻┃", + "▔ " + ], + "K": [ + "┃╹", + "┃▌", + "╵╹" + ], + "L": [ + "┃ ", + "┃ ", + "▔▔" + ], + "M": [ + "┃▉", + "┃┃", + "╵╹" + ], + "N": [ + "┃┃", + "┃▌", + "╵╵" + ], + "O": [ + "┃▌", + "┃┃", + "▔▔" + ], + "P": [ + "┃┃", + "┃▔", + "╵ " + ], + "Q": [ + "┃▌", + "┃┃", + "▔▘" + ], + "R": [ + "┃┃", + "┃▌", + "╵╵" + ], + "S": [ + "┃╲", + " ┃", + "▔▔" + ], + "T": [ + "▕▔", + "▕ ", + " " + ], + "U": [ + "┃┃", + "┃┃", + "▔▔" + ], + "V": [ + "├┃", + "▐▎", + " " + ], + "W": [ + "▛▉", + "┃▌", + "╹╵" + ], + "X": [ + "└▌", + "┍╸", + "╵╹" + ], + "Y": [ + "│╱", + "▕ ", + " " + ], + "Z": [ + "▔▌", + "╺ ", + "▔▔" + ], + "[": [ + "▕ ", + "▕ ", + "▕╴" + ], + "\\": [ + "▐ ", + "▕▎", + " ╵" + ], + "]": [ + " ▎", + " ▎", + "╶▎" + ], + "^": [ + "╷╸", + "╵╵", + " " + ], + "_": [ + " ", + " ", + "──" + ], + "`": [ + " ", + " ", + " " + ], + "a": [ + "╺▖", + "┍▌", + "▔▔" + ], + "b": [ + "┝▖", + "┃┃", + "▔▔" + ], + "c": [ + "╻▖", + "┃ ", + "▔▔" + ], + "d": [ + "╻▌", + "┃┃", + "▔▔" + ], + "e": [ + "╺▖", + "└▘", + "▔▔" + ], + "f": [ + "▕▉", + "▕ ", + " " + ], + "g": [ + "╻▖", + "┃▌", + "╺╸" + ], + "h": [ + "┝▖", + "┃┃", + "╵╵" + ], + "i": [ + "▶▎", + "▕▎", + "▔▔" + ], + "j": [ + "╺▎", + " ▎", + "╺▎" + ], + "k": [ + "┃╻", + "└▎", + "╵╹" + ], + "l": [ + "▜ ", + "▕ ", + " ▔" + ], + "m": [ + "▃╸", + "▶┃", + "▔ " + ], + "n": [ + "╻▖", + "┃┃", + "╵╵" + ], + "o": [ + "╻▖", + "┃┃", + "▔▔" + ], + "p": [ + "╻▖", + "┃┃", + "╵▔" + ], + "q": [ + "╺▖", + "┃┃", + "▔▘" + ], + "r": [ + "╻▖", + "┃╹", + "╵ " + ], + "s": [ + "╺▖", + "╶╸", + "▔▔" + ], + "t": [ + "▶╸", + "▕ ", + " ▔" + ], + "u": [ + "╻╻", + "┃┃", + "▔ " + ], + "v": [ + "╻╻", + "┃▌", + " " + ], + "w": [ + "▃╷", + "┃▌", + "╹╵" + ], + "x": [ + "╻╻", + "▐▎", + "╵╵" + ], + "y": [ + "╻╻", + "▐▌", + "╺ " + ], + "z": [ + "╺▖", + "┍ ", + "▔▔" + ], + "{": [ + "▕▎", + "╹▎", + " ╴" + ], + "|": [ + "▕ ", + "▕ ", + "▕ " + ], + "}": [ + "▕ ", + "▕▔", + "─ " + ], + "~": [ + "╷╻", + "╵╹", + " " + ] + }, + "4": { + "0": [ + "┍▜", + "┃━", + "┕▟", + " " + ], + "1": [ + "┍▌", + " ┃", + "▁▙", + " " + ], + "2": [ + "▶▜", + " ▞", + "▐▁", + " " + ], + "3": [ + "▔▛", + "▕▜", + "┌▟", + " " + ], + "4": [ + " ┙", + "┍▕", + "▔▜", + " " + ], + "5": [ + "▐▔", + "▝▜", + "▶▟", + " " + ], + "6": [ + " ▌", + "▟─", + "┕▟", + " " + ], + "7": [ + "┃▜", + " ┃", + " ▌", + " " + ], + "8": [ + "┍▜", + "┝▜", + "┕▟", + " " + ], + "9": [ + "┍▜", + "┕▟", + " ┙", + " " + ], + " ": [ + " ", + " ", + " ", + " " + ], + "!": [ + " ▌", + " ▌", + " ▖", + " " + ], + "\"": [ + "▐▐", + "▔ ", + " ", + " " + ], + "#": [ + "▕□", + "▐█", + "┃▼", + " " + ], + "$": [ + "▐┝", + "▝▙", + "▶█", + " ╵" + ], + "%": [ + "▛▅", + "▔▲", + "╱├", + " " + ], + "&": [ + "▐┕", + "▞┑", + "▙▞", + " " + ], + "'": [ + " ▌", + " ▔", + " ", + " " + ], + "(": [ + "▕▎", + "▕▎", + "▕▎", + " ▔" + ], + ")": [ + " ┃", + " ┃", + " ┃", + "▝ " + ], + "*": [ + " ▖", + "└▉", + "▝▝", + " " + ], + "+": [ + " ▁", + "╺▙", + " ▘", + " " + ], + ",": [ + " ", + " ", + " ▖", + " ▎" + ], + "-": [ + " ", + "╺━", + " ", + " " + ], + ".": [ + " ", + " ", + " ▖", + " " + ], + "/": [ + " ┃", + " ▌", + "▐ ", + "╹ " + ], + ":": [ + " ▁", + " ▔", + " ▖", + " " + ], + ";": [ + " ▁", + " ▔", + " ▖", + " ▎" + ], + "<": [ + " ▁", + "┌▎", + " ▀", + " " + ], + "=": [ + " ", + "▔▔", + "▔▔", + " " + ], + ">": [ + "╻ ", + " ▜", + "▶▔", + " " + ], + "?": [ + "▔▚", + "▕▞", + "▕▎", + " " + ], + "@": [ + "╱▔", + "┃▛", + "┃━", + "┕╸" + ], + "A": [ + "▕▉", + "▐▐", + "┃▜", + " " + ], + "B": [ + "▐▜", + "▐▶", + "▐▟", + " " + ], + "C": [ + "▐┕", + "┃ ", + "▐┍", + " " + ], + "D": [ + "▐▜", + "┃▕", + "▐▟", + " " + ], + "E": [ + "▐▔", + "▐─", + "▐▁", + " " + ], + "F": [ + "▐▔", + "▐━", + "┃ ", + " " + ], + "G": [ + "┍┕", + "┃▗", + "┕▟", + " " + ], + "H": [ + "┃▕", + "▐▶", + "┃▕", + " " + ], + "I": [ + "▔▛", + " ▌", + "▁▙", + " " + ], + "J": [ + " ▕", + " ▕", + "┌▟", + " " + ], + "K": [ + "┃▐", + "▐┥", + "┃▐", + " " + ], + "L": [ + "▐ ", + "▐ ", + "▐▁", + " " + ], + "M": [ + "█▐", + "┃█", + "┃▕", + " " + ], + "N": [ + "█▕", + "┃□", + "┃▐", + " " + ], + "O": [ + "┍▜", + "┃▕", + "┕▟", + " " + ], + "P": [ + "▐┕", + "▐━", + "┃ ", + " " + ], + "Q": [ + "┍▜", + "┃▕", + "┕▟", + " ┕" + ], + "R": [ + "▐▜", + "▐▟", + "┃▐", + " " + ], + "S": [ + "▐┕", + "▝━", + "▶▟", + " " + ], + "T": [ + "▔▛", + " ▌", + " ▌", + " " + ], + "U": [ + "┃▕", + "┃▕", + "┕▟", + " " + ], + "V": [ + "├▕", + "▐▐", + "▕▉", + " " + ], + "W": [ + "▌▌", + "┝▚", + "▜▐", + " " + ], + "X": [ + "┕▐", + "▕▌", + "┍▐", + " " + ], + "Y": [ + "├▕", + "▕▉", + " ▌", + " " + ], + "Z": [ + "▔▜", + " ┙", + "▟▁", + " " + ], + "[": [ + "▕▎", + "▕▎", + "▕▎", + "▕▀" + ], + "\\": [ + "▐ ", + " ▌", + " ┃", + " ▕" + ], + "]": [ + " ┃", + " ┃", + " ┃", + "▝▘" + ], + "^": [ + "▕▚", + "╹▝", + " ", + " " + ], + "_": [ + " ", + " ", + " ", + "▀▀" + ], + "`": [ + " ", + " ", + " ", + " " + ], + "a": [ + " ▁", + "▁▟", + "┃▟", + " " + ], + "b": [ + "┃▁", + "▐▕", + "▐▟", + " " + ], + "c": [ + " ▁", + "┃▝", + "┕┍", + " " + ], + "d": [ + "▁▕", + "┃▕", + "▜▟", + " " + ], + "e": [ + " ▁", + "┃▟", + "┕▃", + " " + ], + "f": [ + " ▛", + "▀▛", + " ▎", + " " + ], + "g": [ + "▁▁", + "┃▕", + "┕▶", + " ━" + ], + "h": [ + "┃▁", + "▐▕", + "┃▕", + " " + ], + "i": [ + "▁▌", + " ┃", + "▁▙", + " " + ], + "j": [ + "▁▁", + " ┃", + " ┃", + "╺┙" + ], + "k": [ + "┃ ", + "▐▞", + "┃┕", + " " + ], + "l": [ + "▔▎", + " ▎", + " ▙", + " " + ], + "m": [ + "▁▁", + "┃█", + "┃█", + " " + ], + "n": [ + "╷▁", + "▐▕", + "┃▕", + " " + ], + "o": [ + " ▁", + "┃▕", + "┕▟", + " " + ], + "p": [ + "╷▁", + "▐▕", + "▐▟", + "╹ " + ], + "q": [ + "▁▁", + "┃▕", + "▜▟", + " ▕" + ], + "r": [ + "╷▁", + "▐▕", + "┃ ", + " " + ], + "s": [ + "▁▁", + "▐▁", + "▗▟", + " " + ], + "t": [ + "▕▙", + "▕▎", + "▕▙", + " " + ], + "u": [ + "╻ ", + "┃▕", + "┕▟", + " " + ], + "v": [ + "╻ ", + "▐▐", + "▕▉", + " " + ], + "w": [ + " ▁", + "┃▌", + "▐▐", + " " + ], + "x": [ + "╻ ", + "▕▞", + "┍▜", + " " + ], + "y": [ + "╻ ", + "┕▐", + "▕▉", + "╺▎" + ], + "z": [ + "▁▁", + " ▞", + "▟▁", + " " + ], + "{": [ + " ┃", + "━▌", + " ┃", + " ▝" + ], + "|": [ + " ▌", + " ▌", + " ▌", + " ▘" + ], + "}": [ + " ▎", + "▕━", + " ▎", + "▝ " + ], + "~": [ + " ", + "┍▅", + " ", + " " + ] + } +}; diff --git a/src/utils/unicode-heading.ts b/src/utils/unicode-heading.ts new file mode 100644 index 0000000..9bddf24 --- /dev/null +++ b/src/utils/unicode-heading.ts @@ -0,0 +1,73 @@ +/** + * unicode-heading.ts + * Transforms plain ASCII text into Mathematical Unicode font variants, + * following the same stateless-transform pattern as TeXicode. + * + * Each heading level maps to a distinct Unicode Mathematical Alphanumeric + * Symbols block (U+1D400–U+1D7FF), giving a visually heavier/lighter weight + * without any layout overhead — the string is still a single line. + * + * Coverage: A-Z, a-z, 0-9. Everything else passes through unchanged. + * + * H1 — Mathematical Sans-Serif Bold (𝗛𝗲𝗹𝗹𝗼) + * H2 — Mathematical Bold Serif (𝐇𝐞𝐥𝐥𝐨) + * H3 — Mathematical Bold Italic (𝑯𝒆𝒍𝒍𝒐) + * H4 — Mathematical Sans-Serif Bold Italic (𝙃𝙚𝙡𝙡𝙤) + * H5 — Mathematical Italic (𝐻𝑒𝑙𝑙𝑜) + * H6 — Mathematical Monospace (𝙷𝚎𝚕𝚕𝚘) + */ + +type HeadingStyle = { + upper: number; + lower: number; + digit: number | null; + /** Exceptions: some letters aren't in the block, use these overrides */ + exceptions?: Record; +}; + +const STYLES: Record = { + // H1: Mathematical Sans-Serif Bold (U+1D5D4) + 1: { upper: 0x1D5D4, lower: 0x1D5EE, digit: 0x1D7EC }, + // H2: Mathematical Bold Serif (U+1D400) + 2: { upper: 0x1D400, lower: 0x1D41A, digit: 0x1D7CE }, + // H3: Mathematical Bold Italic (U+1D468) + 3: { upper: 0x1D468, lower: 0x1D482, digit: null }, + // H4: Mathematical Sans-Serif Bold Italic (U+1D63C) + 4: { upper: 0x1D63C, lower: 0x1D656, digit: null }, + // H5: Mathematical Italic (U+1D434) — h/italic exceptions apply + 5: { + upper: 0x1D434, + lower: 0x1D44E, + digit: null, + // U+210E (Planck constant) is the canonical italic h + exceptions: { h: "\u210E" }, + }, + // H6: Mathematical Monospace (U+1D670) + 6: { upper: 0x1D670, lower: 0x1D68A, digit: 0x1D7F6 }, +}; + +/** + * Transform `text` into the Unicode Mathematical font for `level` (1–6). + * Non-ASCII and non-alphanumeric characters pass through as-is. + */ +export function toUnicodeHeading(text: string, level: number): string { + const style = STYLES[Math.min(Math.max(level, 1), 6)]; + let out = ""; + for (const char of text) { + if (style.exceptions && char in style.exceptions) { + out += style.exceptions[char]; + continue; + } + const c = char.codePointAt(0)!; + if (c >= 65 && c <= 90) { + out += String.fromCodePoint(style.upper + c - 65); + } else if (c >= 97 && c <= 122) { + out += String.fromCodePoint(style.lower + c - 97); + } else if (c >= 48 && c <= 57 && style.digit !== null) { + out += String.fromCodePoint(style.digit + c - 48); + } else { + out += char; + } + } + return out; +}