diff --git a/docs/developer-guide/css.asciidoc b/docs/developer-guide/css.asciidoc index be293c5836d..cc26e1f38b4 100644 --- a/docs/developer-guide/css.asciidoc +++ b/docs/developer-guide/css.asciidoc @@ -593,7 +593,7 @@ If you want to use a font other than the built-in fonts, you'll need to define t include::../demos/common/src/main/css/guide-snippets-theme.css[tag=css-css-028,indent=0] ---- -IMPORTANT: Only TrueType (`.ttf`) files are supported. OpenType (`.otf`) files compile without an error, but the runtime rejects any font file whose name doesn't end in `.ttf`, and the iOS build registers only `.ttf` files with the operating system. Convert an OpenType font to TrueType before referencing it. +IMPORTANT: Only TrueType (`.ttf`) files are supported. The runtime loads a font by a file name ending in `.ttf` and the iOS build registers only those files with the operating system, so the CSS compiler fails the build on anything else rather than letting it reach the device. Convert an OpenType (`.otf`) font to TrueType before referencing it. The compiler also reads each font at build time, so a file it can't parse, or one with no PostScript name, fails the build too -- iOS resolves fonts by their PostScript name, so a font without one would render everywhere except on an iOS device. Then you'll be able to reference the font using the specified `font-family` in any CSS element. For example: @@ -611,6 +611,8 @@ A relative `src` URL is resolved against the directory that holds the CSS file. include::../demos/common/src/main/css/guide-snippets-theme.css[tag=css-css-044,indent=0] ---- +The font does have to live somewhere under that directory, because only that directory is copied into the build. A font reached through `../`, or one that doesn't exist, fails the build rather than producing an app whose text falls back to the system font. Two `@font-face` rules naming different files that share a file name also fail, since fonts are deployed by file name alone and one would overwrite the other. Pointing two families at the same file is fine. + ===== Family names, weights and styles A `font-family` name that contains spaces must be quoted, both in the `@font-face` rule and wherever you reference it. An unquoted name is parsed as a list of separate identifiers, so `font-family: GuideDemoFont Bold` registers the family as `GuideDemoFont` and collides with your regular weight. diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index ca66f814860..a2f95c3102d 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -2115,7 +2115,199 @@ private void emitColorBinding(EditableResources res, String themeName, String th res.setThemeProperty(themeName, "@cn1-bind:" + themeKey, varName); } + /// Fails the compile for `@font-face` rules the runtime cannot honor. + /// + /// Without this the errors are invisible until the app runs, because the + /// compiler and the device disagree about what a "true type font" is. The + /// compiler loads fonts through `java.awt.Font.createFont(TRUETYPE_FONT, ...)`, + /// which also parses OpenType/CFF, so an `.otf` compiles clean and then + /// `Font.createTrueTypeFont` throws on device because the name doesn't end + /// in `.ttf` -- and the iOS build never registered it with the OS anyway. + /// A font outside the CSS directory fails the same way: merge mode syncs + /// only that directory, so the reference resolves on the authoring machine + /// and breaks in a real build. + /// + /// Checks every declared rule rather than only the referenced ones, so a + /// typo surfaces on the build that introduced it instead of on the build + /// that first uses the family. Remote URLs are checked by name only -- this + /// never downloads a font just to validate it. + void validateFontFaces() { + List errors = new ArrayList(); + // file name -> the source it was first seen at, so two families sharing + // one file stay legal and only genuinely different files collide. + Map sourceByFileName = new HashMap(); + Map familyByFileName = new HashMap(); + for (FontFace face : fontFaces) { + String family = face.fontFamily == null ? "(no font-family)" : face.fontFamily.getStringValue(); + URL url; + try { + url = face.getURL(); + } catch (RuntimeException ex) { + errors.add("@font-face \"" + family + "\" has an unreadable src URL: " + ex.getMessage()); + continue; + } + if (url == null) { + errors.add("@font-face \"" + family + "\" has no usable src; " + + "Codename One only supports src: url(...) pointing at a .ttf file"); + continue; + } + + String fileName = fontFileName(url); + if (!fileName.endsWith(".ttf")) { + errors.add("@font-face \"" + family + "\" points at " + fileName + ". " + + fontExtensionAdvice(fileName)); + continue; + } + + String source = canonicalSource(url); + String previousSource = sourceByFileName.put(fileName, source); + String previousFamily = familyByFileName.put(fileName, family); + if (previousSource != null && !previousSource.equals(source)) { + errors.add("@font-face \"" + family + "\" and \"" + previousFamily + "\" resolve to different " + + "files that are both named " + fileName + ". Fonts are deployed next to the theme " + + "resource by file name alone, so one would overwrite the other; rename one of them"); + } + + if (!"file".equals(url.getProtocol())) { + // Remote fonts are downloaded into the CSS directory, so only the name matters here. + continue; + } + File fontFile; + try { + fontFile = new File(url.toURI()); + } catch (URISyntaxException ex) { + errors.add("@font-face \"" + family + "\" has an unreadable src path: " + url); + continue; + } + if (!fontFile.exists()) { + errors.add("@font-face \"" + family + "\" refers to " + fontFile + ", which doesn't exist"); + continue; + } + if (!isInsideCssDirectory(fontFile)) { + errors.add("@font-face \"" + family + "\" refers to " + fontFile + ", which is outside the " + + "directory holding the CSS file. Only that directory is copied into the build, so " + + "move the font beside the CSS file or into a subdirectory of it"); + continue; + } + String contentError = fontContentError(fontFile); + if (contentError != null) { + errors.add("@font-face \"" + family + "\" refers to " + fontFile.getName() + ", which " + + contentError); + } + } + if (!errors.isEmpty()) { + StringBuilder message = new StringBuilder("Invalid @font-face rules in ") + .append(baseURL == null ? "the stylesheet" : baseURL.toString()) + .append(':'); + for (String error : errors) { + message.append("\n - ").append(error); + } + throw new IllegalArgumentException(message.toString()); + } + } + + /// The file name the runtime will see, which is what `Font.createTrueTypeFont` + /// validates and what the deploy copy is keyed on. Percent-escapes are decoded + /// because the downloaded file lands under the decoded name. + private static String fontFileName(URL url) { + String path = url.getPath(); + try { + path = java.net.URLDecoder.decode(path, "UTF-8"); + } catch (UnsupportedEncodingException ex) { + // UTF-8 is always present; fall through with the raw path. + } + int slash = path.lastIndexOf('/'); + return slash < 0 ? path : path.substring(slash + 1); + } + + /// Reads the font the way the rest of the toolchain will, so a file that + /// can't actually be used is caught here instead of on a device. + /// + /// Two failures hide until far too late otherwise. A file the font parser + /// rejects leaves `EditorTTFFont.actualFont` null -- `refresh()` swallows + /// the Throwable -- and then dies as a bare NPE inside `EditableResources` + /// `save()` naming no rule. And a font with no PostScript name works in the + /// simulator and on Android, which look the font up by file name, while iOS + /// resolves purely by the PostScript name written into the resource and so + /// falls back to the system font on the device. + /// + /// @return the problem phrased to follow "which ...", or null when the font + /// is usable + private static String fontContentError(File fontFile) { + java.awt.Font parsed; + try { + parsed = java.awt.Font.createFont(java.awt.Font.TRUETYPE_FONT, fontFile); + } catch (Exception ex) { + return "isn't a font file Codename One can read (" + ex.getMessage() + ")"; + } + String psName = parsed.getPSName(); + if (psName == null || psName.trim().length() == 0) { + return "has no PostScript name. iOS looks fonts up by that name, so this would render " + + "in the simulator and on Android but fall back to the system font on an iOS device"; + } + return null; + } + + /// Identity of the file a rule points at, used to tell a genuine name + /// collision apart from two families deliberately sharing one font file. + private static String canonicalSource(URL url) { + if ("file".equals(url.getProtocol())) { + try { + return new File(url.toURI()).getCanonicalPath(); + } catch (URISyntaxException ex) { + return url.toString(); + } catch (IOException ex) { + return url.toString(); + } + } + return url.toString(); + } + + /// The constraint is the file NAME, not the outline format: the runtime + /// check is `fileName.endsWith(".ttf")` and the iOS build only registers + /// files matching that. Worth being precise about, because "convert to + /// TrueType" and "rename the file" are different amounts of work and only + /// one of them is actually required. + private static String fontExtensionAdvice(String fileName) { + String lower = fileName.toLowerCase(); + if (lower.endsWith(".otf")) { + return "Codename One loads fonts by a file name ending in .ttf, so an .otf never reaches " + + "the device -- convert it to TrueType"; + } + if (lower.endsWith(".ttf")) { + return "the extension has to be lower-case .ttf; the runtime check is case-sensitive"; + } + return "Codename One loads fonts by a file name ending in .ttf"; + } + + /// Containment is judged against `baseURL`, the stylesheet relative `src` + /// URLs are resolved against, rather than `cssFile` -- the latter is a + /// placeholder unless a caller sets it, so using it would reject every + /// perfectly good font. + private boolean isInsideCssDirectory(File fontFile) { + if (baseURL == null || !"file".equals(baseURL.getProtocol())) { + return true; + } + File cssDir; + try { + cssDir = new File(baseURL.toURI()).getAbsoluteFile().getParentFile(); + } catch (URISyntaxException ex) { + return true; + } + if (cssDir == null) { + return true; + } + try { + String root = cssDir.getCanonicalPath() + File.separator; + return fontFile.getCanonicalPath().startsWith(root); + } catch (IOException ex) { + // Can't prove it's outside, so don't fail the build on it. + return true; + } + } + public void updateResources() { + validateFontFaces(); if (res != null) { Map themeData = res.getTheme(themeName); if (themeData != null) { diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSFontFaceValidationTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSFontFaceValidationTest.java new file mode 100644 index 00000000000..3e78bb5e10f --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSFontFaceValidationTest.java @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Hashtable; + +/** + * The compile has to reject an {@code @font-face} the runtime can't honor, + * because the two disagree about what a true type font is: the compiler parses + * fonts with {@code java.awt.Font.createFont(TRUETYPE_FONT, ...)}, which also + * accepts OpenType, while {@code Font.createTrueTypeFont} rejects any name not + * ending in {@code .ttf} and the iOS build registers only {@code .ttf} files. + * An OpenType font used to compile clean and fail on the device. + * + * @see CSSFontFaceLocationTest for the placements that must keep working + */ +public class CSSFontFaceValidationTest { + + private static final String FIXTURE = "TestFont.ttf"; + + @BeforeAll + static void installHeadlessImplementation() throws Exception { + HeadlessTestSupport.installHeadlessImplementation(); + } + + @Test + void testOpenTypeFontIsRejected() throws Exception { + String message = assertCompileFails( + "@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.otf); }" + + "Label { font-family: \"TestFont\"; }", + "TestFont-Regular.otf"); + assertContains(message, "an .otf never reaches the device"); + assertContains(message, "TestFont-Regular.otf"); + } + + /** + * A file the font parser rejects used to leave {@code EditorTTFFont.actualFont} + * null and then die as a bare NPE in {@code EditableResources.save} naming no + * rule, because {@code refresh()} swallows the Throwable. + */ + @Test + void testUnreadableFontFileIsRejected() throws Exception { + Path cssDir = Files.createTempDirectory("cn1-font-corrupt"); + Path outDir = Files.createTempDirectory("cn1-font-corrupt-out"); + try { + Files.write(cssDir.resolve("Broken.ttf"), "this is not a font".getBytes(StandardCharsets.UTF_8)); + Path cssFile = cssDir.resolve("theme.css"); + Files.write(cssFile, ("@font-face { font-family: \"Broken\"; src: url(Broken.ttf); }" + + "Label { font-family: \"Broken\"; }").getBytes(StandardCharsets.UTF_8)); + + String message = compileExpectingFailure(cssFile, outDir.resolve("theme.res")); + assertContains(message, "isn't a font file Codename One can read"); + } finally { + deleteTree(cssDir); + deleteTree(outDir); + } + } + + /** + * Declared but never referenced still fails. A rule is only resolved when + * some style uses the family, so without this the typo would surface on + * whichever later build first referenced it. + */ + @Test + void testUnreferencedOpenTypeFontIsRejected() throws Exception { + String message = assertCompileFails( + "@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.otf); }" + + "Label { color: #ff0000; }", + "TestFont-Regular.otf"); + assertContains(message, "an .otf never reaches the device"); + } + + /** + * The runtime's {@code endsWith(".ttf")} is case-sensitive, so an upper-case + * extension fails on device even though the file really is TrueType. + */ + @Test + void testUpperCaseExtensionIsRejected() throws Exception { + String message = assertCompileFails( + "@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.TTF); }" + + "Label { font-family: \"TestFont\"; }", + "TestFont-Regular.TTF"); + assertContains(message, "case-sensitive"); + } + + @Test + void testMissingFontFileIsRejected() throws Exception { + String message = assertCompileFails( + "@font-face { font-family: \"TestFont\"; src: url(NotThere.ttf); }" + + "Label { font-family: \"TestFont\"; }", + null); + assertContains(message, "doesn't exist"); + } + + /** + * Merge mode syncs only the CSS directory, so a font reached through {@code ../} + * resolves for the author and breaks in a real build. + */ + @Test + void testFontOutsideTheCssDirectoryIsRejected() throws Exception { + Path root = Files.createTempDirectory("cn1-font-outside"); + Path outDir = Files.createTempDirectory("cn1-font-outside-out"); + try { + Path cssDir = root.resolve("css"); + Files.createDirectories(cssDir); + copyFixture(root.resolve("Stray.ttf")); + Path cssFile = cssDir.resolve("theme.css"); + Files.write(cssFile, ("@font-face { font-family: \"TestFont\"; src: url(../Stray.ttf); }" + + "Label { font-family: \"TestFont\"; }").getBytes(StandardCharsets.UTF_8)); + + String message = compileExpectingFailure(cssFile, outDir.resolve("theme.res")); + assertContains(message, "outside the directory holding the CSS file"); + } finally { + deleteTree(root); + deleteTree(outDir); + } + } + + /** + * Fonts are deployed by file name alone, so two rules naming identically + * named files in different directories would silently clobber each other. + */ + @Test + void testDuplicateFontFileNamesAreRejected() throws Exception { + Path cssDir = Files.createTempDirectory("cn1-font-dupe"); + Path outDir = Files.createTempDirectory("cn1-font-dupe-out"); + try { + Path nested = cssDir.resolve("bold"); + Files.createDirectories(nested); + copyFixture(cssDir.resolve("TestFont.ttf")); + copyFixture(nested.resolve("TestFont.ttf")); + Path cssFile = cssDir.resolve("theme.css"); + Files.write(cssFile, ("@font-face { font-family: \"TestFont\"; src: url(TestFont.ttf); }" + + "@font-face { font-family: \"TestFont Bold\"; src: url(bold/TestFont.ttf); }" + + "Label { font-family: \"TestFont\"; }").getBytes(StandardCharsets.UTF_8)); + + String message = compileExpectingFailure(cssFile, outDir.resolve("theme.res")); + assertContains(message, "resolve to different files that are both named TestFont.ttf"); + } finally { + deleteTree(cssDir); + deleteTree(outDir); + } + } + + /** + * Two families pointing at the SAME file is a legitimate alias, not a + * collision -- the deploy copy is idempotent, so nothing is overwritten. + * Only genuinely different files sharing a name are an error. + */ + @Test + void testTwoFamiliesMaySharedOneFontFile() throws Exception { + Path cssDir = Files.createTempDirectory("cn1-font-alias"); + Path outDir = Files.createTempDirectory("cn1-font-alias-out"); + try { + copyFixture(cssDir.resolve("TestFont.ttf")); + Path cssFile = cssDir.resolve("theme.css"); + Files.write(cssFile, ("@font-face { font-family: \"Body\"; src: url(TestFont.ttf); }" + + "@font-face { font-family: \"Caption\"; src: url(TestFont.ttf); }" + + "Label { font-family: \"Body\"; font-size: 3mm; }" + + "SmallLabel { font-family: \"Caption\"; font-size: 2mm; }") + .getBytes(StandardCharsets.UTF_8)); + + CSSTheme theme = CSSTheme.load(cssFile.toUri().toURL()); + theme.resourceFile = outDir.resolve("theme.res").toFile(); + theme.res = new com.codename1.ui.util.EditableResourcesForCSS(theme.resourceFile); + theme.res.setTheme("Theme", new Hashtable()); + theme.updateResources(); + } finally { + deleteTree(cssDir); + deleteTree(outDir); + } + } + + /** + * A remote font is judged by its URL alone, so a bad one is caught without + * the compile reaching out to the network. + */ + @Test + void testRemoteOpenTypeFontIsRejectedWithoutDownloading() throws Exception { + String message = assertCompileFails( + "@font-face { font-family: \"TestFont\"; " + + "src: url(https://example.invalid/fonts/TestFont.otf); }" + + "Label { font-family: \"TestFont\"; }", + null); + assertContains(message, "an .otf never reaches the device"); + } + + /** A well-formed sheet must still compile, or the check is worthless. */ + @Test + void testValidTrueTypeFontStillCompiles() throws Exception { + Path cssDir = Files.createTempDirectory("cn1-font-ok"); + Path outDir = Files.createTempDirectory("cn1-font-ok-out"); + try { + copyFixture(cssDir.resolve("TestFont-Regular.ttf")); + Path cssFile = cssDir.resolve("theme.css"); + Files.write(cssFile, ("@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.ttf); }" + + "Label { font-family: \"TestFont\"; font-size: 3mm; }") + .getBytes(StandardCharsets.UTF_8)); + + CSSTheme theme = CSSTheme.load(cssFile.toUri().toURL()); + theme.resourceFile = outDir.resolve("theme.res").toFile(); + theme.res = new com.codename1.ui.util.EditableResourcesForCSS(theme.resourceFile); + theme.res.setTheme("Theme", new Hashtable()); + theme.updateResources(); + } finally { + deleteTree(cssDir); + deleteTree(outDir); + } + } + + /** + * Writes the sheet into a scratch CSS directory and returns the failure + * message. {@code fontFile}, when given, is created so the test proves the + * rule was rejected on its name rather than for being absent. + */ + private static String assertCompileFails(String css, String fontFile) throws Exception { + Path cssDir = Files.createTempDirectory("cn1-font-invalid"); + Path outDir = Files.createTempDirectory("cn1-font-invalid-out"); + try { + if (fontFile != null) { + copyFixture(cssDir.resolve(fontFile)); + } + Path cssFile = cssDir.resolve("theme.css"); + Files.write(cssFile, css.getBytes(StandardCharsets.UTF_8)); + return compileExpectingFailure(cssFile, outDir.resolve("theme.res")); + } finally { + deleteTree(cssDir); + deleteTree(outDir); + } + } + + private static String compileExpectingFailure(Path cssFile, Path resFile) throws Exception { + CSSTheme theme = CSSTheme.load(cssFile.toUri().toURL()); + theme.resourceFile = resFile.toFile(); + theme.res = new com.codename1.ui.util.EditableResourcesForCSS(resFile.toFile()); + theme.res.setTheme("Theme", new Hashtable()); + try { + theme.updateResources(); + } catch (IllegalArgumentException expected) { + return expected.getMessage(); + } + throw new AssertionError("Expected the compile to fail for " + cssFile); + } + + private static void copyFixture(Path dest) throws IOException { + try (InputStream in = CSSFontFaceValidationTest.class.getResourceAsStream(FIXTURE)) { + if (in == null) { + throw new AssertionError("Test fixture " + FIXTURE + " was null"); + } + Files.copy(in, dest, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void deleteTree(Path path) { + File file = path.toFile(); + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteTree(child.toPath()); + } + } + file.delete(); + } + + private static void assertContains(String actual, String expected) { + if (actual == null || actual.indexOf(expected) < 0) { + throw new AssertionError("Expected the error to mention \"" + expected + "\" but it was: " + actual); + } + } +} diff --git a/scripts/initializr/common/src/main/resources/skill/references/css.md b/scripts/initializr/common/src/main/resources/skill/references/css.md index e827e76ec42..68293acd245 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/css.md +++ b/scripts/initializr/common/src/main/resources/skill/references/css.md @@ -333,9 +333,9 @@ For the full Lottie feature matrix and troubleshooting, point users to `docs/dev ### Custom TTF fonts -**TrueType only.** A `.otf` compiles without an error but is rejected at runtime — `Font.createTrueTypeFont` throws unless the file name ends in `.ttf`, and the iOS build registers only `.ttf` files with the OS. Convert OpenType fonts to TrueType first. +**TrueType only.** `Font.createTrueTypeFont` throws unless the file name ends in `.ttf`, and the iOS build registers only `.ttf` files with the OS, so the CSS compiler **fails the build** on a `.otf` instead of letting it reach the device. Convert OpenType fonts to TrueType first. The compiler also parses each font at build time, so a corrupt file — or one with no PostScript name, which iOS needs to resolve it — fails the build rather than rendering fine in the simulator and falling back to the system font on an iPhone. -Drop the `.ttf` anywhere under `common/src/main/css/` — relative `src:` URLs resolve against the directory holding `theme.css`, so both `url("Inter-Regular.ttf")` beside the CSS and `url("fonts/Inter-Regular.ttf")` in a subdirectory work. Then reference its **font name (not file name)** in `font-family`: +Drop the `.ttf` anywhere under `common/src/main/css/` — relative `src:` URLs resolve against the directory holding `theme.css`, so both `url("Inter-Regular.ttf")` beside the CSS and `url("fonts/Inter-Regular.ttf")` in a subdirectory work. It must be somewhere under that directory though: only that directory is copied into the build, so a `../` reference, a missing file, or two rules naming different files that share a file name all fail the build. Then reference its **font name (not file name)** in `font-family`: ```css @font-face { @@ -560,7 +560,8 @@ Painters are for **drawing** (custom backgrounds, decorations), not for animatin | New CSS only takes effect after restart | The build cache may be stale — `mvn -pl common clean compile`. | | 9-piece border looks blurry on iPhone Pro | Expected — 9-piece images are rasterized at the bundled resolution. Use a vector border (`RoundBorder`/`RoundRectBorder`) instead. | | Custom TTF doesn't render on device but works in simulator | The `@font-face` `src:` filename and the JS-side `Font.createTrueTypeFont(name, file)` filename must match exactly, and the file must end up packaged with the app. Re-check spelling and confirm the file is under `common/src/main/css/` (beside `theme.css` or in a subdirectory of it). | -| Custom font is ignored entirely, no error | Either the file is an `.otf` (only `.ttf` works), or the family name has an unquoted space, or `theme.css` isn't at `common/src/main/css/theme.css` — the compiler looks for a `css` directory that is a sibling of a compile source root, and silently does nothing if it isn't there. | +| Build fails with "Invalid @font-face rules" | Read the listed reasons — a `.otf` (convert to `.ttf`), an upper-case `.TTF` (the runtime check is case-sensitive), a font outside `common/src/main/css/`, a missing file, or two rules whose files share a name. | +| Custom font is ignored entirely, no error | The family name has an unquoted space, or `theme.css` isn't at `common/src/main/css/theme.css` — the compiler looks for a `css` directory that is a sibling of a compile source root, and silently does nothing if it isn't there. | ## Reaching beyond the compiler