init project
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user