diff --git a/.gitignore b/.gitignore index 25211b2..79f0f06 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +/AGENTS.md +/.agents +/.serena + # ---> Maven target/ pom.xml.tag diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..583df3a --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..658f16a --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..2de1512 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index b915dd2..dc0266f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,146 @@ # timi-icon-font -SVG 字体图标 \ No newline at end of file +纯 Java SVG 图标字体工具库,支持 SVG 构建 TTF,也支持从 TrueType `glyf` 字体导出 SVG 字形。 + +## 构建 TTF + +```java +List 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 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 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 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` 数据,当前会抛出明确异常。 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..e9d7fcb --- /dev/null +++ b/pom.xml @@ -0,0 +1,166 @@ + + + 4.0.0 + + com.imyeyu.font.icon + timi-icon-font + 0.0.1 + jar + + timi-icon-font + SVG icon font builder and TTF SVG exporter. + + + 25.0.3 + true + 25 + 25 + 25 + UTF-8 + UTF-8 + + + + + + org.apache.maven.plugins + maven-deploy-plugin + 3.1.4 + + + org.apache.maven.plugins + maven-source-plugin + 3.4.0 + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + full + + + org.projectlombok + lombok + 1.18.46 + + + + + + org.projectlombok + lombok-maven-plugin + 1.18.20.0 + + ${project.basedir}/src/main/java + ${project.build.directory}/delombok + false + UTF-8 + + + + generate-sources + + delombok + + + + + + org.projectlombok + lombok + 1.18.46 + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.12.0 + + ${project.build.directory}/delombok + UTF-8 + UTF-8 + UTF-8 + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.1 + + + package + + shade + + + false + + + com.imyeyu.font.icon.cli.IconFontCli + + + + + + + + + + + + timi_nexus + https://nexus.imyeyu.com/repository/maven-releases/ + + + + + + timi_nexus + https://nexus.imyeyu.com/repository/maven-public/ + + true + + + true + + + + + + + com.imyeyu.java + timi-java + 0.0.5 + + + info.picocli + picocli + 4.7.7 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.2 + + + org.jsoup + jsoup + 1.17.2 + + + org.projectlombok + lombok + 1.18.46 + + + com.imyeyu.network + timi-network + 0.0.12 + test + + + diff --git a/src/main/java/com/imyeyu/font/icon/IconFontBuilder.java b/src/main/java/com/imyeyu/font/icon/IconFontBuilder.java new file mode 100644 index 0000000..b5e3824 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/IconFontBuilder.java @@ -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 icons) { + return buildFont(IconFontOptions.defaults(family), icons).getBytes(); + } + + /// 使用默认配置和指定起始 Unicode 码点,从 `name -> pathData` 映射构建 TTF 字体 + public static byte[] build(String family, Map svgPaths, int startCodePoint) { + return buildFont(IconFontOptions.defaults(family), svgPaths, startCodePoint).getBytes(); + } + + /// 使用指定配置构建 TTF 字体字节 + public static byte[] build(IconFontOptions options, Collection icons) { + return buildFont(options, icons).getBytes(); + } + + /// 使用指定配置和起始 Unicode 码点,从 `name -> pathData` 映射构建 TTF 字体字节 + public static byte[] build(IconFontOptions options, Map svgPaths, int startCodePoint) { + return buildFont(options, svgPaths, startCodePoint).getBytes(); + } + + /// 使用指定配置和起始 Unicode 码点,从 `name -> pathData` 映射构建完整字体结果 + public static IconFont buildFont(IconFontOptions options, Map svgPaths, int startCodePoint) { + return buildFont(options, IconFontFormats.fromSvgPaths(svgPaths, startCodePoint)); + } + + /// 使用指定配置构建完整字体结果 + public static IconFont buildFont(IconFontOptions options, Collection 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 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 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 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> contours = new SvgPathParser(icon.getSvg(), options.getCurveSamples()).parse(); + return new Glyph(GlyphNormalizer.normalize(contours, options), icon.getCodePoint(), icon.getName()); + } +} diff --git a/src/main/java/com/imyeyu/font/icon/cli/IconFontCli.java b/src/main/java/com/imyeyu/font/icon/cli/IconFontCli.java new file mode 100644 index 0000000..89d1233 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/cli/IconFontCli.java @@ -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 { + + 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 { + + @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 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 { + + @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 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 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); + }; + } +} diff --git a/src/main/java/com/imyeyu/font/icon/format/IconFontFormats.java b/src/main/java/com/imyeyu/font/icon/format/IconFontFormats.java new file mode 100644 index 0000000..1fdee1c --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/format/IconFontFormats.java @@ -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> STRING_MAP = new TypeReference<>() { + }; + + private IconFontFormats() { + } + + /// 从 SVG 目录读取图标,并从指定起始 Unicode 码点连续分配编码 + public static List readSvgDirectory(Path directory, int startCodePoint) throws IOException { + if (!Files.isDirectory(directory)) { + throw new IllegalArgumentException("input directory does not exist: " + directory); + } + try (Stream stream = Files.list(directory)) { + Map 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 readSvgJson(Path jsonFile, int startCodePoint) throws IOException { + LinkedHashMap paths = JSON.readValue(jsonFile.toFile(), STRING_MAP); + return fromSvgPaths(paths, startCodePoint); + } + + /// 将 `name -> pathData` 映射转换为图标,并从指定起始 Unicode 码点连续分配编码 + public static List fromSvgPaths(Map 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 glyphs = new LinkedHashMap<>(); + for (Map.Entry 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 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 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 toSvgPathMap(List glyphs) { + Map 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; + } +} diff --git a/src/main/java/com/imyeyu/font/icon/format/IconFontIO.java b/src/main/java/com/imyeyu/font/icon/format/IconFontIO.java new file mode 100644 index 0000000..8455dfd --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/format/IconFontIO.java @@ -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 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 readSvgGlyphs(Path path) throws IOException { + return TtfIconFontReader.readSvgGlyphs(Files.readAllBytes(path)); + } + + /// 读取 TTF 文件并将 SVG path 字形导出到目录 + public static List writeSvgDirectory(Path ttf, Path directory) throws IOException { + List glyphs = readSvgGlyphs(ttf); + IconFontFormats.writeSvgDirectory(directory, glyphs); + return glyphs; + } + + /// 读取 TTF 文件并将 SVG path 字形导出到 `name -> pathData` JSON 对象 + public static List writeSvgJson(Path ttf, Path jsonFile) throws IOException { + List glyphs = readSvgGlyphs(ttf); + IconFontFormats.writeSvgJson(jsonFile, glyphs); + return glyphs; + } + + /// 读取 TTF 文件完整信息 + public static FontInfo readFontInfo(Path path) throws IOException { + return TtfIconFontReader.readFontInfo(Files.readAllBytes(path)); + } +} diff --git a/src/main/java/com/imyeyu/font/icon/model/CharacterMapping.java b/src/main/java/com/imyeyu/font/icon/model/CharacterMapping.java new file mode 100644 index 0000000..da73e56 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/model/CharacterMapping.java @@ -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; +} diff --git a/src/main/java/com/imyeyu/font/icon/model/FontEmbeddingRestrictions.java b/src/main/java/com/imyeyu/font/icon/model/FontEmbeddingRestrictions.java new file mode 100644 index 0000000..a4860c9 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/model/FontEmbeddingRestrictions.java @@ -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; + } +} diff --git a/src/main/java/com/imyeyu/font/icon/model/FontInfo.java b/src/main/java/com/imyeyu/font/icon/model/FontInfo.java new file mode 100644 index 0000000..f40cc52 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/model/FontInfo.java @@ -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 characters; + + /// SVG 字形列表 + private List svgGlyphs; + + /// 创建字体读取结果 + public FontInfo(FontMetadata metadata, FontMetrics metrics, List characters, List svgGlyphs) { + this.metadata = metadata; + this.metrics = metrics; + this.characters = List.copyOf(characters); + this.svgGlyphs = List.copyOf(svgGlyphs); + } +} diff --git a/src/main/java/com/imyeyu/font/icon/model/FontMetadata.java b/src/main/java/com/imyeyu/font/icon/model/FontMetadata.java new file mode 100644 index 0000000..c6d3351 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/model/FontMetadata.java @@ -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; + } +} diff --git a/src/main/java/com/imyeyu/font/icon/model/FontMetrics.java b/src/main/java/com/imyeyu/font/icon/model/FontMetrics.java new file mode 100644 index 0000000..37121bb --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/model/FontMetrics.java @@ -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; +} diff --git a/src/main/java/com/imyeyu/font/icon/model/GlyphAlignment.java b/src/main/java/com/imyeyu/font/icon/model/GlyphAlignment.java new file mode 100644 index 0000000..a7e105f --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/model/GlyphAlignment.java @@ -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 +} diff --git a/src/main/java/com/imyeyu/font/icon/model/IconFont.java b/src/main/java/com/imyeyu/font/icon/model/IconFont.java new file mode 100644 index 0000000..4fac33f --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/model/IconFont.java @@ -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 glyphs; + + /// 字体元数据 + private FontMetadata metadata; + + /// 创建字体结果 + public IconFont(String family, byte[] bytes, List 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 glyphs) { + this(family, bytes, glyphs, FontMetadata.defaults(family)); + } + + /// 返回 TTF 字节副本 + public byte[] getBytes() { + return bytes.clone(); + } +} diff --git a/src/main/java/com/imyeyu/font/icon/model/IconFontOptions.java b/src/main/java/com/imyeyu/font/icon/model/IconFontOptions.java new file mode 100644 index 0000000..d60bacd --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/model/IconFontOptions.java @@ -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; + } +} diff --git a/src/main/java/com/imyeyu/font/icon/model/IconGlyph.java b/src/main/java/com/imyeyu/font/icon/model/IconGlyph.java new file mode 100644 index 0000000..38bf5eb --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/model/IconGlyph.java @@ -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); + } +} diff --git a/src/main/java/com/imyeyu/font/icon/model/SvgGlyph.java b/src/main/java/com/imyeyu/font/icon/model/SvgGlyph.java new file mode 100644 index 0000000..0a19b7b --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/model/SvgGlyph.java @@ -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; +} diff --git a/src/main/java/com/imyeyu/font/icon/svg/Bounds.java b/src/main/java/com/imyeyu/font/icon/svg/Bounds.java new file mode 100644 index 0000000..34e70c3 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/svg/Bounds.java @@ -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> 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 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()) + ); + } +} diff --git a/src/main/java/com/imyeyu/font/icon/svg/Glyph.java b/src/main/java/com/imyeyu/font/icon/svg/Glyph.java new file mode 100644 index 0000000..e6c17d7 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/svg/Glyph.java @@ -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> contours; + + /// Unicode 码点 + private int codePoint; + + /// 字形名称 + private String name; + + public Glyph(List> 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"); + } +} diff --git a/src/main/java/com/imyeyu/font/icon/svg/GlyphNormalizer.java b/src/main/java/com/imyeyu/font/icon/svg/GlyphNormalizer.java new file mode 100644 index 0000000..98bb9ea --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/svg/GlyphNormalizer.java @@ -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> normalize(List> 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> normalized = new ArrayList<>(); + for (List contour : contours) { + List 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; + } +} diff --git a/src/main/java/com/imyeyu/font/icon/svg/GlyphPoint.java b/src/main/java/com/imyeyu/font/icon/svg/GlyphPoint.java new file mode 100644 index 0000000..a6fbc79 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/svg/GlyphPoint.java @@ -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); + } +} diff --git a/src/main/java/com/imyeyu/font/icon/svg/SvgPathExtractor.java b/src/main/java/com/imyeyu/font/icon/svg/SvgPathExtractor.java new file mode 100644 index 0000000..775335e --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/svg/SvgPathExtractor.java @@ -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(); + } +} diff --git a/src/main/java/com/imyeyu/font/icon/svg/SvgPathParser.java b/src/main/java/com/imyeyu/font/icon/svg/SvgPathParser.java new file mode 100644 index 0000000..708d949 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/svg/SvgPathParser.java @@ -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 tokens; + private final int curveSamples; + private final List> 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 current = new ArrayList<>(); + + public SvgPathParser(String svg, int curveSamples) { + this.tokens = tokenize(extractPath(svg)); + this.curveSamples = curveSamples; + } + + public List> 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 tokenize(String path) { + List 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 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 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; + } +} diff --git a/src/main/java/com/imyeyu/font/icon/ttf/FontBuffer.java b/src/main/java/com/imyeyu/font/icon/ttf/FontBuffer.java new file mode 100644 index 0000000..fcaa40f --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/ttf/FontBuffer.java @@ -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(); + } +} + diff --git a/src/main/java/com/imyeyu/font/icon/ttf/NameRecord.java b/src/main/java/com/imyeyu/font/icon/ttf/NameRecord.java new file mode 100644 index 0000000..9b46117 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/ttf/NameRecord.java @@ -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; +} diff --git a/src/main/java/com/imyeyu/font/icon/ttf/TtfIconFontReader.java b/src/main/java/com/imyeyu/font/icon/ttf/TtfIconFontReader.java new file mode 100644 index 0000000..5c945c3 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/ttf/TtfIconFontReader.java @@ -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 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 readCharacters(byte[] ttf) { + return new TtfReader(ttf).readCharacters(); + } +} diff --git a/src/main/java/com/imyeyu/font/icon/ttf/TtfReader.java b/src/main/java/com/imyeyu/font/icon/ttf/TtfReader.java new file mode 100644 index 0000000..4d320be --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/ttf/TtfReader.java @@ -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 tables = new LinkedHashMap<>(); + private final Map>> 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 readSvgGlyphs() { + Map cmap = readCmap(); + List 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> contours = readGlyph(glyphIndex); + if (contours.isEmpty()) { + return; + } + String path = toPath(contours); + String name = "uni%04X".formatted(codePoint); + String 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 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 readCharacters() { + Table cmap = requireTable("cmap"); + int tableCount = u16(cmap.getOffset() + 2); + List 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 result = new LinkedHashMap<>(); + for (CmapSubtable subtable : candidates) { + Map mappings = subtable.getFormat() == 12 ? readCmap12(subtable.getOffset()) : readCmap4(subtable.getOffset()); + for (Map.Entry 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 readCmap() { + Map 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 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 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 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 names, int id, String fallback) { + String value = names.get(id); + return value == null || value.isBlank() ? fallback : value; + } + + private Map readCmap4(int offset) { + Map 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 readCmap12(int offset) { + Map 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> readGlyph(int glyphIndex) { + List> 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> contours = contourCount >= 0 + ? readSimpleGlyph(offset, contourCount) + : readCompoundGlyph(offset, new ArrayList<>()); + glyphCache.put(glyphIndex, contours); + return contours; + } + + private List> 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 points = new ArrayList<>(); + for (int i = 0; i < pointCount; i++) { + points.add(new GlyphPoint(xs[i], ys[i], (flags[i] & 1) != 0)); + } + List> 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> readCompoundGlyph(int offset, List stack) { + if (stack.contains(offset)) { + throw new IllegalArgumentException("compound glyph reference cycle detected"); + } + stack.add(offset); + List> 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 contour : readGlyphComponent(glyphIndex, stack)) { + List 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> readGlyphComponent(int glyphIndex, List 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> contours) { + StringBuilder path = new StringBuilder(); + for (List contour : contours) { + List 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 normalizeContour(List contour) { + if (contour.isEmpty()) { + return List.of(); + } + List 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; + } +} diff --git a/src/main/java/com/imyeyu/font/icon/ttf/TtfUtil.java b/src/main/java/com/imyeyu/font/icon/ttf/TtfUtil.java new file mode 100644 index 0000000..2f2dc51 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/ttf/TtfUtil.java @@ -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 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); + } +} diff --git a/src/main/java/com/imyeyu/font/icon/ttf/TtfWriter.java b/src/main/java/com/imyeyu/font/icon/ttf/TtfWriter.java new file mode 100644 index 0000000..011f9fb --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/ttf/TtfWriter.java @@ -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 glyphs; + private final Map tables = new LinkedHashMap<>(); + + /// 所有图标使用统一字宽,便于在按钮、标签和输入框中按单元格布局 + private static final int ADVANCE_WIDTH_FACTOR = 1; + + public TtfWriter(IconFontOptions options, List glyphs) { + this.options = options; + this.glyphs = glyphs; + } + + public byte[] write() { + List glyphBodies = new ArrayList<>(); + List 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 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 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 all = glyphs.stream() + .skip(1) + .sorted(Comparator.comparingInt(Glyph::getCodePoint).thenComparing(Glyph::getName)) + .toList(); + List 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 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 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 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 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 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 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 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 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 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) { + } +} diff --git a/src/main/java/com/imyeyu/font/icon/util/UnicodeParser.java b/src/main/java/com/imyeyu/font/icon/util/UnicodeParser.java new file mode 100644 index 0000000..bae8555 --- /dev/null +++ b/src/main/java/com/imyeyu/font/icon/util/UnicodeParser.java @@ -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; + } + } +} diff --git a/src/test/java/com/imyeyu/font/icon/BuildTTF.java b/src/test/java/com/imyeyu/font/icon/BuildTTF.java new file mode 100644 index 0000000..cb5c3b3 --- /dev/null +++ b/src/test/java/com/imyeyu/font/icon/BuildTTF.java @@ -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()); +} diff --git a/src/test/resources/index.html b/src/test/resources/index.html new file mode 100644 index 0000000..aff4577 --- /dev/null +++ b/src/test/resources/index.html @@ -0,0 +1,330 @@ + + + + + + TTF 字体预览 + + + +

TTF 字体预览

+

读取同目录的 timi-icon.ttf,解析字体映射并展示可用字形

+
正在加载...
+
+ + + +