Skip to content
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

Merged
merged 30 commits into from
Jan 7, 2022
Merged

Dynamic font loading #1363

merged 30 commits into from
Jan 7, 2022

Conversation

t-arn
Copy link
Contributor

@t-arn t-arn commented Nov 12, 2021

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:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

Winforms

Android

@t-arn
Copy link
Contributor Author

t-arn commented Nov 12, 2021

@freakboy3742 Please review this PR, thank you!

Copy link
Member

@freakboy3742 freakboy3742 left a 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.

@t-arn
Copy link
Contributor Author

t-arn commented Nov 14, 2021

@freakboy3742

once I've done some code reformatting on the example

You seem to prefer double quotes for strings , instead of single quotes. I thought, they are interchangeable.
Why do you prefer double quotes?

If a font is used multiple times, it is re-created every time, rather than being created once, and then re-used.

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?

firstly, creating the font at the time of registration

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.

and allowing for multiple font files under a single registered name

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?

@freakboy3742
Copy link
Member

@freakboy3742

once I've done some code reformatting on the example

You seem to prefer double quotes for strings , instead of single quotes. I thought, they are interchangeable. Why do you prefer double quotes?

The formatting changes I applied are the result of running black over the codebase. It prefers double quotes over singles because of the escaping problem - single quotes are much more likely to be actually printed (as either apostrophes, or as user-visible quotes); using double quotes for strings minimises the amount of escaping that is required.

If a font is used multiple times, it is re-created every time, rather than being created once, and then re-used.

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?

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.

firstly, creating the font at the time of registration

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.

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.

and allowing for multiple font files under a single registered name

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?

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:

  • a single file that contains multiple variants; or
  • a separate file for each of the variants.

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.

@t-arn
Copy link
Contributor Author

t-arn commented Nov 15, 2021

@freakboy3742

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?

@freakboy3742
Copy link
Member

@t-arn It's not exactly a toga.Font, because that encompasses a size as well. By "generic", I mean capturing the "face, style, weight" attributes, independent of the size; the "generic" font is then used to instantiate a specific font instance.

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
@t-arn
Copy link
Contributor Author

t-arn commented Nov 19, 2021

@freakboy3742 I made following changes:

  • added the RegisteredFont class
  • added support for registering several font files for the same font family
  • font chaching for Android

Please re-review this PR.

Copy link
Member

@freakboy3742 freakboy3742 left a 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.

@@ -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
Copy link
Member

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.

Copy link
Contributor Author

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.

_REGISTERED_FONT_CACHE[registered_font.key] = registered_font

@staticmethod
def find_registered_font(family, weight=NORMAL, style=NORMAL, variant=NORMAL):
Copy link
Member

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.

Copy link
Contributor Author

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

registered_font = RegisteredFont(
family, path, weight=weight, style=style, variant=variant
)
_REGISTERED_FONT_CACHE[registered_font.key] = registered_font
Copy link
Member

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.

Copy link
Member

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?

Copy link
Contributor Author

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

def key(self):
return "<RegisteredFont family={} weight={} style={} variant={}>".format(
self.family, self.weight, self.style, self.variant
)
Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

OK, issue addressed

if variant not in constants.FONT_VARIANTS:
variant = NORMAL
return "<RegisteredFont family={} weight={} style={} variant={}>".format(
family, weight, style, variant
Copy link
Member

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.

Copy link
Contributor Author

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

return None


class RegisteredFont:
Copy link
Member

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.

Copy link
Contributor Author

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

family = Typeface.createFromFile(
str(self.interface.factory.paths.app / registered_font.path)
)
except Exception as ex:
Copy link
Member

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?

Copy link
Contributor Author

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:

  1. The font file does not exist
  2. The file is a folder, not a file
  3. 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?

Copy link
Contributor Author

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

Copy link
Contributor Author

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):
Copy link
Member

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.

Copy link
Contributor Author

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

@t-arn
Copy link
Contributor Author

t-arn commented Nov 21, 2021

@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.
Or, the user explicitly registers all style/weight combinations available in the font file with the register method.

@t-arn
Copy link
Contributor Author

t-arn commented Nov 21, 2021

I googled a bit about fonts and found following:

  1. ttc and otc are font collection files that contain several font style / weight combinations
  2. There are OpenType Font Variations where a font file can contain several font style / weight combinations (e.g. Bahnschrift.ttf on Windows 10)

https://en.wikipedia.org/wiki/TrueType
https://en.wikipedia.org/wiki/OpenType
https://en.wikipedia.org/wiki/Variable_font

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)
@t-arn
Copy link
Contributor Author

t-arn commented Nov 21, 2021

@freakboy3742 I addressed all issues you raised on your second review.
Please re-review this PR.

@t-arn
Copy link
Contributor Author

t-arn commented Dec 9, 2021

@freakboy3742
I fear that the font caching on Android is not working correctly.
I have an Android app where I show a new GUI after tapping a button. And when I use a Toga branch that includes this font PR, the app crashes when the first label or button of the new GUI is created:

java_vm_ext.cc:545] JNI DETECTED ERROR IN APPLICATION: use of deleted local reference 0x2005
java_vm_ext.cc:545]     from java.lang.Object org.beeware.rubicon.PythonInstance.invoke(java.lang.Object, java.lang.reflect.Method, java.lang.Object[])
java_vm_ext.cc:545] "main" prio=5 tid=1 Runnable
java_vm_ext.cc:545]   | group="main" sCount=0 dsCount=0 flags=0 obj=0x741c7f50 self=0xea374000
java_vm_ext.cc:545]   | sysTid=17029 nice=-10 cgrp=default sched=0/0 handle=0xeef49494
java_vm_ext.cc:545]   | state=R schedstat=( 2667825654 1577720597 2136 ) utm=186 stm=80 core=0 HZ=100
java_vm_ext.cc:545]   | stack=0xff1b9000-0xff1bb000 stackSize=8MB
java_vm_ext.cc:545]   | held mutexes= "mutator lock"(shared held)
java_vm_ext.cc:545]   native: #00 pc 004151b6  /system/lib/libart.so (art::DumpNativeStack(std::__1::basic_ostream<char, std::__1::char_traits<char>>&, int, BacktraceMap*, char const*, art::ArtMethod*, void*, bool)+198)
java_vm_ext.cc:545]   native: #01 pc 0051034e  /system/lib/libart.so (art::Thread::DumpStack(std::__1::basic_ostream<char, std::__1::char_traits<char>>&, bool, BacktraceMap*, bool) const+382)
java_vm_ext.cc:545]   native: #02 pc 0050b603  /system/lib/libart.so (art::Thread::Dump(std::__1::basic_ostream<char, std::__1::char_traits<char>>&, bool, BacktraceMap*, bool) const+83)
java_vm_ext.cc:545]   native: #03 pc 0031a720  /system/lib/libart.so (art::JavaVMExt::JniAbort(char const*, char const*)+1088)
java_vm_ext.cc:545]   native: #04 pc 0031ac35  /system/lib/libart.so (art::JavaVMExt::JniAbortF(char const*, char const*, ...)+117)
java_vm_ext.cc:545]   native: #05 pc 00516b37  /system/lib/libart.so (art::Thread::DecodeJObject(_jobject*) const+871)
java_vm_ext.cc:545]   native: #06 pc 000d53d9  /system/lib/libart.so (art::(anonymous namespace)::ScopedCheck::CheckInstance(art::ScopedObjectAccess&, art::(anonymous namespace)::ScopedCheck::InstanceKind, _jobject*, bool)+153)
java_vm_ext.cc:545]   native: #07 pc 000d4637  /system/lib/libart.so (art::(anonymous namespace)::ScopedCheck::CheckPossibleHeapValue(art::ScopedObjectAccess&, char, art::(anonymous namespace)::JniValueType)+551)
java_vm_ext.cc:545]   native: #08 pc 000d4718  /system/lib/libart.so (art::(anonymous namespace)::ScopedCheck::CheckPossibleHeapValue(art::ScopedObjectAccess&, char, art::(anonymous namespace)::JniValueType)+776)
java_vm_ext.cc:545]   native: #09 pc 000d3a5b  /system/lib/libart.so (art::(anonymous namespace)::ScopedCheck::Check(art::ScopedObjectAccess&, bool, char const*, art::(anonymous namespace)::JniValueType*)+811)
java_vm_ext.cc:545]   native: #10 pc 000d9758  /system/lib/libart.so (art::(anonymous namespace)::CheckJNI::CheckCallArgs(art::ScopedObjectAccess&, art::(anonymous namespace)::ScopedCheck&, _JNIEnv*, _jobject*, _jclass*, _jmethodID*, art::InvokeType, art::(anonymous namespace)::VarArgs const*)+200)
java_vm_ext.cc:545]   native: #11 pc 000d8974  /system/lib/libart.so (art::(anonymous namespace)::CheckJNI::CallMethodV(char const*, _JNIEnv*, _jobject*, _jclass*, _jmethodID*, char*, art::Primitive::Type, art::InvokeType)+948)
java_vm_ext.cc:545]   native: #12 pc 000c4a99  /system/lib/libart.so (art::(anonymous namespace)::CheckJNI::CallVoidMethodV(_JNIEnv*, _jobject*, _jmethodID*, char*)+73)
java_vm_ext.cc:545]   native: #13 pc 00003c08  /data/app/ch.tanapro.taapplister-KnOG6mrHMlrGtQmW37syBg==/lib/x86/librubicon.so (CallVoidMethod+56)
java_vm_ext.cc:545]   at org.beeware.rubicon.PythonInstance.invoke(Native method)

I have a strong feeling that the cached native fonts become stale which is the reason for the above crash message.
How can we prevent the cached native fonts from becoming stale?

@paulproteus
Copy link
Contributor

@t-arn One thing I saw in that log is:

java_vm_ext.cc:545] JNI DETECTED ERROR IN APPLICATION: use of deleted local reference 0x2005

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?

@t-arn
Copy link
Contributor Author

t-arn commented Dec 9, 2021

@paulproteus
How (and where) do I change the references to JNI global refs?

@freakboy3742
Copy link
Member

@t-arn The __global__method that I added in the development version of Rubicon is what this is for; in the meantime, you can see how global refs are used in the DetailedList implementation.

@t-arn
Copy link
Contributor Author

t-arn commented Dec 12, 2021

@freakboy3742
Yes, the global() method fixed the problem :-)
I added following line, before adding the font to the cache:

family = family.__global__()  # store a JNI global reference to prevent objects from becoming stale

Please re-review

Copy link
Member

@freakboy3742 freakboy3742 left a 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
Copy link

codecov bot commented Jan 7, 2022

Codecov Report

Merging #1363 (2912073) into master (c621cfd) will increase coverage by 0.03%.
The diff coverage is 100.00%.

Impacted Files Coverage Δ
src/core/toga/fonts.py 96.66% <100.00%> (+2.91%) ⬆️

@freakboy3742 freakboy3742 merged commit 7e5f590 into beeware:master Jan 7, 2022
@freakboy3742
Copy link
Member

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants