331 lines
8.8 KiB
HTML
331 lines
8.8 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>TTF 字体预览</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
font-family: "Microsoft YaHei UI", sans-serif;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
padding: 16px;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
color: #222;
|
|
background: #f7f7f7;
|
|
}
|
|
|
|
h1 {
|
|
margin: 0 0 12px;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.tip {
|
|
margin: 0 0 16px;
|
|
color: #666;
|
|
}
|
|
|
|
#status {
|
|
margin-bottom: 12px;
|
|
color: #666;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px 12px;
|
|
background: #fff;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.icon {
|
|
width: 32px;
|
|
height: 32px;
|
|
flex: 0 0 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
text-align: center;
|
|
justify-content: center;
|
|
font-family: "TimiIconPreview", sans-serif;
|
|
font-size: 32px;
|
|
line-height: 1;
|
|
color: #111;
|
|
}
|
|
|
|
.meta {
|
|
min-width: 0;
|
|
}
|
|
|
|
.code {
|
|
font-family: Consolas, monospace;
|
|
font-size: 13px;
|
|
color: #111;
|
|
}
|
|
|
|
.decimal {
|
|
font-size: 12px;
|
|
color: #666;
|
|
}
|
|
|
|
.error {
|
|
color: #c62828;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>TTF 字体预览</h1>
|
|
<p class="tip">读取同目录的 <code>timi-icon.ttf</code>,解析字体映射并展示可用字形</p>
|
|
<div id="status">正在加载...</div>
|
|
<div id="grid" class="grid"></div>
|
|
|
|
<script>
|
|
const TTF_FILE = "./timi-icon.ttf";
|
|
const statusElement = document.getElementById("status");
|
|
const gridElement = document.getElementById("grid");
|
|
|
|
function createReader(buffer) {
|
|
const view = new DataView(buffer);
|
|
return {
|
|
u16(offset) {
|
|
return view.getUint16(offset, false);
|
|
},
|
|
i16(offset) {
|
|
return view.getInt16(offset, false);
|
|
},
|
|
u32(offset) {
|
|
return view.getUint32(offset, false);
|
|
},
|
|
tag(offset) {
|
|
return String.fromCharCode(
|
|
view.getUint8(offset),
|
|
view.getUint8(offset + 1),
|
|
view.getUint8(offset + 2),
|
|
view.getUint8(offset + 3)
|
|
);
|
|
},
|
|
text(offset, length) {
|
|
let text = "";
|
|
for (let index = 0; index < length; index++) {
|
|
text += String.fromCharCode(view.getUint8(offset + index));
|
|
}
|
|
return text;
|
|
}
|
|
};
|
|
}
|
|
|
|
function readTables(reader) {
|
|
const numTables = reader.u16(4);
|
|
const tables = new Map();
|
|
for (let index = 0; index < numTables; index++) {
|
|
const offset = 12 + index * 16;
|
|
tables.set(reader.tag(offset), {
|
|
offset: reader.u32(offset + 8),
|
|
length: reader.u32(offset + 12)
|
|
});
|
|
}
|
|
return tables;
|
|
}
|
|
|
|
function readName(reader, table) {
|
|
if (!table) {
|
|
return "";
|
|
}
|
|
const tableOffset = table.offset;
|
|
const count = reader.u16(tableOffset + 2);
|
|
const storageOffset = reader.u16(tableOffset + 4);
|
|
for (let index = 0; index < count; index++) {
|
|
const recordOffset = tableOffset + 6 + index * 12;
|
|
const platformId = reader.u16(recordOffset);
|
|
const nameId = reader.u16(recordOffset + 6);
|
|
const length = reader.u16(recordOffset + 8);
|
|
const stringOffset = reader.u16(recordOffset + 10);
|
|
if (nameId !== 1) {
|
|
continue;
|
|
}
|
|
const valueOffset = tableOffset + storageOffset + stringOffset;
|
|
if (platformId === 3) {
|
|
let text = "";
|
|
for (let cursor = 0; cursor < length; cursor += 2) {
|
|
const code = reader.u16(valueOffset + cursor);
|
|
if (code !== 0) {
|
|
text += String.fromCharCode(code);
|
|
}
|
|
}
|
|
if (text) {
|
|
return text;
|
|
}
|
|
}
|
|
if (platformId === 1) {
|
|
const text = reader.text(valueOffset, length).trim();
|
|
if (text) {
|
|
return text;
|
|
}
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function parseFormat4(reader, subtableOffset) {
|
|
const segmentCount = reader.u16(subtableOffset + 6) / 2;
|
|
const endCodesOffset = subtableOffset + 14;
|
|
const startCodesOffset = endCodesOffset + segmentCount * 2 + 2;
|
|
const idDeltasOffset = startCodesOffset + segmentCount * 2;
|
|
const idRangeOffsetsOffset = idDeltasOffset + segmentCount * 2;
|
|
const codePoints = new Set();
|
|
|
|
for (let index = 0; index < segmentCount; index++) {
|
|
const endCode = reader.u16(endCodesOffset + index * 2);
|
|
const startCode = reader.u16(startCodesOffset + index * 2);
|
|
const delta = reader.i16(idDeltasOffset + index * 2);
|
|
const rangeOffset = reader.u16(idRangeOffsetsOffset + index * 2);
|
|
|
|
if (endCode === 0xFFFF && startCode === 0xFFFF) {
|
|
continue;
|
|
}
|
|
for (let codePoint = startCode; codePoint <= endCode; codePoint++) {
|
|
let glyphIndex;
|
|
if (rangeOffset === 0) {
|
|
glyphIndex = (codePoint + delta) & 0xFFFF;
|
|
} else {
|
|
const glyphOffset = idRangeOffsetsOffset + index * 2 + rangeOffset + (codePoint - startCode) * 2;
|
|
glyphIndex = reader.u16(glyphOffset);
|
|
if (glyphIndex !== 0) {
|
|
glyphIndex = (glyphIndex + delta) & 0xFFFF;
|
|
}
|
|
}
|
|
if (glyphIndex !== 0) {
|
|
codePoints.add(codePoint);
|
|
}
|
|
}
|
|
}
|
|
return codePoints;
|
|
}
|
|
|
|
function parseFormat12(reader, subtableOffset) {
|
|
const groups = reader.u32(subtableOffset + 12);
|
|
const codePoints = new Set();
|
|
for (let index = 0; index < groups; index++) {
|
|
const groupOffset = subtableOffset + 16 + index * 12;
|
|
const startCharCode = reader.u32(groupOffset);
|
|
const endCharCode = reader.u32(groupOffset + 4);
|
|
for (let codePoint = startCharCode; codePoint <= endCharCode; codePoint++) {
|
|
codePoints.add(codePoint);
|
|
}
|
|
}
|
|
return codePoints;
|
|
}
|
|
|
|
function parseCmap(reader, table) {
|
|
if (!table) {
|
|
throw new Error("字体缺少 cmap 表");
|
|
}
|
|
const tableOffset = table.offset;
|
|
const numTables = reader.u16(tableOffset + 2);
|
|
let bestUnicodeOffset = 0;
|
|
let fallbackOffset = 0;
|
|
|
|
for (let index = 0; index < numTables; index++) {
|
|
const recordOffset = tableOffset + 4 + index * 8;
|
|
const platformId = reader.u16(recordOffset);
|
|
const encodingId = reader.u16(recordOffset + 2);
|
|
const subtableOffset = tableOffset + reader.u32(recordOffset + 4);
|
|
const format = reader.u16(subtableOffset);
|
|
if (platformId === 3 && encodingId === 10 && format === 12) {
|
|
bestUnicodeOffset = subtableOffset;
|
|
break;
|
|
}
|
|
if ((platformId === 3 || platformId === 0) && (format === 4 || format === 12) && fallbackOffset === 0) {
|
|
fallbackOffset = subtableOffset;
|
|
}
|
|
}
|
|
|
|
const selectedOffset = bestUnicodeOffset || fallbackOffset;
|
|
if (selectedOffset === 0) {
|
|
throw new Error("未找到可解析的 cmap 子表");
|
|
}
|
|
|
|
const format = reader.u16(selectedOffset);
|
|
const codePoints = format === 12
|
|
? parseFormat12(reader, selectedOffset)
|
|
: parseFormat4(reader, selectedOffset);
|
|
|
|
return Array.from(codePoints).sort((left, right) => left - right);
|
|
}
|
|
|
|
function createItem(codePoint) {
|
|
const item = document.createElement("div");
|
|
item.className = "item";
|
|
|
|
const icon = document.createElement("div");
|
|
icon.className = "icon";
|
|
icon.textContent = String.fromCodePoint(codePoint);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "meta";
|
|
|
|
const code = document.createElement("div");
|
|
code.className = "code";
|
|
code.textContent = "U+%s".replace("%s", codePoint.toString(16).toUpperCase().padStart(4, "0"));
|
|
|
|
const decimal = document.createElement("div");
|
|
decimal.className = "decimal";
|
|
decimal.textContent = "十进制 %d".replace("%d", codePoint);
|
|
|
|
meta.appendChild(code);
|
|
meta.appendChild(decimal);
|
|
item.appendChild(icon);
|
|
item.appendChild(meta);
|
|
return item;
|
|
}
|
|
|
|
async function loadFontFace() {
|
|
const font = new FontFace("TimiIconPreview", "url(%s)".replace("%s", TTF_FILE));
|
|
await font.load();
|
|
document.fonts.add(font);
|
|
return font;
|
|
}
|
|
|
|
async function loadFont() {
|
|
try {
|
|
const response = await fetch(TTF_FILE);
|
|
if (!response.ok) {
|
|
throw new Error("读取 timi-icon.ttf 失败");
|
|
}
|
|
const buffer = await response.arrayBuffer();
|
|
const reader = createReader(buffer);
|
|
const tables = readTables(reader);
|
|
const family = readName(reader, tables.get("name")) || "未命名字体";
|
|
const codePoints = parseCmap(reader, tables.get("cmap"));
|
|
await loadFontFace();
|
|
statusElement.textContent = "字体 %s,共 %d 个字形".replace("%s", family).replace("%d", codePoints.length);
|
|
for (const codePoint of codePoints) {
|
|
gridElement.appendChild(createItem(codePoint));
|
|
}
|
|
if (codePoints.length < 1) {
|
|
statusElement.className = "error";
|
|
statusElement.textContent = "字体已加载,但没有可展示的字符映射";
|
|
}
|
|
} catch (error) {
|
|
statusElement.className = "error";
|
|
statusElement.textContent = "加载失败,请先运行 BuildTTF.java,并通过本地静态服务打开 target/ttf-test/index.html";
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
loadFont();
|
|
</script>
|
|
</body>
|
|
</html>
|