From ecadaaaaf1ccf4e566a05ad166f61a6991fd62a9 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 18 Jul 2026 15:33:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=97=E4=BD=93=E5=8A=A0=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- package-lock.json | 127 ++++++++++++- package.json | 5 +- scripts/build-pwa.mjs | 2 +- scripts/encrypt-font.mjs | 381 +++++++++++++++++++++++++++++++++++++ src/styles/anniversary.css | 1 + src/styles/global.css | 4 +- 7 files changed, 517 insertions(+), 8 deletions(-) create mode 100644 scripts/encrypt-font.mjs diff --git a/README.md b/README.md index 468f414..86715b1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - 留言墙:接入 Twikoo 后可在页面中留言。 - 全站搜索:通过 Typesense 搜索并筛选时间线、照片、文章和同学公开资料。 - PWA:支持添加到主屏幕,并离线访问站点外壳及已浏览的同源内容。 +- 字体加密:生产构建会随机混淆页面中的汉字,并生成按需裁剪的 WOFF2 字体,降低静态内容被直接复制或抓取的风险。 ## 技术栈 @@ -38,7 +39,9 @@ npm run preview # 预览构建结果 npm run search:sync # 将公开内容同步到 Typesense ``` -`npm run build` 会在 Astro 构建完成后生成带内容版本号的 Service Worker,并预缓存页面、样式、脚本、应用图标和首页主视觉。照片等大体积资源采用访问后缓存,第三方接口始终走网络。 +`npm run build` 会在 Astro 构建完成后加密页面字体,再生成带内容版本号的 Service Worker,并预缓存页面、样式、脚本、加密字体、应用图标和首页主视觉。照片等大体积资源采用访问后缓存,第三方接口始终走网络。 + +字体加密只作用于 `dist` 中的生产页面,不修改源码,也不会影响本地开发。每次构建默认生成新的随机映射;如需可复现产物,可设置 `FONT_ENCRYPTION_SEED`。`code`、`pre`、表单属性以及脚本动态生成的内容会保留原文,以免影响复制和交互。字体加密只能提高批量采集成本,不能替代访问控制或版权保护。 ## Typesense 全站搜索 diff --git a/package-lock.json b/package-lock.json index f251187..fd9fb55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,12 @@ "typescript": "^6.0.3" }, "devDependencies": { + "@openfonts/noto-sans-sc_chinese-simplified": "^1.44.9", "@types/d3-geo": "^3.1.0", "@types/node": "^26.1.1", "@types/topojson-client": "^3.1.5", + "parse5": "^8.0.0", + "subset-font": "^2.5.0", "tsx": "^4.23.1" } }, @@ -1657,6 +1660,13 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@openfonts/noto-sans-sc_chinese-simplified": { + "version": "1.44.9", + "resolved": "https://registry.npmjs.org/@openfonts/noto-sans-sc_chinese-simplified/-/noto-sans-sc_chinese-simplified-1.44.9.tgz", + "integrity": "sha512-yNmtcyVuDWU/5KQnRdD+u45QsGXZlNvpwV9+/cIR8z90iHroDscQuXZTtsNy0CfhPq2pNncJVgzhNq62XLGikQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@oslojs/encoding": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", @@ -3109,6 +3119,17 @@ "node": ">=20" } }, + "node_modules/fontverter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fontverter/-/fontverter-2.0.0.tgz", + "integrity": "sha512-DFVX5hvXuhi1Jven1tbpebYTCT9XYnvx6/Z+HFUPb7ZRMCW+pj2clU9VMhoTPgWKPhAs7JJDSk3CW1jNUvKCZQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "wawoff2": "^2.0.0", + "woff2sfnt-sfnt2woff": "^1.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3170,6 +3191,13 @@ "uncrypto": "^0.1.3" } }, + "node_modules/harfbuzzjs": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-0.10.3.tgz", + "integrity": "sha512-GJnLUrgLMadlMYrBGEXwYEimObbysy3prWT4HyPpFQERvgTU/OZ+ReUlEPOum6w4RBtFXzXiCCmECOr4sz3qwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -3188,6 +3216,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-html/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -3654,6 +3694,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -3983,10 +4030,18 @@ "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==", "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -4410,6 +4465,48 @@ "node": ">=8" } }, + "node_modules/subset-font": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/subset-font/-/subset-font-2.5.0.tgz", + "integrity": "sha512-Vsa8ngQ/ohhUj0an7on49y9jLZ2rK5U+T1FzPM4/ZQY0xUy5mLis6BfFtPGzecTjFgYXQlvY7FlsJF4t3R/6Ug==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "fontverter": "^2.0.0", + "harfbuzzjs": "^0.10.3", + "lodash": "^4.17.21", + "p-limit": "^3.1.0" + } + }, + "node_modules/subset-font/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/subset-font/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/svgo": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz", @@ -5216,6 +5313,20 @@ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, + "node_modules/wawoff2": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wawoff2/-/wawoff2-2.0.1.tgz", + "integrity": "sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "woff2_compress.js": "bin/woff2_compress.js", + "woff2_decompress.js": "bin/woff2_decompress.js" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -5226,6 +5337,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/woff2sfnt-sfnt2woff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/woff2sfnt-sfnt2woff/-/woff2sfnt-sfnt2woff-1.0.0.tgz", + "integrity": "sha512-edK4COc1c1EpRfMqCZO1xJOvdUtM5dbVb9iz97rScvnTevqEB3GllnLWCmMVp1MfQBdF1DFg/11I0rSyAdS4qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "^1.0.7" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index b49f5bf..c32646a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "astro dev", - "build": "astro build && node scripts/build-pwa.mjs", + "build": "astro build && node scripts/encrypt-font.mjs && node scripts/build-pwa.mjs", "preview": "astro preview", "search:key": "node --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/create-typesense-search-key.mjs", "search:sync": "node --env-file-if-exists=.env --env-file-if-exists=.env.local --import tsx scripts/sync-typesense.ts" @@ -19,9 +19,12 @@ "typescript": "^6.0.3" }, "devDependencies": { + "@openfonts/noto-sans-sc_chinese-simplified": "^1.44.9", "@types/d3-geo": "^3.1.0", "@types/node": "^26.1.1", "@types/topojson-client": "^3.1.5", + "parse5": "^8.0.0", + "subset-font": "^2.5.0", "tsx": "^4.23.1" } } diff --git a/scripts/build-pwa.mjs b/scripts/build-pwa.mjs index 8bf610e..1aebc88 100644 --- a/scripts/build-pwa.mjs +++ b/scripts/build-pwa.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; const projectRoot = fileURLToPath(new URL("../", import.meta.url)); const distRoot = path.join(projectRoot, "dist"); const serviceWorkerPath = path.join(distRoot, "sw.js"); -const precacheExtensions = new Set([".html", ".css", ".js", ".webmanifest"]); +const precacheExtensions = new Set([".html", ".css", ".js", ".webmanifest", ".woff2"]); const precacheAssets = new Set([ "assets/campus-hero.png", "icons/apple-touch-icon.png", diff --git a/scripts/encrypt-font.mjs b/scripts/encrypt-font.mjs new file mode 100644 index 0000000..7812e02 --- /dev/null +++ b/scripts/encrypt-font.mjs @@ -0,0 +1,381 @@ +import { createHash, randomBytes } from "node:crypto"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse, serialize } from "parse5"; +import subsetFont from "subset-font"; + +const projectRoot = fileURLToPath(new URL("../", import.meta.url)); +const distRoot = path.join(projectRoot, "dist"); +const fontSourcePath = path.join( + projectRoot, + "node_modules", + "@openfonts", + "noto-sans-sc_chinese-simplified", + "files", + "noto-sans-sc-chinese-simplified-400.woff2" +); +const fontOutputRoot = path.join(distRoot, "assets", "fonts"); +const cipherFamily = "ClassMemoryCipher"; +const puaStart = 0xe000; +const puaEnd = 0xf8ff; +const skippedElements = new Set([ + "code", + "head", + "noscript", + "pre", + "script", + "style", + "template", + "textarea" +]); +const isHan = (character) => /\p{Script=Han}/u.test(character); + +const listHtmlFiles = async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map((entry) => { + const entryPath = path.join(directory, entry.name); + return entry.isDirectory() + ? listHtmlFiles(entryPath) + : entry.isFile() && entry.name.endsWith(".html") + ? [entryPath] + : []; + }) + ); + + return files.flat(); +}; + +const visitTextNodes = (node, visitor, isSkipped = false) => { + const skipChildren = isSkipped || (node.tagName && skippedElements.has(node.tagName)); + + if (node.nodeName === "#text" && !skipChildren) { + visitor(node); + return; + } + + for (const child of node.childNodes ?? []) { + visitTextNodes(child, visitor, skipChildren); + } +}; + +const seededRandom = (seed) => { + let counter = 0; + return () => { + const digest = createHash("sha256") + .update(seed) + .update(String(counter++)) + .digest(); + return digest.readUInt32BE(0) / 0x1_0000_0000; + }; +}; + +const createCipherMap = (characters) => { + if (characters.length > puaEnd - puaStart + 1) { + throw new Error(`待加密字符 ${characters.length} 个,超过 BMP 私用区容量。`); + } + + const puaCodes = Array.from( + { length: puaEnd - puaStart + 1 }, + (_, index) => puaStart + index + ); + const buildSeed = process.env.FONT_ENCRYPTION_SEED || randomBytes(32).toString("hex"); + const random = seededRandom(buildSeed); + + for (let index = puaCodes.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)); + [puaCodes[index], puaCodes[swapIndex]] = [puaCodes[swapIndex], puaCodes[index]]; + } + + return new Map( + characters.map((character, index) => [character, String.fromCodePoint(puaCodes[index])]) + ); +}; + +const readSfntTables = (fontBuffer) => { + const tableCount = fontBuffer.readUInt16BE(4); + const tables = []; + + for (let index = 0; index < tableCount; index += 1) { + const recordOffset = 12 + index * 16; + const tag = fontBuffer.toString("ascii", recordOffset, recordOffset + 4); + const offset = fontBuffer.readUInt32BE(recordOffset + 8); + const length = fontBuffer.readUInt32BE(recordOffset + 12); + tables.push({ tag, data: Buffer.from(fontBuffer.subarray(offset, offset + length)) }); + } + + return tables; +}; + +const readCmapGlyphMap = (fontBuffer, characters) => { + const cmapTable = readSfntTables(fontBuffer).find((table) => table.tag === "cmap"); + if (!cmapTable) throw new Error("字体缺少 cmap 字符映射表。 "); + + const cmap = cmapTable.data; + const recordCount = cmap.readUInt16BE(2); + const subtableOffsets = new Set(); + + for (let index = 0; index < recordCount; index += 1) { + subtableOffsets.add(cmap.readUInt32BE(4 + index * 8 + 4)); + } + + const subtables = [...subtableOffsets] + .map((offset) => ({ offset, format: cmap.readUInt16BE(offset) })) + .filter(({ format }) => format === 4 || format === 12) + .sort((left, right) => right.format - left.format); + + const readFormat4Glyph = (offset, codePoint) => { + if (codePoint > 0xffff) return 0; + const segmentCount = cmap.readUInt16BE(offset + 6) / 2; + const endCodesOffset = offset + 14; + const startCodesOffset = endCodesOffset + segmentCount * 2 + 2; + const deltasOffset = startCodesOffset + segmentCount * 2; + const rangeOffsetsOffset = deltasOffset + segmentCount * 2; + + for (let index = 0; index < segmentCount; index += 1) { + const start = cmap.readUInt16BE(startCodesOffset + index * 2); + const end = cmap.readUInt16BE(endCodesOffset + index * 2); + if (codePoint < start || codePoint > end) continue; + + const delta = cmap.readInt16BE(deltasOffset + index * 2); + const rangeOffsetPosition = rangeOffsetsOffset + index * 2; + const rangeOffset = cmap.readUInt16BE(rangeOffsetPosition); + if (rangeOffset === 0) return (codePoint + delta) & 0xffff; + + const glyphPosition = rangeOffsetPosition + rangeOffset + (codePoint - start) * 2; + if (glyphPosition + 2 > cmap.length) return 0; + const glyph = cmap.readUInt16BE(glyphPosition); + return glyph === 0 ? 0 : (glyph + delta) & 0xffff; + } + + return 0; + }; + + const readFormat12Glyph = (offset, codePoint) => { + const groupCount = cmap.readUInt32BE(offset + 12); + let low = 0; + let high = groupCount - 1; + + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const groupOffset = offset + 16 + middle * 12; + const start = cmap.readUInt32BE(groupOffset); + const end = cmap.readUInt32BE(groupOffset + 4); + if (codePoint < start) high = middle - 1; + else if (codePoint > end) low = middle + 1; + else return cmap.readUInt32BE(groupOffset + 8) + codePoint - start; + } + + return 0; + }; + + const glyphMap = new Map(); + for (const character of characters) { + const codePoint = character.codePointAt(0); + for (const subtable of subtables) { + const glyph = + subtable.format === 12 + ? readFormat12Glyph(subtable.offset, codePoint) + : readFormat4Glyph(subtable.offset, codePoint); + if (glyph > 0) { + glyphMap.set(character, glyph); + break; + } + } + } + + return glyphMap; +}; + +const buildCipherCmap = (cipherMap, glyphMap) => { + const entries = [...cipherMap.entries()] + .map(([original, encrypted]) => ({ + codePoint: encrypted.codePointAt(0), + glyph: glyphMap.get(original) + })) + .sort((left, right) => left.codePoint - right.codePoint); + entries.push({ codePoint: 0xffff, glyph: 0 }); + + const segmentCount = entries.length; + const subtableLength = 16 + segmentCount * 8; + const cmap = Buffer.alloc(20 + subtableLength); + cmap.writeUInt16BE(0, 0); + cmap.writeUInt16BE(2, 2); + cmap.writeUInt16BE(0, 4); + cmap.writeUInt16BE(3, 6); + cmap.writeUInt32BE(20, 8); + cmap.writeUInt16BE(3, 12); + cmap.writeUInt16BE(1, 14); + cmap.writeUInt32BE(20, 16); + + const subtableOffset = 20; + const entrySelector = Math.floor(Math.log2(segmentCount)); + const searchRange = 2 * 2 ** entrySelector; + cmap.writeUInt16BE(4, subtableOffset); + cmap.writeUInt16BE(subtableLength, subtableOffset + 2); + cmap.writeUInt16BE(0, subtableOffset + 4); + cmap.writeUInt16BE(segmentCount * 2, subtableOffset + 6); + cmap.writeUInt16BE(searchRange, subtableOffset + 8); + cmap.writeUInt16BE(entrySelector, subtableOffset + 10); + cmap.writeUInt16BE(segmentCount * 2 - searchRange, subtableOffset + 12); + + const endCodesOffset = subtableOffset + 14; + const startCodesOffset = endCodesOffset + segmentCount * 2 + 2; + const deltasOffset = startCodesOffset + segmentCount * 2; + const rangeOffsetsOffset = deltasOffset + segmentCount * 2; + entries.forEach(({ codePoint, glyph }, index) => { + cmap.writeUInt16BE(codePoint, endCodesOffset + index * 2); + cmap.writeUInt16BE(codePoint, startCodesOffset + index * 2); + cmap.writeUInt16BE((glyph - codePoint) & 0xffff, deltasOffset + index * 2); + cmap.writeUInt16BE(0, rangeOffsetsOffset + index * 2); + }); + + return cmap; +}; + +const calculateChecksum = (buffer) => { + const paddedLength = Math.ceil(buffer.length / 4) * 4; + const padded = Buffer.alloc(paddedLength); + buffer.copy(padded); + let checksum = 0; + for (let offset = 0; offset < padded.length; offset += 4) { + checksum = (checksum + padded.readUInt32BE(offset)) >>> 0; + } + return checksum; +}; + +const replaceSfntTable = (fontBuffer, tag, replacement) => { + const tables = readSfntTables(fontBuffer) + .filter((table) => table.tag !== "DSIG") + .map((table) => ({ ...table, data: table.tag === tag ? replacement : table.data })); + const head = tables.find((table) => table.tag === "head"); + if (!head) throw new Error("字体缺少 head 表。 "); + head.data.writeUInt32BE(0, 8); + + const tableCount = tables.length; + const entrySelector = Math.floor(Math.log2(tableCount)); + const searchRange = 16 * 2 ** entrySelector; + const headerLength = 12 + tableCount * 16; + let fontLength = headerLength; + for (const table of tables) fontLength += Math.ceil(table.data.length / 4) * 4; + + const output = Buffer.alloc(fontLength); + fontBuffer.copy(output, 0, 0, 4); + output.writeUInt16BE(tableCount, 4); + output.writeUInt16BE(searchRange, 6); + output.writeUInt16BE(entrySelector, 8); + output.writeUInt16BE(tableCount * 16 - searchRange, 10); + + let dataOffset = headerLength; + let headOffset = 0; + tables.forEach((table, index) => { + const recordOffset = 12 + index * 16; + output.write(table.tag, recordOffset, 4, "ascii"); + output.writeUInt32BE(calculateChecksum(table.data), recordOffset + 4); + output.writeUInt32BE(dataOffset, recordOffset + 8); + output.writeUInt32BE(table.data.length, recordOffset + 12); + table.data.copy(output, dataOffset); + if (table.tag === "head") headOffset = dataOffset; + dataOffset += Math.ceil(table.data.length / 4) * 4; + }); + + const adjustment = (0xb1b0afba - calculateChecksum(output)) >>> 0; + output.writeUInt32BE(adjustment, headOffset + 8); + return output; +}; + +const injectFontLoader = (html, fontUrl) => { + const loader = [ + ``, + `` + ].join(""); + + if (!html.includes("")) { + throw new Error("构建页面缺少 ,无法注入加密字体。 "); + } + + return html.replace("", `${loader}`); +}; + +const htmlFiles = await listHtmlFiles(distRoot); +if (htmlFiles.length === 0) { + throw new Error("dist 中没有 HTML 文件,请先执行 Astro 构建。 "); +} + +const pages = await Promise.all( + htmlFiles.map(async (filePath) => { + const source = await readFile(filePath, "utf8"); + return { filePath, document: parse(source) }; + }) +); + +const requestedCharacters = new Set(); +for (const page of pages) { + visitTextNodes(page.document, (node) => { + for (const character of node.value) { + if (isHan(character)) requestedCharacters.add(character); + } + }); +} + +const sourceFontBuffer = await readFile(fontSourcePath); +const subsetFontBuffer = await subsetFont(sourceFontBuffer, [...requestedCharacters].join(""), { + targetFormat: "sfnt" +}); +const glyphMap = readCmapGlyphMap(subsetFontBuffer, requestedCharacters); +const availableCharacters = new Set(glyphMap.keys()); +const cipherMap = createCipherMap([...availableCharacters].sort()); +const encryptedSfnt = replaceSfntTable( + subsetFontBuffer, + "cmap", + buildCipherCmap(cipherMap, glyphMap) +); +const encryptedText = [...cipherMap.values()].join(""); +const encryptedFont = await subsetFont(encryptedSfnt, encryptedText, { + targetFormat: "woff2" +}); +const verificationSfnt = await subsetFont(encryptedFont, encryptedText, { + targetFormat: "sfnt" +}); +const verifiedGlyphs = readCmapGlyphMap(verificationSfnt, cipherMap.values()); +if (verifiedGlyphs.size !== cipherMap.size) { + throw new Error( + `加密字体校验失败:缺少 ${cipherMap.size - verifiedGlyphs.size} 个映射字形。` + ); +} + +const fontHash = createHash("sha256").update(encryptedFont).digest("hex").slice(0, 12); +const fontFileName = `class-memory-cipher.${fontHash}.woff2`; +const fontUrl = `/assets/fonts/${fontFileName}`; + +await mkdir(fontOutputRoot, { recursive: true }); +await writeFile(path.join(fontOutputRoot, fontFileName), encryptedFont); + +let replacementCount = 0; +for (const page of pages) { + visitTextNodes(page.document, (node) => { + node.value = [...node.value] + .map((character) => { + const encrypted = cipherMap.get(character); + if (encrypted) replacementCount += 1; + return encrypted ?? character; + }) + .join(""); + }); + + const encryptedHtml = injectFontLoader(serialize(page.document), fontUrl); + await writeFile(page.filePath, encryptedHtml, "utf8"); +} + +const missingCharacters = [...requestedCharacters].filter( + (character) => !availableCharacters.has(character) +); +console.log( + `[font-encryption] 已处理 ${pages.length} 个页面、${availableCharacters.size} 个汉字、${replacementCount} 处文本,字体 ${Math.ceil(encryptedFont.length / 1024)} KiB。` +); +if (missingCharacters.length > 0) { + console.warn( + `[font-encryption] 源字体缺少 ${missingCharacters.length} 个汉字(${missingCharacters.join("、")}),已保留原文。` + ); +} diff --git a/src/styles/anniversary.css b/src/styles/anniversary.css index d49a4cc..0314cbc 100644 --- a/src/styles/anniversary.css +++ b/src/styles/anniversary.css @@ -2,6 +2,7 @@ color: #24170f; background: #f7efe3; font-family: + "ClassMemoryCipher", "Inter", "PingFang SC", "Microsoft YaHei", "Noto Sans SC", system-ui, sans-serif; } diff --git a/src/styles/global.css b/src/styles/global.css index d8ac35d..c380356 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -24,7 +24,7 @@ body { margin: 0; color: var(--ink); background: var(--paper); - font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans SC", Arial, sans-serif; + font-family: "ClassMemoryCipher", "Microsoft YaHei", "PingFang SC", "Noto Sans SC", Arial, sans-serif; line-height: 1.65; } @@ -531,7 +531,7 @@ h1 { place-items: center; color: rgba(255, 253, 247, 0.9); background: linear-gradient(135deg, rgba(55, 109, 90, 0.94), rgba(69, 111, 148, 0.88)); - font-family: STKaiti, KaiTi, serif; + font-family: "ClassMemoryCipher", STKaiti, KaiTi, serif; font-size: 48px; }