v0.0.1 #1
@@ -1,3 +1,7 @@
|
|||||||
|
/AGENTS.md
|
||||||
|
/.agents
|
||||||
|
/.serena
|
||||||
|
|
||||||
# ---> Maven
|
# ---> Maven
|
||||||
target/
|
target/
|
||||||
pom.xml.tag
|
pom.xml.tag
|
||||||
|
|||||||
Generated
+3
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
Generated
+22
@@ -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>
|
||||||
Generated
+7
@@ -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>
|
||||||
Generated
+25
@@ -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>
|
||||||
Generated
+14
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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` 数据,当前会抛出明确异常。
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TTF 字体预览</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "Microsoft YaHei UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #222;
|
||||||
|
background: #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: 0 0 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: "TimiIconPreview", sans-serif;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decimal {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>TTF 字体预览</h1>
|
||||||
|
<p class="tip">读取同目录的 <code>timi-icon.ttf</code>,解析字体映射并展示可用字形</p>
|
||||||
|
<div id="status">正在加载...</div>
|
||||||
|
<div id="grid" class="grid"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const TTF_FILE = "./timi-icon.ttf";
|
||||||
|
const statusElement = document.getElementById("status");
|
||||||
|
const gridElement = document.getElementById("grid");
|
||||||
|
|
||||||
|
function createReader(buffer) {
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
return {
|
||||||
|
u16(offset) {
|
||||||
|
return view.getUint16(offset, false);
|
||||||
|
},
|
||||||
|
i16(offset) {
|
||||||
|
return view.getInt16(offset, false);
|
||||||
|
},
|
||||||
|
u32(offset) {
|
||||||
|
return view.getUint32(offset, false);
|
||||||
|
},
|
||||||
|
tag(offset) {
|
||||||
|
return String.fromCharCode(
|
||||||
|
view.getUint8(offset),
|
||||||
|
view.getUint8(offset + 1),
|
||||||
|
view.getUint8(offset + 2),
|
||||||
|
view.getUint8(offset + 3)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
text(offset, length) {
|
||||||
|
let text = "";
|
||||||
|
for (let index = 0; index < length; index++) {
|
||||||
|
text += String.fromCharCode(view.getUint8(offset + index));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTables(reader) {
|
||||||
|
const numTables = reader.u16(4);
|
||||||
|
const tables = new Map();
|
||||||
|
for (let index = 0; index < numTables; index++) {
|
||||||
|
const offset = 12 + index * 16;
|
||||||
|
tables.set(reader.tag(offset), {
|
||||||
|
offset: reader.u32(offset + 8),
|
||||||
|
length: reader.u32(offset + 12)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readName(reader, table) {
|
||||||
|
if (!table) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const tableOffset = table.offset;
|
||||||
|
const count = reader.u16(tableOffset + 2);
|
||||||
|
const storageOffset = reader.u16(tableOffset + 4);
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
const recordOffset = tableOffset + 6 + index * 12;
|
||||||
|
const platformId = reader.u16(recordOffset);
|
||||||
|
const nameId = reader.u16(recordOffset + 6);
|
||||||
|
const length = reader.u16(recordOffset + 8);
|
||||||
|
const stringOffset = reader.u16(recordOffset + 10);
|
||||||
|
if (nameId !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const valueOffset = tableOffset + storageOffset + stringOffset;
|
||||||
|
if (platformId === 3) {
|
||||||
|
let text = "";
|
||||||
|
for (let cursor = 0; cursor < length; cursor += 2) {
|
||||||
|
const code = reader.u16(valueOffset + cursor);
|
||||||
|
if (code !== 0) {
|
||||||
|
text += String.fromCharCode(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (platformId === 1) {
|
||||||
|
const text = reader.text(valueOffset, length).trim();
|
||||||
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFormat4(reader, subtableOffset) {
|
||||||
|
const segmentCount = reader.u16(subtableOffset + 6) / 2;
|
||||||
|
const endCodesOffset = subtableOffset + 14;
|
||||||
|
const startCodesOffset = endCodesOffset + segmentCount * 2 + 2;
|
||||||
|
const idDeltasOffset = startCodesOffset + segmentCount * 2;
|
||||||
|
const idRangeOffsetsOffset = idDeltasOffset + segmentCount * 2;
|
||||||
|
const codePoints = new Set();
|
||||||
|
|
||||||
|
for (let index = 0; index < segmentCount; index++) {
|
||||||
|
const endCode = reader.u16(endCodesOffset + index * 2);
|
||||||
|
const startCode = reader.u16(startCodesOffset + index * 2);
|
||||||
|
const delta = reader.i16(idDeltasOffset + index * 2);
|
||||||
|
const rangeOffset = reader.u16(idRangeOffsetsOffset + index * 2);
|
||||||
|
|
||||||
|
if (endCode === 0xFFFF && startCode === 0xFFFF) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let codePoint = startCode; codePoint <= endCode; codePoint++) {
|
||||||
|
let glyphIndex;
|
||||||
|
if (rangeOffset === 0) {
|
||||||
|
glyphIndex = (codePoint + delta) & 0xFFFF;
|
||||||
|
} else {
|
||||||
|
const glyphOffset = idRangeOffsetsOffset + index * 2 + rangeOffset + (codePoint - startCode) * 2;
|
||||||
|
glyphIndex = reader.u16(glyphOffset);
|
||||||
|
if (glyphIndex !== 0) {
|
||||||
|
glyphIndex = (glyphIndex + delta) & 0xFFFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (glyphIndex !== 0) {
|
||||||
|
codePoints.add(codePoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codePoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFormat12(reader, subtableOffset) {
|
||||||
|
const groups = reader.u32(subtableOffset + 12);
|
||||||
|
const codePoints = new Set();
|
||||||
|
for (let index = 0; index < groups; index++) {
|
||||||
|
const groupOffset = subtableOffset + 16 + index * 12;
|
||||||
|
const startCharCode = reader.u32(groupOffset);
|
||||||
|
const endCharCode = reader.u32(groupOffset + 4);
|
||||||
|
for (let codePoint = startCharCode; codePoint <= endCharCode; codePoint++) {
|
||||||
|
codePoints.add(codePoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codePoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCmap(reader, table) {
|
||||||
|
if (!table) {
|
||||||
|
throw new Error("字体缺少 cmap 表");
|
||||||
|
}
|
||||||
|
const tableOffset = table.offset;
|
||||||
|
const numTables = reader.u16(tableOffset + 2);
|
||||||
|
let bestUnicodeOffset = 0;
|
||||||
|
let fallbackOffset = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < numTables; index++) {
|
||||||
|
const recordOffset = tableOffset + 4 + index * 8;
|
||||||
|
const platformId = reader.u16(recordOffset);
|
||||||
|
const encodingId = reader.u16(recordOffset + 2);
|
||||||
|
const subtableOffset = tableOffset + reader.u32(recordOffset + 4);
|
||||||
|
const format = reader.u16(subtableOffset);
|
||||||
|
if (platformId === 3 && encodingId === 10 && format === 12) {
|
||||||
|
bestUnicodeOffset = subtableOffset;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ((platformId === 3 || platformId === 0) && (format === 4 || format === 12) && fallbackOffset === 0) {
|
||||||
|
fallbackOffset = subtableOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedOffset = bestUnicodeOffset || fallbackOffset;
|
||||||
|
if (selectedOffset === 0) {
|
||||||
|
throw new Error("未找到可解析的 cmap 子表");
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = reader.u16(selectedOffset);
|
||||||
|
const codePoints = format === 12
|
||||||
|
? parseFormat12(reader, selectedOffset)
|
||||||
|
: parseFormat4(reader, selectedOffset);
|
||||||
|
|
||||||
|
return Array.from(codePoints).sort((left, right) => left - right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createItem(codePoint) {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "item";
|
||||||
|
|
||||||
|
const icon = document.createElement("div");
|
||||||
|
icon.className = "icon";
|
||||||
|
icon.textContent = String.fromCodePoint(codePoint);
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "meta";
|
||||||
|
|
||||||
|
const code = document.createElement("div");
|
||||||
|
code.className = "code";
|
||||||
|
code.textContent = "U+%s".replace("%s", codePoint.toString(16).toUpperCase().padStart(4, "0"));
|
||||||
|
|
||||||
|
const decimal = document.createElement("div");
|
||||||
|
decimal.className = "decimal";
|
||||||
|
decimal.textContent = "十进制 %d".replace("%d", codePoint);
|
||||||
|
|
||||||
|
meta.appendChild(code);
|
||||||
|
meta.appendChild(decimal);
|
||||||
|
item.appendChild(icon);
|
||||||
|
item.appendChild(meta);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFontFace() {
|
||||||
|
const font = new FontFace("TimiIconPreview", "url(%s)".replace("%s", TTF_FILE));
|
||||||
|
await font.load();
|
||||||
|
document.fonts.add(font);
|
||||||
|
return font;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFont() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(TTF_FILE);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("读取 timi-icon.ttf 失败");
|
||||||
|
}
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
const reader = createReader(buffer);
|
||||||
|
const tables = readTables(reader);
|
||||||
|
const family = readName(reader, tables.get("name")) || "未命名字体";
|
||||||
|
const codePoints = parseCmap(reader, tables.get("cmap"));
|
||||||
|
await loadFontFace();
|
||||||
|
statusElement.textContent = "字体 %s,共 %d 个字形".replace("%s", family).replace("%d", codePoints.length);
|
||||||
|
for (const codePoint of codePoints) {
|
||||||
|
gridElement.appendChild(createItem(codePoint));
|
||||||
|
}
|
||||||
|
if (codePoints.length < 1) {
|
||||||
|
statusElement.className = "error";
|
||||||
|
statusElement.textContent = "字体已加载,但没有可展示的字符映射";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.className = "error";
|
||||||
|
statusElement.textContent = "加载失败,请先运行 BuildTTF.java,并通过本地静态服务打开 target/ttf-test/index.html";
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFont();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user