diff --git a/CodenameOne/src/com/codename1/ui/Font.java b/CodenameOne/src/com/codename1/ui/Font.java index a6d502d31d2..af67d9fa82a 100644 --- a/CodenameOne/src/com/codename1/ui/Font.java +++ b/CodenameOne/src/com/codename1/ui/Font.java @@ -305,6 +305,24 @@ 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. It goes through regionMatches rather than + /// toLowerCase so the answer can't depend on the default locale. + static boolean isSupportedFontFile(String fileName) { + 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 /// 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 +341,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 +358,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..89afbe57e97 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java +++ b/CodenameOne/src/com/codename1/ui/plaf/StyleParser.java @@ -661,6 +661,24 @@ 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. + /// + /// 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) { + 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) { arg = arg.trim(); if (arg.indexOf('/') != -1) { @@ -669,9 +687,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/CodenameOneDesigner/src/com/codename1/designer/AddThemeEntry.java b/CodenameOneDesigner/src/com/codename1/designer/AddThemeEntry.java index a8e94865844..0c26f218f83 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/AddThemeEntry.java +++ b/CodenameOneDesigner/src/com/codename1/designer/AddThemeEntry.java @@ -179,7 +179,11 @@ 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. + return endsWithIgnoreCase(string, ".ttf") || endsWithIgnoreCase(string, ".otf"); } }); if(fontFiles == null) { @@ -3096,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 7353168fe4a..b0b9eaebe38 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,21 @@ 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) { + 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 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..56d992e3baf 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,21 @@ 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) { + 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. */ 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..d6ac8145b96 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,21 @@ 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) { + 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. */ private byte[] readResourceFully(String resource) { InputStream in = getResourceAsStream(WindowsImplementation.class, resource); diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 797d7259761..d95d879f770 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9808,10 +9808,34 @@ static void cn1RegisterBundledFontsOnce() { } cn1FontsRegistered = YES; @autoreleasepool { - NSArray *fontPaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"ttf" inDirectory:nil]; - for (NSString *fontPath in fontPaths) { - NSURL *url = [NSURL fileURLWithPath:fontPath]; + // 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. + // + // 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:[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. CTFontManagerRegisterFontsForURL((BRIDGE_CAST CFURLRef)url, kCTFontManagerScopeProcess, &error); if (error != NULL) { CFRelease(error); 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 00000000000..bd39ecf6b74 Binary files /dev/null and b/docs/demos/common/src/main/css/GuideRootFont.ttf differ 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..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. @@ -419,6 +442,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 589da8ae5da..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 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 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` @@ -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,6 +593,10 @@ 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] ---- +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: [source,css] @@ -600,6 +604,32 @@ 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] +---- +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. + +`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] +---- +include::../demos/common/src/main/css/guide-snippets-theme.css[tag=css-css-045,indent=0] +---- + +===== 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 +637,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/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..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 @@ -4986,7 +4986,11 @@ 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. + return endsWithIgnoreCase(string, ".ttf") || endsWithIgnoreCase(string, ".otf"); } }); @@ -5484,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"); @@ -6159,4 +6166,18 @@ 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()); + } + + /** 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/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..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 @@ -186,13 +186,18 @@ 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. + return endsWithIgnoreCase(name, ".ttf") || endsWithIgnoreCase(name, ".otf"); } }); 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"); } @@ -329,4 +334,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/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/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-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/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index ca66f814860..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 @@ -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); } @@ -2115,7 +2118,227 @@ 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 ? 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(); + } 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 or .otf file"); + continue; + } + + String fileName = fontFileName(url); + if (!isSupportedFontFileName(fileName)) { + errors.add("@font-face \"" + family + "\" points at " + fileName + ", and Codename One " + + "loads fonts from a file named .ttf or .otf"); + continue; + } + + // 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. 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); + if (previousSource != null && !previousSource.equals(source)) { + errors.add("@font-face \"" + family + "\" and \"" + previousFamily + "\" resolve to different " + + "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())) { + // Remote fonts are downloaded into the CSS directory, so only the name matters here. + continue; + } + File fontFile; + try { + fontFile = new File(url.toURI()); + } catch (URISyntaxException ex) { + errors.add("@font-face \"" + family + "\" has an unreadable src path: " + url); + continue; + } + if (!fontFile.exists()) { + errors.add("@font-face \"" + family + "\" refers to " + fontFile + ", which doesn't exist"); + continue; + } + if (!isInsideCssDirectory(fontFile)) { + errors.add("@font-face \"" + family + "\" refers to " + fontFile + ", which is outside the " + + "directory holding the CSS file. Only that directory is copied into the build, so " + + "move the font beside the CSS file or into a subdirectory of it"); + continue; + } + String contentError = fontContentError(fontFile); + if (contentError != null) { + errors.add("@font-face \"" + family + "\" refers to " + fontFile.getName() + ", which " + + contentError); + } + } + if (!errors.isEmpty()) { + StringBuilder message = new StringBuilder("Invalid @font-face rules in ") + .append(baseURL == null ? "the stylesheet" : baseURL.toString()) + .append(':'); + for (String error : errors) { + message.append("\n - ").append(error); + } + throw new IllegalArgumentException(message.toString()); + } + } + + /// The file name the runtime will see, which is what `Font.createTrueTypeFont` + /// validates and what the deploy copy is keyed on. Percent-escapes are decoded + /// because the downloaded file lands under the decoded name. + private static String fontFileName(URL url) { + String path = 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 { + 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; + } + } + + /// Reads the font the way the rest of the toolchain will, so a file that + /// can't actually be used is caught here instead of on a device. + /// + /// Two failures hide until far too late otherwise. A file the font parser + /// rejects leaves `EditorTTFFont.actualFont` null -- `refresh()` swallows + /// the Throwable -- and then dies as a bare NPE inside `EditableResources` + /// `save()` naming no rule. And a font with no PostScript name works in the + /// simulator and on Android, which look the font up by file name, while iOS + /// resolves purely by the PostScript name written into the resource and so + /// falls back to the system font on the device. + /// + /// @return the problem phrased to follow "which ...", or null when the font + /// is usable + private static String fontContentError(File fontFile) { + java.awt.Font parsed; + try { + parsed = java.awt.Font.createFont(java.awt.Font.TRUETYPE_FONT, fontFile); + } catch (Exception ex) { + return "isn't a font file Codename One can read (" + ex.getMessage() + ")"; + } + String psName = parsed.getPSName(); + if (psName == null || psName.trim().length() == 0) { + return "has no PostScript name. iOS looks fonts up by that name, so this would render " + + "in the simulator and on Android but fall back to the system font on an iOS device"; + } + return null; + } + + /// Identity of the file a rule points at, used to tell a genuine name + /// collision apart from two families deliberately sharing one font file. + private static String canonicalSource(URL url) { + if ("file".equals(url.getProtocol())) { + try { + return new File(url.toURI()).getCanonicalPath(); + } catch (URISyntaxException ex) { + return url.toString(); + } catch (IOException ex) { + return url.toString(); + } + } + return url.toString(); + } + + /// 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) { + 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` + /// 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/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/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"`).