Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/developer-guide/css.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ If you want to use a font other than the built-in fonts, you'll need to define t
include::../demos/common/src/main/css/guide-snippets-theme.css[tag=css-css-028,indent=0]
----

IMPORTANT: Only TrueType (`.ttf`) files are supported. OpenType (`.otf`) files compile without an error, but the runtime rejects any font file whose name doesn't end in `.ttf`, and the iOS build registers only `.ttf` files with the operating system. Convert an OpenType font to TrueType before referencing it.
IMPORTANT: Only TrueType (`.ttf`) files are supported. The runtime loads a font by a file name ending in `.ttf` and the iOS build registers only those files with the operating system, so the CSS compiler fails the build on anything else rather than letting it reach the device. Convert an OpenType (`.otf`) font to TrueType before referencing it. The compiler also reads each font at build time, so a file it can't parse, or one with no PostScript name, fails the build too -- iOS resolves fonts by their PostScript name, so a font without one would render everywhere except on an iOS device.

Then you'll be able to reference the font using the specified `font-family` in any CSS element. For example:

Expand All @@ -611,6 +611,8 @@ A relative `src` URL is resolved against the directory that holds the CSS file.
include::../demos/common/src/main/css/guide-snippets-theme.css[tag=css-css-044,indent=0]
----

The font does have to live somewhere under that directory, because only that directory is copied into the build. A font reached through `../`, or one that doesn't exist, fails the build rather than producing an app whose text falls back to the system font. Two `@font-face` rules naming different files that share a file name also fail, since fonts are deployed by file name alone and one would overwrite the other. Pointing two families at the same file is fine.

===== Family names, weights and styles

A `font-family` name that contains spaces must be quoted, both in the `@font-face` rule and wherever you reference it. An unquoted name is parsed as a list of separate identifiers, so `font-family: GuideDemoFont Bold` registers the family as `GuideDemoFont` and collides with your regular weight.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2115,7 +2115,199 @@ 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<String> errors = new ArrayList<String>();
// file name -> the source it was first seen at, so two families sharing
// one file stay legal and only genuinely different files collide.
Map<String, String> sourceByFileName = new HashMap<String, String>();
Map<String, String> familyByFileName = new HashMap<String, String>();
for (FontFace face : fontFaces) {
String family = face.fontFamily == null ? "(no font-family)" : face.fontFamily.getStringValue();
URL url;
try {
url = face.getURL();
} catch (RuntimeException ex) {
errors.add("@font-face \"" + family + "\" has an unreadable src URL: " + ex.getMessage());
continue;
}
if (url == null) {
errors.add("@font-face \"" + family + "\" has no usable src; "
+ "Codename One only supports src: url(...) pointing at a .ttf file");
continue;
}

String fileName = fontFileName(url);
if (!fileName.endsWith(".ttf")) {
errors.add("@font-face \"" + family + "\" points at " + fileName + ". "
+ fontExtensionAdvice(fileName));
continue;
}

String source = canonicalSource(url);
String previousSource = sourceByFileName.put(fileName, source);
String previousFamily = familyByFileName.put(fileName, family);
if (previousSource != null && !previousSource.equals(source)) {
Comment on lines +2162 to +2165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect case-only deployment-name collisions

When compilation runs on a case-insensitive filesystem, rules for different files named, for example, Regular.ttf and regular.ttf occupy separate map keys here even though their deployment targets are the same file. FontFace.getFontFile() then skips copying the second target because it already exists, while theme.res still records each source's original casing; on a case-sensitive device one of those recorded filenames is absent. Reject case-only collisions whenever the deployment filesystem treats the names as identical.

Useful? React with 👍 / 👎.

errors.add("@font-face \"" + family + "\" and \"" + previousFamily + "\" resolve to different "
+ "files that are both named " + fileName + ". Fonts are deployed next to the theme "
+ "resource by file name alone, so one would overwrite the other; rename one of them");
}

if (!"file".equals(url.getProtocol())) {
// Remote fonts are downloaded into the CSS directory, so only the name matters here.
continue;
Comment on lines +2171 to +2173

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject remote schemes the font loader cannot download

When src uses ftp://... or another non-file scheme ending in .ttf, this branch treats it as a downloadable remote font and validation succeeds. However, FontFace.getFontFile() only downloads protocols whose name starts with http; for these accepted schemes it returns null, so a referenced family silently falls back instead of being deployed. Restrict this branch to the remote protocols supported by getFontFile(), or extend the loader accordingly.

Useful? React with 👍 / 👎.

}
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh merged fonts before validating their contents

When a Maven build first fails this check and the developer fixes only the font file, subsequent builds keep validating the stale copy under target/css/cn1-merged-files. In the inspected stateless flow, CN1CSSCLI.updateMergeFileStateless() decides whether to call syncDirectories() using only the input theme.css timestamps, even though CompileCSSMojo correctly reruns for changes anywhere under the CSS directory. Consequently, replacing a corrupt or missing-PostScript-name font without touching theme.css continues to produce the old error until the user cleans target or touches the stylesheet; the merge cache must account for asset changes before this validation runs.

Useful? React with 👍 / 👎.

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 = url.getPath();
try {
path = java.net.URLDecoder.decode(path, "UTF-8");
Comment on lines +2213 to +2215

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve literal plus signs in local font filenames

For a local URL, URLDecoder applies form-encoding semantics and converts a literal + in the path to a space. Thus valid rules for distinct files such as url(A+B.ttf) and url(A%20B.ttf) both produce the collision key A B.ttf and the new validator rejects the stylesheet, even though new File(url.toURI()).getName() deploys them as A+B.ttf and A B.ttf. Decode percent escapes without translating literal plus signs.

Useful? React with 👍 / 👎.

} catch (UnsupportedEncodingException ex) {
// UTF-8 is always present; fall through with the raw path.
}
int slash = path.lastIndexOf('/');
return slash < 0 ? path : path.substring(slash + 1);
}

/// 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore URL fragments when comparing remote font sources

When two families alias the same remote font using URLs such as https://example.com/Font.ttf#body and https://example.com/Font.ttf#caption, this return value differs even though an HTTP fragment is not sent to the server and getFontFile() downloads both into the same Font.ttf cache file. The collision check therefore rejects a legitimate shared-file alias solely because its fragment differs; compare remote identities without the fragment before deciding that their contents can overwrite one another.

Useful? React with 👍 / 👎.

}

/// The constraint is the file NAME, not the outline format: the runtime
/// check is `fileName.endsWith(".ttf")` and the iOS build only registers
/// files matching that. Worth being precise about, because "convert to
/// TrueType" and "rename the file" are different amounts of work and only
/// one of them is actually required.
private static String fontExtensionAdvice(String fileName) {
String lower = fileName.toLowerCase();
if (lower.endsWith(".otf")) {
return "Codename One loads fonts by a file name ending in .ttf, so an .otf never reaches "
+ "the device -- convert it to TrueType";
}
if (lower.endsWith(".ttf")) {
return "the extension has to be lower-case .ttf; the runtime check is case-sensitive";
}
return "Codename One loads fonts by a file name ending in .ttf";
}

/// 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<String, Object> themeData = res.getTheme(themeName);
if (themeData != null) {
Expand Down
Loading
Loading