From b2dbae3510293d50ee0e2e19100b2dfe445b21eb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:29:45 +0700 Subject: [PATCH 1/2] Fail the CSS compile on @font-face rules the runtime can't honor An .otf sailed through compilation and only broke on the device. The compiler loads fonts with java.awt.Font.createFont(TRUETYPE_FONT, ...), which also parses OpenType/CFF, so the font resolved, rendered in the simulator and got written into theme.res -- while Font.createTrueTypeFont rejects any file name not ending in .ttf and IPhoneBuilder never registered it in UIAppFonts. The reported symptom is a font that just doesn't change, with no error anywhere. CSSTheme.updateResources now validates every declared @font-face first and throws with the offending rules listed, which CN1CSSCLI turns into a non-zero exit and CompileCSSMojo into a failed build. Rejected: - anything not ending in .ttf, with the advice split by case: convert an .otf, rename an upper-case .TTF (the runtime's endsWith is case-sensitive) - a local font that doesn't exist, which used to surface as a bare FileNotFoundException naming no rule - a local font outside the directory holding the CSS file; merge mode syncs only that directory, so a ../ reference resolves for the author and breaks in a real build - two rules resolving to different files that share a file name, since fonts are deployed next to theme.res by file name alone and one would overwrite the other. Two families pointing at the same file stay legal -- that copy is idempotent, and rejecting it would break a legitimate alias. Every declared rule is checked, not only the referenced ones, so a typo fails the build that introduced it instead of the later build that first uses the family. Remote fonts are judged by URL alone, so validation never downloads. Containment is measured against baseURL rather than cssFile: cssFile is a "test.css" placeholder unless a caller assigns it, so using it rejected every font in the existing tests. Updates the guide and the initializr CSS reference, which described the old compile-clean-fail-at-runtime behaviour. --- docs/developer-guide/css.asciidoc | 4 +- .../com/codename1/designer/css/CSSTheme.java | 153 ++++++++++ .../css/CSSFontFaceValidationTest.java | 277 ++++++++++++++++++ .../main/resources/skill/references/css.md | 7 +- 4 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/CSSFontFaceValidationTest.java diff --git a/docs/developer-guide/css.asciidoc b/docs/developer-guide/css.asciidoc index be293c5836d..610cb834ba9 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 rejects any font file whose name doesn't end in `.ttf`, and the iOS build registers only `.ttf` 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. 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..cd1a437de37 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,160 @@ 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"); + } + } + 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); + } + + /// 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(); + } + + private static String fontExtensionAdvice(String fileName) { + String lower = fileName.toLowerCase(); + if (lower.endsWith(".otf")) { + return "OpenType fonts aren't supported -- convert it to TrueType (.ttf) first. " + + "It would compile but fail at runtime, and the iOS build wouldn't register it at all"; + } + if (lower.endsWith(".ttf")) { + return "the extension must be lower-case .ttf; the runtime check is case-sensitive"; + } + return "only TrueType (.ttf) fonts are supported"; + } + + /// 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..ce731dd5005 --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSFontFaceValidationTest.java @@ -0,0 +1,277 @@ +/* + * 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, "OpenType fonts aren't supported"); + assertContains(message, "TestFont-Regular.otf"); + } + + /** + * 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, "OpenType fonts aren't supported"); + } + + /** + * 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, "OpenType fonts aren't supported"); + } + + /** 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..1017acf9d44 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. -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 From 6a8151d907a75010e7c809785b855a3e2ffd4f93 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:48:10 +0700 Subject: [PATCH 2/2] Read the font at compile time so iOS-only failures surface in the build Two more ways a font passed the compile and then failed on a device, which is the case the name check alone doesn't catch. A file the font parser rejects left EditorTTFFont.actualFont null, because refresh() swallows the Throwable, and then died as a bare NPE inside EditableResources.save at getNativeFont()).getPSName() naming no rule. This matters more now that the compile insists on a .ttf name: renaming an .otf is the obvious workaround, and anything that isn't really loadable has to be caught here rather than on the device. A font with no PostScript name renders in the simulator and on Android, which both look fonts up by file name, while iOS resolves purely by the PostScript name written into the resource and falls back to the system font. That is invisible until someone runs the app on an iPhone. validateFontFaces now parses each local font and checks for a usable PostScript name, reporting which rule and file is at fault. Also corrects the extension message, which overclaimed. The constraint is the file NAME -- Font.createTrueTypeFont tests endsWith(".ttf") and IPhoneBuilder filters UIAppFonts the same way -- not the outline format. iOS registers through UIAppFonts and resolves with [UIFont fontWithName:], Android uses Typeface.createFromAsset, and both read CFF/OpenType content, so no check is made against the sfnt flavour: an OpenType font renamed to .ttf is not proven broken and isn't rejected on a guess. --- docs/developer-guide/css.asciidoc | 2 +- .../com/codename1/designer/css/CSSTheme.java | 47 +++++++++++++++++-- .../css/CSSFontFaceValidationTest.java | 29 ++++++++++-- .../main/resources/skill/references/css.md | 2 +- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/docs/developer-guide/css.asciidoc b/docs/developer-guide/css.asciidoc index 610cb834ba9..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. 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, 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. +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: 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 cd1a437de37..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 @@ -2187,6 +2187,12 @@ void validateFontFaces() { 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()) { @@ -2214,6 +2220,34 @@ private static String fontFileName(URL url) { 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) { @@ -2229,16 +2263,21 @@ private static String canonicalSource(URL url) { 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 "OpenType fonts aren't supported -- convert it to TrueType (.ttf) first. " - + "It would compile but fail at runtime, and the iOS build wouldn't register it at all"; + 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 must be lower-case .ttf; the runtime check is case-sensitive"; + return "the extension has to be lower-case .ttf; the runtime check is case-sensitive"; } - return "only TrueType (.ttf) fonts are supported"; + return "Codename One loads fonts by a file name ending in .ttf"; } /// Containment is judged against `baseURL`, the stylesheet relative `src` 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 index ce731dd5005..3e78bb5e10f 100644 --- 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 @@ -59,10 +59,33 @@ void testOpenTypeFontIsRejected() throws Exception { "@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.otf); }" + "Label { font-family: \"TestFont\"; }", "TestFont-Regular.otf"); - assertContains(message, "OpenType fonts aren't supported"); + 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 @@ -74,7 +97,7 @@ void testUnreferencedOpenTypeFontIsRejected() throws Exception { "@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.otf); }" + "Label { color: #ff0000; }", "TestFont-Regular.otf"); - assertContains(message, "OpenType fonts aren't supported"); + assertContains(message, "an .otf never reaches the device"); } /** @@ -189,7 +212,7 @@ void testRemoteOpenTypeFontIsRejectedWithoutDownloading() throws Exception { + "src: url(https://example.invalid/fonts/TestFont.otf); }" + "Label { font-family: \"TestFont\"; }", null); - assertContains(message, "OpenType fonts aren't supported"); + assertContains(message, "an .otf never reaches the device"); } /** A well-formed sheet must still compile, or the check is worthless. */ 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 1017acf9d44..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,7 +333,7 @@ For the full Lottie feature matrix and troubleshooting, point users to `docs/dev ### Custom TTF fonts -**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. +**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. 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`: