-
Notifications
You must be signed in to change notification settings - Fork 434
Fail the CSS compile on @font-face rules the runtime can't honor #5506
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)) { | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Maven build first fails this check and the developer fixes only the font file, subsequent builds keep validating the stale copy under 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a local URL, 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(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two families alias the same remote font using URLs such as 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) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When compilation runs on a case-insensitive filesystem, rules for different files named, for example,
Regular.ttfandregular.ttfoccupy 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, whiletheme.resstill 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 👍 / 👎.