@web-ppt/fonts 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/fonts.js +173 -0
- package/dist/index.d.ts +16 -0
- package/dist/load.d.ts +77 -0
- package/dist/sources.d.ts +42 -0
- package/dist/substitute.d.ts +31 -0
- package/package.json +56 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# 更新日志
|
|
2
|
+
|
|
3
|
+
本文件记录对使用者可见的变化。版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
|
+
|
|
5
|
+
## 0.4.0
|
|
6
|
+
|
|
7
|
+
本版围绕**字体**:嵌入字体此前从来没有真正生效过,替换字体则完全没有。
|
|
8
|
+
顺带修掉一个让静态画面把动画的几帧叠在一起的问题。
|
|
9
|
+
|
|
10
|
+
### 新增
|
|
11
|
+
|
|
12
|
+
- **嵌入字体真的能用了**。PowerPoint 的 `ppt/fonts/*.fntdata` 不是裸 TTF,是 EOT 容器,
|
|
13
|
+
而且实测**全部**开着 MTX 压缩(POI 语料 6/6、ORCID 样本 10/10)。此前把这段字节
|
|
14
|
+
原样当 `font/ttf` 塞进 `@font-face`,浏览器一个都不认,只在控制台留一行
|
|
15
|
+
`invalid sfntVersion`。现在 core 自己剥容器(含异或混淆),MTX 解压走
|
|
16
|
+
`setFontDecoder()` 注入——官网接的是 [`mtx-decompressor`](https://www.npmjs.com/package/mtx-decompressor) 的 `eotToTtf`,
|
|
17
|
+
core 本身仍然只依赖 fflate。解不出来就跳过这个字体,不再塞浏览器注定拒绝的字节
|
|
18
|
+
- **`collectFonts(slides)`**:统计若干页用到哪些字体、每个字体要渲染哪些字。
|
|
19
|
+
纯函数、只依赖 `types.ts`,`.ppt` 链路同样适用,Worker 里能跑
|
|
20
|
+
- **新包 [`@web-ppt/fonts`](packages/fonts)**(gzip 2.8KB,**包里零字节字体**):
|
|
21
|
+
文件没带字体、本机也没装时的兜底。拉丁换**度量兼容**的免费字体
|
|
22
|
+
(Calibri→Carlito、Arial→Arimo 等,前进宽度逐字相等,断行与 PowerPoint 对齐),
|
|
23
|
+
中文换思源系 / 霞鹜文楷。切片指向钉死版本的 fontsource,按 `unicode-range` 只下用到的那几片
|
|
24
|
+
- **`Viewer.refresh()`**:重渲当前页而不动页码与动画进度。网络字体到货后必须重渲——
|
|
25
|
+
排版是同步的、加载是异步的,首帧一定是按回退字体断的行
|
|
26
|
+
|
|
27
|
+
### 修复
|
|
28
|
+
|
|
29
|
+
- **静态渲染取动画终态,而不是「全部可见」**。一页里入场与退场的元素属于不同时刻,
|
|
30
|
+
全画出来等于把几帧叠在一起:ORCID 那份样本的第 7 页三段文字本该逐条替换,
|
|
31
|
+
叠起来一个字都读不出。终态会清空整页时(全员退场的收尾页)退回全部可见
|
|
32
|
+
- **run 没写 `a:latin` 时落到主题的 minorFont**。ECMA-376 的继承链最后一站就是
|
|
33
|
+
`fontScheme/minorFont`,此前直接当「没有字体」,渲染掉到 CSS 通用回退上,
|
|
34
|
+
字宽与 PowerPoint 对不齐。真实文件大多在母版 txStyles 里写了 `+mn-lt` 所以少见,
|
|
35
|
+
但这是「断行对不齐」的一个隐藏来源
|
|
36
|
+
- 字体栈去重:主题的 ea 字体常常正好是回退列表里的 `PingFang SC`,之前会拼出重复项
|
|
37
|
+
|
|
38
|
+
### 其它
|
|
39
|
+
|
|
40
|
+
- 测试:1689 → **1848 项断言**,快照 158 → 160 个;新增 `sample-embedfont.pptx`
|
|
41
|
+
覆盖嵌入字体的四种容器形态(未压缩 EOT / 未压缩+异或 / MTX 压缩 / 裸 TTF)
|
|
42
|
+
- `@web-ppt/core` gzip 84KB 不变;`@web-ppt/viewer-core` 6.8KB → 7.4KB
|
|
43
|
+
- 两个既有包的 peer 依赖同步升到 `^0.4.0`
|
|
44
|
+
|
|
45
|
+
## 0.3.0
|
|
46
|
+
|
|
47
|
+
本版把 ECMA-376 的几处主要缺口补齐,并首次拿 **Apache POI 的 242 个公开测试文件**跑批验证:
|
|
48
|
+
排除 22 个故意损坏的模糊测试样本后,真实文件成功率 **210/220 = 95.5%**。
|
|
49
|
+
|
|
50
|
+
### 新增
|
|
51
|
+
|
|
52
|
+
- **预设几何补齐到规范全集**:163 → **187 个**。此前缺的 24 个走的是
|
|
53
|
+
`PRESETS[name] ?? PRESETS.rect`,会**静默画成矩形**,不报错也不进 `unsupported`——
|
|
54
|
+
`accentCallout*` 这类常见形状出现即无声画错
|
|
55
|
+
- **运动路径动画**:`p:animMotion@path` 此前从未被读取,`presetClass="path"` 一律退化成淡入。
|
|
56
|
+
现按弧长等距重采样成位移点,播放层走 linear 缓动(PowerPoint 的路径动画是匀速)
|
|
57
|
+
- **切换效果 21 → 41 种**:补上 PowerPoint 2010+ 的全部 19 种 p14 扩展,以及 p159 的
|
|
58
|
+
**morph**——按形状 id 在前后两页之间配对做几何补间,不是淡入淡出
|
|
59
|
+
- **数学公式 OMML 真排版**:此前只把 `m:t` 拼成一行文字(分式变成 `a+b/2c`)。
|
|
60
|
+
现做完整箱模型排版,覆盖分式 / 根式 / 上下标 / 大算符 / 定界符 / 矩阵 / 重音 / 极限等 12 类结构
|
|
61
|
+
- **加密文档**:
|
|
62
|
+
- `.pptx` 标准加密(AES-ECB)与敏捷加密(AES-CBC 分段)
|
|
63
|
+
- `.ppt` 老式 RC4 CryptoAPI(40 / 56 / 128 位)
|
|
64
|
+
- 用法 `parse(input, { password })`;密码错误抛 `WrongPasswordError`,与「文件损坏」区分开
|
|
65
|
+
- **PICT(Apple QuickDraw)解码器**:Mac 版 PowerPoint 存的 OLE 预览与图片此前只能是裂图
|
|
66
|
+
- **SmartArt 无缓存 drawing part 时自行排布**:python-pptx / Google Slides 导出的文件
|
|
67
|
+
常常只写数据与版式定义,此前只能得到灰色占位框。现支持线性 / 循环 / 金字塔 / 层级 /
|
|
68
|
+
蛇形 / 辐射六种布局族
|
|
69
|
+
|
|
70
|
+
### 修复
|
|
71
|
+
|
|
72
|
+
- `borderCallout2` 的引线是三点**开放**子路径,会被 `fill` 补成实心楔形——引线画成了黑三角
|
|
73
|
+
- 后台标签页的 `document.timeline` 是暂停的,`Animation.finished` 永不 resolve,
|
|
74
|
+
切换的收尾逻辑(移除旧图层)就永远不执行,配合自动换片会让图层持续堆积
|
|
75
|
+
- OLE 预览优先读 `p:oleObj` 内嵌的 `p:pic`(Office 2010+ 的主要写法),旧式 VML 快照作为后备
|
|
76
|
+
|
|
77
|
+
### 其它
|
|
78
|
+
|
|
79
|
+
- `@web-ppt/core` gzip 68KB → 84KB(新增的解码器与公式排版;加密与 PICT 走 hook 注入,可 tree-shake)
|
|
80
|
+
- 测试:1298 → **1689 项断言**,快照 142 → 158 个
|
|
81
|
+
- `@web-ppt/viewer-core` 的 peer 依赖同步升到 `^0.3.0`
|
|
82
|
+
|
|
83
|
+
## 0.2.0 及更早
|
|
84
|
+
|
|
85
|
+
见 [提交历史](https://github.com/unStone/web-ppt/commits/master)。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 unStone
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# @web-ppt/fonts
|
|
2
|
+
|
|
3
|
+
PPT 里指定的字体,本机往往没有。这个包负责换一份**免费的、能自动按需下载的**替代字体,让文本仍然按接近原样的方式排版。
|
|
4
|
+
|
|
5
|
+
包里**一个字节的字体都没有**(gzip 2.8KB)。切片指向 [fontsource](https://fontsource.org/) 已发布的版本,由 jsDelivr 分发,用不到就不下载。
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i @web-ppt/fonts
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 用法
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { collectFonts } from '@web-ppt/core';
|
|
15
|
+
import { loadFontsFor } from '@web-ppt/fonts';
|
|
16
|
+
|
|
17
|
+
// 只统计当前页:翻到了再补,已下过的切片是免费的
|
|
18
|
+
const usages = collectFonts([pres.slides[viewer.index]]);
|
|
19
|
+
await loadFontsFor(usages);
|
|
20
|
+
viewer.refresh(); // 排版是同步的、加载是异步的,首帧一定按回退字体断的行
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`loadFontsFor` 做三件事,缺一不可:
|
|
24
|
+
|
|
25
|
+
| 步骤 | 为什么 |
|
|
26
|
+
|---|---|
|
|
27
|
+
| 本机装了原字体就跳过 | 零下载永远优于任何加载策略 |
|
|
28
|
+
| 取替代字体的 `@font-face`,**把家族名改写成原字体名** | 幻灯片里写的是「Calibri」,CSS 没有别名机制,只有让 `@font-face` 顶着这个名字,那段文字才会用上它 |
|
|
29
|
+
| `src` 开头补一条 `local()` | 同名 `@font-face` 会**盖掉**系统里的同名字体,不补这条,装了原字体的人反而被拖去下载替代品 |
|
|
30
|
+
|
|
31
|
+
按需下载完全交给 `unicode-range`——fontsource 的 CSS 自带切片划分,浏览器只取真正渲染到的那几片。
|
|
32
|
+
|
|
33
|
+
## 替换表
|
|
34
|
+
|
|
35
|
+
**拉丁一栏全是度量兼容字体**:每个字符的前进宽度与原字体逐一相等,断行位置因此与 PowerPoint 逐字对齐。LibreOffice 用的就是这一套。
|
|
36
|
+
|
|
37
|
+
| PPT 里的字体 | 替代 | 度量兼容 |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| Calibri | Carlito | ✓ |
|
|
40
|
+
| Cambria | Caladea | ✓ |
|
|
41
|
+
| Arial / Helvetica | Arimo | ✓ |
|
|
42
|
+
| Times New Roman | Tinos | ✓ |
|
|
43
|
+
| Courier New | Cousine | ✓ |
|
|
44
|
+
| Segoe UI / Tahoma / Verdana | Open Sans | ✗ 仅形近 |
|
|
45
|
+
|
|
46
|
+
中文:微软雅黑 / 苹方 / 黑体 / 等线 → Noto Sans SC,宋体系 → Noto Serif SC,楷体 → 霞鹜文楷。
|
|
47
|
+
|
|
48
|
+
只收**可再分发、可子集化**的字体(OFL / Apache)。MiSans、HarmonyOS Sans、阿里普惠这些「免费商用」的许可各自限制再分发与改字,不进内置表——要用就自己往 `overrides` 里加:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
await loadFontsFor(usages, {
|
|
52
|
+
overrides: { 思源黑体: { family: 'Noto Sans SC', metricCompatible: false, cjk: true } },
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 中文的代价要心里有数
|
|
57
|
+
|
|
58
|
+
中文默认也换——传进来的用量本来就该全部处理。但两边的账完全不同:
|
|
59
|
+
|
|
60
|
+
| | 拉丁 | 中文 |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| 一页的代价 | ~30KB(一个切片) | **553KB**(22 个不同汉字跨 18 个切片) |
|
|
63
|
+
| 换来什么 | 度量兼容,断行对齐 PowerPoint | 换个字形——汉字全角等宽,断行本来就不会变 |
|
|
64
|
+
| 不换的后果 | 字宽不同,行尾全错 | 系统自带中文字体接住,看着没问题 |
|
|
65
|
+
|
|
66
|
+
切片一片约 30KB、装约 160 个码位,用掉一两个也得整片下,整份中文文件收敛下来在 1MB 上下。**边际成本按切片算,不按字算**——「只用了 22 个汉字」反而是最亏的情形。
|
|
67
|
+
|
|
68
|
+
流量敏感的场景可以关掉,或者做成用户可选(官网就是这么做的):
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
await loadFontsFor(usages, { cjk: false });
|
|
72
|
+
|
|
73
|
+
// 用户中途关掉时把已注入的声明撤回,然后重渲当前页。
|
|
74
|
+
// 只撤声明,已下载的字节留在 HTTP 缓存里,再打开是免费的
|
|
75
|
+
unloadFonts({ cjkOnly: true });
|
|
76
|
+
viewer.refresh();
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 自托管
|
|
80
|
+
|
|
81
|
+
不想依赖 jsDelivr 就换基址,目录结构与 fontsource 的 npm 包一致:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
await loadFontsFor(usages, { base: 'https://cdn.example.com/npm' });
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## 导出的另一半
|
|
88
|
+
|
|
89
|
+
`<img>` 加载的 SVG 是隔离文档,拿不到页面注册的字体。导出 PNG / 独立 SVG 时必须把命中的切片内联进去——`collectFonts` 给出的字符集就是为这个准备的。
|
|
90
|
+
|
|
91
|
+
## 许可
|
|
92
|
+
|
|
93
|
+
MIT。替代字体本身各自遵循其原始许可(OFL-1.1 / Apache-2.0),本包不分发字体文件。
|
package/dist/fonts.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
const N = {
|
|
2
|
+
calibri: { family: "Carlito", metricCompatible: !0, cjk: !1 },
|
|
3
|
+
"calibri light": { family: "Carlito", metricCompatible: !0, cjk: !1 },
|
|
4
|
+
cambria: { family: "Caladea", metricCompatible: !0, cjk: !1 },
|
|
5
|
+
"cambria math": { family: "Caladea", metricCompatible: !0, cjk: !1 },
|
|
6
|
+
arial: { family: "Arimo", metricCompatible: !0, cjk: !1 },
|
|
7
|
+
"arial narrow": { family: "Arimo", metricCompatible: !0, cjk: !1 },
|
|
8
|
+
helvetica: { family: "Arimo", metricCompatible: !0, cjk: !1 },
|
|
9
|
+
"helvetica neue": { family: "Arimo", metricCompatible: !0, cjk: !1 },
|
|
10
|
+
"liberation sans": { family: "Arimo", metricCompatible: !0, cjk: !1 },
|
|
11
|
+
"times new roman": { family: "Tinos", metricCompatible: !0, cjk: !1 },
|
|
12
|
+
times: { family: "Tinos", metricCompatible: !0, cjk: !1 },
|
|
13
|
+
"liberation serif": { family: "Tinos", metricCompatible: !0, cjk: !1 },
|
|
14
|
+
"courier new": { family: "Cousine", metricCompatible: !0, cjk: !1 },
|
|
15
|
+
courier: { family: "Cousine", metricCompatible: !0, cjk: !1 },
|
|
16
|
+
consolas: { family: "Cousine", metricCompatible: !1, cjk: !1 },
|
|
17
|
+
"liberation mono": { family: "Cousine", metricCompatible: !0, cjk: !1 },
|
|
18
|
+
// 无度量兼容品,只能形近
|
|
19
|
+
"segoe ui": { family: "Open Sans", metricCompatible: !1, cjk: !1 },
|
|
20
|
+
tahoma: { family: "Open Sans", metricCompatible: !1, cjk: !1 },
|
|
21
|
+
verdana: { family: "Open Sans", metricCompatible: !1, cjk: !1 },
|
|
22
|
+
georgia: { family: "Tinos", metricCompatible: !1, cjk: !1 }
|
|
23
|
+
}, $ = {
|
|
24
|
+
// 黑体系
|
|
25
|
+
微软雅黑: { family: "Noto Sans SC", metricCompatible: !1, cjk: !0 },
|
|
26
|
+
"microsoft yahei": { family: "Noto Sans SC", metricCompatible: !1, cjk: !0 },
|
|
27
|
+
"microsoft yahei ui": { family: "Noto Sans SC", metricCompatible: !1, cjk: !0 },
|
|
28
|
+
苹方: { family: "Noto Sans SC", metricCompatible: !1, cjk: !0 },
|
|
29
|
+
"pingfang sc": { family: "Noto Sans SC", metricCompatible: !1, cjk: !0 },
|
|
30
|
+
黑体: { family: "Noto Sans SC", metricCompatible: !1, cjk: !0 },
|
|
31
|
+
simhei: { family: "Noto Sans SC", metricCompatible: !1, cjk: !0 },
|
|
32
|
+
等线: { family: "Noto Sans SC", metricCompatible: !1, cjk: !0 },
|
|
33
|
+
dengxian: { family: "Noto Sans SC", metricCompatible: !1, cjk: !0 },
|
|
34
|
+
"source han sans sc": { family: "Noto Sans SC", metricCompatible: !1, cjk: !0 },
|
|
35
|
+
思源黑体: { family: "Noto Sans SC", metricCompatible: !1, cjk: !0 },
|
|
36
|
+
"hiragino sans gb": { family: "Noto Sans SC", metricCompatible: !1, cjk: !0 },
|
|
37
|
+
// 宋体系
|
|
38
|
+
宋体: { family: "Noto Serif SC", metricCompatible: !1, cjk: !0 },
|
|
39
|
+
simsun: { family: "Noto Serif SC", metricCompatible: !1, cjk: !0 },
|
|
40
|
+
新宋体: { family: "Noto Serif SC", metricCompatible: !1, cjk: !0 },
|
|
41
|
+
nsimsun: { family: "Noto Serif SC", metricCompatible: !1, cjk: !0 },
|
|
42
|
+
华文宋体: { family: "Noto Serif SC", metricCompatible: !1, cjk: !0 },
|
|
43
|
+
stsong: { family: "Noto Serif SC", metricCompatible: !1, cjk: !0 },
|
|
44
|
+
思源宋体: { family: "Noto Serif SC", metricCompatible: !1, cjk: !0 },
|
|
45
|
+
"source han serif sc": { family: "Noto Serif SC", metricCompatible: !1, cjk: !0 },
|
|
46
|
+
// 楷体:霞鹜文楷的 OFL 头里有一条附加许可,明确允许「专用于 web font 分发」
|
|
47
|
+
// 的子集 / WOFF2 转换保留原名,正好是这里的用法
|
|
48
|
+
楷体: { family: "LXGW WenKai", metricCompatible: !1, cjk: !0 },
|
|
49
|
+
kaiti: { family: "LXGW WenKai", metricCompatible: !1, cjk: !0 },
|
|
50
|
+
华文楷体: { family: "LXGW WenKai", metricCompatible: !1, cjk: !0 },
|
|
51
|
+
stkaiti: { family: "LXGW WenKai", metricCompatible: !1, cjk: !0 },
|
|
52
|
+
// 仿宋没有对位的免费品,退到宋体系比退到黑体近
|
|
53
|
+
仿宋: { family: "Noto Serif SC", metricCompatible: !1, cjk: !0 },
|
|
54
|
+
fangsong: { family: "Noto Serif SC", metricCompatible: !1, cjk: !0 }
|
|
55
|
+
}, T = { ...N, ...$ }, x = /^(wingdings|webdings|symbol|marlett)/i;
|
|
56
|
+
function v(i, t) {
|
|
57
|
+
const e = i.trim().toLowerCase();
|
|
58
|
+
return !e || x.test(e) ? null : (t == null ? void 0 : t[e]) ?? T[e] ?? null;
|
|
59
|
+
}
|
|
60
|
+
const w = "https://cdn.jsdelivr.net/npm", A = {
|
|
61
|
+
Carlito: { pkg: "@fontsource/carlito", version: "5.3.0", weights: [400, 700], italics: [400, 700] },
|
|
62
|
+
Caladea: { pkg: "@fontsource/caladea", version: "5.3.0", weights: [400, 700], italics: [400, 700] },
|
|
63
|
+
Arimo: { pkg: "@fontsource/arimo", version: "5.3.0", weights: [400, 500, 600, 700], italics: [400, 500, 600, 700] },
|
|
64
|
+
Tinos: { pkg: "@fontsource/tinos", version: "5.3.0", weights: [400, 700], italics: [400, 700] },
|
|
65
|
+
Cousine: { pkg: "@fontsource/cousine", version: "5.3.0", weights: [400, 700], italics: [400, 700] },
|
|
66
|
+
"Open Sans": { pkg: "@fontsource/open-sans", version: "5.3.0", weights: [300, 400, 500, 600, 700, 800], italics: [300, 400, 500, 600, 700, 800] },
|
|
67
|
+
"Noto Sans SC": { pkg: "@fontsource/noto-sans-sc", version: "5.3.0", weights: [100, 200, 300, 400, 500, 600, 700, 800, 900], italics: [] },
|
|
68
|
+
"Noto Serif SC": { pkg: "@fontsource/noto-serif-sc", version: "5.3.0", weights: [200, 300, 400, 500, 600, 700, 800, 900], italics: [] },
|
|
69
|
+
// 霞鹜文楷只出 Light / Regular / Bold 三档,没有 400
|
|
70
|
+
"LXGW WenKai": { pkg: "@fontsource/lxgw-wenkai", version: "5.3.0", weights: [300, 500, 700], italics: [] }
|
|
71
|
+
};
|
|
72
|
+
function W(i, t) {
|
|
73
|
+
let e = null;
|
|
74
|
+
for (const s of i)
|
|
75
|
+
(e === null || Math.abs(s - t) < Math.abs(e - t)) && (e = s);
|
|
76
|
+
return e;
|
|
77
|
+
}
|
|
78
|
+
function F(i, t, e, s = w) {
|
|
79
|
+
const a = A[i];
|
|
80
|
+
if (!a) return null;
|
|
81
|
+
const n = e ? a.italics : a.weights, l = W(n.length ? n : a.weights, t);
|
|
82
|
+
if (l === null) return null;
|
|
83
|
+
const o = e && a.italics.includes(l);
|
|
84
|
+
return `${s}/${a.pkg}@${a.version}/${l}${o ? "-italic" : ""}.css`;
|
|
85
|
+
}
|
|
86
|
+
let u = null, g = !1;
|
|
87
|
+
function L(i) {
|
|
88
|
+
if (g) return u;
|
|
89
|
+
g = !0;
|
|
90
|
+
try {
|
|
91
|
+
const t = i.createElement("canvas").getContext("2d");
|
|
92
|
+
u = t && typeof t.measureText == "function" ? t : null;
|
|
93
|
+
} catch {
|
|
94
|
+
u = null;
|
|
95
|
+
}
|
|
96
|
+
return u;
|
|
97
|
+
}
|
|
98
|
+
const d = "MMMMHHHHiiiill漢字語";
|
|
99
|
+
function M(i, t = globalThis.document) {
|
|
100
|
+
const e = L(t);
|
|
101
|
+
if (!e) return !1;
|
|
102
|
+
for (const s of ["monospace", "serif"]) {
|
|
103
|
+
e.font = `72px ${s}`;
|
|
104
|
+
const a = e.measureText(d).width;
|
|
105
|
+
if (e.font = `72px '${i.replace(/'/g, "")}', ${s}`, Math.abs(e.measureText(d).width - a) > 0.5) return !0;
|
|
106
|
+
}
|
|
107
|
+
return !1;
|
|
108
|
+
}
|
|
109
|
+
function O(i, t, e) {
|
|
110
|
+
const s = e.slice(0, e.lastIndexOf("/") + 1), a = t.replace(/['\\]/g, "");
|
|
111
|
+
return i.replace(/font-family:\s*'[^']*'/g, `font-family: '${a}'`).replace(/url\(\.\/([^)]+)\)/g, (n, l) => `url(${s}${l})`).replace(/src:\s*url\(/g, `src: local('${a}'), url(`);
|
|
112
|
+
}
|
|
113
|
+
const p = /* @__PURE__ */ new Set(), h = /* @__PURE__ */ new Map();
|
|
114
|
+
async function I(i) {
|
|
115
|
+
let t = h.get(i);
|
|
116
|
+
return t || (t = fetch(i).then((e) => e.ok ? e.text() : null).catch(() => null), h.set(i, t)), t;
|
|
117
|
+
}
|
|
118
|
+
async function E(i, t = {}) {
|
|
119
|
+
const e = t.document ?? globalThis.document, s = t.base ?? w, a = t.skipInstalled !== !1;
|
|
120
|
+
if (!e || typeof fetch != "function") return [];
|
|
121
|
+
const n = i.map(async (l) => {
|
|
122
|
+
const { family: o } = l;
|
|
123
|
+
if (a && M(o, e)) return { family: o, status: "installed" };
|
|
124
|
+
const c = v(o, t.overrides);
|
|
125
|
+
if (!c) return { family: o, status: "unmapped" };
|
|
126
|
+
if (c.cjk && t.cjk === !1) return { family: o, status: "skipped-cjk" };
|
|
127
|
+
const b = /* @__PURE__ */ new Set();
|
|
128
|
+
for (const r of l.styles) {
|
|
129
|
+
const f = r.bold ? 700 : 400;
|
|
130
|
+
b.add(`${f}|${c.cjk ? !1 : r.italic}`);
|
|
131
|
+
}
|
|
132
|
+
let k = !1;
|
|
133
|
+
for (const r of b) {
|
|
134
|
+
const [f, y] = r.split("|"), C = F(c.family, Number(f), y === "true", s);
|
|
135
|
+
if (!C) continue;
|
|
136
|
+
const S = `${o}|${r}`;
|
|
137
|
+
if (!p.has(S)) {
|
|
138
|
+
const j = await I(C);
|
|
139
|
+
if (!j) continue;
|
|
140
|
+
p.add(S);
|
|
141
|
+
const m = e.createElement("style");
|
|
142
|
+
m.dataset.webPptFont = o, c.cjk && (m.dataset.webPptCjk = "1"), m.textContent = O(j, o, C), e.head.appendChild(m);
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
await e.fonts.load(`${y === "true" ? "italic " : ""}${f} 16px '${o}'`, l.chars), k = !0;
|
|
146
|
+
} catch {
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return k ? { family: o, status: "substituted", substitute: c.family } : { family: o, status: "failed" };
|
|
150
|
+
});
|
|
151
|
+
return Promise.all(n);
|
|
152
|
+
}
|
|
153
|
+
function K(i = {}) {
|
|
154
|
+
const t = i.document ?? globalThis.document;
|
|
155
|
+
if (!t) return;
|
|
156
|
+
const e = i.cjkOnly ? "style[data-web-ppt-cjk]" : "style[data-web-ppt-font]";
|
|
157
|
+
for (const s of t.querySelectorAll(e)) {
|
|
158
|
+
for (const a of [...p])
|
|
159
|
+
a.startsWith(`${s.dataset.webPptFont}|`) && p.delete(a);
|
|
160
|
+
s.remove();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export {
|
|
164
|
+
w as DEFAULT_BASE,
|
|
165
|
+
A as PACKAGES,
|
|
166
|
+
T as SUBSTITUTIONS,
|
|
167
|
+
F as cssUrl,
|
|
168
|
+
M as isInstalled,
|
|
169
|
+
E as loadFontsFor,
|
|
170
|
+
O as rewriteFontFaceCss,
|
|
171
|
+
v as substituteFor,
|
|
172
|
+
K as unloadFonts
|
|
173
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @web-ppt/fonts —— 字体替换与按需加载。
|
|
3
|
+
*
|
|
4
|
+
* 文件带了嵌入字体时不需要这个包(`setFontDecoder` 那条路保真度更高);
|
|
5
|
+
* 这里解决的是**文件没带字体、本机也没装**的情况:拉丁用度量兼容的免费字体,
|
|
6
|
+
* 断行与 PowerPoint 逐字对齐;中文换成思源系,行高靠 @font-face 描述符拉近。
|
|
7
|
+
*
|
|
8
|
+
* 包里**一个字节的字体都没有**:切片指向 fontsource 的已发布版本,
|
|
9
|
+
* 由 jsDelivr 分发,用不到就不下载。
|
|
10
|
+
*/
|
|
11
|
+
export { SUBSTITUTIONS, substituteFor } from './substitute';
|
|
12
|
+
export type { Substitution } from './substitute';
|
|
13
|
+
export { DEFAULT_BASE, PACKAGES, cssUrl } from './sources';
|
|
14
|
+
export type { FontPackage } from './sources';
|
|
15
|
+
export { isInstalled, loadFontsFor, rewriteFontFaceCss, unloadFonts } from './load';
|
|
16
|
+
export type { LoadOptions, LoadResult } from './load';
|
package/dist/load.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { FontUsage } from '@web-ppt/core';
|
|
2
|
+
import { type Substitution } from './substitute';
|
|
3
|
+
/**
|
|
4
|
+
* 把「这份文件用了哪些字体」变成「浏览器真的能按它排版」。
|
|
5
|
+
*
|
|
6
|
+
* 三件事,缺一不可:
|
|
7
|
+
* 1. 本机装了原字体就什么都不做——零下载永远优于任何加载策略;
|
|
8
|
+
* 2. 没装就取替代字体的 `@font-face`,**把家族名改写成原字体名**。
|
|
9
|
+
* 不改写没用:幻灯片里写的是「微软雅黑」,CSS 里没有别名机制,
|
|
10
|
+
* 只有让 `@font-face` 顶着「微软雅黑」这个名字,那段文字才会用上它;
|
|
11
|
+
* 3. `src` 开头补一条 `local()`。声明了同名 `@font-face` 会**盖掉**系统里
|
|
12
|
+
* 的同名字体,不补这一条,装了原字体的人反而被拖去下载替代品。
|
|
13
|
+
*
|
|
14
|
+
* 按需下载完全交给 `unicode-range`:fontsource 的 CSS 自带切片划分,
|
|
15
|
+
* 浏览器只取真正渲染到的那几片。我们不重切、不预测。
|
|
16
|
+
*/
|
|
17
|
+
export interface LoadOptions {
|
|
18
|
+
/** CDN 基址,默认 jsDelivr */
|
|
19
|
+
base?: string;
|
|
20
|
+
/** 覆盖 / 扩充内置替换表,键为小写后的原字体名 */
|
|
21
|
+
overrides?: Readonly<Record<string, Substitution>>;
|
|
22
|
+
/** 本机已装的字体不替换(默认开启)。关掉可强制统一到替代字体 */
|
|
23
|
+
skipInstalled?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* 是否替换中日韩字体,默认开启——传进来的用量本来就该全部处理,
|
|
26
|
+
* 半路砍掉一半才是意外。
|
|
27
|
+
*
|
|
28
|
+
* 但代价要心里有数,这是实测的:一页只有 22 个不同汉字,却要跨 18 个切片、
|
|
29
|
+
* 下 553KB。切片一片约 30KB 装约 160 个码位,用掉一两个也得整片下,
|
|
30
|
+
* 整份中文文件收敛下来在 1MB 上下。拉丁则一个字重才十几 KB。
|
|
31
|
+
*
|
|
32
|
+
* 关掉它的正当理由:流量敏感的场景,或者判断「系统自带中文字体已经够看」——
|
|
33
|
+
* 汉字全角等宽,换字形不改断行,视觉收益确实比拉丁那边小得多。
|
|
34
|
+
*/
|
|
35
|
+
cjk?: boolean;
|
|
36
|
+
/** 目标文档,默认当前文档;渲染到 iframe 时要传 */
|
|
37
|
+
document?: Document;
|
|
38
|
+
}
|
|
39
|
+
/** 一个家族的处理结果 */
|
|
40
|
+
export interface LoadResult {
|
|
41
|
+
family: string;
|
|
42
|
+
/**
|
|
43
|
+
* `installed` 本机已有 · `substituted` 已换成替代字体 ·
|
|
44
|
+
* `skipped-cjk` 是中文字体且调用方关了 `cjk` · `unmapped` 表里没有 · `failed` 取不到
|
|
45
|
+
*/
|
|
46
|
+
status: 'installed' | 'substituted' | 'skipped-cjk' | 'unmapped' | 'failed';
|
|
47
|
+
/** 实际下载的替代字体家族 */
|
|
48
|
+
substitute?: string;
|
|
49
|
+
}
|
|
50
|
+
/** 本机是否装了这个字体。量不出来时保守地当作「没装」,走替换路径 */
|
|
51
|
+
export declare function isInstalled(family: string, doc?: Document): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* 把 fontsource 的 CSS 改造成「顶着原字体名」的 `@font-face`。
|
|
54
|
+
*
|
|
55
|
+
* 两处改写:家族名换成 `original`,相对路径 `./files/x.woff2` 换成绝对地址
|
|
56
|
+
* (我们是把 CSS 文本注入到页面里,相对路径会按页面地址解析,必然 404)。
|
|
57
|
+
* `unicode-range` 原样保留——按需下载全靠它。
|
|
58
|
+
*/
|
|
59
|
+
export declare function rewriteFontFaceCss(css: string, original: string, cssHref: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* 为一份演示文稿的字体用量准备好网络字体。
|
|
62
|
+
*
|
|
63
|
+
* 返回后调用方应当**重新渲染当前页**:排版是同步的、字体加载是异步的,
|
|
64
|
+
* 首帧必然是按回退字体断的行。`foreignObject` 路径由浏览器重排,原生
|
|
65
|
+
* `<text>` 路径则要重新量字宽——两条路都得重渲才对得上。
|
|
66
|
+
*/
|
|
67
|
+
export declare function loadFontsFor(usages: readonly FontUsage[], opts?: LoadOptions): Promise<LoadResult[]>;
|
|
68
|
+
/**
|
|
69
|
+
* 撤回已注入的 `@font-face`。
|
|
70
|
+
*
|
|
71
|
+
* 给「用户中途关掉替换」用。只撤声明,不动浏览器已缓存的字节——再打开时
|
|
72
|
+
* 那些切片是免费的。撤完宿主要自己重渲当前页。
|
|
73
|
+
*/
|
|
74
|
+
export declare function unloadFonts(opts?: {
|
|
75
|
+
cjkOnly?: boolean;
|
|
76
|
+
document?: Document;
|
|
77
|
+
}): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 替代字体的字节从哪儿来。
|
|
3
|
+
*
|
|
4
|
+
* 不自建切片、也不把字体打进包里。理由是实测出来的:拿一份 437 个不同汉字的
|
|
5
|
+
* 中文文档试,Google/fontsource 现成的切片划分要下 538KB,按字频重排后
|
|
6
|
+
* 是 460–700KB——省不下来。原因在分布本身:那 437 个字里 96% 落在词频前 2000 名,
|
|
7
|
+
* 却只用掉其中约两成,任何覆盖「前 2000 名」的切片都得整片下。真正能把它压到
|
|
8
|
+
* ~63KB 的只有精确子集(要服务端)或 IFT(浏览器还没发)。
|
|
9
|
+
*
|
|
10
|
+
* 所以这里做的事只有一件:指向 fontsource 已发布的切片,版本钉死。
|
|
11
|
+
* 发到 npm 的包 jsDelivr 自动分发,`access-control-allow-origin: *`、
|
|
12
|
+
* `cache-control: immutable`,零服务端不破,也不依赖 fonts.googleapis.com
|
|
13
|
+
* (那个域名在国内不可达)。
|
|
14
|
+
*/
|
|
15
|
+
/** 默认 CDN 基址;自托管时换成自己的地址即可 */
|
|
16
|
+
export declare const DEFAULT_BASE = "https://cdn.jsdelivr.net/npm";
|
|
17
|
+
/** 一个替代字体在 fontsource 上的坐标 */
|
|
18
|
+
export interface FontPackage {
|
|
19
|
+
pkg: string;
|
|
20
|
+
version: string;
|
|
21
|
+
/** 该包实际提供的正体字重;请求别的字重要就近取 */
|
|
22
|
+
weights: number[];
|
|
23
|
+
/** 提供真斜体的字重;CJK 一律为空,斜体交给浏览器合成 */
|
|
24
|
+
italics: number[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 替代字体对应的 fontsource 包与版本。
|
|
28
|
+
*
|
|
29
|
+
* 版本必须钉死:fontsource 换代时切片划分和文件名都会变,跟着 latest 走
|
|
30
|
+
* 等于让线上排版随时可能漂。升级时连同这里一起改,别用范围号。
|
|
31
|
+
*
|
|
32
|
+
* `weights` / `italics` 是各包**实际提供**的档位,逐个查过的:霞鹜文楷没有
|
|
33
|
+
* 400,中文包一个真斜体都没有。照着「反正都有 400」写会直接 404。
|
|
34
|
+
*/
|
|
35
|
+
export declare const PACKAGES: Readonly<Record<string, FontPackage>>;
|
|
36
|
+
/**
|
|
37
|
+
* 一个字重 / 斜体的 CSS 地址。
|
|
38
|
+
*
|
|
39
|
+
* 取 fontsource 的**按字重**入口(`400.css`)而不是 `index.css`:后者把九个
|
|
40
|
+
* 字重的声明全塞进来,光解析就上百 KB,而实际用到的通常只有正文和标题两个。
|
|
41
|
+
*/
|
|
42
|
+
export declare function cssUrl(family: string, weight: number, italic: boolean, base?: string): string | null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PPT 字体 → 免费替代字体。
|
|
3
|
+
*
|
|
4
|
+
* 拉丁一栏挑的全是**度量兼容**字体:每个字符的前进宽度与原字体逐一相等,
|
|
5
|
+
* 于是断行位置与 PowerPoint 逐字对齐。LibreOffice 用的就是这一套,
|
|
6
|
+
* 它解决的正是 README 里「字体缺失导致的断行差异」那条。
|
|
7
|
+
*
|
|
8
|
+
* 中文没有度量兼容品,但影响比想象小:汉字都是 1em 全角等宽,换字体不改前进宽度,
|
|
9
|
+
* 偏差只来自 CJK 字体里的西文 / 数字 / 标点。行高用 @font-face 的
|
|
10
|
+
* `size-adjust` / `ascent-override` 拉齐即可。
|
|
11
|
+
*
|
|
12
|
+
* 只收**可再分发、可子集化**的字体(OFL / Apache)。MiSans、HarmonyOS Sans、
|
|
13
|
+
* 阿里普惠、OPPO Sans 这些「免费商用」的许可各自限制再分发与改字,不进内置表——
|
|
14
|
+
* 想用的人自己往 `overrides` 里加。
|
|
15
|
+
*/
|
|
16
|
+
/** 一条替换规则 */
|
|
17
|
+
export interface Substitution {
|
|
18
|
+
/** 替代字体的家族名 */
|
|
19
|
+
family: string;
|
|
20
|
+
/**
|
|
21
|
+
* 前进宽度与原字体逐字相等。为真时断行与 PowerPoint 完全一致,
|
|
22
|
+
* 为假时只是形近,行高仍可能要靠 `size-adjust` 修。
|
|
23
|
+
*/
|
|
24
|
+
metricCompatible: boolean;
|
|
25
|
+
/** 中日韩字体:切片多、体积大,加载策略与拉丁不同 */
|
|
26
|
+
cjk: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** 内置替换表;键是小写后的原字体名 */
|
|
29
|
+
export declare const SUBSTITUTIONS: Readonly<Record<string, Substitution>>;
|
|
30
|
+
/** 查一个字体该用什么替代;没有对应项返回 null */
|
|
31
|
+
export declare function substituteFor(family: string, overrides?: Readonly<Record<string, Substitution>>): Substitution | null;
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@web-ppt/fonts",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "PPT 字体替换与按需加载:度量兼容的免费替代字体表 + unicode-range 切片加载器,零字节字体入包",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pptx",
|
|
7
|
+
"ppt",
|
|
8
|
+
"powerpoint",
|
|
9
|
+
"font",
|
|
10
|
+
"webfont",
|
|
11
|
+
"font-substitution",
|
|
12
|
+
"metric-compatible",
|
|
13
|
+
"unicode-range",
|
|
14
|
+
"subset",
|
|
15
|
+
"cjk",
|
|
16
|
+
"browser",
|
|
17
|
+
"typescript"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "unStone",
|
|
21
|
+
"homepage": "https://github.com/unStone/web-ppt#readme",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/unStone/web-ppt.git",
|
|
25
|
+
"directory": "packages/fonts"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"main": "./dist/fonts.js",
|
|
30
|
+
"module": "./dist/fonts.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"import": "./dist/fonts.js"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"README.md",
|
|
42
|
+
"CHANGELOG.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "vite build && tsc -p tsconfig.build.json",
|
|
47
|
+
"prepublishOnly": "npm run build",
|
|
48
|
+
"prepack": "node ../../tooling/sync-package-docs.mjs"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@web-ppt/core": "^0.4.0"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|