Merge pull request 'v0.0.1' (#1) from dev into master

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-31 07:14:35 +00:00
39 changed files with 4067 additions and 1 deletions
+111
View File
@@ -0,0 +1,111 @@
name: CI/CD
on:
pull_request:
branches:
- master
types:
- closed
jobs:
build-deploy:
runs-on: act_runner_java
if: ${{ github.event.pull_request.merged == true }}
env:
JAVA_HOME: /usr/lib/jvm/java-21-openjdk
steps:
- name: Checkout code
run: |
git clone ${{ github.server_url }}/${{ github.repository }}.git .
git checkout ${{ github.sha }}
- name: Set up environment
run: |
echo "PR #${{ github.event.number }} merged into master"
echo "Source branch: ${{ github.event.pull_request.head.ref }}"
echo "Target branch: ${{ github.event.pull_request.base.ref }}"
- name: Run tests
run: |
echo "Running test suite..."
- name: Build project
run: |
mvn -B -DskipTests clean package source:jar javadoc:jar
- name: Deploy to Nexus
if: success()
run: |
if [ -z "${{ secrets.NEXUS_USERNAME }}" ] || [ -z "${{ secrets.NEXUS_PASSWORD }}" ]; then
echo "Missing secrets.NEXUS_USERNAME or secrets.NEXUS_PASSWORD"
exit 1
fi
mkdir -p ~/.m2
cat > ~/.m2/settings.xml <<EOF
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<servers>
<server>
<id>timi-nexus</id>
<username>${{ secrets.NEXUS_USERNAME }}</username>
<password>${{ secrets.NEXUS_PASSWORD }}</password>
</server>
</servers>
</settings>
EOF
version=$(mvn -q -DforceStdout help:evaluate -Dexpression=project.version)
artifact_id=$(mvn -q -DforceStdout help:evaluate -Dexpression=project.artifactId)
main_jar="target/${artifact_id}-${version}.jar"
sources_jar="target/${artifact_id}-${version}-sources.jar"
javadoc_jar="target/${artifact_id}-${version}-javadoc.jar"
if [ ! -f "$main_jar" ] || [ ! -f "$sources_jar" ] || [ ! -f "$javadoc_jar" ]; then
echo "Missing build artifacts in target"
exit 1
fi
mvn -B deploy:deploy-file \
-Dfile="$main_jar" \
-Dsources="$sources_jar" \
-Djavadoc="$javadoc_jar" \
-DpomFile="./pom.xml" \
-Durl="https://nexus.imyeyu.com/repository/maven-releases/" \
-DrepositoryId="timi-nexus" \
-Dhttps.protocols=TLSv1.2 \
-Djdk.tls.client.protocols=TLSv1.2
- name: Create release
if: ${{ success() && startsWith(github.event.pull_request.title, 'v') }}
env:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }}
RELEASE_TAG: ${{ github.event.pull_request.title }}
RELEASE_TARGET: ${{ github.sha }}
run: |
if [ -z "$GITEA_TOKEN" ]; then
echo "Missing secrets.RUNNER_TOKEN"
exit 1
fi
api_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases"
payload=$(cat <<EOF
{
"tag_name": "$RELEASE_TAG",
"name": "$RELEASE_TAG",
"target_commitish": "$RELEASE_TARGET",
"draft": false,
"prerelease": false
}
EOF
)
response=$(curl -sS -X POST "$api_url" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$payload")
release_id=$(echo "$response" | grep -o '"id":[0-9]*' | head -n 1 | grep -o '[0-9]*')
if [ -z "$release_id" ] || echo "$response" | grep -q '"message"'; then
echo "Create release failed: $response"
exit 1
fi
echo "Release created: id=$release_id"
for asset_path in target/*.jar; do
asset_name=$(basename "$asset_path")
curl -sS -X POST "$api_url/$release_id/assets?name=$asset_name" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$asset_path"
done
+4
View File
@@ -1,3 +1,7 @@
/AGENTS.md
/.agents
/.serena
# ---> Maven # ---> Maven
target/ target/
pom.xml.tag pom.xml.tag
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
</profile>
<profile name="Annotation profile for timi-icon-font" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<processorPath useClasspath="false">
<entry name="$MAVEN_REPOSITORY$/org/projectlombok/lombok/1.18.46/lombok-1.18.46.jar" />
</processorPath>
<module name="timi-icon-font" />
</profile>
</annotationProcessing>
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="timi_nexus" />
<option name="name" value="timi_nexus" />
<option name="url" value="https://nexus.imyeyu.com/repository/maven-public/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
</component>
</project>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="temurin-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+144 -1
View File
@@ -1,3 +1,146 @@
# timi-icon-font # timi-icon-font
SVG 字体图标 纯 Java SVG 图标字体工具库,支持 SVG 构建 TTF,也支持从 TrueType `glyf` 字体导出 SVG 字形。
## 构建 TTF
```java
List<IconGlyph> glyphs = List.of(
IconGlyph.of("home", "e001", "M128 512L512 128L896 512L768 512L768 896L256 896L256 512Z")
);
byte[] ttf = IconFontBuilder.build("TimiIcon", glyphs);
```
也可以直接用 `name -> pathData` 映射并指定起始 Unicode
```java
Map<String, String> paths = new LinkedHashMap<>();
paths.put("home", "M128 512L512 128L896 512L768 512L768 896L256 896L256 512Z");
byte[] ttf = IconFontBuilder.build("TimiIcon", paths, 0xE001);
```
从目录或 JSON 文件构建:
```java
IconFontOptions options = IconFontOptions.defaults("TimiIcon");
IconFontIO.writeTtfFromSvgDirectory(Path.of("icons.ttf"), options, Path.of("icons"), 0xE001);
IconFontIO.writeTtfFromSvgJson(Path.of("icons.ttf"), options, Path.of("icons.json"), 0xE001);
```
如果源图标基于固定画布绘制,例如 `16 x 16`,但图形没有占满整个画布,应显式指定源画布,避免构建 TTF 时按字形外接框被二次放大:
```java
IconFontOptions options = IconFontOptions.defaults("TimiIcon")
.withSourceBounds(0, 0, 16, 16);
```
默认会保留源画布中的原始留白与落点,这对通用图标库更稳妥。如果希望某个方向按实际字形外接框居中,可单独开启:
```java
IconFontOptions options = IconFontOptions.defaults("TimiIcon")
.withSourceBounds(0, 0, 16, 16)
.withAlignment(GlyphAlignment.SOURCE, GlyphAlignment.GLYPH_CENTER);
```
像素风图标如果希望尽量贴齐源网格,可额外启用偏移吸附。它会牺牲部分“数学居中”,换取更稳定的像素边缘:
```java
IconFontOptions options = IconFontOptions.defaults("TimiIcon")
.withSourceBounds(0, 0, 16, 16)
.withAlignment(GlyphAlignment.SOURCE, GlyphAlignment.GLYPH_CENTER)
.withSnapOffsetToGrid(true);
```
## 写入字体元数据
```java
FontMetadata metadata = FontMetadata.defaults("TimiIcon");
metadata.setDesigner("Codex");
metadata.setCopyright("Copyright 2026 Timi");
metadata.setVersion("Version 1.000");
metadata.setLicense("Preview and print embedding only");
metadata.setLicenseUrl("https://example.com/license");
metadata.setEmbeddingRestrictions(FontEmbeddingRestrictions.of(
FontEmbeddingRestrictions.PREVIEW_AND_PRINT
| FontEmbeddingRestrictions.NO_SUBSETTING
));
IconFontOptions options = IconFontOptions.defaults("TimiIcon").withMetadata(metadata);
IconFont font = IconFontBuilder.buildFont(options, glyphs);
```
## TTF 导出 SVG
```java
List<SvgGlyph> glyphs = TtfIconFontReader.readSvgGlyphs(ttfBytes);
String svg = glyphs.getFirst().getSvg();
```
导出为目录或 JSON
```java
IconFontIO.writeSvgDirectory(Path.of("icons.ttf"), Path.of("icons-out"));
IconFontIO.writeSvgJson(Path.of("icons.ttf"), Path.of("icons.json"));
```
## CLI
打包后可通过可执行 jar 调用:
```shell
java -jar target/timi-icon-font-0.0.1.jar build \
-i icons \
-o icons.ttf \
--family TimiIcon \
--start-unicode e001 \
--designer Codex \
--fs-type 0x104
java -jar target/timi-icon-font-0.0.1.jar build \
-i icons.json \
-o icons.ttf \
--input-format json \
--horizontal-alignment source \
--vertical-alignment glyph_center \
--snap-offset-to-grid true \
--source-width 16 \
--source-height 16 \
--metadata-json metadata.json
java -jar target/timi-icon-font-0.0.1.jar export \
-i icons.ttf \
-o icons-out
java -jar target/timi-icon-font-0.0.1.jar export \
-i icons.ttf \
-o icons.json \
--output-format json
```
输入目录格式:`*.svg` 文件名作为图标名称,文件内容为 SVG path 或完整 SVG。
输入 JSON 格式:
```json
{
"home": "M128 512L512 128L896 512L768 512L768 896L256 896L256 512Z"
}
```
## 读取字体信息
```java
FontInfo info = TtfIconFontReader.readFontInfo(ttfBytes);
FontMetadata metadata = info.metadata();
FontMetrics metrics = info.metrics();
List<CharacterMapping> characters = info.characters();
```
## 兼容范围
- SVG path 支持 `M/L/H/V/C/S/Q/T/A/Z` 与相对命令,支持外层 XML、完整 SVG 和多个 `path`,曲线会按配置采样为 TrueType 简单轮廓。
- TTF 导出支持 `cmap format 4/12``glyf` 简单轮廓和常见复合字形。
- 元数据支持 `name` 表常用字段、`OS/2 fsType` 使用限制、weight/width/vendor、字符映射和基础指标信息。
- CFF/OpenType PS 轮廓不是 TrueType `glyf` 数据,当前会抛出明确异常。
+166
View File
@@ -0,0 +1,166 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.imyeyu.font.icon</groupId>
<artifactId>timi-icon-font</artifactId>
<version>0.0.1</version>
<packaging>jar</packaging>
<name>timi-icon-font</name>
<description>SVG icon font builder and TTF SVG exporter.</description>
<properties>
<fx.version>25.0.3</fx.version>
<maven.test.skip>true</maven.test.skip>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-deploy-plugin</artifactId>
<version>3.1.4</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>3.4.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
<configuration>
<proc>full</proc>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-maven-plugin</artifactId>
<version>1.18.20.0</version>
<configuration>
<sourceDirectory>${project.basedir}/src/main/java</sourceDirectory>
<outputDirectory>${project.build.directory}/delombok</outputDirectory>
<addOutputDirectory>false</addOutputDirectory>
<encoding>UTF-8</encoding>
</configuration>
<executions>
<execution>
<phase>generate-sources</phase>
<goals>
<goal>delombok</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.12.0</version>
<configuration>
<sourcepath>${project.build.directory}/delombok</sourcepath>
<encoding>UTF-8</encoding>
<charset>UTF-8</charset>
<docencoding>UTF-8</docencoding>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.imyeyu.font.icon.cli.IconFontCli</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<distributionManagement>
<repository>
<id>timi_nexus</id>
<url>https://nexus.imyeyu.com/repository/maven-releases/</url>
</repository>
</distributionManagement>
<repositories>
<repository>
<id>timi_nexus</id>
<url>https://nexus.imyeyu.com/repository/maven-public/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.imyeyu.java</groupId>
<artifactId>timi-java</artifactId>
<version>0.0.5</version>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>4.7.7</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
</dependency>
<dependency>
<groupId>com.imyeyu.network</groupId>
<artifactId>timi-network</artifactId>
<version>0.0.12</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,107 @@
package com.imyeyu.font.icon;
import com.imyeyu.font.icon.format.IconFontFormats;
import com.imyeyu.font.icon.model.IconFont;
import com.imyeyu.font.icon.model.IconFontOptions;
import com.imyeyu.font.icon.model.IconGlyph;
import com.imyeyu.font.icon.svg.Glyph;
import com.imyeyu.font.icon.svg.GlyphNormalizer;
import com.imyeyu.font.icon.svg.GlyphPoint;
import com.imyeyu.font.icon.svg.SvgPathParser;
import com.imyeyu.font.icon.ttf.TtfWriter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
/// SVG 图标字体构建器
///
/// 入口只负责校验、排序、解析和编排;TTF 表的二进制细节由内部写入器处理
///
/// @author Codex
/// @since 2026-07-13 16:26
public final class IconFontBuilder {
private IconFontBuilder() {
}
/// 使用默认配置构建 TTF 字体
public static byte[] build(String family, Collection<IconGlyph> icons) {
return buildFont(IconFontOptions.defaults(family), icons).getBytes();
}
/// 使用默认配置和指定起始 Unicode 码点,从 `name -> pathData` 映射构建 TTF 字体
public static byte[] build(String family, Map<String, String> svgPaths, int startCodePoint) {
return buildFont(IconFontOptions.defaults(family), svgPaths, startCodePoint).getBytes();
}
/// 使用指定配置构建 TTF 字体字节
public static byte[] build(IconFontOptions options, Collection<IconGlyph> icons) {
return buildFont(options, icons).getBytes();
}
/// 使用指定配置和起始 Unicode 码点,从 `name -> pathData` 映射构建 TTF 字体字节
public static byte[] build(IconFontOptions options, Map<String, String> svgPaths, int startCodePoint) {
return buildFont(options, svgPaths, startCodePoint).getBytes();
}
/// 使用指定配置和起始 Unicode 码点,从 `name -> pathData` 映射构建完整字体结果
public static IconFont buildFont(IconFontOptions options, Map<String, String> svgPaths, int startCodePoint) {
return buildFont(options, IconFontFormats.fromSvgPaths(svgPaths, startCodePoint));
}
/// 使用指定配置构建完整字体结果
public static IconFont buildFont(IconFontOptions options, Collection<IconGlyph> icons) {
if (options == null) {
throw new IllegalArgumentException("options must not be null");
}
if (icons == null || icons.isEmpty()) {
throw new IllegalArgumentException("icons must not be empty");
}
List<IconGlyph> validIcons = icons.stream()
.filter(IconFontBuilder::valid)
.sorted(Comparator.comparingInt(IconGlyph::getCodePoint).thenComparing(IconGlyph::getName))
.toList();
if (validIcons.isEmpty()) {
throw new IllegalArgumentException("icons contains no valid glyph");
}
assertUniqueCodePoints(validIcons);
List<Glyph> glyphs = new ArrayList<>();
glyphs.add(Glyph.isEmpty());
for (IconGlyph icon : validIcons) {
glyphs.add(toGlyph(options, icon));
}
return new IconFont(options.getFamily(), new TtfWriter(options, glyphs).write(), validIcons, options.getMetadata());
}
private static void assertUniqueCodePoints(List<IconGlyph> icons) {
for (int i = 1; i < icons.size(); i++) {
IconGlyph previous = icons.get(i - 1);
IconGlyph current = icons.get(i);
if (previous.getCodePoint() == current.getCodePoint()) {
throw new IllegalArgumentException("存在重复 Unicode 码点 U+%04X: %s, %s".formatted(
current.getCodePoint(),
previous.getName(),
current.getName()
));
}
}
}
private static boolean valid(IconGlyph icon) {
return icon != null
&& icon.getName() != null
&& !icon.getName().isBlank()
&& Character.isValidCodePoint(icon.getCodePoint())
&& icon.getCodePoint() != 0
&& icon.getSvg() != null
&& !icon.getSvg().isBlank();
}
private static Glyph toGlyph(IconFontOptions options, IconGlyph icon) {
List<List<GlyphPoint>> contours = new SvgPathParser(icon.getSvg(), options.getCurveSamples()).parse();
return new Glyph(GlyphNormalizer.normalize(contours, options), icon.getCodePoint(), icon.getName());
}
}
@@ -0,0 +1,316 @@
package com.imyeyu.font.icon.cli;
import com.imyeyu.font.icon.format.IconFontFormats;
import com.imyeyu.font.icon.format.IconFontIO;
import com.imyeyu.font.icon.model.FontEmbeddingRestrictions;
import com.imyeyu.font.icon.model.FontMetadata;
import com.imyeyu.font.icon.model.GlyphAlignment;
import com.imyeyu.font.icon.model.IconFontOptions;
import com.imyeyu.font.icon.model.IconGlyph;
import com.imyeyu.font.icon.model.SvgGlyph;
import com.imyeyu.font.icon.util.UnicodeParser;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Callable;
/// timi-icon-font 命令行入口
///
/// @author Codex
/// @since 2026-07-14 00:00
@Command(
name = "timi-icon-font",
mixinStandardHelpOptions = true,
version = "timi-icon-font 0.0.1",
description = "Build TTF icon fonts from SVG paths and export SVG paths from TTF fonts.",
subcommands = {IconFontCli.BuildCommand.class, IconFontCli.ExportCommand.class}
)
public class IconFontCli implements Callable<Integer> {
static void main(String[] args) {
int exitCode = new CommandLine(new IconFontCli())
.setExecutionExceptionHandler((ex, commandLine, parseResult) -> {
commandLine.getErr().println(ex.getMessage());
return 1;
})
.execute(args);
System.exit(exitCode);
}
@Override
public Integer call() {
CommandLine.usage(this, System.out);
return 0;
}
@Command(
name = "build",
mixinStandardHelpOptions = true,
description = "Build a TTF icon font from a SVG directory or a name/path JSON object."
)
static class BuildCommand implements Callable<Integer> {
@Option(names = {"-i", "--input"}, required = true, description = "Input SVG directory or JSON file.")
private Path input;
@Option(names = {"-o", "--output"}, required = true, description = "Output .ttf file.")
private Path output;
@Option(names = "--input-format", defaultValue = "auto", description = "Input format: auto, dir, json.")
private String inputFormat;
@Option(names = "--start-unicode", defaultValue = "e001", description = "First assigned Unicode code point, for example e001, 0xe001 or U+E001.")
private String startUnicode;
@Option(names = "--units-per-em", defaultValue = "1024", description = "Font units per em.")
private int unitsPerEm;
@Option(names = "--ascent", defaultValue = "896", description = "Font ascent.")
private int ascent;
@Option(names = "--descent", defaultValue = "-128", description = "Font descent.")
private int descent;
@Option(names = "--glyph-padding", defaultValue = "64", description = "Glyph padding in font units.")
private int glyphPadding;
@Option(names = "--horizontal-alignment", defaultValue = "source", description = "Horizontal alignment: source, glyph_center.")
private String horizontalAlignment;
@Option(names = "--vertical-alignment", defaultValue = "source", description = "Vertical alignment: source, glyph_center.")
private String verticalAlignment;
@Option(names = "--snap-offset-to-grid", defaultValue = "false", description = "Snap alignment offsets to the source grid, true for pixel-art icons.")
private boolean snapOffsetToGrid;
@Option(names = "--source-x", defaultValue = "0", description = "Source canvas origin X.")
private int sourceX;
@Option(names = "--source-y", defaultValue = "0", description = "Source canvas origin Y.")
private int sourceY;
@Option(names = "--source-width", defaultValue = "0", description = "Source canvas width, 0 to disable.")
private int sourceWidth;
@Option(names = "--source-height", defaultValue = "0", description = "Source canvas height, 0 to disable.")
private int sourceHeight;
@Option(names = "--curve-samples", defaultValue = "16", description = "Curve sampling count.")
private int curveSamples;
@Mixin
private MetadataOptions metadataOptions;
@Override
public Integer call() throws Exception {
int startCodePoint = UnicodeParser.parseRequired(startUnicode);
FontMetadata metadata = metadataOptions.toMetadata();
IconFontOptions options = new IconFontOptions(
metadata.getFamily(),
unitsPerEm,
ascent,
descent,
glyphPadding,
parseAlignment(horizontalAlignment),
parseAlignment(verticalAlignment),
snapOffsetToGrid,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
curveSamples,
metadata
);
List<IconGlyph> icons = switch (normalizedInputFormat()) {
case "dir" -> IconFontFormats.readSvgDirectory(input, startCodePoint);
case "json" -> IconFontFormats.readSvgJson(input, startCodePoint);
default -> throw new IllegalArgumentException("unsupported input format: " + inputFormat);
};
IconFontIO.writeTtf(output, options, icons);
System.out.printf("Built %s with %d glyphs.%n", output, icons.size());
return 0;
}
private String normalizedInputFormat() {
String format = normalizeFormat(inputFormat);
if (!"auto".equals(format)) {
return format;
}
if (Files.isDirectory(input)) {
return "dir";
}
if (input.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json")) {
return "json";
}
throw new IllegalArgumentException("cannot infer input format, pass --input-format dir or --input-format json");
}
}
@Command(
name = "export",
mixinStandardHelpOptions = true,
description = "Export SVG paths from a TTF font to a directory or a name/path JSON object."
)
static class ExportCommand implements Callable<Integer> {
@Option(names = {"-i", "--input"}, required = true, description = "Input .ttf file.")
private Path input;
@Option(names = {"-o", "--output"}, required = true, description = "Output directory or JSON file.")
private Path output;
@Option(names = "--output-format", defaultValue = "auto", description = "Output format: auto, dir, json.")
private String outputFormat;
@Override
public Integer call() throws Exception {
List<SvgGlyph> glyphs = IconFontIO.readSvgGlyphs(input);
switch (normalizedOutputFormat()) {
case "dir" -> IconFontFormats.writeSvgDirectory(output, glyphs);
case "json" -> IconFontFormats.writeSvgJson(output, glyphs);
default -> throw new IllegalArgumentException("unsupported output format: " + outputFormat);
}
System.out.printf("Exported %d glyphs to %s.%n", glyphs.size(), output);
return 0;
}
private String normalizedOutputFormat() {
String format = normalizeFormat(outputFormat);
if (!"auto".equals(format)) {
return format;
}
if (output.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json")) {
return "json";
}
return "dir";
}
}
static class MetadataOptions {
@Option(names = "--metadata-json", description = "FontMetadata JSON file. CLI metadata options override this file.")
private Path metadataJson;
@Option(names = "--family", description = "Font family.")
private String family;
@Option(names = "--subfamily", description = "Font subfamily.")
private String subfamily;
@Option(names = "--full-name", description = "Full font name.")
private String fullName;
@Option(names = "--post-script-name", description = "PostScript font name.")
private String postScriptName;
@Option(names = "--unique-identifier", description = "Unique font identifier.")
private String uniqueIdentifier;
@Option(names = "--version", description = "Version string.")
private String version;
@Option(names = "--copyright", description = "Copyright text.")
private String copyright;
@Option(names = "--trademark", description = "Trademark text.")
private String trademark;
@Option(names = "--manufacturer", description = "Manufacturer.")
private String manufacturer;
@Option(names = "--designer", description = "Designer.")
private String designer;
@Option(names = "--description", description = "Description.")
private String description;
@Option(names = "--vendor-url", description = "Vendor URL.")
private String vendorUrl;
@Option(names = "--designer-url", description = "Designer URL.")
private String designerUrl;
@Option(names = "--license", description = "License text.")
private String license;
@Option(names = "--license-url", description = "License URL.")
private String licenseUrl;
@Option(names = "--vendor-id", description = "Four-character OS/2 vendor ID.")
private String vendorId;
@Option(names = "--weight-class", description = "OS/2 weight class, 1..1000.")
private Integer weightClass;
@Option(names = "--width-class", description = "OS/2 width class, 1..9.")
private Integer widthClass;
@Option(names = "--fs-type", description = "OS/2 fsType embedding restrictions, for example 0, 0x104.")
private String fsType;
FontMetadata toMetadata() throws Exception {
FontMetadata metadata = metadataJson == null
? FontMetadata.defaults(family == null ? IconFontOptions.DEFAULT_FAMILY : family)
: IconFontFormats.readMetadataJson(metadataJson);
setIfPresent(metadata::setFamily, family);
setIfPresent(metadata::setSubfamily, subfamily);
setIfPresent(metadata::setFullName, fullName);
setIfPresent(metadata::setPostScriptName, postScriptName);
setIfPresent(metadata::setUniqueIdentifier, uniqueIdentifier);
setIfPresent(metadata::setVersion, version);
setIfPresent(metadata::setCopyright, copyright);
setIfPresent(metadata::setTrademark, trademark);
setIfPresent(metadata::setManufacturer, manufacturer);
setIfPresent(metadata::setDesigner, designer);
setIfPresent(metadata::setDescription, description);
setIfPresent(metadata::setVendorUrl, vendorUrl);
setIfPresent(metadata::setDesignerUrl, designerUrl);
setIfPresent(metadata::setLicense, license);
setIfPresent(metadata::setLicenseUrl, licenseUrl);
setIfPresent(metadata::setVendorId, vendorId);
if (weightClass != null) {
metadata.setWeightClass(weightClass);
}
if (widthClass != null) {
metadata.setWidthClass(widthClass);
}
if (fsType != null) {
metadata.setEmbeddingRestrictions(FontEmbeddingRestrictions.of(parseInteger(fsType)));
}
return metadata;
}
private void setIfPresent(java.util.function.Consumer<String> setter, String value) {
if (value != null) {
setter.accept(value);
}
}
}
private static String normalizeFormat(String format) {
return format == null ? "auto" : format.trim().toLowerCase(Locale.ROOT);
}
private static int parseInteger(String value) {
String normalized = value.trim();
if (normalized.startsWith("0x") || normalized.startsWith("0X")) {
return Integer.parseInt(normalized.substring(2), 16);
}
return Integer.parseInt(normalized);
}
private static GlyphAlignment parseAlignment(String value) {
String normalized = value == null ? "source" : value.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "source" -> GlyphAlignment.SOURCE;
case "glyph_center", "glyph-center", "glyphcenter" -> GlyphAlignment.GLYPH_CENTER;
default -> throw new IllegalArgumentException("unsupported alignment: " + value);
};
}
}
@@ -0,0 +1,138 @@
package com.imyeyu.font.icon.format;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.imyeyu.font.icon.model.FontMetadata;
import com.imyeyu.font.icon.model.IconGlyph;
import com.imyeyu.font.icon.model.SvgGlyph;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
/// 图标字体目录和 JSON 格式适配工具
///
/// 目录格式使用 `*.svg` 文件名作为图标名称,文件内容作为 SVG path 或完整 SVG 文本。
/// JSON 格式使用对象字段名作为图标名称,字段值作为 SVG path 或完整 SVG 文本。
///
/// @author Codex
/// @since 2026-07-14 00:00
public final class IconFontFormats {
private static final ObjectMapper JSON = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
private static final TypeReference<LinkedHashMap<String, String>> STRING_MAP = new TypeReference<>() {
};
private IconFontFormats() {
}
/// 从 SVG 目录读取图标,并从指定起始 Unicode 码点连续分配编码
public static List<IconGlyph> readSvgDirectory(Path directory, int startCodePoint) throws IOException {
if (!Files.isDirectory(directory)) {
throw new IllegalArgumentException("input directory does not exist: " + directory);
}
try (Stream<Path> stream = Files.list(directory)) {
Map<String, String> paths = new LinkedHashMap<>();
stream.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().toLowerCase().endsWith(".svg"))
.sorted(Comparator.comparing(path -> path.getFileName().toString()))
.forEach(path -> paths.put(stripExtension(path.getFileName().toString()), readStringUnchecked(path)));
return fromSvgPaths(paths, startCodePoint);
}
}
/// 从 `name -> pathData` JSON 对象读取图标,并从指定起始 Unicode 码点连续分配编码
public static List<IconGlyph> readSvgJson(Path jsonFile, int startCodePoint) throws IOException {
LinkedHashMap<String, String> paths = JSON.readValue(jsonFile.toFile(), STRING_MAP);
return fromSvgPaths(paths, startCodePoint);
}
/// 将 `name -> pathData` 映射转换为图标,并从指定起始 Unicode 码点连续分配编码
public static List<IconGlyph> fromSvgPaths(Map<String, String> paths, int startCodePoint) {
if (paths == null || paths.isEmpty()) {
throw new IllegalArgumentException("svg paths must not be empty");
}
if (!Character.isValidCodePoint(startCodePoint) || startCodePoint == 0) {
throw new IllegalArgumentException("startCodePoint must be a valid non-zero Unicode code point");
}
int codePoint = startCodePoint;
Map<String, IconGlyph> glyphs = new LinkedHashMap<>();
for (Map.Entry<String, String> entry : paths.entrySet()) {
if (entry.getKey() == null || entry.getKey().isBlank()) {
throw new IllegalArgumentException("icon name must not be blank");
}
if (entry.getValue() == null || entry.getValue().isBlank()) {
throw new IllegalArgumentException("svg path must not be blank: " + entry.getKey());
}
if (!Character.isValidCodePoint(codePoint)) {
throw new IllegalArgumentException("assigned Unicode code point is out of range: " + codePoint);
}
glyphs.put(entry.getKey(), new IconGlyph(entry.getKey(), codePoint, entry.getValue()));
codePoint++;
}
return List.copyOf(glyphs.values());
}
/// 将 TTF 导出的 SVG path 写入目录,每个 `*.svg` 文件内容为 pathData
public static void writeSvgDirectory(Path directory, List<SvgGlyph> glyphs) throws IOException {
if (glyphs == null || glyphs.isEmpty()) {
throw new IllegalArgumentException("glyphs must not be empty");
}
Files.createDirectories(directory);
for (SvgGlyph glyph : glyphs) {
Files.writeString(directory.resolve(safeFileName(glyph.getName()) + ".svg"), glyph.getPathData(), StandardCharsets.UTF_8);
}
}
/// 将 TTF 导出的 SVG path 写入 `name -> pathData` JSON 对象
public static void writeSvgJson(Path jsonFile, List<SvgGlyph> glyphs) throws IOException {
if (glyphs == null || glyphs.isEmpty()) {
throw new IllegalArgumentException("glyphs must not be empty");
}
Path parent = jsonFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
JSON.writeValue(jsonFile.toFile(), toSvgPathMap(glyphs));
}
/// 转换为 `name -> pathData` 映射
public static Map<String, String> toSvgPathMap(List<SvgGlyph> glyphs) {
Map<String, String> paths = new LinkedHashMap<>();
for (SvgGlyph glyph : glyphs) {
paths.put(glyph.getName(), glyph.getPathData());
}
return paths;
}
/// 读取字体元数据 JSON 对象
public static FontMetadata readMetadataJson(Path jsonFile) throws IOException {
return JSON.readValue(jsonFile.toFile(), FontMetadata.class);
}
private static String readStringUnchecked(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalArgumentException("failed to read svg file: " + path, e);
}
}
private static String stripExtension(String fileName) {
int dot = fileName.lastIndexOf('.');
return dot <= 0 ? fileName : fileName.substring(0, dot);
}
private static String safeFileName(String name) {
String safe = name.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_").trim();
return safe.isBlank() ? "glyph" : safe;
}
}
@@ -0,0 +1,70 @@
package com.imyeyu.font.icon.format;
import com.imyeyu.font.icon.IconFontBuilder;
import com.imyeyu.font.icon.model.FontInfo;
import com.imyeyu.font.icon.model.IconFont;
import com.imyeyu.font.icon.model.IconFontOptions;
import com.imyeyu.font.icon.model.IconGlyph;
import com.imyeyu.font.icon.model.SvgGlyph;
import com.imyeyu.font.icon.ttf.TtfIconFontReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.List;
/// 图标字体文件读写工具
///
/// @author Codex
/// @since 2026-07-13 16:26
public final class IconFontIO {
private IconFontIO() {
}
/// 构建 TTF 并写入文件
public static IconFont writeTtf(Path path, IconFontOptions options, Collection<IconGlyph> icons) throws IOException {
IconFont font = IconFontBuilder.buildFont(options, icons);
Path parent = path.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.write(path, font.getBytes());
return font;
}
/// 从 SVG 目录构建 TTF 并写入文件
public static IconFont writeTtfFromSvgDirectory(Path output, IconFontOptions options, Path directory, int startCodePoint) throws IOException {
return writeTtf(output, options, IconFontFormats.readSvgDirectory(directory, startCodePoint));
}
/// 从 `name -> pathData` JSON 对象构建 TTF 并写入文件
public static IconFont writeTtfFromSvgJson(Path output, IconFontOptions options, Path jsonFile, int startCodePoint) throws IOException {
return writeTtf(output, options, IconFontFormats.readSvgJson(jsonFile, startCodePoint));
}
/// 读取 TTF 文件并导出 SVG 字形
public static List<SvgGlyph> readSvgGlyphs(Path path) throws IOException {
return TtfIconFontReader.readSvgGlyphs(Files.readAllBytes(path));
}
/// 读取 TTF 文件并将 SVG path 字形导出到目录
public static List<SvgGlyph> writeSvgDirectory(Path ttf, Path directory) throws IOException {
List<SvgGlyph> glyphs = readSvgGlyphs(ttf);
IconFontFormats.writeSvgDirectory(directory, glyphs);
return glyphs;
}
/// 读取 TTF 文件并将 SVG path 字形导出到 `name -> pathData` JSON 对象
public static List<SvgGlyph> writeSvgJson(Path ttf, Path jsonFile) throws IOException {
List<SvgGlyph> glyphs = readSvgGlyphs(ttf);
IconFontFormats.writeSvgJson(jsonFile, glyphs);
return glyphs;
}
/// 读取 TTF 文件完整信息
public static FontInfo readFontInfo(Path path) throws IOException {
return TtfIconFontReader.readFontInfo(Files.readAllBytes(path));
}
}
@@ -0,0 +1,27 @@
package com.imyeyu.font.icon.model;
import lombok.Data;
import lombok.AllArgsConstructor;
/// 字符到字形的编码映射
/// @author Codex
/// @since 2026-07-13 16:26
@Data
@AllArgsConstructor
public class CharacterMapping {
/// Unicode 码点
private int codePoint;
/// 字形索引
private int glyphIndex;
/// 平台 ID
private int platform;
/// 编码 ID
private int encoding;
/// cmap 子表格式
private int format;
}
@@ -0,0 +1,61 @@
package com.imyeyu.font.icon.model;
import lombok.Data;
/// 字体嵌入和使用限制
///
/// `fsType` 来自 OpenType `OS/2` 表。多个限制位可以组合,例如预览打印嵌入加禁止子集化
/// @author Codex
/// @since 2026-07-13 16:26
@Data
public class FontEmbeddingRestrictions {
/// OS/2 fsType 原始位标记
private int fsType;
/// 无嵌入限制
public static final FontEmbeddingRestrictions INSTALLABLE = new FontEmbeddingRestrictions(0);
/// 受限许可嵌入
public static final int RESTRICTED_LICENSE = 0x0002;
/// 预览和打印嵌入
public static final int PREVIEW_AND_PRINT = 0x0004;
/// 可编辑嵌入
public static final int EDITABLE = 0x0008;
/// 禁止子集化
public static final int NO_SUBSETTING = 0x0100;
/// 仅允许位图嵌入
public static final int BITMAP_ONLY = 0x0200;
/// 创建无限制嵌入信息
public FontEmbeddingRestrictions() {
this(0);
}
/// 创建限制信息
public FontEmbeddingRestrictions(int fsType) {
if ((fsType & 0xFFFF0000) != 0) {
throw new IllegalArgumentException("fsType must be an unsigned 16-bit value");
}
this.fsType = fsType;
}
/// 使用原始 fsType 创建限制信息
public static FontEmbeddingRestrictions of(int fsType) {
return new FontEmbeddingRestrictions(fsType);
}
/// 判断是否允许安装式嵌入
public boolean installable() {
return fsType == 0;
}
/// 判断是否设置了指定限制位
public boolean has(int flag) {
return (fsType & flag) != 0;
}
}
@@ -0,0 +1,32 @@
package com.imyeyu.font.icon.model;
import lombok.Data;
import java.util.List;
/// TTF 字体读取结果
/// @author Codex
/// @since 2026-07-13 16:26
@Data
public class FontInfo {
/// 字体元数据
private FontMetadata metadata;
/// 字体指标
private FontMetrics metrics;
/// 字符编码映射
private List<CharacterMapping> characters;
/// SVG 字形列表
private List<SvgGlyph> svgGlyphs;
/// 创建字体读取结果
public FontInfo(FontMetadata metadata, FontMetrics metrics, List<CharacterMapping> characters, List<SvgGlyph> svgGlyphs) {
this.metadata = metadata;
this.metrics = metrics;
this.characters = List.copyOf(characters);
this.svgGlyphs = List.copyOf(svgGlyphs);
}
}
@@ -0,0 +1,96 @@
package com.imyeyu.font.icon.model;
import lombok.Data;
/// 字体标准元数据
///
/// 字段主要写入或读取自 TrueType `name` 表和 `OS/2` 表,包括版权、版本、设计师、许可文本和嵌入限制
/// @author Codex
/// @since 2026-07-13 16:26
@Data
public class FontMetadata {
/// 字体族名称
private String family;
/// 字体子族名称
private String subfamily;
/// 完整字体名称
private String fullName;
/// PostScript 名称
private String postScriptName;
/// 唯一标识
private String uniqueIdentifier;
/// 版本文本
private String version;
/// 版权信息
private String copyright;
/// 商标信息
private String trademark;
/// 制造商
private String manufacturer;
/// 设计师
private String designer;
/// 描述
private String description;
/// 制造商 URL
private String vendorUrl;
/// 设计师 URL
private String designerUrl;
/// 许可说明
private String license;
/// 许可 URL
private String licenseUrl;
/// OS/2 vendor ID
private String vendorId;
/// OS/2 weight class
private int weightClass;
/// OS/2 width class
private int widthClass;
/// 嵌入和使用限制
private FontEmbeddingRestrictions embeddingRestrictions;
/// 返回指定字体族的默认元数据
public static FontMetadata defaults(String family) {
String subfamily = "Regular";
String version = "Version 1.000";
String postScriptName = sanitizedPostScriptName(family);
FontMetadata metadata = new FontMetadata();
metadata.setFamily(family);
metadata.setSubfamily(subfamily);
metadata.setFullName(family + " " + subfamily);
metadata.setPostScriptName(postScriptName);
metadata.setUniqueIdentifier(postScriptName + "; " + version);
metadata.setVersion(version);
metadata.setVendorId("TIMI");
metadata.setWeightClass(400);
metadata.setWidthClass(5);
metadata.setEmbeddingRestrictions(FontEmbeddingRestrictions.INSTALLABLE);
return metadata;
}
private static String sanitizedPostScriptName(String family) {
if (family == null || family.isBlank()) {
return IconFontOptions.DEFAULT_FAMILY;
}
String name = family.replaceAll("[^A-Za-z0-9]", "");
return name.isBlank() ? IconFontOptions.DEFAULT_FAMILY : name;
}
}
@@ -0,0 +1,45 @@
package com.imyeyu.font.icon.model;
import lombok.Data;
import lombok.AllArgsConstructor;
/// 字体指标信息
/// @author Codex
/// @since 2026-07-13 16:26
@Data
@AllArgsConstructor
public class FontMetrics {
/// 每 em 字体单位
private int unitsPerEm;
/// 上升高度
private int ascent;
/// 下降高度
private int descent;
/// 行间距
private int lineGap;
/// 最大前进宽度
private int advanceWidthMax;
/// 字体最小 X
private int xMin;
/// 字体最小 Y
private int yMin;
/// 字体最大 X
private int xMax;
/// 字体最大 Y
private int yMax;
/// 字形数量
private int glyphCount;
/// loca 偏移格式
private int indexToLocFormat;
}
@@ -0,0 +1,17 @@
package com.imyeyu.font.icon.model;
/// 字形对齐模式
///
/// SOURCE 为保留源画布中的原始留白与落点
/// GLYPH_CENTER 为忽略源画布留白,按实际字形外接框居中
///
/// @author Codex
/// @since 2026-07-16 22:20
public enum GlyphAlignment {
/// 保留源画布落点
SOURCE,
/// 按实际字形居中
GLYPH_CENTER
}
@@ -0,0 +1,42 @@
package com.imyeyu.font.icon.model;
import lombok.Data;
import java.util.List;
/// 生成后的图标字体结果
/// @author Codex
/// @since 2026-07-13 16:26
@Data
public class IconFont {
/// 字体族名称
private String family;
/// TTF 字节
private byte[] bytes;
/// 已写入的字形列表
private List<IconGlyph> glyphs;
/// 字体元数据
private FontMetadata metadata;
/// 创建字体结果
public IconFont(String family, byte[] bytes, List<IconGlyph> glyphs, FontMetadata metadata) {
this.family = family;
this.bytes = bytes.clone();
this.glyphs = List.copyOf(glyphs);
this.metadata = metadata == null ? FontMetadata.defaults(family) : metadata;
}
/// 创建字体结果
public IconFont(String family, byte[] bytes, List<IconGlyph> glyphs) {
this(family, bytes, glyphs, FontMetadata.defaults(family));
}
/// 返回 TTF 字节副本
public byte[] getBytes() {
return bytes.clone();
}
}
@@ -0,0 +1,193 @@
package com.imyeyu.font.icon.model;
import lombok.Data;
/// 图标字体构建配置
/// @author Codex
/// @since 2026-07-13 16:26
@Data
public class IconFontOptions {
/// 字体族名称
private String family;
/// 每 em 字体单位
private int unitsPerEm;
/// 上升高度
private int ascent;
/// 下降高度,通常为负数
private int descent;
/// 字形内边距
private int glyphPadding;
/// 水平对齐模式
private GlyphAlignment horizontalAlignment;
/// 垂直对齐模式
private GlyphAlignment verticalAlignment;
/// true 为将对齐偏移吸附到源网格
private boolean snapOffsetToGrid;
/// 源 SVG 画布起点 X,未配置时仅在启用源画布时生效
private int sourceX;
/// 源 SVG 画布起点 Y,未配置时仅在启用源画布时生效
private int sourceY;
/// 源 SVG 画布宽度,0 为按字形外接框自适应
private int sourceWidth;
/// 源 SVG 画布高度,0 为按字形外接框自适应
private int sourceHeight;
/// 贝塞尔曲线离散采样数
private int curveSamples;
/// 字体元数据
private FontMetadata metadata;
/// 默认字体族
public static final String DEFAULT_FAMILY = "TimiIcon";
/// 创建构建配置
public IconFontOptions(String family, int unitsPerEm, int ascent, int descent, int glyphPadding, int curveSamples, FontMetadata metadata) {
this(family, unitsPerEm, ascent, descent, glyphPadding, GlyphAlignment.SOURCE, GlyphAlignment.SOURCE, false, 0, 0, 0, 0, curveSamples, metadata);
}
/// 创建构建配置
public IconFontOptions(String family, int unitsPerEm, int ascent, int descent, int glyphPadding, int sourceX, int sourceY, int sourceWidth, int sourceHeight, int curveSamples, FontMetadata metadata) {
this(family, unitsPerEm, ascent, descent, glyphPadding, GlyphAlignment.SOURCE, GlyphAlignment.SOURCE, false, sourceX, sourceY, sourceWidth, sourceHeight, curveSamples, metadata);
}
/// 创建构建配置
public IconFontOptions(String family, int unitsPerEm, int ascent, int descent, int glyphPadding, GlyphAlignment horizontalAlignment, GlyphAlignment verticalAlignment, boolean snapOffsetToGrid, int sourceX, int sourceY, int sourceWidth, int sourceHeight, int curveSamples, FontMetadata metadata) {
if (family == null || family.isBlank()) {
throw new IllegalArgumentException("family must not be blank");
}
if (unitsPerEm <= 0 || unitsPerEm > 16384) {
throw new IllegalArgumentException("unitsPerEm must be in 1..16384");
}
if (ascent <= descent) {
throw new IllegalArgumentException("ascent must be greater than descent");
}
if (glyphPadding < 0 || glyphPadding * 2 >= unitsPerEm) {
throw new IllegalArgumentException("glyphPadding must fit inside unitsPerEm");
}
if ((sourceWidth == 0) != (sourceHeight == 0)) {
throw new IllegalArgumentException("sourceWidth and sourceHeight must both be zero or both be positive");
}
if (sourceWidth < 0 || sourceHeight < 0) {
throw new IllegalArgumentException("sourceWidth and sourceHeight must not be negative");
}
if (curveSamples < 2 || curveSamples > 128) {
throw new IllegalArgumentException("curveSamples must be in 2..128");
}
horizontalAlignment = horizontalAlignment == null ? GlyphAlignment.SOURCE : horizontalAlignment;
verticalAlignment = verticalAlignment == null ? GlyphAlignment.SOURCE : verticalAlignment;
metadata = metadata == null ? FontMetadata.defaults(family) : metadata;
normalizeMetadata(family, metadata);
this.family = family;
this.unitsPerEm = unitsPerEm;
this.ascent = ascent;
this.descent = descent;
this.glyphPadding = glyphPadding;
this.horizontalAlignment = horizontalAlignment;
this.verticalAlignment = verticalAlignment;
this.snapOffsetToGrid = snapOffsetToGrid;
this.sourceX = sourceX;
this.sourceY = sourceY;
this.sourceWidth = sourceWidth;
this.sourceHeight = sourceHeight;
this.curveSamples = curveSamples;
this.metadata = metadata;
}
/// 创建构建配置
public IconFontOptions(String family, int unitsPerEm, int ascent, int descent, int glyphPadding, int curveSamples) {
this(family, unitsPerEm, ascent, descent, glyphPadding, curveSamples, FontMetadata.defaults(family));
}
/// 返回默认构建配置
public static IconFontOptions defaults() {
return defaults(DEFAULT_FAMILY);
}
/// 返回使用指定字体族的默认构建配置
public static IconFontOptions defaults(String family) {
return new IconFontOptions(family, 1024, 896, -128, 64, 16, FontMetadata.defaults(family));
}
/// 返回替换元数据后的构建配置
public IconFontOptions withMetadata(FontMetadata metadata) {
return new IconFontOptions(family, unitsPerEm, ascent, descent, glyphPadding, horizontalAlignment, verticalAlignment, snapOffsetToGrid, sourceX, sourceY, sourceWidth, sourceHeight, curveSamples, metadata);
}
/// 返回替换源 SVG 画布后的构建配置
public IconFontOptions withSourceBounds(int sourceX, int sourceY, int sourceWidth, int sourceHeight) {
return new IconFontOptions(family, unitsPerEm, ascent, descent, glyphPadding, horizontalAlignment, verticalAlignment, snapOffsetToGrid, sourceX, sourceY, sourceWidth, sourceHeight, curveSamples, metadata);
}
/// 返回替换对齐模式后的构建配置
public IconFontOptions withAlignment(GlyphAlignment horizontalAlignment, GlyphAlignment verticalAlignment) {
return new IconFontOptions(family, unitsPerEm, ascent, descent, glyphPadding, horizontalAlignment, verticalAlignment, snapOffsetToGrid, sourceX, sourceY, sourceWidth, sourceHeight, curveSamples, metadata);
}
/// 返回替换偏移吸附模式后的构建配置
public IconFontOptions withSnapOffsetToGrid(boolean snapOffsetToGrid) {
return new IconFontOptions(family, unitsPerEm, ascent, descent, glyphPadding, horizontalAlignment, verticalAlignment, snapOffsetToGrid, sourceX, sourceY, sourceWidth, sourceHeight, curveSamples, metadata);
}
/// 是否启用源 SVG 画布归一化
public boolean hasSourceBounds() {
return 0 < sourceWidth && 0 < sourceHeight;
}
private static void normalizeMetadata(String family, FontMetadata metadata) {
if (metadata.getFamily() == null || metadata.getFamily().isBlank()) {
metadata.setFamily(family);
}
if (metadata.getSubfamily() == null || metadata.getSubfamily().isBlank()) {
metadata.setSubfamily("Regular");
}
if (metadata.getFullName() == null || metadata.getFullName().isBlank()) {
metadata.setFullName(metadata.getFamily() + " " + metadata.getSubfamily());
}
if (metadata.getPostScriptName() == null || metadata.getPostScriptName().isBlank()) {
metadata.setPostScriptName(sanitizedPostScriptName(metadata.getFamily()));
}
if (metadata.getVersion() == null || metadata.getVersion().isBlank()) {
metadata.setVersion("Version 1.000");
}
if (metadata.getUniqueIdentifier() == null || metadata.getUniqueIdentifier().isBlank()) {
metadata.setUniqueIdentifier(metadata.getPostScriptName() + "; " + metadata.getVersion());
}
if (metadata.getVendorId() == null || metadata.getVendorId().isBlank()) {
metadata.setVendorId("TIMI");
}
metadata.setVendorId((metadata.getVendorId() + " ").substring(0, 4));
if (metadata.getWeightClass() == 0) {
metadata.setWeightClass(400);
}
if (metadata.getWidthClass() == 0) {
metadata.setWidthClass(5);
}
if (metadata.getEmbeddingRestrictions() == null) {
metadata.setEmbeddingRestrictions(FontEmbeddingRestrictions.INSTALLABLE);
}
if (metadata.getWeightClass() < 1 || metadata.getWeightClass() > 1000) {
throw new IllegalArgumentException("metadata.weightClass must be in 1..1000");
}
if (metadata.getWidthClass() < 1 || metadata.getWidthClass() > 9) {
throw new IllegalArgumentException("metadata.widthClass must be in 1..9");
}
}
private static String sanitizedPostScriptName(String family) {
String name = family.replaceAll("[^A-Za-z0-9]", "");
return name.isBlank() ? DEFAULT_FAMILY : name;
}
}
@@ -0,0 +1,48 @@
package com.imyeyu.font.icon.model;
import com.imyeyu.font.icon.util.UnicodeParser;
import lombok.Data;
import java.util.Objects;
/// 待写入字体的 SVG 图标字形
///
/// `svg` 既可以是完整 SVG 文本,也可以只是 `path d` 内容。`codePoint` 支持 BMP 和补充平面码点,
/// 生成器会优先写 `cmap format 4`,必要时补充 `cmap format 12`
/// @author Codex
/// @since 2026-07-13 16:26
@Data
public class IconGlyph {
/// 字形名称
private String name;
/// Unicode 码点
private int codePoint;
/// SVG 文本或路径文本
private String svg;
/// 创建图标字形
public IconGlyph(String name, int codePoint, String svg) {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(svg, "svg");
if (name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
if (!Character.isValidCodePoint(codePoint) || codePoint == 0) {
throw new IllegalArgumentException("codePoint must be a valid non-zero Unicode code point");
}
if (svg.isBlank()) {
throw new IllegalArgumentException("svg must not be blank");
}
this.name = name;
this.codePoint = codePoint;
this.svg = svg;
}
/// 使用十六进制 Unicode 文本创建图标字形
public static IconGlyph of(String name, String unicode, String svg) {
return new IconGlyph(name, UnicodeParser.parseRequired(unicode), svg);
}
}
@@ -0,0 +1,24 @@
package com.imyeyu.font.icon.model;
import lombok.Data;
import lombok.AllArgsConstructor;
/// 从 TTF 字体导出的 SVG 字形
/// @author Codex
/// @since 2026-07-13 16:26
@Data
@AllArgsConstructor
public class SvgGlyph {
/// 字形名称
private String name;
/// Unicode 码点
private int codePoint;
/// SVG `path d` 内容
private String pathData;
/// 完整 SVG 文本
private String svg;
}
@@ -0,0 +1,65 @@
package com.imyeyu.font.icon.svg;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
/// 字形边界
/// @author Codex
/// @since 2026-07-13 16:26
@Data
@AllArgsConstructor
public class Bounds {
/// 最小 X
private int xMin;
/// 最小 Y
private int yMin;
/// 最大 X
private int xMax;
/// 最大 Y
private int yMax;
public static Bounds empty() {
return new Bounds(0, 0, 0, 0);
}
public static Bounds of(List<List<GlyphPoint>> contours) {
if (contours.isEmpty()) {
return empty();
}
int xMin = Integer.MAX_VALUE;
int yMin = Integer.MAX_VALUE;
int xMax = Integer.MIN_VALUE;
int yMax = Integer.MIN_VALUE;
for (List<GlyphPoint> contour : contours) {
for (GlyphPoint point : contour) {
xMin = Math.min(xMin, point.getX());
yMin = Math.min(yMin, point.getY());
xMax = Math.max(xMax, point.getX());
yMax = Math.max(yMax, point.getY());
}
}
return xMin == Integer.MAX_VALUE ? empty() : new Bounds(xMin, yMin, xMax, yMax);
}
public Bounds merge(Glyph glyph) {
Bounds next = of(glyph.getContours());
if (next.equals(empty())) {
return this;
}
if (equals(empty())) {
return next;
}
return new Bounds(
Math.min(xMin, next.getXMin()),
Math.min(yMin, next.getYMin()),
Math.max(xMax, next.getXMax()),
Math.max(yMax, next.getYMax())
);
}
}
@@ -0,0 +1,31 @@
package com.imyeyu.font.icon.svg;
import lombok.Data;
import java.util.List;
/// TrueType 字形
/// @author Codex
/// @since 2026-07-13 16:26
@Data
public class Glyph {
/// 轮廓列表
private List<List<GlyphPoint>> contours;
/// Unicode 码点
private int codePoint;
/// 字形名称
private String name;
public Glyph(List<List<GlyphPoint>> contours, int codePoint, String name) {
this.contours = contours.stream().map(List::copyOf).toList();
this.codePoint = codePoint;
this.name = name;
}
public static Glyph isEmpty() {
return new Glyph(List.of(), 0, ".notdef");
}
}
@@ -0,0 +1,88 @@
package com.imyeyu.font.icon.svg;
import com.imyeyu.font.icon.model.GlyphAlignment;
import com.imyeyu.font.icon.model.IconFontOptions;
import java.util.ArrayList;
import java.util.List;
/// 字形坐标归一化工具
///
/// @author Codex
/// @since 2026-07-13 16:26
public final class GlyphNormalizer {
private GlyphNormalizer() {
}
public static List<List<GlyphPoint>> normalize(List<List<GlyphPoint>> contours, IconFontOptions options) {
Bounds glyphBounds = Bounds.of(contours);
if (glyphBounds.equals(Bounds.empty())) {
return contours;
}
Bounds sourceBounds = resolveSourceBounds(glyphBounds, options);
int width = Math.max(1, sourceBounds.getXMax() - sourceBounds.getXMin());
int height = Math.max(1, sourceBounds.getYMax() - sourceBounds.getYMin());
int glyphWidth = Math.max(1, glyphBounds.getXMax() - glyphBounds.getXMin());
int glyphHeight = Math.max(1, glyphBounds.getYMax() - glyphBounds.getYMin());
double drawable = options.getUnitsPerEm() - options.getGlyphPadding() * 2D;
double scale = drawable / Math.max(width, height);
double offsetX = offsetX(options, sourceBounds, glyphBounds, width, glyphWidth, drawable, scale);
double offsetY = offsetY(options, sourceBounds, glyphBounds, height, glyphHeight, drawable, scale);
List<List<GlyphPoint>> normalized = new ArrayList<>();
for (List<GlyphPoint> contour : contours) {
List<GlyphPoint> normalizedContour = new ArrayList<>();
for (GlyphPoint point : contour) {
int x = (int) Math.round(offsetX + (point.getX() - sourceBounds.getXMin()) * scale);
int y = (int) Math.round(offsetY + (sourceBounds.getYMax() - point.getY()) * scale);
normalizedContour.add(new GlyphPoint(x, y, point.isOnCurve()));
}
if (1 < normalizedContour.size()) {
normalized.add(normalizedContour);
}
}
return normalized;
}
private static double offsetX(IconFontOptions options, Bounds sourceBounds, Bounds glyphBounds, int sourceWidth, int glyphWidth, double drawable, double scale) {
double offset = options.getGlyphPadding() + (drawable - sourceWidth * scale) / 2D;
if (options.getHorizontalAlignment() == GlyphAlignment.GLYPH_CENTER) {
offset += (sourceWidth - glyphWidth) * scale / 2D - (glyphBounds.getXMin() - sourceBounds.getXMin()) * scale;
}
return maybeSnap(offset, scale, options);
}
private static double offsetY(IconFontOptions options, Bounds sourceBounds, Bounds glyphBounds, int sourceHeight, int glyphHeight, double drawable, double scale) {
double offset = options.getDescent() + options.getGlyphPadding() + (drawable - sourceHeight * scale) / 2D;
if (options.getVerticalAlignment() == GlyphAlignment.GLYPH_CENTER) {
offset += (sourceHeight - glyphHeight) * scale / 2D - (sourceBounds.getYMax() - glyphBounds.getYMax()) * scale;
}
return maybeSnap(offset, scale, options);
}
private static double maybeSnap(double offset, double scale, IconFontOptions options) {
if (!options.isSnapOffsetToGrid() || scale <= 0D) {
return offset;
}
return Math.round(offset / scale) * scale;
}
private static Bounds resolveSourceBounds(Bounds glyphBounds, IconFontOptions options) {
if (!options.hasSourceBounds()) {
return glyphBounds;
}
Bounds sourceBounds = new Bounds(
options.getSourceX(),
options.getSourceY(),
options.getSourceX() + options.getSourceWidth(),
options.getSourceY() + options.getSourceHeight()
);
if (glyphBounds.getXMin() < sourceBounds.getXMin()
|| glyphBounds.getYMin() < sourceBounds.getYMin()
|| sourceBounds.getXMax() < glyphBounds.getXMax()
|| sourceBounds.getYMax() < glyphBounds.getYMax()) {
throw new IllegalArgumentException("glyph bounds exceed configured source bounds");
}
return sourceBounds;
}
}
@@ -0,0 +1,31 @@
package com.imyeyu.font.icon.svg;
import lombok.Data;
import lombok.AllArgsConstructor;
/// 字形轮廓点
/// @author Codex
/// @since 2026-07-13 16:26
@Data
@AllArgsConstructor
public class GlyphPoint {
/// X 坐标
private int x;
/// Y 坐标
private int y;
/// 是否在曲线上
private boolean onCurve;
public GlyphPoint move(int dx, int dy) {
return new GlyphPoint(x + dx, y + dy, onCurve);
}
public GlyphPoint transform(double xx, double xy, double yx, double yy, int dx, int dy) {
int nextX = (int) Math.round(x * xx + y * xy + dx);
int nextY = (int) Math.round(x * yx + y * yy + dy);
return new GlyphPoint(nextX, nextY, onCurve);
}
}
@@ -0,0 +1,34 @@
package com.imyeyu.font.icon.svg;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.parser.Parser;
/// SVG path 数据提取器
///
/// 支持完整 SVG、外层 XML 包裹 SVG、多个 path,以及单独的 path d 文本
///
/// @author Codex
/// @since 2026-07-14 01:00
final class SvgPathExtractor {
private SvgPathExtractor() {
}
/// 提取可解析的 SVG path 数据
static String extract(String svg) {
if (svg == null || svg.isBlank()) {
return "";
}
Document document = Jsoup.parse(svg, "", Parser.xmlParser());
StringBuilder builder = new StringBuilder();
for (Element pathElement : document.select("path[d]")) {
String path = pathElement.attr("d");
if (!path.isBlank()) {
builder.append(' ').append(path);
}
}
return builder.isEmpty() ? svg : builder.toString();
}
}
@@ -0,0 +1,359 @@
package com.imyeyu.font.icon.svg;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/// SVG path 解析器
///
/// @author Codex
/// @since 2026-07-13 16:26
public final class SvgPathParser {
private static final Pattern TOKEN = Pattern.compile("[AaCcHhLlMmQqSsTtVvZz]|[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?");
private final List<String> tokens;
private final int curveSamples;
private final List<List<GlyphPoint>> contours = new ArrayList<>();
private int index;
private double x;
private double y;
private double startX;
private double startY;
private double quadX;
private double quadY;
private double cubicX;
private double cubicY;
private char previousCommand;
private List<GlyphPoint> current = new ArrayList<>();
public SvgPathParser(String svg, int curveSamples) {
this.tokens = tokenize(extractPath(svg));
this.curveSamples = curveSamples;
}
public List<List<GlyphPoint>> parse() {
char command = 0;
while (index < tokens.size()) {
String token = tokens.get(index);
if (isCommand(token)) {
command = token.charAt(0);
index++;
}
if (command == 0) {
break;
}
run(command);
previousCommand = command;
}
closeOpenContour(false);
return contours;
}
private void run(char command) {
switch (command) {
case 'M', 'm' -> move(command == 'm');
case 'L', 'l' -> line(command == 'l');
case 'H', 'h' -> horizontal(command == 'h');
case 'V', 'v' -> vertical(command == 'v');
case 'C', 'c' -> cubic(command == 'c');
case 'S', 's' -> smoothCubic(command == 's');
case 'Q', 'q' -> quad(command == 'q');
case 'T', 't' -> smoothQuad(command == 't');
case 'A', 'a' -> arc(command == 'a');
case 'Z', 'z' -> closeOpenContour(true);
default -> index++;
}
}
private void move(boolean relative) {
boolean first = true;
while (hasNumber(2)) {
double nextX = number();
double nextY = number();
if (relative) {
nextX += x;
nextY += y;
}
if (first) {
closeOpenContour(false);
x = nextX;
y = nextY;
startX = x;
startY = y;
current.add(point(x, y));
first = false;
} else {
addLine(nextX, nextY);
}
}
}
private void line(boolean relative) {
while (hasNumber(2)) {
double nextX = number();
double nextY = number();
if (relative) {
nextX += x;
nextY += y;
}
addLine(nextX, nextY);
}
}
private void horizontal(boolean relative) {
while (hasNumber(1)) {
double nextX = number();
if (relative) {
nextX += x;
}
addLine(nextX, y);
}
}
private void vertical(boolean relative) {
while (hasNumber(1)) {
double nextY = number();
if (relative) {
nextY += y;
}
addLine(x, nextY);
}
}
private void cubic(boolean relative) {
while (hasNumber(6)) {
double x1 = number();
double y1 = number();
double x2 = number();
double y2 = number();
double x3 = number();
double y3 = number();
if (relative) {
x1 += x;
y1 += y;
x2 += x;
y2 += y;
x3 += x;
y3 += y;
}
for (int i = 1; i <= curveSamples; i++) {
double t = i / (double) curveSamples;
addLine(cubicValue(x, x1, x2, x3, t), cubicValue(y, y1, y2, y3, t));
}
cubicX = x2;
cubicY = y2;
}
}
private void smoothCubic(boolean relative) {
while (hasNumber(4)) {
double x1 = previousCommand == 'C' || previousCommand == 'c' || previousCommand == 'S' || previousCommand == 's' ? x * 2 - cubicX : x;
double y1 = previousCommand == 'C' || previousCommand == 'c' || previousCommand == 'S' || previousCommand == 's' ? y * 2 - cubicY : y;
double x2 = number();
double y2 = number();
double x3 = number();
double y3 = number();
if (relative) {
x2 += x;
y2 += y;
x3 += x;
y3 += y;
}
for (int i = 1; i <= curveSamples; i++) {
double t = i / (double) curveSamples;
addLine(cubicValue(x, x1, x2, x3, t), cubicValue(y, y1, y2, y3, t));
}
cubicX = x2;
cubicY = y2;
}
}
private void quad(boolean relative) {
while (hasNumber(4)) {
double x1 = number();
double y1 = number();
double x2 = number();
double y2 = number();
if (relative) {
x1 += x;
y1 += y;
x2 += x;
y2 += y;
}
for (int i = 1; i <= curveSamples; i++) {
double t = i / (double) curveSamples;
addLine(quadValue(x, x1, x2, t), quadValue(y, y1, y2, t));
}
quadX = x1;
quadY = y1;
}
}
private void smoothQuad(boolean relative) {
while (hasNumber(2)) {
double x1 = previousCommand == 'Q' || previousCommand == 'q' || previousCommand == 'T' || previousCommand == 't' ? x * 2 - quadX : x;
double y1 = previousCommand == 'Q' || previousCommand == 'q' || previousCommand == 'T' || previousCommand == 't' ? y * 2 - quadY : y;
double x2 = number();
double y2 = number();
if (relative) {
x2 += x;
y2 += y;
}
for (int i = 1; i <= curveSamples; i++) {
double t = i / (double) curveSamples;
addLine(quadValue(x, x1, x2, t), quadValue(y, y1, y2, t));
}
quadX = x1;
quadY = y1;
}
}
private void arc(boolean relative) {
while (hasNumber(7)) {
double rx = number();
double ry = number();
double rotation = number();
double largeArc = number();
double sweep = number();
double nextX = number();
double nextY = number();
if (relative) {
nextX += x;
nextY += y;
}
for (double[] point : approximateArc(x, y, rx, ry, rotation, largeArc != 0, sweep != 0, nextX, nextY, curveSamples)) {
addLine(point[0], point[1]);
}
}
}
private void addLine(double nextX, double nextY) {
GlyphPoint point = point(nextX, nextY);
if (current.isEmpty() || !current.getLast().equals(point)) {
current.add(point);
}
x = nextX;
y = nextY;
}
private void closeOpenContour(boolean lineToStart) {
if (lineToStart && !current.isEmpty()) {
addLine(startX, startY);
}
if (1 < current.size()) {
if (current.getFirst().equals(current.getLast())) {
current.removeLast();
}
if (1 < current.size()) {
contours.add(current);
}
}
current = new ArrayList<>();
x = startX;
y = startY;
}
private boolean hasNumber(int count) {
if (tokens.size() < index + count) {
return false;
}
for (int i = 0; i < count; i++) {
if (isCommand(tokens.get(index + i))) {
return false;
}
}
return true;
}
private double number() {
return Double.parseDouble(tokens.get(index++));
}
private static GlyphPoint point(double svgX, double svgY) {
return new GlyphPoint((int) Math.round(svgX), (int) Math.round(svgY), true);
}
private static String extractPath(String svg) {
return SvgPathExtractor.extract(svg);
}
private static List<String> tokenize(String path) {
List<String> list = new ArrayList<>();
Matcher matcher = TOKEN.matcher(path.replace(',', ' '));
while (matcher.find()) {
list.add(matcher.group());
}
return list;
}
private static boolean isCommand(String token) {
return token.length() == 1 && Character.isLetter(token.charAt(0));
}
private static double cubicValue(double p0, double p1, double p2, double p3, double t) {
double n = 1 - t;
return n * n * n * p0 + 3 * n * n * t * p1 + 3 * n * t * t * p2 + t * t * t * p3;
}
private static double quadValue(double p0, double p1, double p2, double t) {
double n = 1 - t;
return n * n * p0 + 2 * n * t * p1 + t * t * p2;
}
private static List<double[]> approximateArc(double x1, double y1, double rx, double ry, double rotation, boolean largeArc, boolean sweep, double x2, double y2, int samples) {
if (rx == 0 || ry == 0) {
return List.of(new double[] {x2, y2});
}
double phi = Math.toRadians(rotation % 360D);
double cosPhi = Math.cos(phi);
double sinPhi = Math.sin(phi);
double dx = (x1 - x2) / 2D;
double dy = (y1 - y2) / 2D;
double x1p = cosPhi * dx + sinPhi * dy;
double y1p = -sinPhi * dx + cosPhi * dy;
rx = Math.abs(rx);
ry = Math.abs(ry);
double lambda = x1p * x1p / (rx * rx) + y1p * y1p / (ry * ry);
if (1 < lambda) {
double factor = Math.sqrt(lambda);
rx *= factor;
ry *= factor;
}
double numerator = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
double denominator = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
double coefficient = denominator == 0 ? 0 : Math.sqrt(Math.max(0, numerator / denominator));
if (largeArc == sweep) {
coefficient = -coefficient;
}
double cxp = coefficient * rx * y1p / ry;
double cyp = -coefficient * ry * x1p / rx;
double cx = cosPhi * cxp - sinPhi * cyp + (x1 + x2) / 2D;
double cy = sinPhi * cxp + cosPhi * cyp + (y1 + y2) / 2D;
double theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
double delta = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
if (!sweep && 0 < delta) {
delta -= Math.PI * 2D;
} else if (sweep && delta < 0) {
delta += Math.PI * 2D;
}
int count = Math.max(2, (int) Math.ceil(Math.abs(delta) / (Math.PI * 2D) * samples));
List<double[]> points = new ArrayList<>();
for (int i = 1; i <= count; i++) {
double theta = theta1 + delta * i / count;
double px = cx + rx * Math.cos(theta) * cosPhi - ry * Math.sin(theta) * sinPhi;
double py = cy + rx * Math.cos(theta) * sinPhi + ry * Math.sin(theta) * cosPhi;
points.add(new double[] {px, py});
}
return points;
}
private static double angle(double ux, double uy, double vx, double vy) {
double dot = ux * vx + uy * vy;
double length = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy));
double value = length == 0 ? 1 : Math.max(-1, Math.min(1, dot / length));
double angle = Math.acos(value);
return ux * vy - uy * vx < 0 ? -angle : angle;
}
}
@@ -0,0 +1,64 @@
package com.imyeyu.font.icon.ttf;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
/// TrueType 大端字节缓冲区
///
/// @author Codex
/// @since 2026-07-13 16:26
final class FontBuffer {
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
void u8(int value) {
out.write(value & 0xFF);
}
void i16(int value) {
u16(value);
}
void u16(int value) {
out.write((value >>> 8) & 0xFF);
out.write(value & 0xFF);
}
void u32(long value) {
out.write((int) ((value >>> 24) & 0xFF));
out.write((int) ((value >>> 16) & 0xFF));
out.write((int) ((value >>> 8) & 0xFF));
out.write((int) (value & 0xFF));
}
void u64(long value) {
u32(value >>> 32);
u32(value);
}
void tag(String tag) {
byte[] bytes = tag.getBytes(StandardCharsets.US_ASCII);
for (int i = 0; i < 4; i++) {
u8(i < bytes.length ? bytes[i] : 32);
}
}
void bytes(byte[] bytes) {
out.writeBytes(bytes);
}
void pad4() {
while (out.size() % 4 != 0) {
u8(0);
}
}
int length() {
return out.size();
}
byte[] bytes() {
return out.toByteArray();
}
}
@@ -0,0 +1,18 @@
package com.imyeyu.font.icon.ttf;
import lombok.Data;
import lombok.AllArgsConstructor;
/// TTF name 表记录
/// @author Codex
/// @since 2026-07-13 16:26
@Data
@AllArgsConstructor
class NameRecord {
/// 名称 ID
private int id;
/// 名称值
private String value;
}
@@ -0,0 +1,47 @@
package com.imyeyu.font.icon.ttf;
import com.imyeyu.font.icon.model.CharacterMapping;
import com.imyeyu.font.icon.model.FontInfo;
import com.imyeyu.font.icon.model.FontMetadata;
import com.imyeyu.font.icon.model.FontMetrics;
import com.imyeyu.font.icon.model.SvgGlyph;
import java.util.List;
/// TTF 图标字体读取器
///
/// 支持常见 TrueType `glyf` 轮廓字体,能够读取 `cmap format 4/12` 映射并导出 SVG
/// 对 CFF/OpenType PS 轮廓字体会抛出明确异常
///
/// @author Codex
/// @since 2026-07-13 16:26
public final class TtfIconFontReader {
private TtfIconFontReader() {
}
/// 将 TTF 字体导出为 SVG 字形列表
public static List<SvgGlyph> readSvgGlyphs(byte[] ttf) {
return new TtfReader(ttf).readSvgGlyphs();
}
/// 读取 TTF 字体完整信息
public static FontInfo readFontInfo(byte[] ttf) {
return new TtfReader(ttf).readFontInfo();
}
/// 读取 TTF 字体元数据
public static FontMetadata readMetadata(byte[] ttf) {
return new TtfReader(ttf).readMetadata();
}
/// 读取 TTF 字体指标信息
public static FontMetrics readMetrics(byte[] ttf) {
return new TtfReader(ttf).readMetrics();
}
/// 读取 TTF 字符编码映射
public static List<CharacterMapping> readCharacters(byte[] ttf) {
return new TtfReader(ttf).readCharacters();
}
}
@@ -0,0 +1,652 @@
package com.imyeyu.font.icon.ttf;
import com.imyeyu.font.icon.model.CharacterMapping;
import com.imyeyu.font.icon.model.FontEmbeddingRestrictions;
import com.imyeyu.font.icon.model.FontInfo;
import com.imyeyu.font.icon.model.FontMetadata;
import com.imyeyu.font.icon.model.FontMetrics;
import com.imyeyu.font.icon.model.IconFontOptions;
import com.imyeyu.font.icon.model.SvgGlyph;
import com.imyeyu.font.icon.svg.GlyphPoint;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/// TrueType 字体读取器
///
/// @author Codex
/// @since 2026-07-13 16:26
final class TtfReader {
private static final int ARG_1_AND_2_ARE_WORDS = 1;
private static final int ARGS_ARE_XY_VALUES = 2;
private static final int WE_HAVE_A_SCALE = 8;
private static final int MORE_COMPONENTS = 32;
private static final int WE_HAVE_AN_X_AND_Y_SCALE = 64;
private static final int WE_HAVE_A_TWO_BY_TWO = 128;
private static final int WE_HAVE_INSTRUCTIONS = 256;
private final byte[] font;
private final Map<String, Table> tables = new LinkedHashMap<>();
private final Map<Integer, List<List<GlyphPoint>>> glyphCache = new LinkedHashMap<>();
private final int unitsPerEm;
private final int ascent;
private final int descent;
private final int lineGap;
private final int advanceWidthMax;
private final int indexToLocFormat;
private final int numGlyphs;
private final int[] loca;
TtfReader(byte[] font) {
if (font == null || font.length < 12) {
throw new IllegalArgumentException("ttf must not be empty");
}
this.font = font.clone();
readDirectory();
requireTable("glyf");
requireTable("loca");
requireTable("cmap");
Table head = requireTable("head");
Table hhea = requireTable("hhea");
Table maxp = requireTable("maxp");
unitsPerEm = u16(head.getOffset() + 18);
indexToLocFormat = i16(head.getOffset() + 50);
ascent = i16(hhea.getOffset() + 4);
descent = i16(hhea.getOffset() + 6);
lineGap = i16(hhea.getOffset() + 8);
advanceWidthMax = u16(hhea.getOffset() + 10);
numGlyphs = u16(maxp.getOffset() + 4);
loca = readLoca();
}
List<SvgGlyph> readSvgGlyphs() {
Map<Integer, Integer> cmap = readCmap();
List<SvgGlyph> glyphs = new ArrayList<>();
int svgWidth = Math.max(1, advanceWidthMax);
int svgHeight = Math.max(1, ascent - descent);
cmap.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> {
int codePoint = entry.getKey();
int glyphIndex = entry.getValue();
if (glyphIndex <= 0 || numGlyphs <= glyphIndex) {
return;
}
List<List<GlyphPoint>> contours = readGlyph(glyphIndex);
if (contours.isEmpty()) {
return;
}
String path = toPath(contours);
String name = "uni%04X".formatted(codePoint);
String svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 %d %d\"><path d=\"%s\"/></svg>"
.formatted(svgWidth, svgHeight, path);
glyphs.add(new SvgGlyph(name, codePoint, path, svg));
});
return List.copyOf(glyphs);
}
FontInfo readFontInfo() {
return new FontInfo(readMetadata(), readMetrics(), readCharacters(), readSvgGlyphs());
}
FontMetadata readMetadata() {
Map<Integer, String> names = readNameRecords();
Table os2 = tables.get("OS/2");
int weightClass = os2 == null ? 400 : u16(os2.getOffset() + 4);
int widthClass = os2 == null ? 5 : u16(os2.getOffset() + 6);
int fsType = os2 == null ? 0 : u16(os2.getOffset() + 8);
String vendorId = os2 == null ? " " : TtfUtil.readTag(font, os2.getOffset() + 58);
String family = name(names, 1, name(names, 16, IconFontOptions.DEFAULT_FAMILY));
String subfamily = name(names, 2, name(names, 17, "Regular"));
FontMetadata metadata = new FontMetadata();
metadata.setFamily(family);
metadata.setSubfamily(subfamily);
metadata.setFullName(name(names, 4, family + " " + subfamily));
metadata.setPostScriptName(name(names, 6, family.replaceAll("[^A-Za-z0-9]", "")));
metadata.setUniqueIdentifier(name(names, 3, null));
metadata.setVersion(name(names, 5, "Version 1.000"));
metadata.setCopyright(name(names, 0, null));
metadata.setTrademark(name(names, 7, null));
metadata.setManufacturer(name(names, 8, null));
metadata.setDesigner(name(names, 9, null));
metadata.setDescription(name(names, 10, null));
metadata.setVendorUrl(name(names, 11, null));
metadata.setDesignerUrl(name(names, 12, null));
metadata.setLicense(name(names, 13, null));
metadata.setLicenseUrl(name(names, 14, null));
metadata.setVendorId(vendorId);
metadata.setWeightClass(weightClass);
metadata.setWidthClass(widthClass);
metadata.setEmbeddingRestrictions(FontEmbeddingRestrictions.of(fsType));
return metadata;
}
FontMetrics readMetrics() {
Table head = requireTable("head");
return new FontMetrics(
unitsPerEm,
ascent,
descent,
lineGap,
advanceWidthMax,
i16(head.getOffset() + 36),
i16(head.getOffset() + 38),
i16(head.getOffset() + 40),
i16(head.getOffset() + 42),
numGlyphs,
indexToLocFormat
);
}
List<CharacterMapping> readCharacters() {
Table cmap = requireTable("cmap");
int tableCount = u16(cmap.getOffset() + 2);
List<CmapSubtable> candidates = new ArrayList<>();
for (int i = 0; i < tableCount; i++) {
int record = cmap.getOffset() + 4 + i * 8;
int platform = u16(record);
int encoding = u16(record + 2);
int offset = cmap.getOffset() + u32(record + 4);
int format = u16(offset);
if (format == 4 || format == 12) {
candidates.add(new CmapSubtable(platform, encoding, format, offset));
}
}
candidates.sort(Comparator.comparingInt(CmapSubtable::rank));
Map<Integer, CharacterMapping> result = new LinkedHashMap<>();
for (CmapSubtable subtable : candidates) {
Map<Integer, Integer> mappings = subtable.getFormat() == 12 ? readCmap12(subtable.getOffset()) : readCmap4(subtable.getOffset());
for (Map.Entry<Integer, Integer> entry : mappings.entrySet()) {
result.putIfAbsent(entry.getKey(), new CharacterMapping(entry.getKey(), entry.getValue(), subtable.getPlatform(), subtable.getEncoding(), subtable.getFormat()));
}
}
return result.values().stream().sorted(Comparator.comparingInt(CharacterMapping::getCodePoint)).toList();
}
private void readDirectory() {
int tableCount = u16(4);
for (int i = 0; i < tableCount; i++) {
int index = 12 + i * 16;
String tag = TtfUtil.readTag(font, index);
int offset = u32(index + 8);
int length = u32(index + 12);
if (offset < 0 || length < 0 || font.length < offset + length) {
throw new IllegalArgumentException("invalid TTF table: " + tag);
}
tables.put(tag, new Table(offset, length));
}
if (tables.containsKey("CFF ") || tables.containsKey("CFF2")) {
throw new IllegalArgumentException("CFF/OpenType PS outlines are not supported");
}
}
private Table requireTable(String tag) {
Table table = tables.get(tag);
if (table == null) {
throw new IllegalArgumentException("missing TTF table: " + tag);
}
return table;
}
private int[] readLoca() {
Table table = requireTable("loca");
int requiredLength = indexToLocFormat == 0 ? (numGlyphs + 1) * 2 : (numGlyphs + 1) * 4;
if (table.getLength() < requiredLength) {
throw new IllegalArgumentException("invalid loca table length");
}
int[] offsets = new int[numGlyphs + 1];
for (int i = 0; i <= numGlyphs; i++) {
offsets[i] = indexToLocFormat == 0 ? u16(table.getOffset() + i * 2) * 2 : u32(table.getOffset() + i * 4);
}
return offsets;
}
private Map<Integer, Integer> readCmap() {
Map<Integer, Integer> result = new LinkedHashMap<>();
for (CharacterMapping mapping : readCharacters()) {
result.put(mapping.getCodePoint(), mapping.getGlyphIndex());
}
if (result.isEmpty()) {
throw new IllegalArgumentException("no supported cmap subtable found");
}
return result;
}
private Map<Integer, String> readNameRecords() {
Table name = tables.get("name");
if (name == null) {
return Map.of();
}
int count = u16(name.getOffset() + 2);
int stringOffset = name.getOffset() + u16(name.getOffset() + 4);
Map<Integer, NameCandidate> candidates = new LinkedHashMap<>();
for (int i = 0; i < count; i++) {
int record = name.getOffset() + 6 + i * 12;
int platform = u16(record);
int encoding = u16(record + 2);
int language = u16(record + 4);
int id = u16(record + 6);
int length = u16(record + 8);
int offset = stringOffset + u16(record + 10);
if (offset < 0 || font.length < offset + length) {
continue;
}
String value = decodeName(platform, encoding, offset, length);
if (value.isBlank()) {
continue;
}
NameCandidate candidate = new NameCandidate(value, nameRank(platform, encoding, language));
NameCandidate existing = candidates.get(id);
if (existing == null || candidate.getRank() < existing.getRank()) {
candidates.put(id, candidate);
}
}
Map<Integer, String> result = new LinkedHashMap<>();
candidates.forEach((id, candidate) -> result.put(id, candidate.getValue()));
return result;
}
private String decodeName(int platform, int encoding, int offset, int length) {
if (platform == 0 || platform == 3) {
return new String(font, offset, length, StandardCharsets.UTF_16BE).trim();
}
if (platform == 1) {
try {
return new String(font, offset, length, Charset.forName("x-MacRoman")).trim();
} catch (RuntimeException ignored) {
return new String(font, offset, length, StandardCharsets.ISO_8859_1).trim();
}
}
return new String(font, offset, length, StandardCharsets.UTF_8).trim();
}
private int nameRank(int platform, int encoding, int language) {
if (platform == 3 && (encoding == 1 || encoding == 10) && language == 0x0409) {
return 0;
}
if (platform == 3) {
return 1;
}
if (platform == 0) {
return 2;
}
return 3;
}
private String name(Map<Integer, String> names, int id, String fallback) {
String value = names.get(id);
return value == null || value.isBlank() ? fallback : value;
}
private Map<Integer, Integer> readCmap4(int offset) {
Map<Integer, Integer> result = new LinkedHashMap<>();
int segCount = u16(offset + 6) / 2;
int endCodes = offset + 14;
int startCodes = endCodes + segCount * 2 + 2;
int idDeltas = startCodes + segCount * 2;
int idRangeOffsets = idDeltas + segCount * 2;
for (int i = 0; i < segCount; i++) {
int end = u16(endCodes + i * 2);
int start = u16(startCodes + i * 2);
int delta = i16(idDeltas + i * 2);
int rangeOffset = u16(idRangeOffsets + i * 2);
if (start == 0xFFFF && end == 0xFFFF) {
continue;
}
for (int codePoint = start; codePoint <= end; codePoint++) {
int glyphIndex;
if (rangeOffset == 0) {
glyphIndex = (codePoint + delta) & 0xFFFF;
} else {
int glyphOffset = idRangeOffsets + i * 2 + rangeOffset + (codePoint - start) * 2;
glyphIndex = u16(glyphOffset);
if (glyphIndex != 0) {
glyphIndex = (glyphIndex + delta) & 0xFFFF;
}
}
if (glyphIndex != 0) {
result.put(codePoint, glyphIndex);
}
}
}
return result;
}
private Map<Integer, Integer> readCmap12(int offset) {
Map<Integer, Integer> result = new LinkedHashMap<>();
long groupCount = u32Long(offset + 12);
for (int i = 0; i < groupCount; i++) {
int group = offset + 16 + i * 12;
long start = u32Long(group);
long end = u32Long(group + 4);
long glyphStart = u32Long(group + 8);
for (long codePoint = start; codePoint <= end; codePoint++) {
if (Character.isValidCodePoint((int) codePoint)) {
result.put((int) codePoint, (int) (glyphStart + codePoint - start));
}
}
}
return result;
}
private List<List<GlyphPoint>> readGlyph(int glyphIndex) {
List<List<GlyphPoint>> cached = glyphCache.get(glyphIndex);
if (cached != null) {
return cached;
}
if (glyphIndex < 0 || numGlyphs <= glyphIndex || loca[glyphIndex] == loca[glyphIndex + 1]) {
return List.of();
}
Table glyf = requireTable("glyf");
if (loca[glyphIndex] < 0 || loca[glyphIndex + 1] < loca[glyphIndex] || glyf.getLength() < loca[glyphIndex + 1]) {
throw new IllegalArgumentException("invalid glyph loca range");
}
int offset = glyf.getOffset() + loca[glyphIndex];
int contourCount = i16(offset);
List<List<GlyphPoint>> contours = contourCount >= 0
? readSimpleGlyph(offset, contourCount)
: readCompoundGlyph(offset, new ArrayList<>());
glyphCache.put(glyphIndex, contours);
return contours;
}
private List<List<GlyphPoint>> readSimpleGlyph(int offset, int contourCount) {
if (contourCount == 0) {
return List.of();
}
int p = offset + 10;
int[] endPoints = new int[contourCount];
for (int i = 0; i < contourCount; i++) {
endPoints[i] = u16(p);
p += 2;
}
int pointCount = endPoints[contourCount - 1] + 1;
int instructionLength = u16(p);
p += 2 + instructionLength;
int[] flags = new int[pointCount];
for (int i = 0; i < pointCount; i++) {
int flag = u8(p++);
flags[i] = flag;
if ((flag & 8) != 0) {
int repeat = u8(p++);
for (int j = 0; j < repeat; j++) {
if (i + 1 >= pointCount) {
throw new IllegalArgumentException("invalid glyph flags repeat");
}
flags[++i] = flag;
}
}
}
int[] xs = readCoordinates(flags, true, p);
p += coordinateByteLength(flags, true);
int[] ys = readCoordinates(flags, false, p);
List<GlyphPoint> points = new ArrayList<>();
for (int i = 0; i < pointCount; i++) {
points.add(new GlyphPoint(xs[i], ys[i], (flags[i] & 1) != 0));
}
List<List<GlyphPoint>> contours = new ArrayList<>();
int start = 0;
for (int endPoint : endPoints) {
contours.add(List.copyOf(points.subList(start, endPoint + 1)));
start = endPoint + 1;
}
return List.copyOf(contours);
}
private int[] readCoordinates(int[] flags, boolean xAxis, int offset) {
int shortFlag = xAxis ? 2 : 4;
int sameOrPositiveFlag = xAxis ? 16 : 32;
int[] values = new int[flags.length];
int p = offset;
int current = 0;
for (int i = 0; i < flags.length; i++) {
int delta;
if ((flags[i] & shortFlag) != 0) {
delta = u8(p++);
if ((flags[i] & sameOrPositiveFlag) == 0) {
delta = -delta;
}
} else if ((flags[i] & sameOrPositiveFlag) != 0) {
delta = 0;
} else {
delta = i16(p);
p += 2;
}
current += delta;
values[i] = current;
}
return values;
}
private int coordinateByteLength(int[] flags, boolean xAxis) {
int shortFlag = xAxis ? 2 : 4;
int sameFlag = xAxis ? 16 : 32;
int length = 0;
for (int flag : flags) {
if ((flag & shortFlag) != 0) {
length++;
} else if ((flag & sameFlag) == 0) {
length += 2;
}
}
return length;
}
private List<List<GlyphPoint>> readCompoundGlyph(int offset, List<Integer> stack) {
if (stack.contains(offset)) {
throw new IllegalArgumentException("compound glyph reference cycle detected");
}
stack.add(offset);
List<List<GlyphPoint>> contours = new ArrayList<>();
int p = offset + 10;
int flags;
do {
flags = u16(p);
int glyphIndex = u16(p + 2);
p += 4;
int arg1;
int arg2;
if ((flags & ARG_1_AND_2_ARE_WORDS) != 0) {
arg1 = i16(p);
arg2 = i16(p + 2);
p += 4;
} else {
arg1 = (byte) u8(p);
arg2 = (byte) u8(p + 1);
p += 2;
}
int dx = (flags & ARGS_ARE_XY_VALUES) != 0 ? arg1 : 0;
int dy = (flags & ARGS_ARE_XY_VALUES) != 0 ? arg2 : 0;
double xx = 1;
double xy = 0;
double yx = 0;
double yy = 1;
if ((flags & WE_HAVE_A_SCALE) != 0) {
xx = yy = fixed2Dot14(p);
p += 2;
} else if ((flags & WE_HAVE_AN_X_AND_Y_SCALE) != 0) {
xx = fixed2Dot14(p);
yy = fixed2Dot14(p + 2);
p += 4;
} else if ((flags & WE_HAVE_A_TWO_BY_TWO) != 0) {
xx = fixed2Dot14(p);
yx = fixed2Dot14(p + 2);
xy = fixed2Dot14(p + 4);
yy = fixed2Dot14(p + 6);
p += 8;
}
for (List<GlyphPoint> contour : readGlyphComponent(glyphIndex, stack)) {
List<GlyphPoint> transformed = new ArrayList<>();
for (GlyphPoint point : contour) {
transformed.add(point.transform(xx, xy, yx, yy, dx, dy));
}
contours.add(List.copyOf(transformed));
}
} while ((flags & MORE_COMPONENTS) != 0);
if ((flags & WE_HAVE_INSTRUCTIONS) != 0) {
int instructionLength = u16(p);
p += 2 + instructionLength;
}
stack.removeLast();
return List.copyOf(contours);
}
private List<List<GlyphPoint>> readGlyphComponent(int glyphIndex, List<Integer> stack) {
if (glyphIndex < 0 || numGlyphs <= glyphIndex || loca[glyphIndex] == loca[glyphIndex + 1]) {
return List.of();
}
Table glyf = requireTable("glyf");
if (loca[glyphIndex] < 0 || loca[glyphIndex + 1] < loca[glyphIndex] || glyf.getLength() < loca[glyphIndex + 1]) {
throw new IllegalArgumentException("invalid glyph component loca range");
}
int offset = glyf.getOffset() + loca[glyphIndex];
int contourCount = i16(offset);
return contourCount >= 0 ? readSimpleGlyph(offset, contourCount) : readCompoundGlyph(offset, stack);
}
private String toPath(List<List<GlyphPoint>> contours) {
StringBuilder path = new StringBuilder();
for (List<GlyphPoint> contour : contours) {
List<GlyphPoint> normalized = normalizeContour(contour);
if (normalized.isEmpty()) {
continue;
}
GlyphPoint start = normalized.getFirst();
path.append('M').append(start.getX()).append(' ').append(svgY(start.getY()));
int i = 1;
while (i < normalized.size()) {
GlyphPoint point = normalized.get(i);
if (point.isOnCurve()) {
path.append('L').append(point.getX()).append(' ').append(svgY(point.getY()));
i++;
} else {
GlyphPoint next = normalized.get((i + 1) % normalized.size());
GlyphPoint end = next.isOnCurve() ? next : midpoint(point, next);
path.append('Q')
.append(point.getX()).append(' ').append(svgY(point.getY()))
.append(' ')
.append(end.getX()).append(' ').append(svgY(end.getY()));
i += next.isOnCurve() ? 2 : 1;
}
}
path.append('Z');
}
return path.toString();
}
private List<GlyphPoint> normalizeContour(List<GlyphPoint> contour) {
if (contour.isEmpty()) {
return List.of();
}
List<GlyphPoint> points = new ArrayList<>(contour);
GlyphPoint first = points.getFirst();
GlyphPoint last = points.getLast();
if (!first.isOnCurve()) {
if (last.isOnCurve()) {
points.addFirst(last);
points.removeLast();
} else {
points.addFirst(midpoint(last, first));
}
}
return points;
}
private GlyphPoint midpoint(GlyphPoint a, GlyphPoint b) {
return new GlyphPoint((a.getX() + b.getX()) / 2, (a.getY() + b.getY()) / 2, true);
}
private int svgY(int fontY) {
return ascent - fontY;
}
private double fixed2Dot14(int offset) {
return i16(offset) / 16384D;
}
private int u8(int index) {
return font[index] & 0xFF;
}
private int u16(int index) {
return TtfUtil.readUInt16(font, index);
}
private int i16(int index) {
return TtfUtil.readInt16(font, index);
}
private int u32(int index) {
return TtfUtil.readUInt32(font, index);
}
private long u32Long(int index) {
return TtfUtil.readUInt32Long(font, index);
}
/// TTF 表目录项
/// @author Codex
/// @since 2026-07-13 16:26
@Data
@AllArgsConstructor
private static class Table {
/// 表偏移
private int offset;
/// 表长度
private int length;
}
/// cmap 子表候选项
/// @author Codex
/// @since 2026-07-13 16:26
@Data
@AllArgsConstructor
private static class CmapSubtable {
/// 平台 ID
private int platform;
/// 编码 ID
private int encoding;
/// 子表格式
private int format;
/// 子表偏移
private int offset;
int rank() {
if (format == 12 && platform == 3 && encoding == 10) {
return 0;
}
if (format == 4 && platform == 3 && encoding == 1) {
return 1;
}
return format == 12 ? 2 : 3;
}
}
/// name 表候选值
/// @author Codex
/// @since 2026-07-13 16:26
@Data
@AllArgsConstructor
private static class NameCandidate {
/// 字段值
private String value;
/// 优先级
private int rank;
}
}
@@ -0,0 +1,90 @@
package com.imyeyu.font.icon.ttf;
import java.nio.charset.StandardCharsets;
import java.util.List;
/// TrueType 二进制工具
///
/// @author Codex
/// @since 2026-07-13 16:26
final class TtfUtil {
private TtfUtil() {
}
static byte[] concatAligned(List<byte[]> bodies) {
FontBuffer out = new FontBuffer();
for (byte[] body : bodies) {
out.bytes(body);
out.pad4();
}
return out.bytes();
}
static int align4(int length) {
return (length + 3) & ~3;
}
static int highestPowerOfTwo(int value) {
int result = 1;
while (result * 2 <= value) {
result *= 2;
}
return result;
}
static long checksum(byte[] bytes) {
long sum = 0;
for (int i = 0; i < align4(bytes.length); i += 4) {
long value = 0;
for (int j = 0; j < 4; j++) {
int index = i + j;
value = (value << 8) + (index < bytes.length ? bytes[index] & 0xFF : 0);
}
sum = (sum + value) & 0xFFFFFFFFL;
}
return sum;
}
static int tableOffset(byte[] font, String tag) {
int tableCount = readUInt16(font, 4);
for (int i = 0; i < tableCount; i++) {
int index = 12 + i * 16;
String current = new String(font, index, 4, StandardCharsets.US_ASCII);
if (current.equals(tag)) {
return readUInt32(font, index + 8);
}
}
return -1;
}
static int readUInt16(byte[] bytes, int index) {
return ((bytes[index] & 0xFF) << 8) | (bytes[index + 1] & 0xFF);
}
static short readInt16(byte[] bytes, int index) {
return (short) readUInt16(bytes, index);
}
static int readUInt32(byte[] bytes, int index) {
return ((bytes[index] & 0xFF) << 24)
| ((bytes[index + 1] & 0xFF) << 16)
| ((bytes[index + 2] & 0xFF) << 8)
| (bytes[index + 3] & 0xFF);
}
static long readUInt32Long(byte[] bytes, int index) {
return readUInt32(bytes, index) & 0xFFFFFFFFL;
}
static String readTag(byte[] bytes, int index) {
return new String(bytes, index, 4, StandardCharsets.US_ASCII);
}
static void writeUInt32(byte[] bytes, int index, long value) {
bytes[index] = (byte) ((value >>> 24) & 0xFF);
bytes[index + 1] = (byte) ((value >>> 16) & 0xFF);
bytes[index + 2] = (byte) ((value >>> 8) & 0xFF);
bytes[index + 3] = (byte) (value & 0xFF);
}
}
@@ -0,0 +1,483 @@
package com.imyeyu.font.icon.ttf;
import com.imyeyu.font.icon.model.FontMetadata;
import com.imyeyu.font.icon.model.IconFontOptions;
import com.imyeyu.font.icon.svg.Bounds;
import com.imyeyu.font.icon.svg.Glyph;
import com.imyeyu.font.icon.svg.GlyphPoint;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/// TrueType 字体写入器
///
/// @author Codex
/// @since 2026-07-13 16:26
public final class TtfWriter {
private static final long CHECKSUM_ADJUSTMENT = 0xB1B0AFBAL;
private static final long MAC_EPOCH_OFFSET_SECONDS = 2082844800L;
private final IconFontOptions options;
private final List<Glyph> glyphs;
private final Map<String, byte[]> tables = new LinkedHashMap<>();
/// 所有图标使用统一字宽,便于在按钮、标签和输入框中按单元格布局
private static final int ADVANCE_WIDTH_FACTOR = 1;
public TtfWriter(IconFontOptions options, List<Glyph> glyphs) {
this.options = options;
this.glyphs = glyphs;
}
public byte[] write() {
List<byte[]> glyphBodies = new ArrayList<>();
List<Integer> offsets = new ArrayList<>();
Bounds bounds = Bounds.empty();
int offset = 0;
for (Glyph glyph : glyphs) {
offsets.add(offset);
byte[] body = glyf(glyph);
glyphBodies.add(body);
offset += TtfUtil.align4(body.length);
bounds = bounds.merge(glyph);
}
offsets.add(offset);
tables.put("cmap", cmap());
tables.put("glyf", TtfUtil.concatAligned(glyphBodies));
tables.put("head", head(bounds));
tables.put("hhea", hhea());
tables.put("hmtx", hmtx());
tables.put("loca", loca(offsets));
tables.put("maxp", maxp());
tables.put("name", name());
tables.put("OS/2", os2());
tables.put("post", post());
byte[] font = assemble();
TtfUtil.writeUInt32(font, TtfUtil.tableOffset(font, "head") + 8, CHECKSUM_ADJUSTMENT - TtfUtil.checksum(font));
return font;
}
private byte[] assemble() {
List<String> tags = tables.keySet().stream().sorted().toList();
int tableCount = tags.size();
int searchRange = 16 * TtfUtil.highestPowerOfTwo(tableCount);
int entrySelector = (int) (Math.log(TtfUtil.highestPowerOfTwo(tableCount)) / Math.log(2));
int rangeShift = tableCount * 16 - searchRange;
FontBuffer out = new FontBuffer();
out.u32(0x00010000);
out.u16(tableCount);
out.u16(searchRange);
out.u16(entrySelector);
out.u16(rangeShift);
int tableOffset = 12 + tableCount * 16;
Map<String, Integer> offsets = new LinkedHashMap<>();
for (String tag : tags) {
offsets.put(tag, tableOffset);
tableOffset += TtfUtil.align4(tables.get(tag).length);
}
for (String tag : tags) {
byte[] table = tables.get(tag);
out.tag(tag);
out.u32(TtfUtil.checksum(table));
out.u32(offsets.get(tag));
out.u32(table.length);
}
for (String tag : tags) {
out.bytes(tables.get(tag));
out.pad4();
}
return out.bytes();
}
private byte[] cmap() {
List<Glyph> all = glyphs.stream()
.skip(1)
.sorted(Comparator.comparingInt(Glyph::getCodePoint).thenComparing(Glyph::getName))
.toList();
List<Glyph> bmp = all.stream().filter(glyph -> glyph.getCodePoint() <= 0xFFFF).toList();
byte[] format4 = cmapFormat4(bmp);
byte[] format12 = all.stream().anyMatch(glyph -> 0xFFFF < glyph.getCodePoint()) ? cmapFormat12(all) : null;
int records = format12 == null ? 1 : 2;
FontBuffer out = new FontBuffer();
out.u16(0);
out.u16(records);
out.u16(3);
out.u16(1);
out.u32(4 + records * 8L);
if (format12 != null) {
out.u16(3);
out.u16(10);
out.u32(4 + records * 8L + format4.length);
}
out.bytes(format4);
if (format12 != null) {
out.bytes(format12);
}
return out.bytes();
}
private byte[] cmapFormat4(List<Glyph> mapped) {
int segCount = mapped.size() + 1;
int searchRange = 2 * TtfUtil.highestPowerOfTwo(segCount);
int entrySelector = (int) (Math.log(TtfUtil.highestPowerOfTwo(segCount)) / Math.log(2));
int rangeShift = segCount * 2 - searchRange;
FontBuffer format = new FontBuffer();
format.u16(4);
format.u16(16 + segCount * 8);
format.u16(0);
format.u16(segCount * 2);
format.u16(searchRange);
format.u16(entrySelector);
format.u16(rangeShift);
for (Glyph glyph : mapped) {
format.u16(glyph.getCodePoint());
}
format.u16(0xFFFF);
format.u16(0);
for (Glyph glyph : mapped) {
format.u16(glyph.getCodePoint());
}
format.u16(0xFFFF);
for (int i = 0; i < mapped.size(); i++) {
format.u16((glyphIndex(mapped.get(i)) - mapped.get(i).getCodePoint()) & 0xFFFF);
}
format.u16(1);
for (int i = 0; i < segCount; i++) {
format.u16(0);
}
return format.bytes();
}
private byte[] cmapFormat12(List<Glyph> mapped) {
FontBuffer out = new FontBuffer();
out.u16(12);
out.u16(0);
out.u32(16L + mapped.size() * 12L);
out.u32(0);
out.u32(mapped.size());
for (Glyph glyph : mapped) {
out.u32(glyph.getCodePoint());
out.u32(glyph.getCodePoint());
out.u32(glyphIndex(glyph));
}
return out.bytes();
}
private int glyphIndex(Glyph glyph) {
return glyphs.indexOf(glyph);
}
private byte[] glyf(Glyph glyph) {
FontBuffer out = new FontBuffer();
Bounds bounds = Bounds.of(glyph.getContours());
out.i16(glyph.getContours().size());
out.i16(bounds.getXMin());
out.i16(bounds.getYMin());
out.i16(bounds.getXMax());
out.i16(bounds.getYMax());
int pointCount = 0;
for (List<GlyphPoint> contour : glyph.getContours()) {
pointCount += contour.size();
out.u16(pointCount - 1);
}
out.u16(0);
writeFlags(out, glyph);
writeCoordinateDeltas(out, glyph, true);
writeCoordinateDeltas(out, glyph, false);
return out.bytes();
}
private void writeFlags(FontBuffer out, Glyph glyph) {
for (List<GlyphPoint> contour : glyph.getContours()) {
for (GlyphPoint point : contour) {
out.u8(point.isOnCurve() ? 1 : 0);
}
}
}
private void writeCoordinateDeltas(FontBuffer out, Glyph glyph, boolean xAxis) {
int last = 0;
for (List<GlyphPoint> contour : glyph.getContours()) {
for (GlyphPoint point : contour) {
int value = xAxis ? point.getX() : point.getY();
out.i16(value - last);
last = value;
}
}
}
private byte[] head(Bounds bounds) {
long now = Instant.now().getEpochSecond() + MAC_EPOCH_OFFSET_SECONDS;
FontBuffer out = new FontBuffer();
out.u32(0x00010000);
out.u32(0x00010000);
out.u32(0);
out.u32(0x5F0F3CF5);
out.u16(3);
out.u16(options.getUnitsPerEm());
out.u64(now);
out.u64(now);
out.i16(bounds.getXMin());
out.i16(bounds.getYMin());
out.i16(bounds.getXMax());
out.i16(bounds.getYMax());
out.u16(0);
out.u16(8);
out.i16(2);
out.i16(1);
out.i16(0);
return out.bytes();
}
private byte[] hhea() {
int minLeftSideBearing = 0;
int minRightSideBearing = 0;
int xMaxExtent = 0;
boolean initialized = false;
for (Glyph glyph : glyphs) {
GlyphMetrics metrics = metrics(glyph);
if (!initialized) {
minLeftSideBearing = metrics.leftSideBearing();
minRightSideBearing = metrics.rightSideBearing();
xMaxExtent = metrics.xMaxExtent();
initialized = true;
continue;
}
minLeftSideBearing = Math.min(minLeftSideBearing, metrics.leftSideBearing());
minRightSideBearing = Math.min(minRightSideBearing, metrics.rightSideBearing());
xMaxExtent = Math.max(xMaxExtent, metrics.xMaxExtent());
}
FontBuffer out = new FontBuffer();
out.u32(0x00010000);
out.i16(options.getAscent());
out.i16(options.getDescent());
out.i16(0);
out.u16(advanceWidth());
out.i16(minLeftSideBearing);
out.i16(minRightSideBearing);
out.i16(xMaxExtent);
out.i16(1);
out.i16(0);
for (int i = 0; i < 5; i++) {
out.i16(0);
}
out.i16(0);
out.u16(glyphs.size());
return out.bytes();
}
private byte[] hmtx() {
FontBuffer out = new FontBuffer();
for (Glyph glyph : glyphs) {
GlyphMetrics metrics = metrics(glyph);
out.u16(advanceWidth());
out.i16(metrics.leftSideBearing());
}
return out.bytes();
}
private GlyphMetrics metrics(Glyph glyph) {
Bounds bounds = Bounds.of(glyph.getContours());
int leftSideBearing = bounds.equals(Bounds.empty()) ? 0 : bounds.getXMin();
int width = bounds.equals(Bounds.empty()) ? 0 : bounds.getXMax() - bounds.getXMin();
int xMax = bounds.equals(Bounds.empty()) ? 0 : bounds.getXMax();
int xMaxExtent = leftSideBearing + width;
int rightSideBearing = advanceWidth() - xMaxExtent;
return new GlyphMetrics(leftSideBearing, rightSideBearing, xMaxExtent);
}
private int advanceWidth() {
return options.getUnitsPerEm() * ADVANCE_WIDTH_FACTOR;
}
private byte[] loca(List<Integer> offsets) {
FontBuffer out = new FontBuffer();
for (Integer offset : offsets) {
out.u32(offset);
}
return out.bytes();
}
private byte[] maxp() {
int maxPoints = 0;
int maxContours = 0;
for (Glyph glyph : glyphs) {
int pointCount = 0;
for (List<GlyphPoint> contour : glyph.getContours()) {
pointCount += contour.size();
}
maxPoints = Math.max(maxPoints, pointCount);
maxContours = Math.max(maxContours, glyph.getContours().size());
}
FontBuffer out = new FontBuffer();
out.u32(0x00010000);
out.u16(glyphs.size());
out.u16(maxPoints);
out.u16(maxContours);
out.u16(0);
out.u16(0);
out.u16(2);
out.u16(0);
out.u16(0);
out.u16(0);
out.u16(0);
out.u16(0);
out.u16(0);
out.u16(0);
out.u16(0);
return out.bytes();
}
private byte[] name() {
FontMetadata metadata = options.getMetadata();
List<NameRecord> records = new ArrayList<>();
addName(records, 0, metadata.getCopyright());
addName(records, 1, metadata.getFamily());
addName(records, 2, metadata.getSubfamily());
addName(records, 3, metadata.getUniqueIdentifier());
addName(records, 4, metadata.getFullName());
addName(records, 5, metadata.getVersion());
addName(records, 6, metadata.getPostScriptName());
addName(records, 7, metadata.getTrademark());
addName(records, 8, metadata.getManufacturer());
addName(records, 9, metadata.getDesigner());
addName(records, 10, metadata.getDescription());
addName(records, 11, metadata.getVendorUrl());
addName(records, 12, metadata.getDesignerUrl());
addName(records, 13, metadata.getLicense());
addName(records, 14, metadata.getLicenseUrl());
addName(records, 16, metadata.getFamily());
addName(records, 17, metadata.getSubfamily());
FontBuffer stringData = new FontBuffer();
FontBuffer out = new FontBuffer();
out.u16(0);
out.u16(records.size());
out.u16(6 + records.size() * 12);
for (NameRecord record : records) {
byte[] body = record.getValue().getBytes(StandardCharsets.UTF_16BE);
out.u16(3);
out.u16(1);
out.u16(0x0409);
out.u16(record.getId());
out.u16(body.length);
out.u16(stringData.length());
stringData.bytes(body);
}
out.bytes(stringData.bytes());
return out.bytes();
}
private void addName(List<NameRecord> records, int id, String value) {
if (value != null && !value.isBlank()) {
records.add(new NameRecord(id, value));
}
}
private byte[] os2() {
FontMetadata metadata = options.getMetadata();
int firstChar = glyphs.stream().skip(1).mapToInt(Glyph::getCodePoint).filter(code -> code <= 0xFFFF).min().orElse(0);
int lastChar = glyphs.stream().skip(1).mapToInt(Glyph::getCodePoint).filter(code -> code <= 0xFFFF).max().orElse(0xFFFF);
long[] unicodeRanges = unicodeRanges();
FontBuffer out = new FontBuffer();
out.u16(4);
out.i16(500);
out.u16(metadata.getWeightClass());
out.u16(metadata.getWidthClass());
out.u16(metadata.getEmbeddingRestrictions().getFsType());
for (int i = 0; i < 10; i++) {
out.i16(0);
}
out.i16(0);
for (int i = 0; i < 10; i++) {
out.u8(0);
}
for (long range : unicodeRanges) {
out.u32(range);
}
out.tag(metadata.getVendorId());
out.u16(0);
out.u16(firstChar);
out.u16(lastChar);
out.i16(options.getAscent());
out.i16(options.getDescent());
out.i16(0);
out.u16(options.getAscent());
out.u16(-options.getDescent());
out.u32(0);
out.u32(0);
out.i16((int) Math.round(options.getUnitsPerEm() * 0.5));
out.i16((int) Math.round(options.getUnitsPerEm() * 0.7));
out.u16(0);
out.u16(32);
out.u16(0);
return out.bytes();
}
private long[] unicodeRanges() {
long[] ranges = new long[4];
for (Glyph glyph : glyphs.stream().skip(1).toList()) {
int bit = unicodeRangeBit(glyph.getCodePoint());
if (0 <= bit) {
ranges[bit / 32] |= 1L << (bit % 32);
}
}
return ranges;
}
private int unicodeRangeBit(int codePoint) {
if (codePoint <= 0x007F) {
return 0;
}
if (codePoint <= 0x00FF) {
return 1;
}
if (0x0100 <= codePoint && codePoint <= 0x017F) {
return 2;
}
if (0x0370 <= codePoint && codePoint <= 0x03FF) {
return 7;
}
if (0x0400 <= codePoint && codePoint <= 0x052F) {
return 9;
}
if (0x0590 <= codePoint && codePoint <= 0x05FF) {
return 11;
}
if (0x0600 <= codePoint && codePoint <= 0x06FF) {
return 13;
}
if (0x0900 <= codePoint && codePoint <= 0x097F) {
return 15;
}
if (0x4E00 <= codePoint && codePoint <= 0x9FFF) {
return 59;
}
if (0xE000 <= codePoint && codePoint <= 0xF8FF) {
return 60;
}
return -1;
}
private byte[] post() {
FontBuffer out = new FontBuffer();
out.u32(0x00030000);
out.u32(0);
out.i16(0);
out.i16(0);
out.u32(0);
out.u32(0);
out.u32(0);
out.u32(0);
out.u32(0);
return out.bytes();
}
private record GlyphMetrics(int leftSideBearing, int rightSideBearing, int xMaxExtent) {
}
}
@@ -0,0 +1,44 @@
package com.imyeyu.font.icon.util;
/// Unicode 码点解析器
///
/// @author Codex
/// @since 2026-07-13 16:26
public final class UnicodeParser {
private UnicodeParser() {
}
public static int parseRequired(String unicode) {
int codePoint = parse(unicode);
if (codePoint <= 0 || !Character.isValidCodePoint(codePoint)) {
throw new IllegalArgumentException("invalid Unicode code point: " + unicode);
}
return codePoint;
}
public static int parse(String unicode) {
if (unicode == null) {
return -1;
}
String value = unicode.trim()
.replace("&#x", "")
.replace("&#X", "")
.replace("&#", "")
.replace("\\u", "")
.replace("\\U", "")
.replace("U+", "")
.replace("u+", "")
.replace("0x", "")
.replace("0X", "")
.replace(";", "");
try {
if (value.length() == 1) {
return value.codePointAt(0);
}
return Integer.parseInt(value, 16);
} catch (NumberFormatException e) {
return -1;
}
}
}
@@ -0,0 +1,13 @@
import com.imyeyu.io.IO;
import com.imyeyu.network.FileRequest;
private static final String API_TTF_FILE = "http://localhost:8091/icon/export/ttf";
private static final Path TEST_FOLDER = Path.of("target/ttf-test");
private static final Path HTML_TO = TEST_FOLDER.resolve("index.html");
private static final Path TTF_PATH = TEST_FOLDER.resolve("timi-icon.ttf");
void main() throws Exception {
IO.resourceToDisk(getClass(), "index.html", HTML_TO.toFile().getAbsolutePath());
FileRequest.get(API_TTF_FILE).toFile(TTF_PATH.toFile());
}
+330
View File
@@ -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>