-
-
Notifications
You must be signed in to change notification settings - Fork 684
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
Dynamic font loading #1363
Dynamic font loading #1363
Conversation
* added Font.register()
* added documentation
* but try...except in toga_android/fonts.py is not catching Java Exception
* changed MANIFEST.in to include TTF and OTF files in examples
@freakboy3742 Please review this PR, thank you! |
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.
This looks really good - thanks!
The implementation looks reasonable for what it's doing (once I've done some code reformatting on the example); however, I have two questions about the technical approach.
The first is that, as far as I can make out, "registration" is really not much more than associating a font name with a filename. If a font is used multiple times, it is re-created every time, rather than being created once, and then re-used. Is there any particular reason for this?
Second - the two example fonts you've provided evidently include multiple font weights. However, it's reasonably common for fonts to be distributed with different weights in different files. Using the approach you've described here, you're associating a name with a single file - which would seem to preclude using a font that comes in multiple files. The winforms API in particular would seem to allow for this, but the implementation only ever puts one font in the collection.
I'm wondering if we need to modify font registration process to accomodate these two - firstly, creating the font at the time of registration (and then returning that instance whenever it is needed); and allowing for multiple font files under a single registered name.
You seem to prefer double quotes for strings , instead of single quotes. I thought, they are interchangeable.
This is probably true for Android. But on Winforms, the loaded fonts are cached and re-used. I did not cache the fonts on Android, because I wasn't sure if there was a particular reason why this has been implemented differently to Winforms and Cocoa. Do you think, we should cache the fonts on Android as well?
From what I understand, you need to define the font size when creating a font. At registration time, you do not know the size yet. That's why, I create the font only when it is actually used and when we know the family, the weight, the style and the size.
Yes, that could be done. So, when we have a separate font file for a certain weight and style, we would use this font with "regular" weight and style, instead of trying to render the weight and style on the base font? |
The formatting changes I applied are the result of running
Ah - I see the caching on Winforms now; it was part of the original implementation. As for Android - yes, I think caching should be added. It was less important when most typefaces were being loaded as system defaults; but if we're encouraging people to create new typefaces, we should ensure we're not needlessly re-creating them.
In both Android and Windows, there's a differentiation between the set of objects that define a "font" (the face, weight, and style) - and the specific instance of a font that is passed to labels etc. The "generic" font combination of face, weight and style is definitely something that should be cached (otherwise we'll be re-loading the font file every time we render a label). This can be done at time of registration, because you're only caching the existence of a font. The specific font at a given size should also be cached if possible, as it's one less object that needs to be re-created. The latter is what Windows and Cocoa are currently caching.
Not quite.Many fonts - especially the common ones - will have multiple variants: Regular, Bold, Italic, Oblique, Bold Italic (and potentially many others). We need to be able to register:
Regardless of how the font is split into files, we then need to be able to ask "give me a 12 pt bold italic version". If we can't satisfy that because the variant doesn't exist (e.g., we don't have a bold italic version) then we fall back to a variant that we can satisfy (e.g., 12pt bold, then 12pt italic, then 12pt regular). So - part of the registration process will involve working out what we actually have. |
I don't quite know what you mean with "generic font". Do you mean a toga.Font? Should then the REGISTERED_FONTS be the cache for these "generic fonts" and contain toga.Fonts instead of just the family name? |
@t-arn It's not exactly a REGISTERED_FONTS is a cache of these "generic" pieces; the existing _FONT_CACHE in Winforms/Cocoa is a cache of specific font instances. That said - I'm aware that there isn't (on any platform) a clear single object that represent this "generic" font. However, there are objects being created/loaded as part of the font creation process that are being re-created if you ask for a second font with the same face/weight/style, but a different style. What I'm suggesting is we should be caching any intermediate object to avoid re-reading the font file, and accommodating the case where a single font file may contain multiple weights & styles. |
* added support for loading several font file for the same family
* updated tests
* updated Android implementation
* fixed Winforms exception when adding many custom fonts * code clean up * applied black formatting
@freakboy3742 I made following changes:
Please re-review this PR. |
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.
This definitely works... but I get the impression that it's simultaneously much more complex than it needs to be in some places, and also not as complex as it needs to be in others.
The process of registration creates a whole new object that doesn't actually seem to serve any purpose beyond being an expensive cache key - except that it also contains the one detail that actually needs to be cached - the path to the file. However, the RegisteredFont object is both the key for lookup, and the object being looked up. Is there any reason this couldn't just be a dictionary of a tuple (family, weight, style, variant) mapping to path?
However, even with the complexity that is there - I'm not sure I see how this works for "multiple font file" situations. If I have a font file that contains multiple weights/variants, it will be registered as "Normal/Normal/Normal"... so how do I get the "Bold" variant?
The _pfc
object also confuses me; see the commentary inline.
src/winforms/toga_winforms/fonts.py
Outdated
@@ -17,20 +18,35 @@ def points_to_pixels(points, dpi): | |||
|
|||
class Font: | |||
def __init__(self, interface): | |||
self._pfc = None # this needs to be a class variable, otherwise we might get Winforms exceptions later |
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.
I'm confused... on what condition can we get a Winforms exception? AFAICT, self._pfc
is only referenced between lines 32 and 36... and it's recreated every time. How does this error manifest?
And, if it does needs to be a class variable then it should be declared on the class. This is currently declared as an instance variable.
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.
See the discussion between me and SyntheticBee on Discord and here: https://stackoverflow.com/questions/11829344/parameter-is-not-valid-when-draw-text-in-label-with-custom-font
The exception seems to happen, when there are too many custom fonts being used.
I'm not sure myself, where exactly the exception occurs and why the use of this instance variable (you're right: it is not a class variable) helps.
src/core/toga/fonts.py
Outdated
_REGISTERED_FONT_CACHE[registered_font.key] = registered_font | ||
|
||
@staticmethod | ||
def find_registered_font(family, weight=NORMAL, style=NORMAL, variant=NORMAL): |
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.
Having a simple register method, and a complex lookup method effectively puts the load on the wrong end of the lookup. Registration is something that is done once; font lookup will be a regular event. If we pre-insert font at all the cache locations where it might be useful, we can make cache retrieval a single operation, rather than needing to have complex logic every time a font is requested. This also removes the need for a "find_registered_font" function - it's just a lookup on a dictionary.
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.
OK, I addressed this issue
src/core/toga/fonts.py
Outdated
registered_font = RegisteredFont( | ||
family, path, weight=weight, style=style, variant=variant | ||
) | ||
_REGISTERED_FONT_CACHE[registered_font.key] = registered_font |
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.
If you define __hash__()
on a class, you don't need to explicitly reference a key like this; you can just use the object as the key.
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.
I'm also a little unclear about what is going on here. You've got a dictionary mapping from a font... to a font. Why is the path on the RegisteredFont, rather than being the thing that is being registered?
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.
OK, I addressed this issue
src/core/toga/fonts.py
Outdated
def key(self): | ||
return "<RegisteredFont family={} weight={} style={} variant={}>".format( | ||
self.family, self.weight, self.style, self.variant | ||
) |
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.
Strings are a great object to use as a key - it's better to use tuples; or better still, the actual object.
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.
OK, issue addressed
src/core/toga/fonts.py
Outdated
if variant not in constants.FONT_VARIANTS: | ||
variant = NORMAL | ||
return "<RegisteredFont family={} weight={} style={} variant={}>".format( | ||
family, weight, style, variant |
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.
This effectively duplicates the key construction logic. This should be a single utility function, rather than a duplication.
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.
OK, I removed the RegisteredFont class
src/core/toga/fonts.py
Outdated
return None | ||
|
||
|
||
class RegisteredFont: |
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.
I'm not sure I see what having a second class gains us here. It's an internal cache lookup; a tuple is more than enough to operate as a key.
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.
OK, I addressed this issue
src/android/toga_android/fonts.py
Outdated
family = Typeface.createFromFile( | ||
str(self.interface.factory.paths.app / registered_font.path) | ||
) | ||
except Exception as ex: |
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.
This is a red flag; blanket exception handling can accidentally hide a wide range of problems. Can we narrow this down to a specific error?
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.
I can think of these cases:
- The font file does not exist
- The file is a folder, not a file
- The file is not a supported font file
What Python exceptions correspond to these cases?
How to find out if there are other cases?
In PrivateFontCollection, it says If you try to use a font that is not supported, such as an unsupported OpenType font or a Bitmap font, an exception will occur.
But what exception exactly?
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.
OK, I addressed this issue cleanly on Winforms.
On Android I can only handle the (crashing) file-not-found exception. The exception when using an invalid font file cannot be catched, but luckily, it does not crash the app
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.
I now catch the file-not-found exception when loading the font. Would it be better to catch this exception already in Font.register()? But then, register could not be a static method anymore because we need access to factory.paths.app
@@ -38,3 +44,9 @@ def test_variant(self): | |||
|
|||
def test_weight(self): | |||
self.assertEqual(self.font.weight, self.weight) | |||
|
|||
def test_register(self): |
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.
There's a few other use cases here that should be tested - especially on the fallback lookup path.
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.
OK, there is no fallback lookup path anymore
@freakboy3742 Regarding the case where you have multiple styles/weights in 1 font file: You will register this font as normal/normal/normal and when requesting "bold", the find method will not find a specific font file for bold and therefore default to normal/normal/normal and apply bold there which should create the intended font. |
I googled a bit about fonts and found following:
https://en.wikipedia.org/wiki/TrueType I'm not sure yet how to handle ttc / otc files. Maybe, it works with calling the register() method for every combination with the same collection file. Or, maybe, ttc / otc files are not supported for dynamic font loading at all... |
* catch exception when font file cannot be loaded (only on Winforms - not possible on Android)
@freakboy3742 I addressed all issues you raised on your second review. |
* catch exception when font file cannot be loaded (only on Winforms - not possible on Android)
@freakboy3742
I have a strong feeling that the cached native fonts become stale which is the reason for the above crash message. |
@t-arn One thing I saw in that log is:
It seems to me that if the references were JNI global refs, the problem would be fixed. Can you try that and see if it solves the issue? |
@paulproteus |
@t-arn The |
@freakboy3742
Please re-review |
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.
Ok - I've made a couple of minor stylistic tweaks, but I think this is good to go. The API is very simple, but it makes sense; and it should be relatively easy to adapt to Cocoa, iOS and GTK.
I might take a quick swing to see if I can make it work on macOS at least; if I can't, I'll merge as is.
Codecov Report
|
Ok - looks like it's not that simple. It's easy enough to register a font... but it looks like Cocoa uses the font name that is internal to the font, which won't necessarily be the registration name - and it's not trivial to extract the internal registration name from a registered font. I've put my WIP on #1399; if anyone wants to try their hand at a fix before I get there, feel free. |
This PR allows us to include a font file to a Toga project and use the font in the Toga project without needing to install the font on the system.
The PR includes 3 free fonts from here:
https://www.1001freefonts.com/d/4555/endor.zip (ENDOR___.ttf)
https://use.fontawesome.com/releases/v5.15.4/fontawesome-free-5.15.4-desktop.zip (Font Awesome 5 Free-Solid-900.otf)
https://fonts.google.com/specimen/Roboto (Roboto*.ttf)
Dynamic font loading should also work on iOS and macOS:
https://marco.org/2012/12/21/ios-dynamic-font-loading
https://stackoverflow.com/questions/2703085/how-can-you-load-a-font-ttf-from-a-file-using-core-text
But the implementation for these platforms are not in this PR.
Maybe, someone more savvy with iOS and macOS can add this.
PR Checklist: