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/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..b594cba272b --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSFontFaceValidationTest.java @@ -0,0 +1,436 @@ +/* + * 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(); + } + + /** + * 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 testUnknownFontExtensionIsRejected() throws Exception { + String message = assertCompileFails( + "@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.woff); }" + + "Label { font-family: \"TestFont\"; }", + "TestFont-Regular.woff"); + assertContains(message, "loads fonts from a file named .ttf or .otf"); + assertContains(message, "TestFont-Regular.woff"); + } + + /** + * A file the font parser rejects used to leave {@code EditorTTFFont.actualFont} + * null and then die as a bare NPE in {@code EditableResources.save} naming no + * rule, because {@code refresh()} swallows the Throwable. + */ + @Test + void testUnreadableFontFileIsRejected() throws Exception { + Path cssDir = Files.createTempDirectory("cn1-font-corrupt"); + Path outDir = Files.createTempDirectory("cn1-font-corrupt-out"); + try { + Files.write(cssDir.resolve("Broken.ttf"), "this is not a font".getBytes(StandardCharsets.UTF_8)); + Path cssFile = cssDir.resolve("theme.css"); + Files.write(cssFile, ("@font-face { font-family: \"Broken\"; src: url(Broken.ttf); }" + + "Label { font-family: \"Broken\"; }").getBytes(StandardCharsets.UTF_8)); + + String message = compileExpectingFailure(cssFile, outDir.resolve("theme.res")); + assertContains(message, "isn't a font file Codename One can read"); + } finally { + deleteTree(cssDir); + deleteTree(outDir); + } + } + + /** + * Declared but never referenced still fails. A rule is only resolved when + * some style uses the family, so without this the typo would surface on + * whichever later build first referenced it. + */ + @Test + void testUnreferencedBadFontIsRejected() throws Exception { + String message = assertCompileFails( + "@font-face { font-family: \"TestFont\"; src: url(TestFont-Regular.woff); }" + + "Label { color: #ff0000; }", + "TestFont-Regular.woff"); + 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( + "@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 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); + } + } + + /** + * 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. + * Only genuinely different files sharing a name are an error. + */ + @Test + void testTwoFamiliesMayShareOneFontFile() 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 testRemoteUnknownExtensionIsRejectedWithoutDownloading() throws Exception { + String message = assertCompileFails( + "@font-face { font-family: \"TestFont\"; " + + "src: url(https://example.invalid/fonts/TestFont.woff); }" + + "Label { font-family: \"TestFont\"; }", + null); + assertContains(message, "loads fonts from a file named .ttf or .otf"); + } + + /** 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); + } + } + + /** 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(); + 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/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 00000000000..bd39ecf6b74 Binary files /dev/null and b/maven/css-compiler/src/test/resources/com/codename1/designer/css/TestFont.ttf differ 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..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` | `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`. `.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 90786b6c9e0..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,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`: +**`.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`: ```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. + +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: @@ -555,7 +559,9 @@ 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). | +| 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 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"`).