From aecde507f47e10082d955b64c628b4d79e4d637f Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Fri, 31 Jul 2026 15:57:33 +0300
Subject: [PATCH 01/12] Correct the CSS font docs: TrueType only, and no fonts/
subdirectory requirement
A customer bundled Nexa as .otf files, put them in common/src/css, and got no
font change and no error. Two of the three reasons were things the docs told
them.
OTF is not supported. It compiles without complaint because the CSS compiler
loads fonts through java.awt.Font.createFont(TRUETYPE_FONT, ...), which also
parses OpenType/CFF, but Font.createTrueTypeFont rejects any file name that
doesn't end in .ttf, and IPhoneBuilder registers only .ttf files in UIAppFonts.
The developer guide claimed "TTF/OTF fonts" and the initializr CSS skill
reference said ".ttf (or .otf)". Both now say TrueType only and explain the
failure mode.
The fonts/ subdirectory was never a requirement either. A relative src URL is
resolved against the directory holding the CSS file, and merge mode syncs that
whole directory, so a font sitting directly beside theme.css works exactly as
well as one under fonts/. The guide didn't document the resolution rule at all
and three skill references presented common/src/main/css/fonts/ as the location.
While in the section, document the parts that make a font silently do nothing:
a font-family name with an unquoted space parses as separate identifiers and
only the first is read back, so every weight collides under one family; and the
@font-face font-weight/font-style descriptors are parsed but never consulted
when a family is matched, so each weight needs its own family name and a
whole-theme swap goes through the Default selector. Also corrects two stale
claims: fonts land next to the compiled theme.res rather than in the project
src directory, and the remote-font download cache lives in the build directory.
Tests pin both halves of the location contract, since it is now documented:
CSSFontFaceLocationTest compiles a theme with the font in the CSS root, in a
subdirectory, and with two quoted multi-word families, asserting which file each
family resolves to and that it is deployed flat next to theme.res.
CN1CSSCLILogicTest covers the merge-mode url() rewrite that Maven actually
takes, including that remote and absolute URLs pass through untouched.
---
docs/developer-guide/css.asciidoc | 41 +++-
.../designer/css/CN1CSSCLILogicTest.java | 26 +++
.../designer/css/CSSFontFaceLocationTest.java | 221 ++++++++++++++++++
.../com/codename1/designer/css/TestFont.ttf | Bin 0 -> 5832 bytes
.../skill/references/android-to-cn1.md | 2 +-
.../main/resources/skill/references/css.md | 11 +-
.../skill/references/react-to-cn1.md | 3 +-
7 files changed, 296 insertions(+), 8 deletions(-)
create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/CSSFontFaceLocationTest.java
create mode 100644 maven/css-compiler/src/test/resources/com/codename1/designer/css/TestFont.ttf
diff --git a/docs/developer-guide/css.asciidoc b/docs/developer-guide/css.asciidoc
index 589da8ae5da..45125271f3f 100644
--- a/docs/developer-guide/css.asciidoc
+++ b/docs/developer-guide/css.asciidoc
@@ -560,7 +560,7 @@ CN1 resource files support both PNG and JPEG images, but PNG is the default. Mul
=== Fonts
-This library supports the https://developer.mozilla.org/en/docs/Web/CSS/font[font], https://developer.mozilla.org/en/docs/Web/CSS/font-size[font-size], https://developer.mozilla.org/en/docs/Web/CSS/font-family[font-family], https://developer.mozilla.org/en/docs/Web/CSS/font-style[font-style], https://developer.mozilla.org/en/docs/Web/CSS/font-weight[font-weight], and https://developer.mozilla.org/en/docs/Web/CSS/text-decoration[text-decoration] properties, as well at the https://developer.mozilla.org/en/docs/Web/CSS/@font-face[@font-face] CSS "at" rule for including TTF/OTF fonts.
+This library supports the https://developer.mozilla.org/en/docs/Web/CSS/font[font], https://developer.mozilla.org/en/docs/Web/CSS/font-size[font-size], https://developer.mozilla.org/en/docs/Web/CSS/font-family[font-family], https://developer.mozilla.org/en/docs/Web/CSS/font-style[font-style], https://developer.mozilla.org/en/docs/Web/CSS/font-weight[font-weight], and https://developer.mozilla.org/en/docs/Web/CSS/text-decoration[text-decoration] properties, as well at the https://developer.mozilla.org/en/docs/Web/CSS/@font-face[@font-face] CSS "at" rule for including TTF fonts.
==== `font-family`
@@ -593,6 +593,8 @@ 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.
+
Then you'll be able to reference the font using the specified `font-family` in any CSS element. For example:
[source,css]
@@ -600,6 +602,39 @@ Then you'll be able to reference the font using the specified `font-family` in a
include::../demos/common/src/main/css/guide-snippets-theme.css[tag=css-css-029,indent=0]
----
+===== Where to put the font file
+
+A relative `src` URL is resolved against the directory that holds the CSS file. You can keep font files directly beside `theme.css`, or in any subdirectory of it, whichever you prefer:
+
+[source,css]
+----
+@font-face {
+ font-family: "MyFont";
+ src: url(MyFont-Regular.ttf);
+}
+
+@font-face {
+ font-family: "MyFont Bold";
+ src: url(fonts/MyFont-Bold.ttf);
+}
+----
+
+===== 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: MyFont Bold` registers the family as `MyFont` and collides with your regular weight.
+
+`font-weight` and `font-style` select between the built-in `native:` fonts, but they have no effect once `font-family` resolves to a `@font-face` rule. Declare one `@font-face` per weight and style you need, each with its own family name, as in the example above, then reference the right family from each UIID.
+
+To change the base font of an entire theme, set `font-family` on the special `Default` selector, then override the UIIDs that need a bold or italic face:
+
+[source,css]
+----
+Default { font-family: "MyFont"; }
+Title { font-family: "MyFont Bold"; }
+----
+
+===== Remote and GitHub-hosted fonts
+
The `@font-face` directive's `src` property will accept both local and remote URLs. The guide fixture below uses a local font so the demo build remains offline and repeatable; application CSS can replace the URL with an HTTPS font URL:
[source,css]
@@ -607,9 +642,9 @@ The `@font-face` directive's `src` property will accept both local and remote UR
include::../demos/common/src/main/css/guide-snippets-theme.css[tag=css-css-030,indent=0]
----
-In this case, it will download the `myfont.ttf` file to the same directory as the CSS file. From then on it will use that locally downloaded version of the font so that it doesn't have to make a network request for each build.
+In this case, it will download the `myfont.ttf` file into the build directory alongside the merged CSS file, and reuse that copy on later builds so that it doesn't have to make a network request every time. A `mvn clean` discards the cache and the next build downloads the font again.
-Fonts are automatically copied to the project's "src" directory when the CSS file is compiled so that they will be distributed with the app and available at runtime.
+Fonts are automatically copied next to the compiled `theme.res` when the CSS file is compiled, so that they're distributed with the app and available at runtime. The copy uses the font's file name only, which means two `@font-face` rules that point at identically named files in different directories will collide. The copy is also skipped when a file of that name is already there, so run `mvn clean` after you replace a font file with a different one of the same name.
**GitHub URLs**
diff --git a/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLILogicTest.java b/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLILogicTest.java
index 3121de5477f..a4ebe7a20e1 100644
--- a/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLILogicTest.java
+++ b/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLILogicTest.java
@@ -88,4 +88,30 @@ void detectsContainmentBeyondADirectParent(@TempDir Path tempDir) throws Excepti
assertFalse((Boolean) invoke("contains", sig, root, sibling), "unrelated directory");
assertFalse((Boolean) invoke("contains", sig, child, root), "containment is not symmetric");
}
+
+ /**
+ * Merge mode re-anchors every relative url() at the synced copy of the CSS
+ * directory, so an @font-face src that names a file sitting directly in the
+ * CSS root is as valid as one under a fonts/ subdirectory. Absolute and
+ * remote URLs have to come through untouched.
+ */
+ @Test
+ void prefixesRootLevelFontUrlsAlongsideNestedOnes() throws Exception {
+ String css = "@font-face { font-family: \"A\"; src: url(A-Regular.ttf); }\n"
+ + "@font-face { font-family: \"B\"; src: url('fonts/B-Regular.ttf'); }\n"
+ + "@font-face { font-family: \"C\"; src: url(\"https://example.com/C.ttf\"); }\n"
+ + "@font-face { font-family: \"D\"; src: url(/opt/fonts/D.ttf); }\n";
+
+ String out = (String) invoke("prefixUrls", new Class>[]{String.class, String.class},
+ css, "cn1-merged-files/abc123/");
+
+ assertTrue(out.contains("url(\"cn1-merged-files/abc123/A-Regular.ttf\")"),
+ "font in the CSS root is prefixed, was: " + out);
+ assertTrue(out.contains("url(\"cn1-merged-files/abc123/fonts/B-Regular.ttf\")"),
+ "font in a subdirectory is prefixed, was: " + out);
+ assertTrue(out.contains("url(\"https://example.com/C.ttf\")"),
+ "remote URL is left alone, was: " + out);
+ assertTrue(out.contains("url(\"/opt/fonts/D.ttf\")"),
+ "absolute path is left alone, was: " + out);
+ }
}
diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSFontFaceLocationTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSFontFaceLocationTest.java
new file mode 100644
index 00000000000..8a3c2201755
--- /dev/null
+++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSFontFaceLocationTest.java
@@ -0,0 +1,221 @@
+/*
+ * 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 com.codename1.ui.EditorTTFFont;
+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;
+
+/**
+ * Regression tests for where an {@code @font-face} {@code src:} URL is allowed
+ * to point. A relative URL resolves against the directory holding the CSS file,
+ * so a font sitting directly beside {@code theme.css} is just as valid as one in
+ * a subdirectory. The documentation used to imply a {@code fonts/} subdirectory
+ * was required, and these tests pin the looser contract down.
+ *
+ *
They also cover the quoted multi-word family name. An unquoted
+ * {@code font-family: TestFont Bold} parses as two idents and only the first is
+ * read back, so quoting is what keeps two weights apart.
+ */
+public class CSSFontFaceLocationTest {
+
+ /**
+ * The icon font already carried by the CSSFontFaceTest sample, reused here
+ * so the module doesn't need a second font of its own.
+ */
+ private static final String FIXTURE = "TestFont.ttf";
+
+ @BeforeAll
+ static void installHeadlessImplementation() throws Exception {
+ HeadlessTestSupport.installHeadlessImplementation();
+ }
+
+ /**
+ * A font file dropped straight into the CSS directory, with no
+ * subdirectory, has to resolve and ship.
+ */
+ @Test
+ void testFontBesideThemeCssResolves() throws Exception {
+ Path cssDir = Files.createTempDirectory("cn1-font-root");
+ Path outDir = Files.createTempDirectory("cn1-font-root-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));
+
+ Hashtable themeProps = compile(cssFile, outDir.resolve("theme.res"));
+
+ EditorTTFFont font = fontFor(themeProps, "Label.font");
+ assertNotNull(font.getFontFile(), "Label.font resolved to a font file");
+ assertEquals("TestFont-Regular.ttf", font.getFontFile().getName(), "Resolved font file");
+ assertTrue(outDir.resolve("TestFont-Regular.ttf").toFile().exists(),
+ "Font deployed next to theme.res");
+ } finally {
+ deleteTree(cssDir);
+ deleteTree(outDir);
+ }
+ }
+
+ /**
+ * The subdirectory form keeps working, and the deployed copy is flattened
+ * to the bare file name because the runtime forbids a path separator in a
+ * true type font name.
+ */
+ @Test
+ void testFontInSubdirectoryResolvesAndDeploysFlat() throws Exception {
+ Path cssDir = Files.createTempDirectory("cn1-font-sub");
+ Path outDir = Files.createTempDirectory("cn1-font-sub-out");
+ try {
+ Path fontsDir = cssDir.resolve("fonts");
+ Files.createDirectories(fontsDir);
+ copyFixture(fontsDir.resolve("TestFont-Regular.ttf"));
+ Path cssFile = cssDir.resolve("theme.css");
+ Files.write(cssFile, ("@font-face {"
+ + " font-family: \"TestFont\";"
+ + " src: url(fonts/TestFont-Regular.ttf);"
+ + "}"
+ + "Label { font-family: \"TestFont\"; font-size: 3mm; }")
+ .getBytes(StandardCharsets.UTF_8));
+
+ Hashtable themeProps = compile(cssFile, outDir.resolve("theme.res"));
+
+ EditorTTFFont font = fontFor(themeProps, "Label.font");
+ assertNotNull(font.getFontFile(), "Label.font resolved to a font file");
+ assertEquals("TestFont-Regular.ttf", font.getFontFile().getName(), "Resolved font file");
+ assertTrue(outDir.resolve("TestFont-Regular.ttf").toFile().exists(),
+ "Font deployed flat next to theme.res");
+ } finally {
+ deleteTree(cssDir);
+ deleteTree(outDir);
+ }
+ }
+
+ /**
+ * Two weights, two quoted family names, two distinct files. This is the
+ * shape the guide tells people to use, since the {@code font-weight}
+ * descriptor on {@code @font-face} is not consulted when a family is
+ * matched.
+ */
+ @Test
+ void testQuotedMultiWordFamilyKeepsWeightsApart() throws Exception {
+ Path cssDir = Files.createTempDirectory("cn1-font-weights");
+ Path outDir = Files.createTempDirectory("cn1-font-weights-out");
+ try {
+ copyFixture(cssDir.resolve("TestFont-Regular.ttf"));
+ copyFixture(cssDir.resolve("TestFont-Bold.ttf"));
+ Path cssFile = cssDir.resolve("theme.css");
+ Files.write(cssFile, ("@font-face {"
+ + " font-family: \"TestFont\";"
+ + " src: url(TestFont-Regular.ttf);"
+ + "}"
+ + "@font-face {"
+ + " font-family: \"TestFont Bold\";"
+ + " src: url(TestFont-Bold.ttf);"
+ + "}"
+ + "Label { font-family: \"TestFont\"; font-size: 3mm; }"
+ + "Title { font-family: \"TestFont Bold\"; font-size: 4mm; }")
+ .getBytes(StandardCharsets.UTF_8));
+
+ Hashtable themeProps = compile(cssFile, outDir.resolve("theme.res"));
+
+ assertEquals("TestFont-Regular.ttf", fontFor(themeProps, "Label.font").getFontFile().getName(),
+ "Regular weight");
+ assertEquals("TestFont-Bold.ttf", fontFor(themeProps, "Title.font").getFontFile().getName(),
+ "Bold weight resolved through the quoted family name");
+ } finally {
+ deleteTree(cssDir);
+ deleteTree(outDir);
+ }
+ }
+
+ private static Hashtable compile(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());
+ theme.updateResources();
+ return theme.res.getTheme("Theme");
+ }
+
+ private static EditorTTFFont fontFor(Hashtable themeProps, String key) {
+ Object font = themeProps.get(key);
+ assertNotNull(font, "Theme property " + key);
+ assertTrue(font instanceof EditorTTFFont, key + " is a true type font, was " + font.getClass());
+ return (EditorTTFFont) font;
+ }
+
+ /**
+ * Writes the shared TTF fixture out under whatever name the test needs. The
+ * tests only care about which file a family resolves to, not about what the
+ * glyphs look like, so one fixture stands in for every weight.
+ */
+ private static void copyFixture(Path dest) throws IOException {
+ try (InputStream in = CSSFontFaceLocationTest.class.getResourceAsStream(FIXTURE)) {
+ assertNotNull(in, "Test fixture " + FIXTURE);
+ 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 assertEquals(Object expected, Object actual, String message) {
+ if (expected == null ? actual != null : !expected.equals(actual)) {
+ throw new AssertionError(message + " expected=" + expected + " actual=" + actual);
+ }
+ }
+
+ private static void assertNotNull(Object actual, String message) {
+ if (actual == null) {
+ throw new AssertionError(message + " was null");
+ }
+ }
+
+ private static void assertTrue(boolean condition, String message) {
+ if (!condition) {
+ throw new AssertionError(message);
+ }
+ }
+}
diff --git a/maven/css-compiler/src/test/resources/com/codename1/designer/css/TestFont.ttf b/maven/css-compiler/src/test/resources/com/codename1/designer/css/TestFont.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..bd39ecf6b74ddcf02175767998f710e0180d6b9d
GIT binary patch
literal 5832
zcmd^DZERcDc|PY}@{*z?k`gIWHeLBpSCSR+lKP1CQHNF-Qk3G#X=Pb<$)sy|O;H~X
zC9)Kg*(q8S-B-Y6S!xtPQM5o?w7~YKIF#EWGrBq*imna3qAM^A#ei);R=_Yepc&d?
z*kWPJN9x}5ocBEEocFw6_fm{A#yspZOl9Sz^B0ohPlsQI(#M!~3*$3e5ICMt*BEnPO*XW(Q@zsv
zRO2`3kD`C9SjwBqz6$*u3+Q`y%qwN~x1HZZA9I-4HFpZPzC0k%uQI0mUAeSZHFvZ(
zu|M(G%aua;7f(gL%UB?R{vR_syNgY*d8RBZo?T<^qFLQ#M;Yx-9~yrz=EdzzlQ~->
z9Ah-fA>mAGOV!`!uQNOLe_6p!?A2z=|CLR01yW{LA$`PWWh1@L61UkqJ?d)XGJ95C
zWp98+Iy6666JBD>z=eJJZfk4{QeqisU6$O&;^;f^#g4btUrxT$(`W#(Ho|CAZ#Q#W
zNWokiw8kkoo#g-fr^_-xrey|L?vbMUs`&u0Q&e$DG;N~6)Z
ztbSGPMpT4ZBr$rNbLQqMSC*`3vauY_rYc;`F-7TC&IEP8uh(ur65%IfT=NI!IrnRW
z<6P4RW6Hd3KneOC!GQ7y*M52pe|*3=<-fW9$(3tc2_;^9?dz`<<2-${kAHUan)1fm
zU(jAX_~Hk~`fjG@FTeh^*LF|Yk{fS4^U3v_NPbER*7&}fR1R2>jkEql-{^^vVSVtZ
z&)&fnFuu{_<#vCB4{*)HXJ@8IPBg!R=*{zh${iQ;`{MD11OLx#v;04lfn#@n?TODFQAhc|c>23KxJ~(|Ks+9db#!<8
zeMkBOolZqnCjwsg!8-@O$8A63ceq2l`@bjJlMel
zLAy`!s(Tauw!J^ETvw-HZ^1?qI#W~**Y7Zn
zs=`#2Q9V^N2x|4Lum5PkX77*moSdD3wWkJPU%x|x-h(Gp_+i!`n3_2`GN^@)Opbs0
z&p(wr8VdFH{vt5n|8D_z=;zBXEaQ*=d@*r)YJ7S+H=lSlF?!_S8@kS)J3g3LoIbn!
z!r8OPtH#|%U435dWL>N$;YKX-61l$5r`Q7#jufD-?U~s@LZv%14VZi
zqGAsy-bv3x`t-7YKHOs(eQ03aoou>=73N^4TUdpz3oUE|KHI`})(KBF=W38Y-@+~y
zVVf=7$$ab@9La4R*x@q!MhkQ1<(?K+Pza8As6TiDJ5{FxTkAkVk3i(TY<
zE!@dQ_>WVi^3}@r=2lgVrXCYh6O(h|(p6D{XnWTz3iHM4R;jWl5@MsYTP+lerC7eS
z)5fcX%@>PirQL0}7YmiW?b5E8j7_vfONHG+#jFZS~RVLUdq>rJUg6k&P9MB~&1ei_MHrr(;
zD?(zj7cpbY8refXfolW3T}TUH6oF$bkNeI;@>MW4F~5jaDi4i5xXMMaD+r!#Yjpv=
zV$kkE))H8|7SpseScg7M-S^l_*lh~ZDrS+E6-z6@7OfTU=__Cl8oP{j@?gg-t5hL9
zg`zzE(4Ot{A*$MWgtw1JejFT+6>j5pu5kx%u0SsD;IY2+r{p(SuRwf
z<)XP;h)z1`Rj&$D{%Thc4
zY(}08$?km
zl&G0Px>hndNCb_!2+a_h$%S&+Y&Il$B%9SGTgeo%*{DY9%A>dQxh7
z66$9q&qbxp(4m)DuiGyr1&uT}r#r^ed8v+tAy0{#sA28;gnbytI+w|o-
zY9?}7qU07VC7s5;qm!l5$+7UEu{(_Wjqhr%!Xr>2g%jsQx~7}t2+L9|MAnod1SQ*A
zN_AK_7n)mizRz?ygjtw;c(nIU>Y|v@llz@6HJu5E^l)}89F^ThT~X3<-CT%Dw}Ar)
zA-hr^qH_W0$*kZWt{py~grdrfyqb$PUTw
zf_^0`dyM6CndS3MVJHl_&yx2Vb>>N3$kaU^_|HsAPlSRLp_{CC(XAUd$pi3&I=qsp
zQ{=)}$r{{C+m3~GOl#v%bDX9i_GvVWlPp31r5v1ipR3--fpx}wI!v6BYgNg01F5x
zLTwm|P&)`ksMVnewL?&Z+F>X{?Fba1_5>86b`*+G`Jmcgk@=#Vb2`Ux
z(sKeFo_;u8Q3W2U7Rg!QTUHHTnAhj(H0Y5eSYjejS><-^yAN>$c|K`P>bn8
z#A{e<=H9S~HP?_ftO=HoIZEclxy*G%s3LS-8BvdBlT_fGD3#a`JL-!_EqL+%JV8?P
zNVAeFsoc6Q)s(r88l$Al5O6Mw#C&j$36)TF^+j_oq+^FgoC}S$3)XwYCQyVbp$*v$
z+uIRHsM`-{f)!0n9=4Rg9gh(zVfXezl+2P11a`NNv}~ZqVVXHhES<<+Ar|!|+KjyQ
zLCa)xfR;tsS|%pqc<_A?I^pNRNZ(sR>uzmBwqYb3~$~OpFR{@?Do|AIP->_jOUEP`qixg_oj**LCly=gI6U$Kj@;KwPCUwyhrXDcL@uV^N4|(!
z%l$q%1-U}Fzbhmea4;wSO(w(&KmpNk~{-oNj^-R
zB!)gh90DuEA#jd31XhVN4{)A11lEW{-~w?7TqI5c;G@JLkRc9%EO7`tOPmzIbHpL=
zG2#&TJ>n4fIB}i?c%C=}a>OBE5{JMgL(U$WIZqw=1jOqWiUSp_c!KyeIva+ZJ7jNC
z$6{|;h}hc}BI;#Be(;d`0(C5E(LzMsu@F&r4SDL2TB43cEnA4FpRf>7E5`K>o6>$W
zN=Br!Al0FjEA*&|GQ4E}XcaFY{52Hi=4*Ai&g@B?o{bT>?ozn3iSVnQke0lmyoKQo
zU`O%>OEj^{7V1O%m2-G&d?iy?*B9y|)VtyQ41T;OUdiLt2^72>XRmiDyld=D-uNSF
M`!hT!@IRve1>`O_E&u=k
literal 0
HcmV?d00001
diff --git a/scripts/initializr/common/src/main/resources/skill/references/android-to-cn1.md b/scripts/initializr/common/src/main/resources/skill/references/android-to-cn1.md
index 96654d3b9ed..abc6d44d881 100644
--- a/scripts/initializr/common/src/main/resources/skill/references/android-to-cn1.md
+++ b/scripts/initializr/common/src/main/resources/skill/references/android-to-cn1.md
@@ -129,7 +129,7 @@ The EDT/UI-thread rule is identical in spirit to Android: never touch a componen
| `res/values/strings.xml` | `common/src/main/l10n/messages.properties` (and per-locale `messages_de.properties`, etc.) — see `references/build-and-run.md`. |
| `res/drawable/foo.png` | `common/src/main/resources/foo.png` (flat namespace — `references/java-api-subset.md`). |
| `res/values/colors.xml` | Theme constants in `theme.css` under `#Constants { ... }`. |
-| `res/font/x.ttf` | `common/src/main/css/fonts/x.ttf`, declared via `@font-face` in `theme.css`. |
+| `res/font/x.ttf` | Anywhere under `common/src/main/css/` (beside `theme.css` or in a subdirectory), declared via `@font-face` in `theme.css`. TrueType only — convert `.otf` files first. |
| `res/raw/seed.json` | `common/src/main/resources/seed.json` — read with `Display.getInstance().getResourceAsStream("/seed.json")`. |
| `res/layout/*.xml` | No equivalent — build the layout in Java (`Container` + `Layout` + components). |
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 90786b6c9e0..e827e76ec42 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,9 @@ For the full Lottie feature matrix and troubleshooting, point users to `docs/dev
### Custom TTF fonts
-Drop a `.ttf` (or `.otf`) under `common/src/main/css/fonts/`, then reference its **font name (not file name)** in `font-family`:
+**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.
+
+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`:
```css
@font-face {
@@ -349,7 +351,9 @@ Title { font-family: "Inter Bold"; font-size: 4mm; }
Body { font-family: "Inter"; font-size: 3mm; }
```
-Custom TTF/OTF files are **packaged with the app binary** (placed under the build output so the runtime can `Font.createTrueTypeFont(name, file)` them at startup) — they are **not** embedded inside `theme.res`. That means each font you add increases the deployed app size; choose lean subsets where possible.
+A family name containing a space must be quoted everywhere it appears — unquoted, `font-family: Inter Bold` parses as two identifiers and registers under `Inter`, silently colliding with the regular weight. Note also that `font-weight` / `font-style` only select between the built-in `native:` fonts; once `font-family` matches a `@font-face`, they are ignored. That is why each weight needs its own family name. For a whole-theme font swap, set `font-family` on the `Default` selector, then override the UIIDs that need bold or italic.
+
+Custom TTF files are **packaged with the app binary** (placed under the build output so the runtime can `Font.createTrueTypeFont(name, file)` them at startup) — they are **not** embedded inside `theme.res`. That means each font you add increases the deployed app size; choose lean subsets where possible.
To load a TTF programmatically:
@@ -555,7 +559,8 @@ Painters are for **drawing** (custom backgrounds, decorations), not for animatin
| `text-align` does nothing | Add the `align` fallback (the initializr appends one automatically for `text-align`). |
| 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/fonts/`. |
+| 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. |
## Reaching beyond the compiler
diff --git a/scripts/initializr/common/src/main/resources/skill/references/react-to-cn1.md b/scripts/initializr/common/src/main/resources/skill/references/react-to-cn1.md
index a22514dfa5e..9bf97c74326 100644
--- a/scripts/initializr/common/src/main/resources/skill/references/react-to-cn1.md
+++ b/scripts/initializr/common/src/main/resources/skill/references/react-to-cn1.md
@@ -80,7 +80,8 @@ Then translate sizes. A web design is in **px**; CN1 sizes in **mm** (density
independent). At the desktop/browser scale CN1 effectively renders ~3.78 px/mm, so
`16px ~= 4.2mm`, `24px ~= 6.3mm`. Borders/radii in `px` stay crisp; size text and
spacing in `mm`. **Bundle the real font** if you want to match the typeface: drop
-the `.ttf` files under `common/src/main/css/fonts/` and reference them with
+the `.ttf` files anywhere under `common/src/main/css/` (beside `theme.css` or in a
+subdirectory of it) and reference them with
`@font-face { font-family: "Inter"; src: url("fonts/Inter-Regular.ttf"); }` (one
`@font-face` per weight, distinct family names like `"Inter SemiBold"`).
From fb7301ce3c7521ac4a03eba72a597dafe73d76ee Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:50:15 +0300
Subject: [PATCH 02/12] Back the new font snippets with demo includes
The guide has a gate I missed: validate-guide-snippets.py requires every
[source] block to be include-backed from docs/demos, so the two inline CSS
examples I added failed the docs build.
Moved both into guide-snippets-theme.css as tags css-css-044 and css-css-045.
That means the demo build now compiles them, which is the point of the fixture
rule -- so the examples had to use real fonts rather than a made-up MyFont.
Added GuideRootFont.ttf (the 5.8KB icon font the CSSFontFaceTest sample already
carries) at the CSS root, alongside the existing res/GuideDemoFont-Bold.ttf, so
the snippet demonstrates the root and subdirectory forms with files that exist.
Pointed Default at "GuideRootFont" rather than the regular demo font on
purpose. A @font-face is only copied to the build output when some style
actually references it, so without a reference the root-level font would
compile silently and prove nothing. With it, mvn -f docs/demos/pom.xml
process-classes deploys GuideRootFont.ttf next to guide-snippets-theme.res,
which is exactly the behaviour the new section documents.
---
.../common/src/main/css/GuideRootFont.ttf | Bin 0 -> 5832 bytes
.../src/main/css/guide-snippets-theme.css | 28 ++++++++++++++++++
docs/developer-guide/css.asciidoc | 15 ++--------
3 files changed, 31 insertions(+), 12 deletions(-)
create mode 100644 docs/demos/common/src/main/css/GuideRootFont.ttf
diff --git a/docs/demos/common/src/main/css/GuideRootFont.ttf b/docs/demos/common/src/main/css/GuideRootFont.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..bd39ecf6b74ddcf02175767998f710e0180d6b9d
GIT binary patch
literal 5832
zcmd^DZERcDc|PY}@{*z?k`gIWHeLBpSCSR+lKP1CQHNF-Qk3G#X=Pb<$)sy|O;H~X
zC9)Kg*(q8S-B-Y6S!xtPQM5o?w7~YKIF#EWGrBq*imna3qAM^A#ei);R=_Yepc&d?
z*kWPJN9x}5ocBEEocFw6_fm{A#yspZOl9Sz^B0ohPlsQI(#M!~3*$3e5ICMt*BEnPO*XW(Q@zsv
zRO2`3kD`C9SjwBqz6$*u3+Q`y%qwN~x1HZZA9I-4HFpZPzC0k%uQI0mUAeSZHFvZ(
zu|M(G%aua;7f(gL%UB?R{vR_syNgY*d8RBZo?T<^qFLQ#M;Yx-9~yrz=EdzzlQ~->
z9Ah-fA>mAGOV!`!uQNOLe_6p!?A2z=|CLR01yW{LA$`PWWh1@L61UkqJ?d)XGJ95C
zWp98+Iy6666JBD>z=eJJZfk4{QeqisU6$O&;^;f^#g4btUrxT$(`W#(Ho|CAZ#Q#W
zNWokiw8kkoo#g-fr^_-xrey|L?vbMUs`&u0Q&e$DG;N~6)Z
ztbSGPMpT4ZBr$rNbLQqMSC*`3vauY_rYc;`F-7TC&IEP8uh(ur65%IfT=NI!IrnRW
z<6P4RW6Hd3KneOC!GQ7y*M52pe|*3=<-fW9$(3tc2_;^9?dz`<<2-${kAHUan)1fm
zU(jAX_~Hk~`fjG@FTeh^*LF|Yk{fS4^U3v_NPbER*7&}fR1R2>jkEql-{^^vVSVtZ
z&)&fnFuu{_<#vCB4{*)HXJ@8IPBg!R=*{zh${iQ;`{MD11OLx#v;04lfn#@n?TODFQAhc|c>23KxJ~(|Ks+9db#!<8
zeMkBOolZqnCjwsg!8-@O$8A63ceq2l`@bjJlMel
zLAy`!s(Tauw!J^ETvw-HZ^1?qI#W~**Y7Zn
zs=`#2Q9V^N2x|4Lum5PkX77*moSdD3wWkJPU%x|x-h(Gp_+i!`n3_2`GN^@)Opbs0
z&p(wr8VdFH{vt5n|8D_z=;zBXEaQ*=d@*r)YJ7S+H=lSlF?!_S8@kS)J3g3LoIbn!
z!r8OPtH#|%U435dWL>N$;YKX-61l$5r`Q7#jufD-?U~s@LZv%14VZi
zqGAsy-bv3x`t-7YKHOs(eQ03aoou>=73N^4TUdpz3oUE|KHI`})(KBF=W38Y-@+~y
zVVf=7$$ab@9La4R*x@q!MhkQ1<(?K+Pza8As6TiDJ5{FxTkAkVk3i(TY<
zE!@dQ_>WVi^3}@r=2lgVrXCYh6O(h|(p6D{XnWTz3iHM4R;jWl5@MsYTP+lerC7eS
z)5fcX%@>PirQL0}7YmiW?b5E8j7_vfONHG+#jFZS~RVLUdq>rJUg6k&P9MB~&1ei_MHrr(;
zD?(zj7cpbY8refXfolW3T}TUH6oF$bkNeI;@>MW4F~5jaDi4i5xXMMaD+r!#Yjpv=
zV$kkE))H8|7SpseScg7M-S^l_*lh~ZDrS+E6-z6@7OfTU=__Cl8oP{j@?gg-t5hL9
zg`zzE(4Ot{A*$MWgtw1JejFT+6>j5pu5kx%u0SsD;IY2+r{p(SuRwf
z<)XP;h)z1`Rj&$D{%Thc4
zY(}08$?km
zl&G0Px>hndNCb_!2+a_h$%S&+Y&Il$B%9SGTgeo%*{DY9%A>dQxh7
z66$9q&qbxp(4m)DuiGyr1&uT}r#r^ed8v+tAy0{#sA28;gnbytI+w|o-
zY9?}7qU07VC7s5;qm!l5$+7UEu{(_Wjqhr%!Xr>2g%jsQx~7}t2+L9|MAnod1SQ*A
zN_AK_7n)mizRz?ygjtw;c(nIU>Y|v@llz@6HJu5E^l)}89F^ThT~X3<-CT%Dw}Ar)
zA-hr^qH_W0$*kZWt{py~grdrfyqb$PUTw
zf_^0`dyM6CndS3MVJHl_&yx2Vb>>N3$kaU^_|HsAPlSRLp_{CC(XAUd$pi3&I=qsp
zQ{=)}$r{{C+m3~GOl#v%bDX9i_GvVWlPp31r5v1ipR3--fpx}wI!v6BYgNg01F5x
zLTwm|P&)`ksMVnewL?&Z+F>X{?Fba1_5>86b`*+G`Jmcgk@=#Vb2`Ux
z(sKeFo_;u8Q3W2U7Rg!QTUHHTnAhj(H0Y5eSYjejS><-^yAN>$c|K`P>bn8
z#A{e<=H9S~HP?_ftO=HoIZEclxy*G%s3LS-8BvdBlT_fGD3#a`JL-!_EqL+%JV8?P
zNVAeFsoc6Q)s(r88l$Al5O6Mw#C&j$36)TF^+j_oq+^FgoC}S$3)XwYCQyVbp$*v$
z+uIRHsM`-{f)!0n9=4Rg9gh(zVfXezl+2P11a`NNv}~ZqVVXHhES<<+Ar|!|+KjyQ
zLCa)xfR;tsS|%pqc<_A?I^pNRNZ(sR>uzmBwqYb3~$~OpFR{@?Do|AIP->_jOUEP`qixg_oj**LCly=gI6U$Kj@;KwPCUwyhrXDcL@uV^N4|(!
z%l$q%1-U}Fzbhmea4;wSO(w(&KmpNk~{-oNj^-R
zB!)gh90DuEA#jd31XhVN4{)A11lEW{-~w?7TqI5c;G@JLkRc9%EO7`tOPmzIbHpL=
zG2#&TJ>n4fIB}i?c%C=}a>OBE5{JMgL(U$WIZqw=1jOqWiUSp_c!KyeIva+ZJ7jNC
z$6{|;h}hc}BI;#Be(;d`0(C5E(LzMsu@F&r4SDL2TB43cEnA4FpRf>7E5`K>o6>$W
zN=Br!Al0FjEA*&|GQ4E}XcaFY{52Hi=4*Ai&g@B?o{bT>?ozn3iSVnQke0lmyoKQo
zU`O%>OEj^{7V1O%m2-G&d?iy?*B9y|)VtyQ41T;OUdiLt2^72>XRmiDyld=D-uNSF
M`!hT!@IRve1>`O_E&u=k
literal 0
HcmV?d00001
diff --git a/docs/demos/common/src/main/css/guide-snippets-theme.css b/docs/demos/common/src/main/css/guide-snippets-theme.css
index 63c674395d1..9be3f311121 100644
--- a/docs/demos/common/src/main/css/guide-snippets-theme.css
+++ b/docs/demos/common/src/main/css/guide-snippets-theme.css
@@ -419,6 +419,34 @@ MyLabel {
}
/* end::css-css-029[] */
+/* A relative src URL resolves against the directory holding this CSS file, so
+ GuideRootFont.ttf sits beside it and GuideDemoFont-Bold.ttf sits in res/.
+ Both forms compile, which is the point the guide makes with this snippet. */
+/* tag::css-css-044[] */
+@font-face {
+ font-family: "GuideRootFont";
+ src: url(GuideRootFont.ttf);
+}
+
+@font-face {
+ font-family: "GuideDemoFont Bold";
+ src: url(res/GuideDemoFont-Bold.ttf);
+}
+/* end::css-css-044[] */
+
+/* Both families come from the snippet above, so this fixture also proves the
+ root-level font actually resolves and ships: a @font-face is only copied to
+ the build output when some style references it. */
+/* tag::css-css-045[] */
+Default {
+ font-family: "GuideRootFont";
+}
+
+Title {
+ font-family: "GuideDemoFont Bold";
+}
+/* end::css-css-045[] */
+
/* tag::css-css-030[] */
@font-face {
font-family: "GuideDownloadedFont";
diff --git a/docs/developer-guide/css.asciidoc b/docs/developer-guide/css.asciidoc
index 45125271f3f..be293c5836d 100644
--- a/docs/developer-guide/css.asciidoc
+++ b/docs/developer-guide/css.asciidoc
@@ -608,20 +608,12 @@ A relative `src` URL is resolved against the directory that holds the CSS file.
[source,css]
----
-@font-face {
- font-family: "MyFont";
- src: url(MyFont-Regular.ttf);
-}
-
-@font-face {
- font-family: "MyFont Bold";
- src: url(fonts/MyFont-Bold.ttf);
-}
+include::../demos/common/src/main/css/guide-snippets-theme.css[tag=css-css-044,indent=0]
----
===== 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: MyFont Bold` registers the family as `MyFont` and collides with your regular weight.
+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.
`font-weight` and `font-style` select between the built-in `native:` fonts, but they have no effect once `font-family` resolves to a `@font-face` rule. Declare one `@font-face` per weight and style you need, each with its own family name, as in the example above, then reference the right family from each UIID.
@@ -629,8 +621,7 @@ To change the base font of an entire theme, set `font-family` on the special `De
[source,css]
----
-Default { font-family: "MyFont"; }
-Title { font-family: "MyFont Bold"; }
+include::../demos/common/src/main/css/guide-snippets-theme.css[tag=css-css-045,indent=0]
----
===== Remote and GitHub-hosted fonts
From f71e01789f96f1968ed3ac552e97268dc8f28352 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:00:17 +0300
Subject: [PATCH 03/12] Add the CN1 copyright header to
guide-snippets-theme.css
Touching the file pulled it into the copyright gate's scope, and it never had
a header. It is first-party CN1 content, so it gets the header rather than an
entry in copyright-header-exclusions.txt, which is reserved for third-party
sources.
---
.../src/main/css/guide-snippets-theme.css | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/docs/demos/common/src/main/css/guide-snippets-theme.css b/docs/demos/common/src/main/css/guide-snippets-theme.css
index 9be3f311121..fd01290cfb3 100644
--- a/docs/demos/common/src/main/css/guide-snippets-theme.css
+++ b/docs/demos/common/src/main/css/guide-snippets-theme.css
@@ -1,3 +1,26 @@
+/*
+ * 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.
+ */
+
/**
* Compiled developer-guide CSS snippets.
* Complete examples that do not require external assets or generated image borders live here.
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 04/12] 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 05/12] 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`:
From dfb5b11385861e4db02f660f0c8cadc352d183a8 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Sun, 2 Aug 2026 07:29:53 +0700
Subject: [PATCH 06/12] Support OpenType fonts on every port
An .otf was never rejected by any rendering stack -- it was rejected by our own
file name checks. Core Text, Android's Typeface, DirectWrite, FontConfig,
java.awt and the browser's FontFace all read the font container whether the
outlines are TrueType or PostScript, so the .ttf-only gates cost users a
conversion step for no reason, and gave no diagnostic when they skipped it.
Every gate now accepts .ttf and .otf, case insensitively -- capitalisation
never said anything about whether a font loads either:
- Font.createTrueTypeFont, via the new Font.isSupportedFontFile
- StyleParser.parseFontFile, which appended ".ttf" to anything lacking it and
so turned "Foo.otf" into "Foo.otf.ttf"
- IPhoneBuilder and TvNativeBuilder, which register UIAppFonts
- JavaScriptBuilder, which relocates bundled fonts into assets/
- JavaSEPort, for fonts registered out of the app resources
- WindowsImplementation and LinuxImplementation, on the in-memory loader path,
plus cn1WinIsTtf in cn1_windows_text.c (renamed cn1WinIsFontFile)
- the CSS compiler's @font-face validation
HTML5Implementation derived the FontFace format hint from the extension rather
than always claiming "truetype". Browsers treat the two hints as the same
container, but a hint that matches the file leaves no room for a UA that
decides to skip a source whose hint disagrees with its bytes.
Formats no port can load, .woff among them, still fail the build rather than
reaching a device.
Verified a real OpenType font (NotoSansJavanese-Regular.otf, sfnt OTTO) through
the CLI the CSS goal forks: exit 0, deployed next to theme.res. Full
core-unittests suite green at 4677 tests.
NOTE: IPhoneBuilder, TvNativeBuilder and JavaScriptBuilder are mirrored in the
BuildDaemon repo and cloud builds use that copy, so the three builder changes
need twin PRs there before a cloud iOS build will register a bundled .otf.
---
CodenameOne/src/com/codename1/ui/Font.java | 23 +++++-
.../com/codename1/ui/plaf/StyleParser.java | 17 +++-
.../com/codename1/impl/javase/JavaSEPort.java | 16 +++-
.../impl/html5/HTML5Implementation.java | 12 ++-
.../impl/linux/LinuxImplementation.java | 12 ++-
.../nativeSources/cn1_windows_text.c | 19 +++--
.../impl/windows/WindowsImplementation.java | 12 ++-
docs/developer-guide/css.asciidoc | 8 +-
.../com/codename1/builders/IPhoneBuilder.java | 7 +-
.../codename1/builders/JavaScriptBuilder.java | 1 +
.../codename1/builders/TvNativeBuilder.java | 5 +-
.../test/java/com/codename1/ui/FontTest.java | 41 +++++++++-
.../com/codename1/designer/css/CSSTheme.java | 28 +++----
.../css/CSSFontFaceValidationTest.java | 77 +++++++++++++------
.../skill/references/android-to-cn1.md | 2 +-
.../main/resources/skill/references/css.md | 6 +-
16 files changed, 217 insertions(+), 69 deletions(-)
diff --git a/CodenameOne/src/com/codename1/ui/Font.java b/CodenameOne/src/com/codename1/ui/Font.java
index a6d502d31d2..dee296ae940 100644
--- a/CodenameOne/src/com/codename1/ui/Font.java
+++ b/CodenameOne/src/com/codename1/ui/Font.java
@@ -305,6 +305,22 @@ public static Font createTrueTypeFont(String fontName, float size, byte sizeUnit
derive(Display.getInstance().convertToPixels(size, sizeUnit), STYLE_PLAIN);
}
+ /// True when the file name carries an extension the bundled font loaders accept.
+ ///
+ /// Both TrueType and OpenType are supported: every port loads fonts through an
+ /// API that reads the SFNT container regardless of whether the outlines are
+ /// glyf or CFF (Core Text on iOS, Typeface on Android, DirectWrite on Windows,
+ /// FreeType on Linux, java.awt on the simulator and FontFace in the browser).
+ /// The comparison is case insensitive so a file named `.TTF` isn't rejected
+ /// for its capitalisation alone.
+ static boolean isSupportedFontFile(String fileName) {
+ if (fileName == null) {
+ return false;
+ }
+ String lower = fileName.toLowerCase();
+ return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ }
+
/// Creates a true type font with the given name/filename (font name might be different from the file name
/// and is required by some devices e.g. iOS). The font file must reside in the src root of the project in
/// order to be detectable. The file name should contain no slashes or any such value.
@@ -323,7 +339,8 @@ public static Font createTrueTypeFont(String fontName, float size, byte sizeUnit
///
/// - `fontName`: the name of the font
///
- /// - `fileName`: the file name of the font as it appears in the src directory of the project, it MUST end with the .ttf extension!
+ /// - `fileName`: the file name of the font as it appears in the src directory of the project, it MUST end
+ /// with the .ttf or .otf extension!
///
/// #### Returns
///
@@ -339,8 +356,8 @@ public static Font createTrueTypeFont(String fontName, String fileName) {
return null;
}
} else {
- if (fileName != null && (fileName.indexOf('/') > -1 || fileName.indexOf('\\') > -1 || !fileName.endsWith(".ttf"))) {
- throw new IllegalArgumentException("The font file name must be relative to the root and end with ttf: " + fileName);
+ if (fileName != null && (fileName.indexOf('/') > -1 || fileName.indexOf('\\') > -1 || !isSupportedFontFile(fileName))) {
+ throw new IllegalArgumentException("The font file name must be relative to the root and end with ttf or otf: " + fileName);
}
}
Object font = Display.impl.loadTrueTypeFont(fontName, fileName);
diff --git a/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java b/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java
index c722c85af50..2af307de0d7 100644
--- a/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java
+++ b/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java
@@ -661,6 +661,16 @@ private static FontInfo parseFontName(FontInfo out, String arg) {
return out;
}
+ /**
+ * True when the argument already names a font file rather than a bare
+ * family, for either of the container extensions the runtime loads.
+ */
+ private static boolean hasFontFileSuffix(String arg) {
+ String lower = arg.toLowerCase();
+ return lower.indexOf(".ttf") == lower.length() - 4
+ || lower.indexOf(".otf") == lower.length() - 4;
+ }
+
private static FontInfo parseFontFile(FontInfo out, String arg) {
arg = arg.trim();
if (arg.indexOf('/') != -1) {
@@ -669,9 +679,12 @@ private static FontInfo parseFontFile(FontInfo out, String arg) {
if (arg.length() > 0 && arg.charAt(0) == '/') {
arg = arg.substring(1);
} else {
+ // A bare family name gets the default .ttf suffix, but an explicit
+ // .ttf/.otf is left alone -- appending to "Foo.otf" would ask for
+ // "Foo.otf.ttf" and lose the font.
arg = arg.indexOf("native:") == 0 ? arg :
- arg.indexOf(".ttf") != arg.length() - 4 ? arg + ".ttf" :
- arg;
+ hasFontFileSuffix(arg) ? arg :
+ arg + ".ttf";
}
out.setFile(arg);
return out;
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index 7353168fe4a..928c6125cfb 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -4502,8 +4502,9 @@ private void loadSkinFile(InputStream skin, final JFrame frm) {
e = z.getNextEntry();
continue;
}
- if (name.endsWith(".ttf")) {
+ if (isBundledFontFile(name)) {
try {
+ // TRUETYPE_FONT covers the whole SFNT family, OpenType included.
java.awt.Font result = java.awt.Font.createFont(java.awt.Font.TRUETYPE_FONT, z);
GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(result);
} catch (FontFormatException ex) {
@@ -12073,6 +12074,19 @@ private String nativeFontName(String fontName) {
return nativeFontNameForIOS(fontName, getAvailableFontNamesLowercase());
}
+ /**
+ * True for a font file bundled with the app. java.awt's TRUETYPE_FONT
+ * reads the whole SFNT family, so an OpenType file registers exactly like
+ * a TrueType one.
+ */
+ private static boolean isBundledFontFile(String fileName) {
+ if (fileName == null) {
+ return false;
+ }
+ String lower = fileName.toLowerCase();
+ return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ }
+
@Override
public Object loadTrueTypeFont(String fontName, String fileName) {
File fontFile = null;
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 4de2065201f..7081d7656f8 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -9018,13 +9018,23 @@ public Object loadTrueTypeFont(String fontName, String fileName) {
// which is not reachable from the worker and truncates the data URL
// to 26 chars, so the old byte->dataURL approach 100% failed to load
// the font.
- loadTrueTypeFont_(resolvedFontName, resolvedFileName, "truetype");
+ loadTrueTypeFont_(resolvedFontName, resolvedFileName, fontFormatOf(resolvedFileName));
loadedFonts.add(resolvedFontName);
}
return createFallbackTrueTypeFont(resolvedFontName, resolvedFileName);
}
+ /**
+ * The format hint handed to the FontFace constructor. Browsers treat
+ * "truetype" and "opentype" as the same SFNT container, but naming the
+ * actual one keeps the hint honest and leaves no room for a UA that decides
+ * to skip a source whose hint doesn't match.
+ */
+ private static String fontFormatOf(String fileName) {
+ return fileName != null && fileName.toLowerCase().endsWith(".otf") ? "opentype" : "truetype";
+ }
+
private NativeFont createFallbackTrueTypeFont(String fontName, String fileName) {
NativeFont out = (NativeFont)createFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM);
out.fontName = nativeFontName(fontName);
diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
index 431d1775b57..5e3bb77a91d 100644
--- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
+++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
@@ -1775,7 +1775,7 @@ public Object loadTrueTypeFont(String fontName, String fileName) {
// executable via the DirectWrite in-memory loader so there is no file
// next to the exe. Falls back to the file-based loader (a font staged
// beside the exe) when the resource isn't embedded.
- if (fileName != null && fileName.toLowerCase().endsWith(".ttf")) {
+ if (fileName != null && isBundledFontFile(fileName)) {
byte[] data = readResourceFully("/" + fileName);
if (data != null) {
font = LinuxNative.loadTrueTypeFontFromMemory(fontName, data);
@@ -1790,6 +1790,16 @@ public Object loadTrueTypeFont(String fontName, String fileName) {
return Long.valueOf(font);
}
+ /**
+ * True for a font the app bundles. FontConfig and FreeType read the SFNT
+ * container whether the outlines are glyf or CFF, so OpenType is as
+ * loadable as TrueType here.
+ */
+ private static boolean isBundledFontFile(String fileName) {
+ String lower = fileName.toLowerCase();
+ return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ }
+
/** Reads an embedded classpath resource fully into a byte[], or null. */
private byte[] readResourceFully(String resource) {
InputStream in = getResourceAsStream(LinuxImplementation.class, resource);
diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_text.c b/Ports/WindowsPort/nativeSources/cn1_windows_text.c
index 6eef5583811..f0b7fa0db62 100644
--- a/Ports/WindowsPort/nativeSources/cn1_windows_text.c
+++ b/Ports/WindowsPort/nativeSources/cn1_windows_text.c
@@ -84,8 +84,10 @@ static int cn1WinExeRelativePath(const wchar_t* fileName, wchar_t* out, int outL
return 1;
}
-/* Case-insensitive ".ttf" suffix test. */
-static int cn1WinIsTtf(const wchar_t* name) {
+/* Case-insensitive ".ttf"/".otf" suffix test. DirectWrite reads the SFNT
+ * container whether the outlines are glyf or CFF, so both extensions name a
+ * font it can register. */
+static int cn1WinIsFontFile(const wchar_t* name) {
if (name == NULL) {
return 0;
}
@@ -94,10 +96,13 @@ static int cn1WinIsTtf(const wchar_t* name) {
return 0;
}
const wchar_t* ext = name + (len - 4);
- return ext[0] == L'.'
- && (ext[1] == L't' || ext[1] == L'T')
- && (ext[2] == L't' || ext[2] == L'T')
- && (ext[3] == L'f' || ext[3] == L'F');
+ if (ext[0] != L'.' || (ext[3] != L'f' && ext[3] != L'F')) {
+ return 0;
+ }
+ if ((ext[1] == L't' || ext[1] == L'T') && (ext[2] == L't' || ext[2] == L'T')) {
+ return 1;
+ }
+ return (ext[1] == L'o' || ext[1] == L'O') && (ext[2] == L't' || ext[2] == L'T');
}
/* Builds a CN1Font for an explicit family / pixel size / style. */
@@ -267,7 +272,7 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_loadTrueTypeFont___java_lang_
float dpi = cn1Win.dpiScale > 0.0f ? cn1Win.dpiScale : 1.0f;
CN1Font* font = NULL;
- if (cn1WinIsTtf(fileName)) {
+ if (cn1WinIsFontFile(fileName)) {
wchar_t fullPath[MAX_PATH];
wchar_t registered[128];
registered[0] = L'\0';
diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
index 41d720777da..322b7522054 100644
--- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
+++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
@@ -1778,7 +1778,7 @@ public Object loadTrueTypeFont(String fontName, String fileName) {
// executable via the DirectWrite in-memory loader so there is no file
// next to the exe. Falls back to the file-based loader (a font staged
// beside the exe) when the resource isn't embedded.
- if (fileName != null && fileName.toLowerCase().endsWith(".ttf")) {
+ if (fileName != null && isBundledFontFile(fileName)) {
byte[] data = readResourceFully("/" + fileName);
if (data != null) {
font = WindowsNative.loadTrueTypeFontFromMemory(fontName, data);
@@ -1793,6 +1793,16 @@ public Object loadTrueTypeFont(String fontName, String fileName) {
return Long.valueOf(font);
}
+ /**
+ * True for a font the app bundles. DirectWrite reads the SFNT container
+ * whether the outlines are glyf or CFF, so OpenType is as loadable as
+ * TrueType here.
+ */
+ private static boolean isBundledFontFile(String fileName) {
+ String lower = fileName.toLowerCase();
+ return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ }
+
/** Reads an embedded classpath resource fully into a byte[], or null. */
private byte[] readResourceFully(String resource) {
InputStream in = getResourceAsStream(WindowsImplementation.class, resource);
diff --git a/docs/developer-guide/css.asciidoc b/docs/developer-guide/css.asciidoc
index cc26e1f38b4..ed66abaf9cc 100644
--- a/docs/developer-guide/css.asciidoc
+++ b/docs/developer-guide/css.asciidoc
@@ -560,7 +560,7 @@ CN1 resource files support both PNG and JPEG images, but PNG is the default. Mul
=== Fonts
-This library supports the https://developer.mozilla.org/en/docs/Web/CSS/font[font], https://developer.mozilla.org/en/docs/Web/CSS/font-size[font-size], https://developer.mozilla.org/en/docs/Web/CSS/font-family[font-family], https://developer.mozilla.org/en/docs/Web/CSS/font-style[font-style], https://developer.mozilla.org/en/docs/Web/CSS/font-weight[font-weight], and https://developer.mozilla.org/en/docs/Web/CSS/text-decoration[text-decoration] properties, as well at the https://developer.mozilla.org/en/docs/Web/CSS/@font-face[@font-face] CSS "at" rule for including TTF fonts.
+This library supports the https://developer.mozilla.org/en/docs/Web/CSS/font[font], https://developer.mozilla.org/en/docs/Web/CSS/font-size[font-size], https://developer.mozilla.org/en/docs/Web/CSS/font-family[font-family], https://developer.mozilla.org/en/docs/Web/CSS/font-style[font-style], https://developer.mozilla.org/en/docs/Web/CSS/font-weight[font-weight], and https://developer.mozilla.org/en/docs/Web/CSS/text-decoration[text-decoration] properties, as well at the https://developer.mozilla.org/en/docs/Web/CSS/@font-face[@font-face] CSS "at" rule for including TrueType and OpenType fonts.
==== `font-family`
@@ -584,7 +584,7 @@ If you omit the `font-family` directive altogether, it will use `native:MainRegu
. `native:ItalicBold`
. `native:ItalicBlack`
-===== Using TTF fonts
+===== Using bundled fonts
If you want to use a font other than the built-in fonts, you'll need to define the font using the `@font-face` rule. For example:
@@ -593,7 +593,9 @@ 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 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.
+Both TrueType (`.ttf`) and OpenType (`.otf`) files work, upper-case or lower-case. Every platform loads both formats through its own font API, so an OpenType font needs no conversion before you bundle it. Web-only formats such as `.woff` aren't supported, and the CSS compiler fails the build on one rather than letting it reach the device.
+
+IMPORTANT: The compiler reads each font at build time, so a file it can't parse, or one with no PostScript name, fails the build. That second case is worth knowing about: iOS resolves fonts by their PostScript name, so a font without one renders 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/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
index 68d474a7d26..6b2fa6671b4 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
@@ -4986,7 +4986,12 @@ private void injectToPlist(File tmpFile, File resDir, BuildRequest request) thro
@Override
public boolean accept(File file, String string) {
- return string.toLowerCase().endsWith(".ttf");
+ // Core Text reads the SFNT container whether the outlines are
+ // glyf or CFF, so OpenType registers through UIAppFonts exactly
+ // like TrueType. Leaving .otf out here is what made a bundled
+ // OpenType font fall back to the system font on the device.
+ String lower = string.toLowerCase();
+ return lower.endsWith(".ttf") || lower.endsWith(".otf");
}
});
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java
index d134334a691..d8bec442dd3 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java
@@ -836,6 +836,7 @@ private void mergeTranslatorRootResources(File translatorOut, File distDir) thro
}
String lower = rf.getName().toLowerCase();
boolean relocate = lower.endsWith(".ttf")
+ || lower.endsWith(".otf")
|| lower.endsWith(".zip")
|| lower.endsWith("-pom.xml");
if (relocate) {
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java
index 62b9bdc2683..d389b152a15 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java
@@ -186,7 +186,10 @@ void writeTvInfoPlist(BuildRequest request, File appSrcDir, File resDir) throws
File[] fontFiles = resDir == null ? null : resDir.listFiles(new FilenameFilter() {
@Override
public boolean accept(File dir, String name) {
- return name.toLowerCase().endsWith(".ttf");
+ // Core Text handles OpenType as readily as TrueType, so both
+ // belong in UIAppFonts.
+ String lower = name.toLowerCase();
+ return lower.endsWith(".ttf") || lower.endsWith(".otf");
}
});
if (fontFiles != null && fontFiles.length > 0) {
diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/FontTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/FontTest.java
index e4388d6af0f..703603bac65 100644
--- a/maven/core-unittests/src/test/java/com/codename1/ui/FontTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/ui/FontTest.java
@@ -1,3 +1,25 @@
+/*
+ * 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.ui;
import com.codename1.junit.EdtTest;
@@ -31,7 +53,24 @@ void testCreateTrueTypeFontCachesByFileNameAndHeight() {
@EdtTest
void testCreateTrueTypeFontRejectsInvalidFileNames() {
assertThrows(IllegalArgumentException.class, () -> Font.createTrueTypeFont("BadFont", "path/bad.ttf"));
- assertThrows(IllegalArgumentException.class, () -> Font.createTrueTypeFont("BadFont", "badfont.otf"));
+ assertThrows(IllegalArgumentException.class, () -> Font.createTrueTypeFont("BadFont", "badfont.woff"));
+ }
+
+ /**
+ * OpenType is loadable on every port -- Core Text, Typeface, DirectWrite,
+ * FontConfig, java.awt and FontFace all read the SFNT container whether the
+ * outlines are glyf or CFF -- so the file name check must not reject it.
+ * The check is also case insensitive, since capitalisation says nothing
+ * about whether a font will load.
+ */
+ @EdtTest
+ void testCreateTrueTypeFontAcceptsBothFontContainers() {
+ assertTrue(Font.isSupportedFontFile("font.ttf"));
+ assertTrue(Font.isSupportedFontFile("font.otf"));
+ assertTrue(Font.isSupportedFontFile("font.TTF"));
+ assertTrue(Font.isSupportedFontFile("font.OTF"));
+ assertFalse(Font.isSupportedFontFile("font.woff"));
+ assertFalse(Font.isSupportedFontFile(null));
}
@EdtTest
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 a2f95c3102d..05d26e70c0e 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
@@ -2148,14 +2148,14 @@ void validateFontFaces() {
}
if (url == null) {
errors.add("@font-face \"" + family + "\" has no usable src; "
- + "Codename One only supports src: url(...) pointing at a .ttf file");
+ + "Codename One only supports src: url(...) pointing at a .ttf or .otf file");
continue;
}
String fileName = fontFileName(url);
- if (!fileName.endsWith(".ttf")) {
- errors.add("@font-face \"" + family + "\" points at " + fileName + ". "
- + fontExtensionAdvice(fileName));
+ if (!isSupportedFontFileName(fileName)) {
+ errors.add("@font-face \"" + family + "\" points at " + fileName + ", and Codename One "
+ + "loads fonts from a file named .ttf or .otf");
continue;
}
@@ -2263,21 +2263,13 @@ 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) {
+ /// Both SFNT container extensions are accepted, case insensitively. Every
+ /// port loads fonts through an API that reads the container regardless of
+ /// whether the outlines are glyf or CFF, so there is no reason to make an
+ /// author convert an OpenType file or rename it.
+ private static boolean isSupportedFontFileName(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";
+ return lower.endsWith(".ttf") || lower.endsWith(".otf");
}
/// 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 3e78bb5e10f..4a23fe5df0a 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
@@ -53,14 +53,34 @@ static void installHeadlessImplementation() throws Exception {
HeadlessTestSupport.installHeadlessImplementation();
}
+ /**
+ * OpenType is loadable on every port, so the file name must not be what
+ * rejects it. Only the name is under test here -- the fixture's bytes are
+ * TrueType, which keeps the test hermetic while still driving the .otf
+ * branch of the name check.
+ */
+ @Test
+ void testOpenTypeFontNameIsAccepted() throws Exception {
+ assertCompiles("@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.otf); }"
+ + "Label { font-family: \"TestFont\"; font-size: 3mm; }", "TestFont-Regular.otf");
+ }
+
+ /** Capitalisation says nothing about whether a font loads. */
+ @Test
+ void testUpperCaseExtensionIsAccepted() throws Exception {
+ assertCompiles("@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.TTF); }"
+ + "Label { font-family: \"TestFont\"; font-size: 3mm; }", "TestFont-Regular.TTF");
+ }
+
+ /** An extension no port can load is still refused. */
@Test
- void testOpenTypeFontIsRejected() throws Exception {
+ void testUnknownFontExtensionIsRejected() throws Exception {
String message = assertCompileFails(
- "@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.otf); }"
+ "@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.woff); }"
+ "Label { font-family: \"TestFont\"; }",
- "TestFont-Regular.otf");
- assertContains(message, "an .otf never reaches the device");
- assertContains(message, "TestFont-Regular.otf");
+ "TestFont-Regular.woff");
+ assertContains(message, "loads fonts from a file named .ttf or .otf");
+ assertContains(message, "TestFont-Regular.woff");
}
/**
@@ -92,25 +112,12 @@ void testUnreadableFontFileIsRejected() throws Exception {
* whichever later build first referenced it.
*/
@Test
- void testUnreferencedOpenTypeFontIsRejected() throws Exception {
+ void testUnreferencedBadFontIsRejected() throws Exception {
String message = assertCompileFails(
- "@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.otf); }"
+ "@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.woff); }"
+ "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");
+ "TestFont-Regular.woff");
+ assertContains(message, "loads fonts from a file named .ttf or .otf");
}
@Test
@@ -206,13 +213,13 @@ void testTwoFamiliesMaySharedOneFontFile() throws Exception {
* the compile reaching out to the network.
*/
@Test
- void testRemoteOpenTypeFontIsRejectedWithoutDownloading() throws Exception {
+ void testRemoteUnknownExtensionIsRejectedWithoutDownloading() throws Exception {
String message = assertCompileFails(
"@font-face { font-family: \"TestFont\"; "
- + "src: url(https://example.invalid/fonts/TestFont.otf); }"
+ + "src: url(https://example.invalid/fonts/TestFont.woff); }"
+ "Label { font-family: \"TestFont\"; }",
null);
- assertContains(message, "an .otf never reaches the device");
+ assertContains(message, "loads fonts from a file named .ttf or .otf");
}
/** A well-formed sheet must still compile, or the check is worthless. */
@@ -259,6 +266,26 @@ private static String assertCompileFails(String css, String fontFile) throws Exc
}
}
+ /** Compiles the sheet with the fixture written out under {@code fontFile}. */
+ private static void assertCompiles(String css, String fontFile) throws Exception {
+ Path cssDir = Files.createTempDirectory("cn1-font-valid");
+ Path outDir = Files.createTempDirectory("cn1-font-valid-out");
+ try {
+ copyFixture(cssDir.resolve(fontFile));
+ Path cssFile = cssDir.resolve("theme.css");
+ Files.write(cssFile, css.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);
+ }
+ }
+
private static String compileExpectingFailure(Path cssFile, Path resFile) throws Exception {
CSSTheme theme = CSSTheme.load(cssFile.toUri().toURL());
theme.resourceFile = resFile.toFile();
diff --git a/scripts/initializr/common/src/main/resources/skill/references/android-to-cn1.md b/scripts/initializr/common/src/main/resources/skill/references/android-to-cn1.md
index abc6d44d881..59da1f79451 100644
--- a/scripts/initializr/common/src/main/resources/skill/references/android-to-cn1.md
+++ b/scripts/initializr/common/src/main/resources/skill/references/android-to-cn1.md
@@ -129,7 +129,7 @@ The EDT/UI-thread rule is identical in spirit to Android: never touch a componen
| `res/values/strings.xml` | `common/src/main/l10n/messages.properties` (and per-locale `messages_de.properties`, etc.) — see `references/build-and-run.md`. |
| `res/drawable/foo.png` | `common/src/main/resources/foo.png` (flat namespace — `references/java-api-subset.md`). |
| `res/values/colors.xml` | Theme constants in `theme.css` under `#Constants { ... }`. |
-| `res/font/x.ttf` | Anywhere under `common/src/main/css/` (beside `theme.css` or in a subdirectory), declared via `@font-face` in `theme.css`. TrueType only — convert `.otf` files first. |
+| `res/font/x.ttf` | Anywhere under `common/src/main/css/` (beside `theme.css` or in a subdirectory), declared via `@font-face` in `theme.css`. `.ttf` and `.otf` both work. |
| `res/raw/seed.json` | `common/src/main/resources/seed.json` — read with `Display.getInstance().getResourceAsStream("/seed.json")`. |
| `res/layout/*.xml` | No equivalent — build the layout in Java (`Container` + `Layout` + components). |
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 68293acd245..2767353c9f2 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. 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.
+**`.ttf` and `.otf` both work**, upper-case or lower-case — every port loads both formats through its own font API (Core Text, Typeface, DirectWrite, FontConfig, java.awt, FontFace), so there's no need to convert an OpenType font. Web-only formats like `.woff` are not supported and **fail the build**. 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`:
@@ -353,7 +353,7 @@ Body { font-family: "Inter"; font-size: 3mm; }
A family name containing a space must be quoted everywhere it appears — unquoted, `font-family: Inter Bold` parses as two identifiers and registers under `Inter`, silently colliding with the regular weight. Note also that `font-weight` / `font-style` only select between the built-in `native:` fonts; once `font-family` matches a `@font-face`, they are ignored. That is why each weight needs its own family name. For a whole-theme font swap, set `font-family` on the `Default` selector, then override the UIIDs that need bold or italic.
-Custom TTF files are **packaged with the app binary** (placed under the build output so the runtime can `Font.createTrueTypeFont(name, file)` them at startup) — they are **not** embedded inside `theme.res`. That means each font you add increases the deployed app size; choose lean subsets where possible.
+Bundled font files are **packaged with the app binary** (placed under the build output so the runtime can `Font.createTrueTypeFont(name, file)` them at startup) — they are **not** embedded inside `theme.res`. That means each font you add increases the deployed app size; choose lean subsets where possible.
To load a TTF programmatically:
@@ -560,7 +560,7 @@ 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). |
-| 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. |
+| Build fails with "Invalid @font-face rules" | Read the listed reasons — a container the runtime can't load (`.woff`; `.ttf` and `.otf` are both fine), a font outside `common/src/main/css/`, a missing file, a file that isn't a readable font, a font with no PostScript name, 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 ea0f00576de9d234c3053ab6a1cd6afb3a24d246 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Sun, 2 Aug 2026 08:21:38 +0700
Subject: [PATCH 07/12] Register .otf bundled fonts on watchOS
The bundled-font scan in IOSNative.m only looked for "ttf" resources, and that
scan is the whole registration story on watchOS: WatchNativeBuilder's plist
carries no UIAppFonts array, unlike the iOS and tvOS ones. So an .otf would
have rendered everywhere and fallen back to the system font on the watch --
exactly the split this change set exists to remove.
The scan now covers both container extensions in either case. The font file
itself already reaches the watch bundle: the watch target mirrors the iOS app's
resources build phase, skipping only .xcassets/.storyboard/.xib.
Registering a font that UIAppFonts already registered errors, which is expected
on iOS/tvOS and discarded as before.
Reported by Codex review on #5508.
---
Ports/iOSPort/nativeSources/IOSNative.m | 25 ++++++++++++++++++-------
1 file changed, 18 insertions(+), 7 deletions(-)
diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m
index 797d7259761..e86753c4918 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -9808,13 +9808,24 @@ static void cn1RegisterBundledFontsOnce() {
}
cn1FontsRegistered = YES;
@autoreleasepool {
- NSArray *fontPaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"ttf" inDirectory:nil];
- for (NSString *fontPath in fontPaths) {
- NSURL *url = [NSURL fileURLWithPath:fontPath];
- CFErrorRef error = NULL;
- CTFontManagerRegisterFontsForURL((BRIDGE_CAST CFURLRef)url, kCTFontManagerScopeProcess, &error);
- if (error != NULL) {
- CFRelease(error);
+ // Both container extensions, in either case. Core Text reads OpenType
+ // as readily as TrueType, and this scan is the only thing registering
+ // bundled fonts on watchOS -- its plist carries no UIAppFonts array -- so
+ // anything missing here falls back to the system font on the watch even
+ // though it renders everywhere else. pathsForResourcesOfType matches the
+ // extension exactly, hence the upper-case variants.
+ NSArray *fontTypes = @[@"ttf", @"otf", @"TTF", @"OTF"];
+ for (NSString *fontType in fontTypes) {
+ NSArray *fontPaths = [[NSBundle mainBundle] pathsForResourcesOfType:fontType inDirectory:nil];
+ for (NSString *fontPath in fontPaths) {
+ NSURL *url = [NSURL fileURLWithPath:fontPath];
+ CFErrorRef error = NULL;
+ // A font already registered by UIAppFonts errors here; that is
+ // expected on iOS/tvOS and the error is discarded.
+ CTFontManagerRegisterFontsForURL((BRIDGE_CAST CFURLRef)url, kCTFontManagerScopeProcess, &error);
+ if (error != NULL) {
+ CFRelease(error);
+ }
}
}
}
From adba3809c67b82eb9a8b825c1c74b776c2d1d27b Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Sun, 2 Aug 2026 14:42:09 +0700
Subject: [PATCH 08/12] Use /// markdown doc comments in StyleParser
CodenameOne and Ports/CLDC11 are held to markdown doc comments by
.github/scripts/validate-java25-markdown-docs.sh, which runs as the first step
of build-test. The classic /** block I added failed all three JDK legs before a
single test ran.
---
CodenameOne/src/com/codename1/ui/plaf/StyleParser.java | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java b/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java
index 2af307de0d7..97b1abd5cfd 100644
--- a/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java
+++ b/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java
@@ -661,10 +661,8 @@ private static FontInfo parseFontName(FontInfo out, String arg) {
return out;
}
- /**
- * True when the argument already names a font file rather than a bare
- * family, for either of the container extensions the runtime loads.
- */
+ /// True when the argument already names a font file rather than a bare
+ /// family, for either of the container extensions the runtime loads.
private static boolean hasFontFileSuffix(String arg) {
String lower = arg.toLowerCase();
return lower.indexOf(".ttf") == lower.length() - 4
From 0d1239cad411df11b3ecfe68703580320a18d649 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Sun, 2 Aug 2026 17:01:46 +0700
Subject: [PATCH 09/12] Address review: StyleParser short-name bug, case
handling and editor support
Eight review findings from Codex and Copilot, all real.
StyleParser.hasFontFileSuffix used indexOf(...) == length() - 4, which is true
for any name shorter than the suffix because both sides are -1. A three
character family like "Foo" looked as though it already carried an extension
and shipped with none at all. Uses endsWith now, with a regression test over
bare names, both containers, an upper-case extension and the native: scheme.
The watchOS scan enumerated four spellings, which only ever covers the
spellings someone thought to write down while the rest of the stack accepts any
case -- ".TtF" would be bundled and never registered. It now walks every bundle
resource and compares the lower-cased extension.
AddThemeEntry filtered the Resource Editor's font combo with a case-sensitive
".ttf" test, so resource-based themes could not select an OpenType font the API
now loads.
The @font-face collision key is lower-cased: fonts are flattened into one
directory, and "Body.TTF" and "body.ttf" are the same file on a Windows target
or in an Apple bundle even when the authoring host keeps them apart, so one
would overwrite the other. Covered by a new test.
Also: the IllegalArgumentException names ".ttf or .otf" with the dots, the
alias test is testTwoFamiliesMayShareOneFontFile, and the guide's long-standing
"as well at the" typo on the line this change set already touched is now "as
well as the".
---
CodenameOne/src/com/codename1/ui/Font.java | 2 +-
.../com/codename1/ui/plaf/StyleParser.java | 8 +++-
.../com/codename1/designer/AddThemeEntry.java | 7 +++-
Ports/iOSPort/nativeSources/IOSNative.m | 42 +++++++++++--------
docs/developer-guide/css.asciidoc | 2 +-
.../codename1/ui/plaf/BorderAndPlafTest.java | 23 ++++++++++
.../com/codename1/designer/css/CSSTheme.java | 15 +++++--
.../css/CSSFontFaceValidationTest.java | 32 +++++++++++++-
8 files changed, 102 insertions(+), 29 deletions(-)
diff --git a/CodenameOne/src/com/codename1/ui/Font.java b/CodenameOne/src/com/codename1/ui/Font.java
index dee296ae940..10426b7944c 100644
--- a/CodenameOne/src/com/codename1/ui/Font.java
+++ b/CodenameOne/src/com/codename1/ui/Font.java
@@ -357,7 +357,7 @@ public static Font createTrueTypeFont(String fontName, String fileName) {
}
} else {
if (fileName != null && (fileName.indexOf('/') > -1 || fileName.indexOf('\\') > -1 || !isSupportedFontFile(fileName))) {
- throw new IllegalArgumentException("The font file name must be relative to the root and end with ttf or otf: " + fileName);
+ throw new IllegalArgumentException("The font file name must be relative to the root and end with .ttf or .otf: " + fileName);
}
}
Object font = Display.impl.loadTrueTypeFont(fontName, fileName);
diff --git a/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java b/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java
index 97b1abd5cfd..ff945862f3c 100644
--- a/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java
+++ b/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java
@@ -663,10 +663,14 @@ private static FontInfo parseFontName(FontInfo out, String arg) {
/// True when the argument already names a font file rather than a bare
/// family, for either of the container extensions the runtime loads.
+ ///
+ /// Uses endsWith rather than comparing indexOf against length - 4: for a
+ /// name shorter than the suffix both sides are -1, so a three character
+ /// family like "Foo" would look as though it already carried an extension
+ /// and would be left without one.
private static boolean hasFontFileSuffix(String arg) {
String lower = arg.toLowerCase();
- return lower.indexOf(".ttf") == lower.length() - 4
- || lower.indexOf(".otf") == lower.length() - 4;
+ return lower.endsWith(".ttf") || lower.endsWith(".otf");
}
private static FontInfo parseFontFile(FontInfo out, String arg) {
diff --git a/CodenameOneDesigner/src/com/codename1/designer/AddThemeEntry.java b/CodenameOneDesigner/src/com/codename1/designer/AddThemeEntry.java
index a8e94865844..8f10e685eb9 100644
--- a/CodenameOneDesigner/src/com/codename1/designer/AddThemeEntry.java
+++ b/CodenameOneDesigner/src/com/codename1/designer/AddThemeEntry.java
@@ -179,7 +179,12 @@ public AddThemeEntry(boolean adding, EditableResources resources, ResourceEditor
String[] fontFiles = ResourceEditorView.getLoadedFile().getParentFile().list(new FilenameFilter() {
@Override
public boolean accept(File file, String string) {
- return string.endsWith(".ttf");
+ // Same set the runtime loads (see Font.isSupportedFontFile):
+ // both container extensions, in any case. A .ttf-only filter
+ // here left resource-based themes unable to pick an OpenType
+ // font the API otherwise accepts.
+ String lower = string.toLowerCase();
+ return lower.endsWith(".ttf") || lower.endsWith(".otf");
}
});
if(fontFiles == null) {
diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m
index e86753c4918..2a2824ee18a 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -9808,24 +9808,30 @@ static void cn1RegisterBundledFontsOnce() {
}
cn1FontsRegistered = YES;
@autoreleasepool {
- // Both container extensions, in either case. Core Text reads OpenType
- // as readily as TrueType, and this scan is the only thing registering
- // bundled fonts on watchOS -- its plist carries no UIAppFonts array -- so
- // anything missing here falls back to the system font on the watch even
- // though it renders everywhere else. pathsForResourcesOfType matches the
- // extension exactly, hence the upper-case variants.
- NSArray *fontTypes = @[@"ttf", @"otf", @"TTF", @"OTF"];
- for (NSString *fontType in fontTypes) {
- NSArray *fontPaths = [[NSBundle mainBundle] pathsForResourcesOfType:fontType inDirectory:nil];
- for (NSString *fontPath in fontPaths) {
- NSURL *url = [NSURL fileURLWithPath:fontPath];
- CFErrorRef error = NULL;
- // A font already registered by UIAppFonts errors here; that is
- // expected on iOS/tvOS and the error is discarded.
- CTFontManagerRegisterFontsForURL((BRIDGE_CAST CFURLRef)url, kCTFontManagerScopeProcess, &error);
- if (error != NULL) {
- CFRelease(error);
- }
+ // Core Text reads OpenType as readily as TrueType, and this scan is the
+ // only thing registering bundled fonts on watchOS -- its plist carries no
+ // UIAppFonts array -- so anything missed here falls back to the system
+ // font on the watch even though it renders everywhere else.
+ //
+ // Enumerate every resource and compare the lower-cased extension rather
+ // than calling pathsForResourcesOfType: per spelling. That call matches
+ // the extension exactly, so a per-spelling list can only ever cover the
+ // spellings someone thought to write down, while Font.isSupportedFontFile
+ // and the builders accept any case -- ".TtF" would be bundled and then
+ // never registered.
+ NSArray *resourcePaths = [[NSBundle mainBundle] pathsForResourcesOfType:nil inDirectory:nil];
+ for (NSString *resourcePath in resourcePaths) {
+ NSString *ext = [[resourcePath pathExtension] lowercaseString];
+ if (![ext isEqualToString:@"ttf"] && ![ext isEqualToString:@"otf"]) {
+ continue;
+ }
+ NSURL *url = [NSURL fileURLWithPath:resourcePath];
+ CFErrorRef error = NULL;
+ // A font already registered by UIAppFonts errors here; that is
+ // expected on iOS/tvOS and the error is discarded.
+ CTFontManagerRegisterFontsForURL((BRIDGE_CAST CFURLRef)url, kCTFontManagerScopeProcess, &error);
+ if (error != NULL) {
+ CFRelease(error);
}
}
}
diff --git a/docs/developer-guide/css.asciidoc b/docs/developer-guide/css.asciidoc
index ed66abaf9cc..eb415d6d4e8 100644
--- a/docs/developer-guide/css.asciidoc
+++ b/docs/developer-guide/css.asciidoc
@@ -560,7 +560,7 @@ CN1 resource files support both PNG and JPEG images, but PNG is the default. Mul
=== Fonts
-This library supports the https://developer.mozilla.org/en/docs/Web/CSS/font[font], https://developer.mozilla.org/en/docs/Web/CSS/font-size[font-size], https://developer.mozilla.org/en/docs/Web/CSS/font-family[font-family], https://developer.mozilla.org/en/docs/Web/CSS/font-style[font-style], https://developer.mozilla.org/en/docs/Web/CSS/font-weight[font-weight], and https://developer.mozilla.org/en/docs/Web/CSS/text-decoration[text-decoration] properties, as well at the https://developer.mozilla.org/en/docs/Web/CSS/@font-face[@font-face] CSS "at" rule for including TrueType and OpenType fonts.
+This library supports the https://developer.mozilla.org/en/docs/Web/CSS/font[font], https://developer.mozilla.org/en/docs/Web/CSS/font-size[font-size], https://developer.mozilla.org/en/docs/Web/CSS/font-family[font-family], https://developer.mozilla.org/en/docs/Web/CSS/font-style[font-style], https://developer.mozilla.org/en/docs/Web/CSS/font-weight[font-weight], and https://developer.mozilla.org/en/docs/Web/CSS/text-decoration[text-decoration] properties, as well as the https://developer.mozilla.org/en/docs/Web/CSS/@font-face[@font-face] CSS "at" rule for including TrueType and OpenType fonts.
==== `font-family`
diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/BorderAndPlafTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/BorderAndPlafTest.java
index cd8eb332eee..ea6a7f10685 100644
--- a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/BorderAndPlafTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/BorderAndPlafTest.java
@@ -358,6 +358,29 @@ void testDefaultLookAndFeelBidiAlignmentReversal() {
assertEquals(Component.LEFT, DefaultLookAndFeel.reverseAlignForBidi(component, Component.LEFT));
}
+ /**
+ * A bare family name gets the default .ttf suffix; a name that already
+ * carries either container extension is left alone. The short-name case is
+ * the interesting one: a suffix check written as
+ * {@code indexOf(".ttf") == length() - 4} says "true" for anything shorter
+ * than the suffix, because both sides are -1, and the file would ship with
+ * no extension at all.
+ */
+ @FormTest
+ void testStyleParserFontFileSuffixes() {
+ assertEquals("Foo.ttf", parseFontFile("Foo"));
+ assertEquals("Handlee-Regular.ttf", parseFontFile("Handlee-Regular"));
+ assertEquals("Nexa.otf", parseFontFile("Nexa.otf"));
+ assertEquals("Nexa.ttf", parseFontFile("Nexa.ttf"));
+ assertEquals("Nexa.OTF", parseFontFile("Nexa.OTF"));
+ assertEquals("native:MainRegular", parseFontFile("native:MainRegular"));
+ }
+
+ private static String parseFontFile(String family) {
+ StyleParser.StyleInfo info = StyleParser.parseString("font: " + family);
+ return StyleParser.parseFont(new FontInfo(), info.values.get("font")).getFile();
+ }
+
@FormTest
void testStyleParserMergesFontDefinitions() {
Font defaultFont = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM);
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 05d26e70c0e..46b15797575 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
@@ -2159,13 +2159,20 @@ void validateFontFaces() {
continue;
}
+ // Key on the lower-cased name. The fonts are flattened into one
+ // directory, and on a case-insensitive target -- Windows, or an
+ // Apple bundle -- "Body.TTF" and "body.ttf" are the same file there
+ // even when the authoring host kept them apart, so one would
+ // overwrite the other.
+ String deployKey = fileName.toLowerCase();
String source = canonicalSource(url);
- String previousSource = sourceByFileName.put(fileName, source);
- String previousFamily = familyByFileName.put(fileName, family);
+ String previousSource = sourceByFileName.put(deployKey, source);
+ String previousFamily = familyByFileName.put(deployKey, 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");
+ + "files that deploy under the same name, " + fileName + ". Fonts are deployed next to "
+ + "the theme resource by file name alone, and that name is matched without regard to "
+ + "case, so one would overwrite the other; rename one of them");
}
if (!"file".equals(url.getProtocol())) {
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 4a23fe5df0a..b72e2b62bb1 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
@@ -172,7 +172,35 @@ void testDuplicateFontFileNamesAreRejected() throws Exception {
+ "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");
+ assertContains(message, "resolve to different files that deploy under the same name, TestFont.ttf");
+ } finally {
+ deleteTree(cssDir);
+ deleteTree(outDir);
+ }
+ }
+
+ /**
+ * Names differing only in case collide too. The authoring host may keep
+ * {@code Body.TTF} and {@code body.ttf} apart, but they are flattened into
+ * one directory and a Windows target or an Apple bundle treats them as the
+ * same file, so one silently wins.
+ */
+ @Test
+ void testFontFileNamesDifferingOnlyInCaseAreRejected() throws Exception {
+ Path cssDir = Files.createTempDirectory("cn1-font-case");
+ Path outDir = Files.createTempDirectory("cn1-font-case-out");
+ try {
+ Path nested = cssDir.resolve("bold");
+ Files.createDirectories(nested);
+ copyFixture(cssDir.resolve("Body.ttf"));
+ copyFixture(nested.resolve("BODY.TTF"));
+ Path cssFile = cssDir.resolve("theme.css");
+ Files.write(cssFile, ("@font-face { font-family: \"Body\"; src: url(Body.ttf); }"
+ + "@font-face { font-family: \"Body Bold\"; src: url(bold/BODY.TTF); }"
+ + "Label { font-family: \"Body\"; }").getBytes(StandardCharsets.UTF_8));
+
+ String message = compileExpectingFailure(cssFile, outDir.resolve("theme.res"));
+ assertContains(message, "deploy under the same name");
} finally {
deleteTree(cssDir);
deleteTree(outDir);
@@ -185,7 +213,7 @@ void testDuplicateFontFileNamesAreRejected() throws Exception {
* Only genuinely different files sharing a name are an error.
*/
@Test
- void testTwoFamiliesMaySharedOneFontFile() throws Exception {
+ void testTwoFamiliesMayShareOneFontFile() throws Exception {
Path cssDir = Files.createTempDirectory("cn1-font-alias");
Path outDir = Files.createTempDirectory("cn1-font-alias-out");
try {
From 252b61b19dd4b1dece1b5a03fb2c679f763e572f Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Sun, 2 Aug 2026 21:38:37 +0700
Subject: [PATCH 10/12] Scan the bundle resource root, not the whole bundle,
for fonts
build-ios passed on ea0f0057 and adba3809 and failed on 0d1239ca, the commit
that switched cn1RegisterBundledFontsOnce to pathsForResourcesOfType:nil. The
BrowserComponent screenshot test stopped emitting output; the other 142
screenshots still matched. The whole-bundle enumeration is the only iOS-side
change in that commit.
Keeps the case-insensitive match that the review asked for, but gets it from a
single shallow listing of the bundle resource root instead of walking every
resource in the bundle -- pods, map assets and TensorFlow models included -- on
the way to the first glyph. Nothing is missed: fonts are deployed flat next to
theme.res, which is exactly why createTrueTypeFont forbids a path separator in
the name.
---
Ports/iOSPort/nativeSources/IOSNative.m | 27 ++++++++++++++++---------
1 file changed, 17 insertions(+), 10 deletions(-)
diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m
index 2a2824ee18a..d95d879f770 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -9813,19 +9813,26 @@ static void cn1RegisterBundledFontsOnce() {
// UIAppFonts array -- so anything missed here falls back to the system
// font on the watch even though it renders everywhere else.
//
- // Enumerate every resource and compare the lower-cased extension rather
- // than calling pathsForResourcesOfType: per spelling. That call matches
- // the extension exactly, so a per-spelling list can only ever cover the
- // spellings someone thought to write down, while Font.isSupportedFontFile
- // and the builders accept any case -- ".TtF" would be bundled and then
- // never registered.
- NSArray *resourcePaths = [[NSBundle mainBundle] pathsForResourcesOfType:nil inDirectory:nil];
- for (NSString *resourcePath in resourcePaths) {
- NSString *ext = [[resourcePath pathExtension] lowercaseString];
+ // List the resource directory once and compare the lower-cased extension,
+ // rather than calling pathsForResourcesOfType: per spelling: that call
+ // matches the extension exactly, so a per-spelling list only ever covers
+ // the spellings someone thought to write down while the rest of the stack
+ // accepts any case, and ".TtF" would be bundled and never registered.
+ //
+ // A shallow listing of the resource root is deliberate. Fonts are
+ // deployed flat next to theme.res -- that is why createTrueTypeFont
+ // forbids a path separator in the name -- so nothing is missed, and it
+ // avoids walking every resource in the bundle (pods, map assets, models)
+ // on the way to the first glyph.
+ NSString *resourceRoot = [[NSBundle mainBundle] resourcePath];
+ NSArray *resourceNames = resourceRoot == nil ? nil
+ : [[NSFileManager defaultManager] contentsOfDirectoryAtPath:resourceRoot error:NULL];
+ for (NSString *resourceName in resourceNames) {
+ NSString *ext = [[resourceName pathExtension] lowercaseString];
if (![ext isEqualToString:@"ttf"] && ![ext isEqualToString:@"otf"]) {
continue;
}
- NSURL *url = [NSURL fileURLWithPath:resourcePath];
+ NSURL *url = [NSURL fileURLWithPath:[resourceRoot stringByAppendingPathComponent:resourceName]];
CFErrorRef error = NULL;
// A font already registered by UIAppFonts errors here; that is
// expected on iOS/tvOS and the error is discarded.
From 02436e13d05bdfc4fa6ccc4f4f65bef4589bcee5 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Sun, 2 Aug 2026 22:17:29 +0700
Subject: [PATCH 11/12] Make font name matching locale-independent and stop
decoding + as a space
Two more review findings, both real.
The deployment collision key used the no-argument toLowerCase(), so the answer
depended on the build machine: under Turkish rules "I.ttf" folds to a dotless
"i.ttf" and stops matching "i.ttf", and two files that really do collide when
flattened onto a case-insensitive target would both be accepted. Keys now fold
with Locale.ROOT, covered by a test that runs under tr-TR and fails without it.
fontFileName() decoded the URL path with URLDecoder, which is form decoding:
"+" means a space there. A font named "A+B.ttf" was validated as "A B.ttf" -- a
name that never exists on disk, and one that could collide with an unrelated
"A B.ttf". Percent escapes are now decoded without the form rule, and
getFontFile() uses the same helper so a downloaded font lands under the name
the validator checked.
While here, every extension test goes through regionMatches(true, ...) instead
of toLowerCase(). The letters in ".ttf"/".otf" happen to be locale-safe, but a
reader shouldn't have to verify that: the checks are now independent of locale
by construction, in core, the CSS compiler, the JavaSE/Windows/Linux ports, the
iOS and tvOS builders and the resource editor.
---
CodenameOne/src/com/codename1/ui/Font.java | 14 ++--
.../com/codename1/ui/plaf/StyleParser.java | 10 ++-
.../com/codename1/designer/AddThemeEntry.java | 9 ++-
.../com/codename1/impl/javase/JavaSEPort.java | 12 ++--
.../impl/linux/LinuxImplementation.java | 9 ++-
.../impl/windows/WindowsImplementation.java | 9 ++-
.../com/codename1/builders/IPhoneBuilder.java | 9 ++-
.../codename1/builders/TvNativeBuilder.java | 9 ++-
.../com/codename1/designer/css/CSSTheme.java | 45 +++++++++----
.../css/CSSFontFaceValidationTest.java | 66 +++++++++++++++++++
10 files changed, 158 insertions(+), 34 deletions(-)
diff --git a/CodenameOne/src/com/codename1/ui/Font.java b/CodenameOne/src/com/codename1/ui/Font.java
index 10426b7944c..af67d9fa82a 100644
--- a/CodenameOne/src/com/codename1/ui/Font.java
+++ b/CodenameOne/src/com/codename1/ui/Font.java
@@ -312,13 +312,15 @@ public static Font createTrueTypeFont(String fontName, float size, byte sizeUnit
/// glyf or CFF (Core Text on iOS, Typeface on Android, DirectWrite on Windows,
/// FreeType on Linux, java.awt on the simulator and FontFace in the browser).
/// The comparison is case insensitive so a file named `.TTF` isn't rejected
- /// for its capitalisation alone.
+ /// for its capitalisation alone. It goes through regionMatches rather than
+ /// toLowerCase so the answer can't depend on the default locale.
static boolean isSupportedFontFile(String fileName) {
- if (fileName == null) {
- return false;
- }
- String lower = fileName.toLowerCase();
- return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ return endsWithIgnoreCase(fileName, ".ttf") || endsWithIgnoreCase(fileName, ".otf");
+ }
+
+ private static boolean endsWithIgnoreCase(String value, String suffix) {
+ return value != null && value.length() >= suffix.length()
+ && value.regionMatches(true, value.length() - suffix.length(), suffix, 0, suffix.length());
}
/// Creates a true type font with the given name/filename (font name might be different from the file name
diff --git a/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java b/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java
index ff945862f3c..89afbe57e97 100644
--- a/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java
+++ b/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java
@@ -669,8 +669,14 @@ private static FontInfo parseFontName(FontInfo out, String arg) {
/// family like "Foo" would look as though it already carried an extension
/// and would be left without one.
private static boolean hasFontFileSuffix(String arg) {
- String lower = arg.toLowerCase();
- return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ return endsWithIgnoreCase(arg, ".ttf") || endsWithIgnoreCase(arg, ".otf");
+ }
+
+ /// Case-insensitive suffix test that doesn't route through toLowerCase, so
+ /// the result can't depend on the default locale.
+ private static boolean endsWithIgnoreCase(String value, String suffix) {
+ return value != null && value.length() >= suffix.length()
+ && value.regionMatches(true, value.length() - suffix.length(), suffix, 0, suffix.length());
}
private static FontInfo parseFontFile(FontInfo out, String arg) {
diff --git a/CodenameOneDesigner/src/com/codename1/designer/AddThemeEntry.java b/CodenameOneDesigner/src/com/codename1/designer/AddThemeEntry.java
index 8f10e685eb9..0c26f218f83 100644
--- a/CodenameOneDesigner/src/com/codename1/designer/AddThemeEntry.java
+++ b/CodenameOneDesigner/src/com/codename1/designer/AddThemeEntry.java
@@ -183,8 +183,7 @@ public boolean accept(File file, String string) {
// both container extensions, in any case. A .ttf-only filter
// here left resource-based themes unable to pick an OpenType
// font the API otherwise accepts.
- String lower = string.toLowerCase();
- return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ return endsWithIgnoreCase(string, ".ttf") || endsWithIgnoreCase(string, ".otf");
}
});
if(fontFiles == null) {
@@ -3101,4 +3100,10 @@ public void run() {
private javax.swing.JSpinner trueTypeFontSizeValue;
private javax.swing.JButton videoTutorial;
// End of variables declaration//GEN-END:variables
+
+ /** Locale-independent case-insensitive suffix test. */
+ static boolean endsWithIgnoreCase(String value, String suffix) {
+ return value != null && value.length() >= suffix.length()
+ && value.regionMatches(true, value.length() - suffix.length(), suffix, 0, suffix.length());
+ }
}
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index 928c6125cfb..b0b9eaebe38 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -12080,11 +12080,13 @@ private String nativeFontName(String fontName) {
* a TrueType one.
*/
private static boolean isBundledFontFile(String fileName) {
- if (fileName == null) {
- return false;
- }
- String lower = fileName.toLowerCase();
- return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ return endsWithIgnoreCase(fileName, ".ttf") || endsWithIgnoreCase(fileName, ".otf");
+ }
+
+ /** Locale-independent case-insensitive suffix test. */
+ private static boolean endsWithIgnoreCase(String value, String suffix) {
+ return value != null && value.length() >= suffix.length()
+ && value.regionMatches(true, value.length() - suffix.length(), suffix, 0, suffix.length());
}
@Override
diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
index 5e3bb77a91d..56d992e3baf 100644
--- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
+++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
@@ -1796,8 +1796,13 @@ public Object loadTrueTypeFont(String fontName, String fileName) {
* loadable as TrueType here.
*/
private static boolean isBundledFontFile(String fileName) {
- String lower = fileName.toLowerCase();
- return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ return endsWithIgnoreCase(fileName, ".ttf") || endsWithIgnoreCase(fileName, ".otf");
+ }
+
+ /** Locale-independent case-insensitive suffix test. */
+ private static boolean endsWithIgnoreCase(String value, String suffix) {
+ return value != null && value.length() >= suffix.length()
+ && value.regionMatches(true, value.length() - suffix.length(), suffix, 0, suffix.length());
}
/** Reads an embedded classpath resource fully into a byte[], or null. */
diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
index 322b7522054..d6ac8145b96 100644
--- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
+++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
@@ -1799,8 +1799,13 @@ public Object loadTrueTypeFont(String fontName, String fileName) {
* TrueType here.
*/
private static boolean isBundledFontFile(String fileName) {
- String lower = fileName.toLowerCase();
- return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ return endsWithIgnoreCase(fileName, ".ttf") || endsWithIgnoreCase(fileName, ".otf");
+ }
+
+ /** Locale-independent case-insensitive suffix test. */
+ private static boolean endsWithIgnoreCase(String value, String suffix) {
+ return value != null && value.length() >= suffix.length()
+ && value.regionMatches(true, value.length() - suffix.length(), suffix, 0, suffix.length());
}
/** Reads an embedded classpath resource fully into a byte[], or null. */
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
index 6b2fa6671b4..6dd4ef2b39f 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
@@ -4990,8 +4990,7 @@ public boolean accept(File file, String string) {
// glyf or CFF, so OpenType registers through UIAppFonts exactly
// like TrueType. Leaving .otf out here is what made a bundled
// OpenType font fall back to the system font on the device.
- String lower = string.toLowerCase();
- return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ return endsWithIgnoreCase(string, ".ttf") || endsWithIgnoreCase(string, ".otf");
}
});
@@ -6164,4 +6163,10 @@ private static boolean isLanguageFeatureClass(String cls) {
|| "com/codename1/ai/language/SmartReply".equals(cls);
}
+
+ /** Locale-independent case-insensitive suffix test. */
+ static boolean endsWithIgnoreCase(String value, String suffix) {
+ return value != null && value.length() >= suffix.length()
+ && value.regionMatches(true, value.length() - suffix.length(), suffix, 0, suffix.length());
+ }
}
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java
index d389b152a15..78318868b8f 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java
@@ -188,8 +188,7 @@ void writeTvInfoPlist(BuildRequest request, File appSrcDir, File resDir) throws
public boolean accept(File dir, String name) {
// Core Text handles OpenType as readily as TrueType, so both
// belong in UIAppFonts.
- String lower = name.toLowerCase();
- return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ return endsWithIgnoreCase(name, ".ttf") || endsWithIgnoreCase(name, ".otf");
}
});
if (fontFiles != null && fontFiles.length > 0) {
@@ -332,4 +331,10 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion)
throw new BuildException("Failed to apply tvNative Xcode settings", ex);
}
}
+
+ /** Locale-independent case-insensitive suffix test. */
+ static boolean endsWithIgnoreCase(String value, String suffix) {
+ return value != null && value.length() >= suffix.length()
+ && value.regionMatches(true, value.length() - suffix.length(), suffix, 0, suffix.length());
+ }
}
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 46b15797575..4fa656bfa40 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
@@ -1190,8 +1190,11 @@ File getFontFile() {
if (url.getProtocol().startsWith("http")) {
// If it is remote, check so see if we've already downloaded
// the font to the current directory.
- String fontName = java.net.URLDecoder.decode(url.getPath(), "UTF-8");
-
+ // Same decoding the validator uses, so the file that
+ // lands here is the name it checked -- URLDecoder alone
+ // would turn a font named "A+B.ttf" into "A B.ttf".
+ String fontName = decodeUrlPath(url.getPath());
+
if (fontName.indexOf("/") != -1) {
fontName = fontName.substring(fontName.lastIndexOf("/")+1);
}
@@ -2159,12 +2162,14 @@ void validateFontFaces() {
continue;
}
- // Key on the lower-cased name. The fonts are flattened into one
+ // Key on the case-folded name. The fonts are flattened into one
// directory, and on a case-insensitive target -- Windows, or an
// Apple bundle -- "Body.TTF" and "body.ttf" are the same file there
// even when the authoring host kept them apart, so one would
- // overwrite the other.
- String deployKey = fileName.toLowerCase();
+ // overwrite the other. Locale.ROOT because the default locale would
+ // make the answer depend on the build machine: under Turkish rules
+ // "I.ttf" folds to "ı.ttf" and stops matching "i.ttf".
+ String deployKey = fileName.toLowerCase(java.util.Locale.ROOT);
String source = canonicalSource(url);
String previousSource = sourceByFileName.put(deployKey, source);
String previousFamily = familyByFileName.put(deployKey, family);
@@ -2217,14 +2222,26 @@ void validateFontFaces() {
/// 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();
+ String path = decodeUrlPath(url.getPath());
+ int slash = path.lastIndexOf('/');
+ return slash < 0 ? path : path.substring(slash + 1);
+ }
+
+ /// Decodes percent escapes in a URL path **without** treating `+` as a
+ /// space. `URLDecoder` implements form decoding, where `+` means a space, so
+ /// a font legitimately named `A+B.ttf` would come back as `A B.ttf` -- the
+ /// validator would then compare a name that never exists on disk, and could
+ /// report a collision against an unrelated `A B.ttf`.
+ private static String decodeUrlPath(String path) {
try {
- path = java.net.URLDecoder.decode(path, "UTF-8");
+ return java.net.URLDecoder.decode(path.replace("+", "%2B"), "UTF-8");
} catch (UnsupportedEncodingException ex) {
// UTF-8 is always present; fall through with the raw path.
+ return path;
+ } catch (IllegalArgumentException ex) {
+ // Malformed escape: keep the path as written rather than guessing.
+ return 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
@@ -2275,8 +2292,14 @@ private static String canonicalSource(URL url) {
/// whether the outlines are glyf or CFF, so there is no reason to make an
/// author convert an OpenType file or rename it.
private static boolean isSupportedFontFileName(String fileName) {
- String lower = fileName.toLowerCase();
- return lower.endsWith(".ttf") || lower.endsWith(".otf");
+ return endsWithIgnoreCase(fileName, ".ttf") || endsWithIgnoreCase(fileName, ".otf");
+ }
+
+ /// Case-insensitive suffix test that doesn't route through toLowerCase, so
+ /// the result can't depend on the build machine's locale.
+ private static boolean endsWithIgnoreCase(String value, String suffix) {
+ return value != null && value.length() >= suffix.length()
+ && value.regionMatches(true, value.length() - suffix.length(), suffix, 0, suffix.length());
}
/// 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 b72e2b62bb1..49d3ebe47ad 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
@@ -207,6 +207,72 @@ void testFontFileNamesDifferingOnlyInCaseAreRejected() throws Exception {
}
}
+ /**
+ * The collision check must not depend on the build machine's locale. Under
+ * Turkish rules the default {@code toLowerCase()} folds {@code I} to a
+ * dotless {@code ı}, so {@code I.ttf} would stop matching {@code i.ttf} and
+ * two files that really do collide on the target would both be accepted.
+ */
+ @Test
+ void testCaseCollisionIsDetectedUnderATurkishLocale() throws Exception {
+ java.util.Locale previous = java.util.Locale.getDefault();
+ Path cssDir = Files.createTempDirectory("cn1-font-tr");
+ Path outDir = Files.createTempDirectory("cn1-font-tr-out");
+ try {
+ java.util.Locale.setDefault(new java.util.Locale("tr", "TR"));
+ // "I" and "i" specifically: Turkish folds "I" to a dotless "i", so
+ // these two names case-fold apart under the default locale and
+ // together under Locale.ROOT. Separate directories because the
+ // authoring filesystem may itself be case-insensitive.
+ Path nested = cssDir.resolve("bold");
+ Files.createDirectories(nested);
+ copyFixture(cssDir.resolve("I.ttf"));
+ copyFixture(nested.resolve("i.ttf"));
+ Path cssFile = cssDir.resolve("theme.css");
+ Files.write(cssFile, ("@font-face { font-family: \"Upper\"; src: url(I.ttf); }"
+ + "@font-face { font-family: \"Lower\"; src: url(bold/i.ttf); }"
+ + "Label { font-family: \"Upper\"; }").getBytes(StandardCharsets.UTF_8));
+
+ String message = compileExpectingFailure(cssFile, outDir.resolve("theme.res"));
+ assertContains(message, "deploy under the same name");
+ } finally {
+ java.util.Locale.setDefault(previous);
+ deleteTree(cssDir);
+ deleteTree(outDir);
+ }
+ }
+
+ /**
+ * A {@code +} in a font name is a literal, not a space. URLDecoder applies
+ * form-decoding rules, so validating a decoded name would compare
+ * {@code A B.ttf} against a file that is really called {@code A+B.ttf} --
+ * and could report a collision with an unrelated font of that name.
+ */
+ @Test
+ void testPlusInFontNameIsNotDecodedAsSpace() throws Exception {
+ Path cssDir = Files.createTempDirectory("cn1-font-plus");
+ Path outDir = Files.createTempDirectory("cn1-font-plus-out");
+ try {
+ copyFixture(cssDir.resolve("A+B.ttf"));
+ copyFixture(cssDir.resolve("A B.ttf"));
+ Path cssFile = cssDir.resolve("theme.css");
+ Files.write(cssFile, ("@font-face { font-family: \"Plus\"; src: url(A+B.ttf); }"
+ + "@font-face { font-family: \"Space\"; src: url(A%20B.ttf); }"
+ + "Label { font-family: \"Plus\"; font-size: 3mm; }"
+ + "Title { font-family: \"Space\"; font-size: 4mm; }")
+ .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);
+ }
+ }
+
/**
* Two families pointing at the SAME file is a legitimate alias, not a
* collision -- the deploy copy is idempotent, so nothing is overwritten.
From d97b5783dbb191a5f3a3db902e45e72ae7a801b7 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Mon, 3 Aug 2026 02:26:25 +0700
Subject: [PATCH 12/12] Reject @font-face without a family, and escape font
names in the plists
Two more review findings.
An @font-face with a valid src but no font-family passed validation under a
placeholder label. findFontFace() matches on the family name, so such a rule can
never be referenced: the custom font is dropped and the app falls back to a
native font, which is the silent failure this validation exists to surface. It
is an error now, with a test.
The UIAppFonts writers appended the font's file name straight into a .
A name carrying an XML metacharacter -- "A&B.ttf" is legal on every filesystem
we target and Font.createTrueTypeFont accepts it -- produced a malformed
Info.plist and failed the Xcode build. Both the iOS and tvOS writers escape now.
---
.../com/codename1/builders/IPhoneBuilder.java | 13 ++++++++++++-
.../com/codename1/builders/TvNativeBuilder.java | 5 ++++-
.../java/com/codename1/designer/css/CSSTheme.java | 11 ++++++++++-
.../designer/css/CSSFontFaceValidationTest.java | 15 +++++++++++++++
4 files changed, 41 insertions(+), 3 deletions(-)
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
index 6dd4ef2b39f..827d28dd581 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
@@ -5488,8 +5488,11 @@ public boolean accept(File file, String string) {
if(fontFiles != null && fontFiles.length > 0) {
b.append(" UIAppFonts\n \n");
for(File f : fontFiles) {
+ // Escaped: a font name is an arbitrary file name, and an
+ // XML metacharacter in it (e.g. "A&B.ttf") would produce
+ // a malformed Info.plist and fail the Xcode build.
b.append(" ");
- b.append(f.getName());
+ b.append(plistEscape(f.getName()));
b.append("\n");
}
b.append(" \n");
@@ -6169,4 +6172,12 @@ static boolean endsWithIgnoreCase(String value, String suffix) {
return value != null && value.length() >= suffix.length()
&& value.regionMatches(true, value.length() - suffix.length(), suffix, 0, suffix.length());
}
+
+ /** Escapes a value for inclusion in a plist/XML text node. */
+ static String plistEscape(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value.replace("&", "&").replace("<", "<").replace(">", ">");
+ }
}
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java
index 78318868b8f..93307bc0acc 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java
@@ -194,7 +194,10 @@ public boolean accept(File dir, String name) {
if (fontFiles != null && fontFiles.length > 0) {
sb.append(" UIAppFonts\n \n");
for (File f : fontFiles) {
- sb.append(" ").append(f.getName()).append("\n");
+ // Escaped for the same reason as the iOS plist: an XML
+ // metacharacter in a font's file name would otherwise produce a
+ // malformed Info.plist.
+ sb.append(" ").append(IPhoneBuilder.plistEscape(f.getName())).append("\n");
}
sb.append(" \n");
}
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 4fa656bfa40..459380e47ad 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
@@ -2141,7 +2141,16 @@ void validateFontFaces() {
Map sourceByFileName = new HashMap();
Map familyByFileName = new HashMap();
for (FontFace face : fontFaces) {
- String family = face.fontFamily == null ? "(no font-family)" : face.fontFamily.getStringValue();
+ String family = face.fontFamily == null ? null : face.fontFamily.getStringValue();
+ if (family == null || family.trim().length() == 0) {
+ // findFontFace() matches on the family name, so a rule without
+ // one can never be referenced: the custom font would be dropped
+ // and the app would fall back to a native font, which is the
+ // failure this validation exists to surface.
+ errors.add("An @font-face rule has no usable font-family, so nothing can reference it"
+ + (face.src == null ? "" : " (src: " + face.src.getStringValue() + ")"));
+ continue;
+ }
URL url;
try {
url = face.getURL();
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 49d3ebe47ad..b594cba272b 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
@@ -120,6 +120,21 @@ void testUnreferencedBadFontIsRejected() throws Exception {
assertContains(message, "loads fonts from a file named .ttf or .otf");
}
+ /**
+ * A rule with no {@code font-family} can never be referenced, because
+ * {@code findFontFace} matches on the family name. The custom font would be
+ * dropped and the app would fall back to a native font -- the exact silent
+ * failure this validation exists to surface.
+ */
+ @Test
+ void testFontFaceWithoutFamilyIsRejected() throws Exception {
+ String message = assertCompileFails(
+ "@font-face { src: url(TestFont-Regular.ttf); }"
+ + "Label { color: #ff0000; }",
+ "TestFont-Regular.ttf");
+ assertContains(message, "no usable font-family");
+ }
+
@Test
void testMissingFontFileIsRejected() throws Exception {
String message = assertCompileFails(