简介:Windows 系统字体合集面向从事平面设计、文档排版、网页前端与软件界面开发的人员,以及需要补充中文字体库的普通用户,用于解决系统自带字体样式有限、跨设备显示不一致以及某些文档或设计稿缺失字库等问题。压缩包共 160 个文件,其中 159 个为 ttf 字体文件,另有 1 个 xml 配置文件;ttf 覆盖宋体、黑体、楷体、仿宋等常用中文字体及多款西文字体,xml 用于字体信息登记与兼容说明,整体约 106.14MB。已有 2608 人学习下载。字体按名称规范整理,安装后可直接在 Office、Photoshop、浏览器等软件中调用,便于统一文档与设计稿的显示效果,也适合作为字体比对、字库替换与兼容性排查的基础素材。
1. Windows 字体配合集:重装系统后我第一件事是补 Fonts 目录
重装完系统,多数人先装驱动,我先翻 Fonts 目录。原因是打开编辑器那一刻差别太明显:代码等宽对齐、中英文混排的基线、标题字重,系统默认那几套字体一上来就露怯,行高跳变、粗体糊成一片,屏幕上看着不舒服,导出的图给同事一看更明显。这套 Windows 字体配合集就是围绕这个场景整理的:一批 .ttf / .otf / .ttc 字体文件,覆盖等宽编程、中文正文、标题展示三类用途,可以直接补齐系统 Fonts 目录,也可以按需挑着装。适合两类人:经常重装机器、要在多台设备上保持视觉一致的开发者;以及需要脱机字体库、不想每次从网页上零散下载的设计与排版岗位。后面几章讲清格式怎么分、批量装怎么脚本化、替换表怎么改、装完不生效往哪查。
2. 字体格式与命名解析:ttf、otf、ttc、可变字体到底怎么分
同一批字体文件堆在文件夹里,肉眼几乎看不出差别,但装进系统后表现完全不同:有的软件里能选到,有的选不到;有的一个文件带出四五个字重,有的只带一个。动手装之前先把容器格式和命名规则搞清楚,这一步省下来的时间,比后面反复卸载重装多得多。
2.1 四种容器格式的实际差别
字体文件后缀只是冰山一角,真正决定兼容性的是轮廓格式和容器结构。TrueType 用二次贝塞尔曲线描述字形,OpenType 可以封装 TrueType 轮廓,也可以封装 CFF(PostScript)轮廓。Windows 对这两者的支持深度并不一样:DirectWrite 走的是新链路,支持完整;而一批老程序还在用 GDI 枚举和渲染字体,遇到 CFF 轮廓的 .otf 时偶尔会掉字或者干脆不显示。
| 后缀 | 轮廓/容器 | 一个文件内的字体数 | Windows 支持情况 | 常见来源 |
|---|---|---|---|---|
| .ttf | TrueType 轮廓,单字体 | 1 | 原生,兼容性最好 | 系统字体、多数商业字体 |
| .otf | CFF 轮廓,单字体 | 1 | 原生,老 GDI 程序偶有渲染问题 | 设计类字体、部分开源字体 |
| .ttc | TrueType 集合 | 多个字体族 | 原生,安装后注册全部子字体 | 多语言系统字体、开源家族 |
| .otf/.ttf 可变字体 | 单文件多字重 | 1 个族,多个命名实例 | Win10 1709 之后支持 | 现代开源字体 |
| .woff/.woff2 | Web 封装 | 1 | 不直接安装,需先转回 ttf/otf | 网页下载、前端资源 |
最容易踩的是最后一行。从网页上扒下来的字体经常是 woff2 封装,双击没有任何反应,右键也没有“安装”菜单——不是文件坏了,是 Windows 压根不认这个容器。常见做法是用 fontTools 转一道:
# 把 woff2 还原成 ttf,注意保留原文件名便于追溯 python -m fontTools.ttLib.woff2 decompress input.woff2 --output-dir ./out.ttc则是另一个方向的坑:它像一个压缩包,里面塞了好几个字体族。安装时 Windows 会把里面所有子字体一起注册,所以你只想装一个等宽字体,结果字体列表里多出三四个陌生名字。这个行为没法在安装时筛选,只能装完再按需清理。
2.2 用 fontTools 读出真实字体名与字重
文件名和字体内部记录的名字经常对不上。你下载的时候叫SourceHanSans-Bold.ttf,装进系统后在软件里显示的可能完全是另一个名字,因为软件读的是字体内部 name 表的记录,不是文件名。批量处理之前,先让脚本把每个文件内部信息扫一遍,是最省事的做法。
# scan_fonts.py:扫描目录下所有字体,输出内部命名与字重信息 from pathlib import Path from fontTools.ttLib import TTFont, TTCollection def read_names(font): """从 name 表里取出族名、子族名、全名、PostScript 名""" info = {} # nameID 1=族名 2=子族名 4=全名 6=PostScript名 16/17=排版族名/子族名 for rec in font["name"].names: if rec.nameID in (1, 2, 4, 6, 16, 17) and rec.platformID == 3: try: info.setdefault(rec.nameID, rec.toUnicode()) except UnicodeDecodeError: pass # 少数记录编码异常,跳过不影响主流程 return info def scan_one(path): rows = [] if path.suffix.lower() == ".ttc": # TTC 是集合,需要逐个展开 for idx, font in enumerate(TTCollection(str(path)).fonts): rows.append(build_row(path, font, idx)) else: rows.append(build_row(path, TTFont(str(path), lazy=True), None)) return rows def build_row(path, font, idx): names = read_names(font) weight = font["OS/2"].usWeightClass if "OS/2" in font else -1 return { "file": path.name, "index": idx if idx is not None else "", "family": names.get(16) or names.get(1, ""), # 优先取排版族名 "subfamily": names.get(17) or names.get(2, ""), "ps_name": names.get(6, ""), # PostScript 名可作唯一键 "weight_class": weight, } if __name__ == "__main__": root = Path(r"D:\fonts") all_rows = [] for p in sorted(root.rglob("*")): if p.suffix.lower() in (".ttf", ".otf", ".ttc"): all_rows.extend(scan_one(p)) for r in all_rows: print(f'{r["family"]:<28} {r["subfamily"]:<12} {r["weight_class"]:<4} {r["file"]}')这段脚本的逻辑很直白:TTCollection负责拆 TTC,TTFont用lazy=True只解析头部和 name 表,扫描几百个文件也不会卡住。read_names里限定platformID == 3是取 Windows 平台记录,避免拿到 Mac 平台的旧名字。nameID那一组编号是固定的:1 和 2 是传统族名/子族名,16 和 17 是排版族名/子族名,现代字体会把“某字体 Light”“某字体 Bold”统一归到同一个族下,用 16/17 才拿得到干净的家族关系。usWeightClass是 OS/2 表里的数值字重,400 常规、700 粗体,用来排序和判重比字符串靠谱。
提示:
ps_name(PostScript 名)在规范里要求全局唯一,拿它做去重键比文件名和族名都稳。
2.3 按需筛选:生成自己的字体清单
扫出结果之后,下一步是决定装哪些。我一般按ps_name去重,再按用途分组:usWeightClass在 400 附近且subfamily含 Mono、Code 之类字样的归入等宽组;usWeightClass400 到 700 之间、字符覆盖完整的中文字体归入正文组。分组不看文件名,只看内部记录,这样即使某次下载把文件重命名得乱七八糟也不影响判断。
清单建议存成 CSV 而不是直接打印,装字体的时候拿它当账本:哪些装过、装的是哪个版本、有没有同名冲突,一目了然。
# 在扫描结果基础上落一份 CSV 账本 import csv with open("font_inventory.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["file", "index", "family", "subfamily", "ps_name", "weight_class"]) writer.writeheader() writer.writerows(all_rows)encoding="utf-8-sig"是为了让 Excel 直接双击打开不乱码,这个小细节在交接给别人时特别有用。清单落到磁盘之后,装字体就从"点右键"变成了"读账本执行",第 3 章的脚本直接吃这份 CSV 做批处理,谁装了什么都有记录。
3. 批量安装与卸载:把右键菜单换成可重复执行的脚本
装了二三十个字体文件之后,右键逐一点“安装”的耐心基本耗尽,更麻烦的是换机器要重来一遍。这一步的目标是把安装变成可重复执行的动作:同一份清单,喂给脚本,几分钟装完;装错了能整批撤掉,不留残渣。
3.1 用户级还是系统级:先想清楚装给谁用
Windows 提供两条安装路径,落在不同目录、不同注册表分支,行为差别不小:
| 维度 | 用户级安装 | 系统级安装 |
|---|---|---|
| 字体目录 | %LOCALAPPDATA%\Microsoft\Windows\Fonts | C:\Windows\Fonts |
| 注册表分支 | HKCU\Software\Microsoft\Windows NT\CurrentVersion\Fonts | HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts |
| 权限要求 | 普通用户即可 | 需要管理员 |
| 作用范围 | 仅当前账户 | 全机器所有账户 |
| 老程序识别 | 部分 GDI 程序枚举不到 | 全部可识别 |
| 卸载影响 | 只影响自己 | 可能影响其他用户与主题 |
选哪条,看用途。个人开发机、单账户、只给编辑器和终端用,用户级足够,还省掉管理员弹窗。但只要有任何一个老程序需要用到这批字体,就得走系统级——用户级字体不进系统字体表,用 GDI 枚举字体的程序看不到它,这个问题在第 5 章会展开。我的习惯是:等宽字体和编辑器用字体走用户级,中文字体走系统级,因为中文字体被各种程序引用的概率高得多。
3.2 一份能跑通的批量安装脚本
安装的本质是两个动作:把文件复制到字体目录,在注册表对应分支写一条记录。注册表值的名字用于在字体目录里显示,数据才是真正的文件名,Windows 加载时认数据不认名字,所以名字写规范就行,写错也不至于装不上——但会让人工核对变得很痛苦,建议统一成字体文件名 (TrueType)或(OpenType)的格式。
# Install-FontsUser.ps1:把目录内字体装到当前用户,无需管理员 param( [Parameter(Mandatory)] [string]$SourceDir, [switch]$WhatIf # 试运行,只打印不落地 ) $dstDir = Join-Path $env:LOCALAPPDATA "Microsoft\Windows\Fonts" $regPath = "HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Fonts" # 字体目录可能不存在,先兜底创建 if (-not (Test-Path $dstDir)) { New-Item -ItemType Directory -Path $dstDir -Force | Out-Null } if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null } $files = Get-ChildItem -Path $SourceDir -Recurse -Include *.ttf,*.otf,*.ttc -File foreach ($f in $files) { $suffix = if ($f.Extension -eq ".ttf") { "(TrueType)" } else { "(OpenType)" } $valueName = "$($f.BaseName) $suffix" $target = Join-Path $dstDir $f.Name # 同名文件已存在时,先比对大小,不同则备份后再覆盖 if (Test-Path $target) { $old = Get-Item $target if ($old.Length -ne $f.Length) { Copy-Item $target "$target.bak" -Force Write-Host "版本不同,已备份:$($f.Name)" } else { Write-Host "跳过(已存在且大小一致):$($f.Name)" continue } } if ($WhatIf) { Write-Host "[试运行] 将安装 $($f.Name)"; continue } Copy-Item $f.FullName $target -Force # 用户级注册表数据必须写完整路径,写文件名会导致加载失败 New-ItemProperty -Path $regPath -Name $valueName -Value $target ` -PropertyType String -Force | Out-Null Write-Host "已安装:$($f.Name)" }几个关键点值得单独说。-WhatIf参数是自己实现的,不是 PowerShell 内置的通用开关,作用是把所有复制和写注册表的动作拦下来,先看一遍要装什么,尤其适合第一次跑陌生字体包的场景。备份逻辑用文件大小比对,虽然粗略,但足以拦住“同名不同版本”这个最常见的覆盖翻车。最后一行-Value $target是整个脚本里最容易写错的地方:系统级安装写文件名即可,用户级安装必须写完整路径,否则重启后字体条目还在但字体加载不出来,表现为“看起来装了,软件里没有”。
装完不是立刻全生效,需要刷新字体缓存,或者干脆重启相关应用。刷新缓存最轻量的做法是重启 FontCache 服务:
# 重启字体缓存服务,让新装字体尽快生效(需管理员) Restart-Service -Name FontCache -Force-Force的意思是连带停掉依赖它的服务,不加的话服务正在被占用时会报错退出。
3.3 卸载、备份与回滚
卸载比安装更需要小心,因为删错一个系统字体,界面上的文字可能直接变成方块。写卸载脚本时我强制加一条保护:只处理注册表数据指向用户字体目录的条目,系统字体一律不碰。
# Uninstall-FontsUser.ps1:按关键字卸载用户级字体 param([Parameter(Mandatory)] [string]$Keyword) $regPath = "HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Fonts" $userDir = (Join-Path $env:LOCALAPPDATA "Microsoft\Windows\Fonts").ToLower() Get-ItemProperty -Path $regPath | Get-Member -MemberType NoteProperty | Where-Object { $_.Name -notmatch '^PS' } | ForEach-Object { $name = $_.Name $data = (Get-ItemProperty -Path $regPath -Name $name).$name # 双保险:名字命中关键字,且文件确实在用户字体目录下 if ($name -like "*$Keyword*" -and $data.ToLower().StartsWith($userDir)) { Remove-ItemProperty -Path $regPath -Name $name -Force if (Test-Path $data) { Remove-Item $data -Force } Write-Host "已卸载:$name" } }Get-Member -MemberType NoteProperty用来把注册表值名和 PowerShell 自带的PSPath、PSParentPath之类的属性区分开,不加过滤会报一堆找不到文件的错。StartsWith($userDir)那行是后悔药:万一关键字写得太宽泛,匹配到了系统字体,这条判断会把它拦下来。
回滚依赖 3.2 里生成的.bak文件。字体被覆盖后如果发现渲染异常,把.bak改回原名、重新走一遍安装脚本就是最快的方式。所以那行备份代码不是可选项,我把它当成脚本的硬性要求。
4. 字体替换与回退链路:FontSubstitutes 里藏着最终决定权
字体装了不等于用上。一个文档里指定了某字体,本机没有,Windows 不会直接显示方块,它会沿一条回退链路去找替身。理解这条链路,才能解释“为什么我装了新字体,打开文档还是老样子”这种看似玄学的问题。
4.1 替换表的生效顺序
常见的行为是这样的:应用请求某个族名 → 系统先查字体替换表HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\FontSubstitutes→ 没命中就找同族里可用的字重 → 再不行走字体链接和默认回退。替换表是这个链条里唯一能被人工干预的一环,它的结构极简单,就是一组“请求名 = 实际使用名”的键值对。
这里有个反直觉的点:替换表的优先级高于字体是否存在。如果你在表里写了“某字体 = 另一个字体”,那么即使原字体已经装了,程序请求时依然会被换成替身。这条特性可以用来做统一风格(比如让所有文档里的旧字体名都指向新字体),也足以让人排查半天——明明装了却不用,八成是历史遗留的替换项在作祟。
查看当前有哪些替换项:
# 列出系统字体替换表的所有条目 $p = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\FontSubstitutes" Get-ItemProperty -Path $p | Get-Member -MemberType NoteProperty | Where-Object { $_.Name -notmatch '^PS' } | ForEach-Object { $v = (Get-ItemProperty -Path $p -Name $_.Name).($_.Name) "{0} -> {1}" -f $_.Name, $v }输出里常见的是一堆系统内置的映射,比如某些老字体名指向新字体名。排查字体问题前先跑一遍这个,比在字体列表里翻半天有效。
4.2 改替换表做统一映射
修改替换表需要管理员权限,方式有两种:写一个.reg文件双击导入,或者用 PowerShell 直接写。前者便于分发给多台机器,后者便于纳入脚本流程。
# Add-FontSubstitute.ps1:新增或覆盖一条字体替换(需管理员) param( [Parameter(Mandatory)] [string]$From, # 被请求的字体名 [Parameter(Mandatory)] [string]$To # 实际使用的字体名 ) $p = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\FontSubstitutes" New-ItemProperty -Path $p -Name $From -Value $To -PropertyType String -Force | Out-Null Write-Host "已设置替换:$From -> $To"-Force让同名的旧值被直接覆盖,省掉先删后建的步骤。改完之后必须重启目标应用,替换表是在应用启动时读取的,光刷新字体缓存没用——这一点很多人会漏掉,然后怀疑脚本没生效。想彻底一点就注销重登。
反向操作是删除条目,Remove-ItemProperty -Path $p -Name $From即可。我习惯在改之前先把整张表导出成.reg备份,出问题直接导回去,比逐条回忆改了什么靠谱。
注意:替换表是全局设置,影响所有应用。做临时验证时更推荐在单个应用里改字体设置,别一上来就动注册表。
4.3 验证替换是否真的命中
改完怎么确认生效?最直接的办法是让系统自己回答:按字体名创建一个字体对象,看它最终解析成了哪个族。如果请求的名字不存在或被替换,返回的族名会和请求名不一致。
# Test-FontResolve.ps1:验证一个字体名在本机最终解析成什么 param([Parameter(Mandatory)] [string]$Family) Add-Type -AssemblyName System.Drawing $font = New-Object System.Drawing.Font($Family, 12) "请求: $Family" "实际: $($font.FontFamily.Name)" # 若与请求不同,说明发生了替换或回退 $font.Dispose()这段代码用的是System.Drawing,底层走 GDI+,和大部分桌面程序的取字体路径接近,所以它的结果对排查“软件里选不到”类问题有参考价值。如果“实际”一栏返回的是某个通用默认字体,说明请求的名字根本没被系统识别,问题在安装环节而不是替换表。如果返回的是另一个具体族名,那就是替换表或者字体链接在起作用,去第 4.2 节查条目。
配合第 2 章的清单脚本,一套流程就闭合了:清单确认字体文件本身合规,解析脚本确认系统认这个名字,两步都过,剩下的就是应用层自己的字体配置问题,不用再怀疑系统。
5. 避坑与排查:装完不生效的五种典型翻车
字体这块的坑有个共同特征:现象都很像,原因却分散在安装路径、注册表、缓存、应用枚举方式几个层面。下面五条是实际遇到频率最高的,按“现象 → 原因 → 解决”记下来,下次遇到直接对号入座。
5.1 安装与注册环节的三处翻车
第一条:字体装完,新版编辑器里能选到,某个老工具里死活没有。
原因基本可以锁定在安装位置上。用户级字体只写进当前账户的注册表分支,字体文件也不在系统字体目录里,用 GDI 枚举系统字体表的程序压根看不到它。解决办法是把这批字体改成系统级安装:文件复制到C:\Windows\Fonts,注册表写到HKLM分支,需要管理员权限。改完之后重启那个程序,多数情况立刻就能选到。
第二条:同一个字体装了新版本,软件里显示的还是旧样子。
这是同名不同版本打架。Windows 允许注册表里同时存在多条指向同一族名的记录,只要文件名不同就都保留,最终哪个生效取决于枚举顺序,不可控。解决思路是先查后装:用第 2 章的清单脚本按ps_name扫一遍现有字体目录,发现同名项就先把旧条目和旧文件清掉,再装新的。别指望覆盖安装能自动替换,它做不到。
第三条:把字体文件直接拖进 Fonts 目录,字体列表里没有。
复制文件只是把字节放过去了,注册表没写,系统不认。这个目录不是普通文件夹,它有一套由字体驱动维护的索引。解决办法是老老实实走安装流程:右键安装,或者用第 3 章的脚本写注册表。已经拖进去的,删掉重来,别在目录里反复折腾。
5.2 卸载与渲染环节的两处翻车
第四条:卸载了几个自己装的字体,桌面图标文字和部分界面变成了方块或错位。
原因很硬:删到了被系统主题、界面框架或某个应用硬编码引用的字体。系统字体不是“能不用就不用”的装饰品,很多界面元素直接按名字取。解决办法是立刻从备份恢复,没有备份就从系统安装介质里把对应文件找回来。预防手段更简单:卸载脚本里那条“数据必须指向用户字体目录”的判断不要删,只删自己装的,系统目录里的一律不碰。
第五条:字体明明装了,渲染出来却糊、字重对不上、标点挤在一起。
这类问题一般不是字体本身坏了,而是取到了替代品或者渲染链路不对。先用第 4.3 节的解析脚本确认请求的族名是否真的命中;如果命中了还糊,检查是不是打开了系统的字体平滑设置、以及应用是否走了老式 GDI 渲染。另一个高频原因是可变字体:单文件多字重的字体在部分老程序里只会用到默认实例,Bold 请求被忽略,看起来就像“字重没生效”。解决办法是给这类老程序单独准备静态字重文件,别指望它认可变字体。
提示:排查顺序固定成“清单核对 → 解析验证 → 缓存刷新 → 应用重启”,四步走完还没解决,再怀疑字体文件本身。
6. 进阶:子集化与交付前的一套自检
字体装到本机只是自用,真正容易出问题的是往外交付:把字体嵌进网页、塞进安装包、跟着设计稿一起发出去。整份字体动辄几 MB 到十几 MB,中文字体尤其夸张,直接打包不现实。我一般用子集化把字符集裁到实际用到的范围,体积能压到原来的几分之一。
# 抽取页面实际用到的字符,再做子集化,输出 woff2 # chars.txt 里是所有出现过的字符,去重后一行一个或用整段文本都行 pyftsubset input.ttf \ --text-file=chars.txt \ --output-file=out.woff2 \ --flavor=woff2 \ --layout-features='*' \ --no-hinting \ --desubroutinize参数逐个说:--text-file决定保留哪些字形,这个文件必须是页面真实用到的字符集合,多留一个字符就多一份体积,少留一个就掉字;--flavor=woff2直接输出网页字体格式,省掉二次转换;--layout-features='*'保留全部 OpenType 特性,连字、上下标这些不会被裁掉;--no-hinting去掉微调指令,屏幕渲染影响很小但体积收益明显;--desubroutinize只在 CFF 轮廓时才需要,把子程序展开,某些老渲染引擎处理子程序会出错。子集化之后务必在目标环境里实际打开一遍页面看掉不掉字,别只看体积变小就收工。
交付前我还会跑一遍自检,重点看三件事:ps_name有没有重复、字符覆盖率够不够、字重是否齐全。
# check_font.py:交付前的三项自检 from fontTools.ttLib import TTFont def check(path, need_chars): f = TTFont(path) cmap = f.getBestCmap() # 取最优字符映射表 ps = f["name"].getDebugName(6) # PostScript 名,唯一性键 missing = [c for c in need_chars if ord(c) not in cmap] print(f"{path}\n PS名: {ps}\n 字形数: {len(cmap)}\n 缺失字符: {''.join(missing) or '无'}") check("out.woff2", "常用汉字ABC123,。")getBestCmap()会自动挑出 Unicode 平台下最完整的那张映射表,不同字体里 cmap 子表数量不一样,手工挑容易挑错。getDebugName(6)是取 PostScript 名的便捷写法。这套检查跑完,字体能不能用基本就有答案了。
踩过的坑是:早期我做子集化时图省事,直接拿页面上可见的文字去抽字符,结果前端动态渲染出来的内容、表单校验提示、日期格式化后的符号全都不在集合里,上线当天页面局部变成方块,回滚得手忙脚乱。从那以后我每次交付字体,都强制走一遍“抓全量字符 → 子集化 → 三项目检 → 目标环境实开”的流程,一步都不跳。希望帮到你。
本文还有配套的精品资源,点击获取