Compare commits

...
Author SHA1 Message Date
Raphael Michel 24de110fe9 Lazy-load TTF fonts for PDFs 2026-09-28 19:01:35 +02:00
Kara Engelhardt 77adaa69fe Implement basic font fallback
pretixprint-PR: https://github.com/pretix/libpretixprint/pull/10
2026-09-28 17:43:41 +02:00
4 changed files with 145 additions and 48 deletions
+1
View File
@@ -126,6 +126,7 @@ dev = [
"pytest-sugar",
"pytest-xdist==3.8.*",
"pytest-playwright",
"pytest-memray",
"pytest==9.1.*",
"playwright",
"responses",
+33 -18
View File
@@ -84,7 +84,8 @@ from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format
from pretix.helpers.daterange import datetimerange
from pretix.helpers.reportlab import (
ThumbnailingImageReader, register_ttf_font_if_new, reshaper,
ThumbnailingImageReader, find_font_supporting_text,
register_ttf_font_if_new, reshaper,
)
from pretix.presale.style import get_fonts
@@ -827,7 +828,10 @@ class Renderer:
else:
self.bg_bytes = None
self.bg_pdf = None
self.event_fonts = list(get_fonts(event, pdf_support_required=True).keys()) + ['Open Sans']
event_fonts = get_fonts(event, pdf_support_required=True) | {'Open Sans': {"bold", "italic", "bolditalic"}}
# sorted by font name to match ordering of libpretixprint
self.event_fonts = dict(sorted(event_fonts.items(), key=lambda x: x[0]))
@classmethod
def _register_fonts(cls, event: Event = None):
@@ -1026,7 +1030,25 @@ class Renderer:
)
canvas.restoreState()
def _text_paragraph(self, op: OrderPosition, order: Order, o: dict, legacy_lineheight=False, override_fontsize=None):
def _prepare_text_paragraph_text(self, op: OrderPosition, order: Order, o: dict):
# add an almost-invisible space   after hyphens as word-wrap in ReportLab only works on space chars
text = conditional_escape(
self._get_text_content(op, order, o) or "",
).replace("\n", "<br/>\n").replace("-", "-&hairsp;")
# reportlab does not support unicode combination characters
# It's important we do this before we use ArabicReshaper
text = unicodedata.normalize("NFC", text)
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
# to resolve all ligatures and python-bidi to switch RTL texts.
try:
text = "<br/>".join(get_display(reshaper.reshape(l)) for l in text.split("<br/>"))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
return text
def _get_text_paragraph_font(self, o: dict, text: str):
font = o['fontfamily']
# Since pdfmetrics.registerFont is global, we want to make sure that no one tries to sneak in a font, they
@@ -1040,6 +1062,14 @@ class Renderer:
if o['italic']:
font += ' I'
font = find_font_supporting_text(self.event_fonts, text, font)
return font
def _text_paragraph(self, op: OrderPosition, order: Order, o: dict, legacy_lineheight=False, override_fontsize=None):
text = self._prepare_text_paragraph_text(op, order, o)
font = self._get_text_paragraph_font(o, text)
fontsize = override_fontsize if override_fontsize is not None else float(o['fontsize'])
try:
ad = getAscentDescent(font, fontsize)
@@ -1068,21 +1098,6 @@ class Renderer:
alignment=align_map[o['align']],
splitLongWords=o.get('splitlongwords', True),
)
# add an almost-invisible space &hairsp; after hyphens as word-wrap in ReportLab only works on space chars
text = conditional_escape(
self._get_text_content(op, order, o) or "",
).replace("\n", "<br/>\n").replace("-", "-&hairsp;")
# reportlab does not support unicode combination characters
# It's important we do this before we use ArabicReshaper
text = unicodedata.normalize("NFC", text)
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
# to resolve all ligatures and python-bidi to switch RTL texts.
try:
text = "<br/>".join(get_display(reshaper.reshape(l)) for l in text.split("<br/>"))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
p = Paragraph(text, style=style) # not using AutoEscapeParagraph is safe as we escape above
return p, ad, lineheight
+96 -30
View File
@@ -26,13 +26,13 @@ import unicodedata
from arabic_reshaper import ArabicReshaper
from bidi import get_display
from django.conf import settings
from django.utils.functional import SimpleLazyObject
from django.core.cache import cache
from django.utils.functional import SimpleLazyObject, cached_property
from django.utils.html import escape
from PIL import Image
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Paragraph
from pretix.presale.style import get_fonts
@@ -89,39 +89,50 @@ def normalize_text(text: str) -> str:
return text
def font_supports_text(text, font_name):
from reportlab.pdfbase.ttfonts import TTFont
if not text:
return True
font = pdfmetrics.getFont(font_name)
if isinstance(font, TTFont):
return all(
ord(c) in font.face.charToGlyph or not c.isprintable()
for c in text
)
elif isinstance(font, LazyTTFont):
return all(
ord(c) in font.face.supported_glyphs or not c.isprintable()
for c in text
)
else: # unsupported
return True
def find_font_supporting_text(fonts, text, preferred_font):
if font_supports_text(text, preferred_font):
return preferred_font
for family, styles in fonts.items():
if font_supports_text(text, family):
if (preferred_font.endswith("It") or preferred_font.endswith(" I")) and "italic" in styles:
return family + " I"
if (preferred_font.endswith("Bd") or preferred_font.endswith(" B")) and "bold" in styles:
return family + " B"
return family
return preferred_font
class FontFallbackParagraph(Paragraph):
def __init__(self, text, style=None, *args, **kwargs):
if style is None:
style = ParagraphStyle(name='paragraphImplicitDefaultStyle')
if not self._font_supports_text(text, style.fontName):
newFont = self._find_font(text, style.fontName)
if newFont:
logger.debug(f"replacing {style.fontName} with {newFont} for {text!r}")
style = style.clone(name=style.name + '_' + newFont, fontName=newFont)
supporting_font = find_font_supporting_text(get_fonts(pdf_support_required=True), text, style.fontName)
if supporting_font != style.fontName:
logger.debug(f"replacing {style.fontName} with {supporting_font} for {text!r}")
style = style.clone(name=style.name + '_' + supporting_font, fontName=supporting_font)
super().__init__(text, style, *args, **kwargs)
def _font_supports_text(self, text, font_name):
if not text:
return True
font = pdfmetrics.getFont(font_name)
if not isinstance(font, TTFont):
return True
return all(
ord(c) in font.face.charToGlyph or not c.isprintable()
for c in text
)
def _find_font(self, text, original_font):
for family, styles in get_fonts(pdf_support_required=True).items():
if self._font_supports_text(text, family):
if (original_font.endswith("It") or original_font.endswith(" I")) and "italic" in styles:
return family + " I"
if (original_font.endswith("Bd") or original_font.endswith(" B")) and "bold" in styles:
return family + " B"
return family
class PlainTextParagraph(FontFallbackParagraph):
def __init__(self, text, style=None, linebreaks=True, *args, **kwargs):
@@ -141,9 +152,64 @@ class PlainTextParagraph(FontFallbackParagraph):
super().__init__(text, style, *args, **kwargs)
class LazyTTFontFace:
def __init__(self, *args, **kwargs):
self.__args = args
self.__kwargs = kwargs
self.__casted = None
def __cast(self):
if self.__casted is None:
from reportlab.pdfbase.ttfonts import TTFontFace
print("casting TTFontFace", self.__args)
self.__casted = TTFontFace(*self.__args, **self.__kwargs)
return self.__casted
def __getattr__(self, item):
print("attr", item)
return getattr(self.__cast(), item)
@cached_property
def supported_glyphs(self):
if self.__casted is not None:
return set(self.__casted.charToGlyph.keys())
return cache.get_or_set(
key=f'font_ttf_glyphs:{self.__args[0]}',
default=lambda: set(self.__cast().charToGlyph.keys())
)
@property
def name(self):
if self.__casted is not None:
return self.__casted.name
return cache.get_or_set(
key=f'font_ttf_name:{self.__args[0]}',
default=lambda: self.__cast().name
)
class LazyTTFont:
_multiByte = 1 # noqa
_dynamicFont = 1 # noqa
def __init__(self, name, filename, validate=0, subfontIndex=0): # noqa
self.fontName = name
self.__file = filename
self.__casted = None
self.face = LazyTTFontFace(filename, validate=validate, subfontIndex=subfontIndex)
def __cast(self):
if self.__casted is None:
from reportlab.pdfbase.ttfonts import TTFont
self.__casted = TTFont(self.fontName, self.__file)
return self.__casted
def __getattr__(self, item):
return getattr(self.__cast(), item)
def register_ttf_font_if_new(name, path):
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
if name not in pdfmetrics.getRegisteredFontNames():
pdfmetrics.registerFont(TTFont(name, path))
pdfmetrics.registerFont(LazyTTFont(name, path))
+15
View File
@@ -20,9 +20,13 @@
# <https://www.gnu.org/licenses/>.
#
import pytest
from django.contrib.staticfiles import finders
from django.core.exceptions import SuspiciousFileOperation
from reportlab.pdfbase import pdfmetrics
from reportlab.platypus import Paragraph
from pretix.helpers.reportlab import LazyTTFont
def test_http_access_disabled(monkeypatch):
def guard(*args, **kwargs):
@@ -48,3 +52,14 @@ def test_file_access_disabled_direct(monkeypatch):
Paragraph(
'<img src="/etc/passwd" />',
)
@pytest.mark.limit_memory("50KB") # loading Open Sans allocates >300KB
def test_lazy_font_loading_does_not_load_immediately(monkeypatch):
@property
def name_prop(self):
return 'CachedName'
monkeypatch.setattr('pretix.helpers.reportlab.LazyTTFontFace.name', name_prop)
font = LazyTTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf'))
pdfmetrics.registerFont(font)