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

Perform font fallback #6926

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft

Perform font fallback #6926

wants to merge 10 commits into from

Conversation

nulano
Copy link
Contributor

@nulano nulano commented Feb 2, 2023

Fixes #4808.

Add a new type, ImageFont.FreeTypeFontFamily(font1, font2, ..., layout_engine=layout_engine), that can be used with ImageDraw.text*(...) functions performing font fallback. Font fallback is done per cluster with Raqm layout (similar to Chromium) and per codepoint with basic layout.

This PR is far from complete, several TODOs:

  • Font families have only a minimal API so far, e.g. retrieving metrics or setting font variations should be supported
  • Maybe add a wrapper similar to ImageFont.truetype(...), perhaps ImageFont.truetype_family(...)?
  • Lots of tests
  • Documentation

I would like to get some feedback, both on the API and the implementation, before working on the TODOs above.
A dev build for Windows is available from the artifact here: https://github.com/nulano/Pillow/actions/runs/8583137967

A few examples (click to expand):

All examples use this helper block:

from PIL import Image, ImageDraw, ImageFont

im = Image.new("RGBA", (500, 200), "white")
draw = ImageDraw.Draw(im)
def line(y, string, font, name, **kwargs):
  draw.text((10, y), name, fill="black", font=font, **kwargs)
  draw.text((300, y), string, fill="black", font=font, **kwargs)

example()

im.show()

Combining Latin, symbols, and an emoji:

def example():
  s = "smile ⊗ 😀"

  times = ImageFont.truetype("times.ttf", 24)
  segoe_ui_emoji = ImageFont.truetype("seguiemj.ttf", 24)
  segoe_ui_symbol = ImageFont.truetype("seguisym.ttf", 24)
  family = ImageFont.FreeTypeFontFamily(times, segoe_ui_emoji, segoe_ui_symbol)

  line(30, s, times, "Times New Roman", anchor="ls", embedded_color=True)
  line(80, s, segoe_ui_emoji, "Segoe UI Emoji", anchor="ls", embedded_color=True)
  line(130, s, segoe_ui_symbol, "Segoe UI Symbol", anchor="ls", embedded_color=True)
  line(180, s, family, "Font Family", anchor="ls", embedded_color=True)

fallback_emoji

Combining Arabic, Greek, Latin, and a symbol:

def example():
  s = "ية↦α,abc"

  scriptin = ImageFont.truetype(r"C:\Users\Nulano\AppData\Local\Microsoft\Windows\Fonts\SCRIPTIN.ttf", 24)
  segoe_ui = ImageFont.truetype("segoeui.ttf", 24)
  segoe_ui_symbol = ImageFont.truetype("seguisym.ttf", 24)
  family = ImageFont.FreeTypeFontFamily(scriptin, segoe_ui, segoe_ui_symbol)

  line(30, s, scriptin, "Scriptina", direction="ltr", anchor="ls")
  line(80, s, segoe_ui, "Segoe UI", direction="ltr", anchor="ls")
  line(130, s, segoe_ui_symbol, "Segoe UI Symbol", direction="ltr", anchor="ls")
  line(180, s, family, "Font Family", direction="ltr", anchor="ls")

fallback_arabic

Combining characters are treated as part of a single cluster (with Raqm layout):

def example():
  import unicodedata

  s = " ̌,ῶ,ω̃,ώ,ώ, ́,á,č,č"
  for c in s:
    print(unicodedata.name(c))

  le = ImageFont.Layout.RAQM  # or ImageFont.Layout.BASIC
  scriptin = ImageFont.truetype(r"C:\Users\Nulano\AppData\Local\Microsoft\Windows\Fonts\SCRIPTIN.ttf", 24, layout_engine=le)
  dubai = ImageFont.truetype(r"DUBAI-REGULAR.TTF", 24, layout_engine=le)
  gentium = ImageFont.truetype(r"C:\Users\Nulano\AppData\Local\Microsoft\Windows\Fonts\GentiumPlus-Regular.ttf", 24, layout_engine=le)
  family = ImageFont.FreeTypeFontFamily(scriptin, dubai, gentium, layout_engine=le)

  line(30, s, scriptin, "Scriptina", anchor="ls")
  line(80, s, dubai, "Dubai", anchor="ls")
  line(130, s, gentium, "GentiumPlus", anchor="ls")
  line(180, s, family, "Font Family", anchor="ls")

Raqm layout:
fallback_greek

Basic layout:
fallback_greek_basic

The string s contains:

SPACE
COMBINING CARON
COMMA
GREEK SMALL LETTER OMEGA WITH PERISPOMENI
COMMA
GREEK SMALL LETTER OMEGA
COMBINING TILDE
COMMA
GREEK SMALL LETTER OMEGA WITH TONOS
COMMA
GREEK SMALL LETTER OMEGA
COMBINING ACUTE ACCENT
COMMA
SPACE
COMBINING ACUTE ACCENT
COMMA
LATIN SMALL LETTER A
COMBINING ACUTE ACCENT
COMMA
LATIN SMALL LETTER C WITH CARON
COMMA
LATIN SMALL LETTER C
COMBINING CARON

src/_imagingft.c Outdated
Comment on lines 951 to 999
switch (anchor[1]) {
case 'a': // ascender
y_anchor = PIXEL(self->face->size->metrics.ascender);
y_anchor = PIXEL(family->faces[0]->size->metrics.ascender);
break;
case 't': // top
y_anchor = y_max;
break;
case 'm': // middle (ascender + descender) / 2
y_anchor = PIXEL(
(self->face->size->metrics.ascender +
self->face->size->metrics.descender) /
(family->faces[0]->size->metrics.ascender +
family->faces[0]->size->metrics.descender) /
2);
break;
case 's': // horizontal baseline
Copy link
Contributor

Choose a reason for hiding this comment

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

I think here you would need to get the ascenders and descenders of every font used on this line, not just the first one, and use the greatest value.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Perhaps. I don't think looking just at the fonts used in a given line would be good - that could create an inconsistent layout for the text_multiline functions.

This should use either the first font's metrics or the greatest value across all fonts in a family object, and it should be consistent with family.getmetrics() (not yet implemented).

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've just tested what MS Edge (i.e. Chromium) does - it uses the metrics of all fonts up to the last one that is in use for a given line, even if it causes uneven line spacing.

For example, with three fonts, a paragraph that only uses the second font will have line spacing computed from the first two fonts. If one word in the paragraph then uses the third font, that line will include the third font's line spacing, but the other lines are unaffected.

This is incompatible with how Pillow currently calculates line spacing for multiline text (see #6469 (comment)), so I think I'll just use the maximum over all fonts (i.e. assume that if a user creates a FontFamily object they actually want to use all of the fonts), and leave fixing it for #1646 (+document the limitation).

}
}
/* prefer first font's missing glyph if no font support this codepoint */
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
/* prefer first font's missing glyph if no font support this codepoint */
/* prefer first font's missing glyph if no font supports this codepoint */

@@ -209,6 +222,177 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) {
return (PyObject *)self;
}

static PyObject *
getfamily(PyObject *self_, PyObject *args, PyObject *kw) {
/* create a font family object from a list of file names and a sizes (in pixels) */
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
/* create a font family object from a list of file names and a sizes (in pixels) */
/* create a font family object from a list of file names and sizes (in pixels) */

src/_imagingft.c Outdated Show resolved Hide resolved
(*glyph_info)[i].x_offset = 0;
(*glyph_info)[i].y_offset = 0;

/* This has been broken and had no effect for many years now...
Copy link
Member

Choose a reason for hiding this comment

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

Could you expand on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@nissansz
Copy link

How to download the module or the build with below module?

AttributeError: module 'PIL.ImageFont' has no attribute 'FreeTypeFontFamily'

@nulano
Copy link
Contributor Author

nulano commented Jul 16, 2023

You can use a source install from the branch https://github.com/nulano/Pillow/archive/refs/heads/font-fallback.zip using the installation instructions: https://pillow.readthedocs.io/en/stable/installation.html#building-from-source

The previous Windows dev build has expired so I've re-run the CI for this branch here: https://github.com/nulano/Pillow/actions/runs/5568846705
You can download the dist-font-fallback-build.zip artifact from there and install the relevant wheel for your version of Python.
(I've had to mark the test step as ignored because some crash test files seem to have been moved since.)

@nissansz
Copy link

nissansz commented Jul 16, 2023

Thank you.
I tried. It works now.
Will it be updated to new pillow version 10.0 too?

@nissansz
Copy link

If I want to use below method to draw text for each char. is it slower than fontfamily?

Iterate all chacters in a string,
judge each character to see whether it is in the char list of a desire font,
if not in the list, specify the backup font to draw the character, then continue

@nissansz
Copy link

nissansz commented Sep 30, 2023

If a character is not in char list of a font, it can be displayed by a backup font.

But some fonts are strange, the backupfont did not work too.

image

@mic-user
Copy link

mic-user commented Feb 1, 2024

Hi. Is there any progress?

@aclark4life
Copy link
Member

aclark4life commented Feb 1, 2024

Hi. Is there any progress?

@Mitchell-kw-Lee Looks like this is still a WIP, are you able to test the branch and report results? That may help …

@nulano
Copy link
Contributor Author

nulano commented Feb 1, 2024

This needs a pretty large rebase before it can cleanly merge with the main branch.

Currently, I see #6926 (comment) as the biggest unresolved issue with this PR. I think I have an idea that could work, but I've not had time to work on this.

However, if you are able to test this PR as is (even though it is made for an older version of Pillow) and report whether it works / causes issues, it could perhaps be helpful.

@mic-user
Copy link

mic-user commented Feb 2, 2024

@nulano @aclark4life Errr, actually have no idea that the test process. Zero knowledge of git cowork test and manual installation stuff. And there is running environment that may impact own work.
Well...seems some most of part works as the post at #4808 (comment).
So, my suggestion is that, if possible, release current code base to public,
and see and wait the bug report(include from me) that may helpful variety of sample code by report can check widely.
Holding and being abandoned the prior effort/code are so shame which have potential..... sorry, just giving an idea is all i can do for now.

@nulano
Copy link
Contributor Author

nulano commented Feb 2, 2024

As I wrote above, this branch has a non-trivial merge conflict I haven't had time to resolve, even though I would like to get to it at some point.

If you'd like (keep in mind this is based on Pillow from a year ago), I can re-run the CI to generate new Windows wheels for easier installation. If you are on Linux/macOS, you'll need to figure out source installation (hint: https://pillow.readthedocs.io/en/stable/installation.html#building-from-source)

@mic-user
Copy link

mic-user commented Feb 2, 2024

@nulano Ah? You are going to give me aa package that can be installed by a command 'python -m pip install pillow==TESTVERSION --upgrade --user' or so? Then lets do it. I'll test it by applying piilow code in my code. And I can do feedback.(I'm running on Windows 11 64bit BUT Python 3.11.7- 32BIT, beware please.)
Before we proceed, I need the syntax example to do that set a custom single font path for main usage and I will have another font for fallbacking(It should be #4808 (comment), right?). And those are Korean(part of CJK) and an open source symbol font. Send me the installation code

@nulano
Copy link
Contributor Author

nulano commented Feb 3, 2024

I've rerun the CI: https://github.com/nulano/Pillow/actions/runs/7765749734
You can download dist-font-fallback-build from the list of artifacts at the bottom, unzip it, and install with python -m pip install Pillow-9.5.0.dev0-cp311-cp311-win32.whl --user.

Example usage is at the top of this page (click on A few examples (click to expand) in the top comment) or #4808 (comment).

@mic-user
Copy link

mic-user commented Feb 3, 2024

@nulano okay, after fix version conflict, I have got the installation on my system now. BUT will comback early next week. Give me sometime.

@mic-user
Copy link

mic-user commented Feb 4, 2024

@nulano When I call the FreeTypeFontFamily I got the error <class 'AttributeError'>'FreeTypeFontFamily' object has no attribute 'getsize'. (Fortunately, there is no 'No FreeTypeFontFamily method found' stuff.
Do I need to do something prerequsition part like ...remove some library or so? What should I do for now?

@radarhere
Copy link
Member

FreeTypeFontFamily doesn't have getsize() - but that's not a method that needs to be added later, it's a method that has been transitioned out of handling fonts in the latest Pillow versions. I suggest you use getbbox() instead. You can read more at https://pillow.readthedocs.io/en/stable/deprecations.html#font-size-and-offset-methods

@mic-user
Copy link

mic-user commented Feb 4, 2024

@radarhere I do not catch. Or you don't catch the whole situation.
The error message occured from the method FreeTypeFontFamily that something happended inside.

@mic-user
Copy link

mic-user commented Feb 4, 2024

The error above came from the line
_font_family = ImageFont.FreeTypeFontFamily(_font, _backup_font) <<-- HERE

@nissansz
Copy link

nissansz commented Apr 6, 2024

@mic-user I have rebased this PR to include Pillow 10.3.0 changes, multiline text seems to work now. You should be able to get a built wheel from https://github.com/nulano/Pillow/actions/runs/8583137967

Cannot see download link for windows. Does this wheel 10.3.0 support fontfamily?
image

@nissansz
Copy link

nissansz commented Apr 6, 2024

fontfamily (font1 (include some chars)+font2(include most chars)) cannot show some characters

font_file0: ballpen.zip.

font_file1: tianshiyanti2.0.ttf.zip

str = 'をも資资儲储議议歷历權权個个TextInAaBbCcDdEeFfGgHhIi family PILLOW'

image

image = Image.new('RGB', (image_width=1000, image_height=25), color='white')
draw = ImageDraw.Draw(image)

font0 = ImageFont.truetype(font_file0, size=20)
font1 = ImageFont.truetype(font_file1, size=20)

font_family = ImageFont.FreeTypeFontFamily(font0, font1)
draw.text((0, 0), textstr, fill='black', font=font_family)

@radarhere
Copy link
Member

Cannot see download link for windows. Does this wheel 10.3.0 support fontfamily?

If you scroll down on that page, you will see dist-windows-x86, dist-windows-ARM64 and dist-windows-AMD64.

Yes, those wheels would support FreeTypeFontFamily.

@nissansz
Copy link

nissansz commented Apr 6, 2024

Cannot see download link for windows. Does this wheel 10.3.0 support fontfamily?

If you scroll down on that page, you will see dist-windows-x86, dist-windows-ARM64 and dist-windows-AMD64.

Yes, those wheels would support FreeTypeFontFamily.

I can download from your above link, but I don't see such link on list. How to find above links?
Any new function for fontfamily in new version?

What is difference between [dist-windows-x86] and [dist-windows-AMD64]?

(https://github.com/nulano/Pillow/actions/runs/8583137967/artifacts/1391023653).

image

@nulano
Copy link
Contributor Author

nulano commented Apr 6, 2024

What is difference between [dist-windows-x86] and [dist-windows-AMD64]?

dist-windows-x86 is 32-bit x86
dist-windows-AMD64 is 64-bit x86.
dist-windows-ARM64 is 64-bit ARM.

Any new function for fontfamily in new version?

No, except multiline text seems to work now.

I can download from your above link, but I don't see such link on list. How to find above links?

Go to https://github.com/nulano/Pillow/actions/runs/8583137967 and scroll down:
image

@nissansz
Copy link

nissansz commented Apr 6, 2024

What is difference between [dist-windows-x86] and [dist-windows-AMD64]?

dist-windows-x86 is 32-bit x86 dist-windows-AMD64 is 64-bit x86. dist-windows-ARM64 is 64-bit ARM.

Any new function for fontfamily in new version?

No, except multiline text seems to work now.

I can download from your above link, but I don't see such link on list. How to find above links?

Go to https://github.com/nulano/Pillow/actions/runs/8583137967 and scroll down: image

Saw it. Thank you.
How to use multiple lines for font family?

There is still below problem from yesterday.
fontfamily (font1 (include some chars)+font2(include most chars)) cannot show some characters

font_file0: ballpen.zip.

font_file1: tianshiyanti2.0.ttf.zip

str = 'をも資资儲储議议歷历權权個个TextInAaBbCcDdEeFfGgHhIi family PILLOW'

image

image = Image.new('RGB', (image_width=1000, image_height=25), color='white')
draw = ImageDraw.Draw(image)

font0 = ImageFont.truetype(font_file0, size=20)
font1 = ImageFont.truetype(font_file1, size=20)

font_family = ImageFont.FreeTypeFontFamily(font0, font1)
draw.text((0, 0), textstr, fill='black', font=font_family)

@nulano
Copy link
Contributor Author

nulano commented Apr 6, 2024

How to use multiple lines for font family?

Same as for regular text:

draw.text((0, 0), "line1\nline2", font=family, ...)

There is still below problem from yesterday.

I'll take a look at it when I have the time.

@nissansz
Copy link

nissansz commented Apr 6, 2024

How to use multiple lines for font family?

Same as for regular text:

draw.text((0, 0), "line1\nline2", font=family, ...)

There is still below problem from yesterday.

I'll take a look at it when I have the time.

I wonder whether there is any function to import html to pillow canvas, like rendering a web page.
Write html tables, etc.

@radarhere
Copy link
Member

I wonder whether there is any function to import html to pillow canvas, like rendering a web page.
Write html tables, etc.

This is off-topic for this discussion of multiple fonts, but no, Pillow doesn't have any functionality for rendering HTML to an image.

@PaulOlteanu
Copy link

Would it be possible to get a getmask method on FreeTypeFontFamily so that it could be used with TransposedFont?

@nulano
Copy link
Contributor Author

nulano commented Apr 21, 2024

Would it be possible to get a getmask method on FreeTypeFontFamily so that it could be used with TransposedFont?

Sure, I didn't bother for a WIP PR when I wasn't sure it would be useful, but it's quite simple (literally just copy-pasted from FreeTypeFont 😄).

You should be able to download wheels from this workflow in a few hours: https://github.com/nulano/Pillow/actions/runs/8774721560

@lavnishhh
Copy link

You should be able to download wheels from this workflow in a few hours: https://github.com/nulano/Pillow/actions/runs/8774721560

Hi, the builds are expired. Could you please re run? I'm fairly new to this, so I don't know if there's another way to get the build

@nulano
Copy link
Contributor Author

nulano commented Aug 25, 2024

I've rebased the PR and triggered a build, you should see the wheels here in a few hours: https://github.com/nulano/Pillow/actions/runs/10547427520

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

Successfully merging this pull request may close these issues.

Backup font for missing characters when drawing text
8 participants