From a826cabe23cf3e396a792b31576f81177a1ee03f Mon Sep 17 00:00:00 2001 From: Erin Schnabel Date: Mon, 16 Oct 2023 20:40:47 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Generate=20CSS=20snippet=20for=20re?= =?UTF-8?q?ferenced=20fonts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dev/ebullient/convert/io/FontRef.java | 53 +++++++++++++++++++ .../dev/ebullient/convert/io/Templates.java | 21 ++++++++ .../java/dev/ebullient/convert/io/Tui.java | 37 +++++++++++++ .../tools/dnd5e/JsonTextReplacement.java | 18 ++++++- .../convert/tools/dnd5e/Tools5eIndex.java | 26 ++++++++- .../convert/tools/dnd5e/Tools5eIndexType.java | 1 + .../tools/dnd5e/Tools5eMarkdownConverter.java | 1 + .../convert/tools/dnd5e/Tools5eSources.java | 36 +++++++++++++ .../resources/templates/tools5e/css-font.txt | 7 +++ .../java/dev/ebullient/convert/TestUtils.java | 1 + .../convert/Tools5eDataConvertTest.java | 3 ++ src/test/resources/sources-homebrew.json | 2 + 12 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 src/main/java/dev/ebullient/convert/io/FontRef.java create mode 100644 src/main/resources/templates/tools5e/css-font.txt diff --git a/src/main/java/dev/ebullient/convert/io/FontRef.java b/src/main/java/dev/ebullient/convert/io/FontRef.java new file mode 100644 index 000000000..3d026047d --- /dev/null +++ b/src/main/java/dev/ebullient/convert/io/FontRef.java @@ -0,0 +1,53 @@ +package dev.ebullient.convert.io; + +public class FontRef { + /** Font family */ + public final String fontFamily; + /** Path to font source (unresolved local or remote) */ + public final String sourcePath; + + boolean hasTextReference = false; + + private FontRef(String fontFamily, String sourcePath) { + this.fontFamily = fontFamily; + this.sourcePath = sourcePath; + } + + public void addTextReference() { + hasTextReference = true; + } + + public boolean hasTextReference() { + return hasTextReference; + } + + @Override + public String toString() { + return "FontRef [fontFamily=" + fontFamily + ", sourcePath=" + sourcePath + "]"; + } + + public static String fontFamily(String fontPath) { + fontPath = fontPath.trim(); + int pos1 = fontPath.lastIndexOf('/'); + int pos2 = fontPath.lastIndexOf('.'); + if (pos1 > 0 && pos2 > 0) { + fontPath = fontPath.substring(pos1 + 1, pos2); + } else if (pos1 > 0) { + fontPath = fontPath.substring(pos1 + 1); + } else if (pos2 > 0) { + fontPath = fontPath.substring(0, pos2); + } + return fontPath; + } + + public static FontRef of(String fontString) { + return of(fontFamily(fontString), fontString); + } + + public static FontRef of(String fontFamily, String fontString) { + if (fontString == null || fontString.isEmpty()) { + return null; + } + return new FontRef(fontFamily, fontString); + } +} diff --git a/src/main/java/dev/ebullient/convert/io/Templates.java b/src/main/java/dev/ebullient/convert/io/Templates.java index 06a5a3746..f0e8a01ed 100644 --- a/src/main/java/dev/ebullient/convert/io/Templates.java +++ b/src/main/java/dev/ebullient/convert/io/Templates.java @@ -1,8 +1,10 @@ package dev.ebullient.convert.io; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Base64; import java.util.Collection; import jakarta.enterprise.context.ApplicationScoped; @@ -103,4 +105,23 @@ public String renderIndex(String name, Collection resources) { return "%% ERROR: " + message + " %%"; } } + + public String renderCss(FontRef fontRef, InputStream data) throws IOException { + Template tpl = customTemplateOrDefault("css-font.txt"); + try { + String encoded = Base64.getEncoder().encodeToString(data.readAllBytes()); + int extpos = fontRef.sourcePath.lastIndexOf("."); + String type = fontRef.sourcePath.substring(extpos + 1); + return tpl + .data("fontFamily", fontRef.fontFamily) + .data("type", type) + .data("encoded", encoded) + .render(); + } catch (TemplateException tex) { + Throwable cause = tex.getCause(); + String message = cause != null ? cause.toString() : tex.toString(); + tui.error(tex, message); + return "%% ERROR: " + message + " %%"; + } + } } diff --git a/src/main/java/dev/ebullient/convert/io/Tui.java b/src/main/java/dev/ebullient/convert/io/Tui.java index ac1013c1b..423e8e56f 100644 --- a/src/main/java/dev/ebullient/convert/io/Tui.java +++ b/src/main/java/dev/ebullient/convert/io/Tui.java @@ -1,9 +1,11 @@ package dev.ebullient.convert.io; +import java.io.BufferedInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -55,6 +57,12 @@ @ApplicationScoped public class Tui { + static Tui instance; + + public static Tui instance() { + return instance; + } + public final static TypeReference> LIST_STRING = new TypeReference<>() { }; public final static TypeReference> LIST_INT = new TypeReference<>() { @@ -176,6 +184,8 @@ public Tui() { this.err = new PrintWriter(System.err); this.debug = false; this.verbose = true; + + Tui.instance = this; } public void init(CommandSpec spec, boolean debug, boolean verbose) { @@ -304,6 +314,33 @@ public Optional resolvePath(Path path) { .findFirst(); } + public void copyFonts(Collection fonts, Map fallbackPaths) { + for (FontRef fontRef : fonts) { + Path targetPath = output.resolve(Path.of("css-snippets", slugify(fontRef.fontFamily) + ".css")); + targetPath.getParent().toFile().mkdirs(); + + printlnf("⏱️ Generating CSS snippet for %s", fontRef.sourcePath); + if (fontRef.sourcePath.startsWith("http")) { + try (InputStream is = URI.create(fontRef.sourcePath.replace(" ", "%20")).toURL().openStream()) { + Files.writeString(targetPath, templates.renderCss(fontRef, is)); + } catch (IOException e) { + errorf(e, "Unable to copy font from %s to %s", fontRef.sourcePath, targetPath); + } + } else { + Optional resolvedSource = resolvePath(Path.of(fontRef.sourcePath)); + if (resolvedSource.isEmpty()) { + errorf("Unable to find font '%s'", fontRef.sourcePath); + continue; + } + try (BufferedInputStream is = new BufferedInputStream(Files.newInputStream(resolvedSource.get()))) { + Files.writeString(targetPath, templates.renderCss(fontRef, is)); + } catch (IOException e) { + errorf(e, "Unable to copy font from %s to %s", fontRef.sourcePath, targetPath); + } + } + } + } + public void copyImages(Collection images, Map fallbackPaths) { for (ImageRef image : images) { Path targetPath = output.resolve(image.targetFilePath()); diff --git a/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonTextReplacement.java b/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonTextReplacement.java index 31334275e..18cac4463 100644 --- a/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonTextReplacement.java +++ b/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonTextReplacement.java @@ -31,6 +31,7 @@ public interface JsonTextReplacement extends JsonTextConverter Pattern dicePattern = Pattern.compile("\\{@(dice|damage) ([^{}]+)}"); Pattern chancePattern = Pattern.compile("\\{@chance ([^}]+)}"); + Pattern fontPattern = Pattern.compile("\\{@font ([^}]+)}"); Pattern homebrewPattern = Pattern.compile("\\{@homebrew ([^}]+)}"); Pattern quickRefPattern = Pattern.compile("\\{@quickref ([^}]+)}"); Pattern notePattern = Pattern.compile("\\{@note (\\*|Note:)?\\s?([^}]+)}"); @@ -186,6 +187,17 @@ default String _replaceTokenText(String input, boolean nested) { result = linkifyPattern.matcher(result) .replaceAll(this::linkify); + result = fontPattern.matcher(result) + .replaceAll((match) -> { + String[] parts = match.group(1).split("\\|"); + String fontFamily = Tools5eSources.getFontReference(parts[1]); + if (fontFamily != null) { + return String.format("%s", + fontFamily, parts[0]); + } + return parts[0]; + }); + try { result = result .replace("{@hitYourSpellAttack}", "the summoner's spell attack modifier") @@ -216,8 +228,8 @@ default String _replaceTokenText(String input, boolean nested) { .replaceAll("\\{@cult ([^|}]+)}", "$1") .replaceAll("\\{@language ([^|}]+)\\|?[^}]*}", "$1") .replaceAll("\\{@book ([^}|]+)\\|?[^}]*}", "\"$1\"") - .replaceAll("\\{@hit ([+-][^}<]+)}", "$1") - .replaceAll("\\{@hit ([^}<]+)}", "+$1") + .replaceAll("\\{@(hit|h) ([+-][^}<]+)}", "$2") + .replaceAll("\\{@(hit|h) ([^}<]+)}", "+$2") .replaceAll("\\{@h}", "*Hit:* ") .replaceAll("\\{@m}", "*Miss:* ") .replaceAll("\\{@atk a}", "*Area Attack:*") @@ -235,6 +247,7 @@ default String _replaceTokenText(String input, boolean nested) { .replaceAll("\\{@atk ms}", "*Melee Spell Attack:*") .replaceAll("\\{@atk rs}", "*Ranged Spell Attack:*") .replaceAll("\\{@atk ms,rs}", "*Melee or Ranged Spell Attack:*") + .replaceAll("\\{@spell\\s*}", "") // error in homebrew .replaceAll("\\{@color ([^|}]+)\\|?[^}]*}", "$1") .replaceAll("\\{@style ([^|}]+)\\|?[^}]*}", "$1") .replaceAll("\\{@b ([^}]+?)}", "**$1**") @@ -269,6 +282,7 @@ default String _replaceTokenText(String input, boolean nested) { String[] parts = match.group(1).split("\\|"); if (parts[0].contains("")) { // This already assumes what the footnote name will be + // TODO: Note content is lost on this path at the moment return String.format("%s", parts[0]); } if (parts.length > 2) { diff --git a/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndex.java b/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndex.java index 6499e5cbf..a4f9d34ef 100644 --- a/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndex.java +++ b/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndex.java @@ -98,6 +98,8 @@ private void indexTypes(String filename, JsonNode node) { Tools5eIndexType.spellFluff.withArrayFrom(node, this::addToIndex); Tools5eIndexType.vehicleFluff.withArrayFrom(node, this::addToIndex); + Tools5eIndexType.language.withArrayFrom(node, this::addToIndex); + Tools5eIndexType.itemEntry.withArrayFrom(node, this::addToIndex); Tools5eIndexType.itemTypeAdditionalEntries.withArrayFrom(node, this::addToIndex); Tools5eIndexType.magicvariant.withArrayFrom(node, this::addToIndex); @@ -220,11 +222,12 @@ private boolean addHomebrewSourcesIfPresent(String filename, JsonNode node) { } } + JsonNode fonts = SourceField._meta.getFieldFrom(node, HomebrewFields.fonts); JsonNode featureTypes = SourceField._meta.getFieldFrom(node, HomebrewFields.optionalFeatureTypes); JsonNode spellSchools = SourceField._meta.getFieldFrom(node, HomebrewFields.spellSchools); JsonNode psionicTypes = SourceField._meta.getFieldFrom(node, HomebrewFields.psionicTypes); JsonNode skillTypes = HomebrewFields.skill.getFrom(node); - if (featureTypes != null || spellSchools != null || psionicTypes != null || skillTypes != null) { + if (fonts != null || featureTypes != null || spellSchools != null || psionicTypes != null || skillTypes != null) { for (Entry entry : iterableFields(featureTypes)) { metaTypes.setOptionalFeatureType(entry.getKey(), entry.getValue().asText()); } @@ -245,6 +248,17 @@ private boolean addHomebrewSourcesIfPresent(String filename, JsonNode node) { } metaTypes.setSkillType(skillName, skill); } + if (fonts != null) { + if (fonts.isArray()) { + for (JsonNode font : iterableElements(fonts)) { + Tools5eSources.addFont(font.asText()); + } + } else { + for (Entry font : iterableFields(fonts)) { + Tools5eSources.addFont(font.getKey(), font.getValue().asText()); + } + } + } } return true; } @@ -307,6 +321,15 @@ void addToIndex(Tools5eIndexType type, JsonNode node) { SourceAndPage sp = new SourceAndPage(node); tableIndex.computeIfAbsent(sp, k -> new ArrayList<>()).add(node); } + if (type == Tools5eIndexType.language && HomebrewFields.fonts.existsIn(node)) { + // Make a note of all fonts + JsonNode fonts = HomebrewFields.fonts.getFrom(node); + if (fonts.isArray()) { + for (JsonNode font : iterableElements(fonts)) { + Tools5eSources.addFont(font.asText()); + } + } + } if (node.has("srd")) { srdKeys.add(key); @@ -1172,6 +1195,7 @@ public void setItemProperty(String key, CustomItemProperty value) { enum HomebrewFields implements JsonNodeReader { abbreviation, + fonts, full, json, optionalFeatureTypes, diff --git a/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndexType.java b/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndexType.java index ecfa44a3d..dd4c970dc 100644 --- a/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndexType.java +++ b/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndexType.java @@ -43,6 +43,7 @@ public enum Tools5eIndexType implements IndexType, JsonNodeReader { itemType, itemTypeAdditionalEntries, itemProperty, + language, legendaryGroup, magicvariant, monster, diff --git a/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eMarkdownConverter.java b/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eMarkdownConverter.java index 3c7b3eb8d..8f5ba5e09 100644 --- a/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eMarkdownConverter.java +++ b/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eMarkdownConverter.java @@ -44,6 +44,7 @@ public Tools5eMarkdownConverter writeNotesAndTables() { public Tools5eMarkdownConverter writeImages() { index.tui().copyImages(Tools5eSources.getImages(), fallbackPaths); + index.tui().copyFonts(Tools5eSources.getFonts(), fallbackPaths); return this; } diff --git a/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eSources.java b/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eSources.java index 004be4ea5..145a4c11c 100644 --- a/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eSources.java +++ b/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eSources.java @@ -12,6 +12,8 @@ import com.fasterxml.jackson.databind.JsonNode; import dev.ebullient.convert.config.TtrpgConfig; +import dev.ebullient.convert.io.FontRef; +import dev.ebullient.convert.io.Tui; import dev.ebullient.convert.qute.ImageRef; import dev.ebullient.convert.qute.QuteBase; import dev.ebullient.convert.tools.CompendiumSources; @@ -27,6 +29,7 @@ public class Tools5eSources extends CompendiumSources { private static final Map keyToSources = new HashMap<>(); private static final Map imageSourceToRef = new HashMap<>(); + private static final Map fontSourceToRef = new HashMap<>(); private static final Map> keyToInlineNotes = new HashMap<>(); public static Tools5eSources findSources(String key) { @@ -86,6 +89,39 @@ public void addInlineNote(QuteBase note) { keyToInlineNotes.computeIfAbsent(this.key, k -> new ArrayList<>()).add(note); } + public static Collection getFonts() { + return fontSourceToRef.values().stream() + .filter(FontRef::hasTextReference) + .toList(); + } + + public static void addFont(String fontFamily, String fontString) { + FontRef ref = FontRef.of(fontFamily, fontString); + if (ref == null) { + Tui.instance().warnf("Font '%s' is invalid, empty, or not found", fontString); + } else { + FontRef previous = fontSourceToRef.putIfAbsent(fontFamily, ref); + if (previous != null) { + Tui.instance().warnf("Font '%s' is already defined as '%s'", fontString, previous); + } + } + } + + public static void addFont(String fontString) { + String fontFamily = FontRef.fontFamily(fontString); + addFont(fontFamily, fontString); + } + + public static String getFontReference(String fontString) { + String fontFamily = FontRef.fontFamily(fontString); + FontRef ref = fontSourceToRef.get(fontFamily); + if (ref == null) { + return null; + } + ref.addTextReference(); + return fontFamily; + } + final boolean srd; final boolean basicRules; final Tools5eIndexType type; diff --git a/src/main/resources/templates/tools5e/css-font.txt b/src/main/resources/templates/tools5e/css-font.txt new file mode 100644 index 000000000..f17310fcb --- /dev/null +++ b/src/main/resources/templates/tools5e/css-font.txt @@ -0,0 +1,7 @@ +@font-face { + font-family: "{fontFamily}"; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url("data:font/{type};charset=utf8;base64,{encoded}"); +} diff --git a/src/test/java/dev/ebullient/convert/TestUtils.java b/src/test/java/dev/ebullient/convert/TestUtils.java index 1e368b16d..cbf9c54e8 100644 --- a/src/test/java/dev/ebullient/convert/TestUtils.java +++ b/src/test/java/dev/ebullient/convert/TestUtils.java @@ -287,6 +287,7 @@ static List checkDirectoryContents(Path directory, Tui tui, if (!p.toString().endsWith(".md")) { if (!p.toString().endsWith(".png") && !p.toString().endsWith(".jpg") + && !p.toString().endsWith(".css") && !p.toString().endsWith(".svg") && !p.toString().endsWith(".webp") && !p.toString().endsWith(".json") diff --git a/src/test/java/dev/ebullient/convert/Tools5eDataConvertTest.java b/src/test/java/dev/ebullient/convert/Tools5eDataConvertTest.java index 4f6b31432..f7219b316 100644 --- a/src/test/java/dev/ebullient/convert/Tools5eDataConvertTest.java +++ b/src/test/java/dev/ebullient/convert/Tools5eDataConvertTest.java @@ -147,6 +147,7 @@ void testLiveData_5eHomebrew(QuarkusMainLauncher launcher) { TestUtils.TEST_RESOURCES.resolve("psion.json").toString(), TestUtils.TEST_RESOURCES.resolve("ermis-bg.json").toString(), TestUtils.HOMEBREW_PATH_5E.resolve("adventure/Anthony Joyce; The Blood Hunter Adventure.json").toString(), + TestUtils.HOMEBREW_PATH_5E.resolve("adventure/JVC Parry; Call from the Deep.json").toString(), TestUtils.HOMEBREW_PATH_5E.resolve("adventure/Kobold Press; Book of Lairs.json").toString(), TestUtils.HOMEBREW_PATH_5E.resolve("background/D&D Wiki; Featured Quality Backgrounds.json").toString(), TestUtils.HOMEBREW_PATH_5E.resolve("book/Ghostfire Gaming; Grim Hollow Campaign Guide.json").toString(), @@ -167,6 +168,8 @@ void testLiveData_5eHomebrew(QuarkusMainLauncher launcher) { TestUtils.HOMEBREW_PATH_5E .resolve("collection/MCDM Productions; The Talent and Psionics Open Playtest Round 2.json") .toString(), + TestUtils.HOMEBREW_PATH_5E.resolve("creature/Kobold Press; Creature Codex.json") + .toString(), TestUtils.HOMEBREW_PATH_5E.resolve("creature/MCDM Productions; Flee, Mortals!.json").toString(), TestUtils.HOMEBREW_PATH_5E.resolve("creature/Nerzugal Role-Playing; Nerzugal's Extended Bestiary.json") .toString(), diff --git a/src/test/resources/sources-homebrew.json b/src/test/resources/sources-homebrew.json index 1fb55ff79..92ddebfc3 100644 --- a/src/test/resources/sources-homebrew.json +++ b/src/test/resources/sources-homebrew.json @@ -4,6 +4,8 @@ "B:P", "BH2022", "BookofLairs", + "CallfromtheDeep", + "CCodex", "DM14", "DM:BFS", "DMG",