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 1/3] 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*kW