From 84ae39ead4e168cf2dd2f8ed52d4695b2661a115 Mon Sep 17 00:00:00 2001 From: Tontyna <35614937+Tontyna@users.noreply.github.com> Date: Wed, 14 Mar 2018 11:13:23 +0100 Subject: [PATCH 1/6] Enable font-family on Windows When a working `libfontconfig` is detected on Windows, the font rendering is done via FontConfig and FreeType instead of using the native Win32 APIs. --- weasyprint/fonts.py | 174 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 150 insertions(+), 24 deletions(-) diff --git a/weasyprint/fonts.py b/weasyprint/fonts.py index e62cc40d..232b5bbd 100644 --- a/weasyprint/fonts.py +++ b/weasyprint/fonts.py @@ -33,17 +33,33 @@ class FontConfiguration: """Add a font into the application.""" -if sys.platform.startswith('win'): - warnings.warn('@font-face is currently not supported on Windows') -elif pango.pango_version() < 13800: +fontconfig = None +pangoft2 = None + +if pango.pango_version() < 13800: warnings.warn('@font-face support needs Pango >= 1.38') else: + try: + fontconfig = dlopen(ffi, 'fontconfig', 'libfontconfig', + 'libfontconfig-1.dll', + 'libfontconfig.so.1', 'libfontconfig-1.dylib') + pangoft2 = dlopen(ffi, 'pangoft2-1.0', 'libpangoft2-1.0-0', + 'libpangoft2-1.0.so', 'libpangoft2-1.0.dylib') + except Exception as err: + warnings.warn("'@font-face not supported: {0}".format(err)) + fontconfig = None + pangoft2 = None + + +# if both libraries are present: Use them +if fontconfig and pangoft2: ffi.cdef(''' // FontConfig typedef int FcBool; typedef struct _FcConfig FcConfig; typedef struct _FcPattern FcPattern; + typedef struct _FcStrList FcStrList; typedef unsigned char FcChar8; typedef enum { @@ -55,8 +71,20 @@ else: FcMatchPattern, FcMatchFont, FcMatchScan } FcMatchKind; + + typedef struct _FcFontSet { + int nfont; + int sfont; + FcPattern **fonts; + } FcFontSet; + + typedef enum _FcSetName { + FcSetSystem = 0, + FcSetApplication = 1 + } FcSetName; + FcConfig * FcInitLoadConfigAndFonts (void); - FcPattern * FcConfigDestroy (FcConfig *config); + void FcConfigDestroy (FcConfig *config); FcBool FcConfigAppFontAddFile ( FcConfig *config, const FcChar8 *file); FcConfig * FcConfigGetCurrent (void); @@ -64,6 +92,10 @@ else: FcBool FcConfigParseAndLoad ( FcConfig *config, const FcChar8 *file, FcBool complain); + FcFontSet * FcConfigGetFonts(FcConfig *config, FcSetName set); + FcStrList * FcConfigGetConfigFiles(FcConfig *config); + FcChar8 * FcStrListNext(FcStrList *list); + void FcDefaultSubstitute (FcPattern *pattern); FcBool FcConfigSubstitute ( FcConfig *config, FcPattern *p, FcMatchKind kind); @@ -97,6 +129,7 @@ else: ''') fontconfig = dlopen(ffi, 'fontconfig', 'libfontconfig', + 'libfontconfig-1.dll', 'libfontconfig.so.1', 'libfontconfig-1.dylib') pangoft2 = dlopen(ffi, 'pangoft2-1.0', 'libpangoft2-1.0-0', 'libpangoft2-1.0.so', 'libpangoft2-1.0.dylib') @@ -133,6 +166,52 @@ else: 'ultra-expanded': 'ultraexpanded', } + def _checkfontconfiguration(font_config): + """ + Check whether the given font_config has fonts. + + Maybe that never happens on Nix, but the GTK3 Runtime for Windows, + https://github.com/tschoonj/ + GTK-for-Windows-Runtime-Environment-Installer + which is recommended at + http://weasyprint.readthedocs.io/en/latest/install.html#windows + comes without fonts.conf in etc/fonts, giving + "Fontconfig error: Cannot load default config file" + + No default config == No fonts. + No fonts == expect ugly output. + If you happen to have an html without a valid @font-face all + letters turn into rectangles. + If you happen to have an html with at least one valid @font-face + all text is styled with that font. + """ + # Nobody ever complained about such a situation on Nix... + # Since I cannot test this on Linux, and dont know whta happens + # without FontConfig, I leave it as it was before an return True + if not sys.platform.startswith('win'): + return True + + fonts = fontconfig.FcConfigGetFonts( + font_config, fontconfig.FcSetSystem) + if fonts.nfont > 0: + return True + # Is the reason a missing default config file? + configfiles = fontconfig.FcConfigGetConfigFiles(font_config) + file = fontconfig.FcStrListNext(configfiles) + if file == ffi.NULL: + warnings.warn( + '@font-face not supported: Cannot load default config file') + else: + warnings.warn('@font-face not supported: no fonts configured') + # fall back to defaul @font-face-less behaviour + return False + # on Windows we could try to add the system fonts like that: + # fontdir = os.path.join(os.environ['WINDIR'], 'Fonts') + # fontconfig.FcConfigAppFontAddDir( + # font_config, + # # not shure which encoding fontconfig expects + # fontdir.encode('mbcs')) + class FontConfiguration(FontConfiguration): def __init__(self): """Create a FT2 font configuration. @@ -142,26 +221,48 @@ else: how-to-use-custom-application-fonts.html """ + # load the master config file and the fonts self._fontconfig_config = ffi.gc( fontconfig.FcInitLoadConfigAndFonts(), fontconfig.FcConfigDestroy) - self.font_map = ffi.gc( - pangocairo.pango_cairo_font_map_new_for_font_type( - cairo.FONT_TYPE_FT), - gobject.g_object_unref) - pangoft2.pango_fc_font_map_set_config( - ffi.cast('PangoFcFontMap *', self.font_map), - self._fontconfig_config) - # pango_fc_font_map_set_config keeps a reference to config - fontconfig.FcConfigDestroy(self._fontconfig_config) + # usable config? + if not _checkfontconfiguration(self._fontconfig_config): + self.font_map = None + else: + self.font_map = ffi.gc( + pangocairo.pango_cairo_font_map_new_for_font_type( + cairo.FONT_TYPE_FT), + gobject.g_object_unref) + pangoft2.pango_fc_font_map_set_config( + ffi.cast('PangoFcFontMap *', self.font_map), + self._fontconfig_config) + # pango_fc_font_map_set_config keeps a reference to config + fontconfig.FcConfigDestroy(self._fontconfig_config) + # On Windows the font tempfiles cannot be deleted + # putting them in a subfolder made my life easier. + self._tempdir = None + if sys.platform.startswith('win'): + self._tempdir = os.path.join( + tempfile.gettempdir(), 'weasyprint') + try: + os.mkdir(self._tempdir) + except FileExistsError: + pass + except Exception: + # back to default + self._tempdir = None self._filenames = [] def add_font_face(self, rule_descriptors, url_fetcher): + if self.font_map is None: + return for font_type, url in rule_descriptors['src']: if url is None: continue if font_type in ('external', 'local'): config = self._fontconfig_config + # default: use `url_fetcher` to fetch the font + fetch_as_url = True if font_type == 'local': font_name = url.encode('utf-8') pattern = ffi.gc( @@ -193,20 +294,33 @@ else: config, pattern, result) fontconfig.FcPatternGetString( matching_pattern, b'file', 0, filename) - url = ( - u'file://' + - ffi.string(filename[0]).decode('utf-8')) + # cant use urlopen('file://..') on Windows. + # Fails with + # URLError: + if sys.platform.startswith('win'): + fetch_as_url = False + url = ( + ffi.string(filename[0]).decode( + sys.getfilesystemencoding())) + else: + url = ( + u'file://' + + ffi.string(filename[0]).decode('utf-8')) else: LOGGER.warning( 'Failed to load local font "%s"', font_name.decode('utf-8')) continue try: - with fetch(url_fetcher, url) as result: - if 'string' in result: - font = result['string'] - else: - font = result['file_obj'].read() + if fetch_as_url: + with fetch(url_fetcher, url) as result: + if 'string' in result: + font = result['string'] + else: + font = result['file_obj'].read() + else: + with open(url, 'rb') as fd: + font = fd.read() except Exception as exc: LOGGER.error( 'Failed to load font at "%s" (%s)', url, exc) @@ -222,7 +336,7 @@ else: **font_features).items(): features_string += '%s %s' % ( key, value) - fd, filename = tempfile.mkstemp() + fd, filename = tempfile.mkstemp(dir=self._tempdir) os.write(fd, font) os.close(fd) self._filenames.append(filename) @@ -263,7 +377,7 @@ else: FONTCONFIG_STRETCH_CONSTANTS[ rule_descriptors.get('font_stretch', 'normal')], filename, features_string) - fd, conf_filename = tempfile.mkstemp() + fd, conf_filename = tempfile.mkstemp(dir=self._tempdir) # TODO: is this encoding OK? os.write(fd, xml.encode('utf-8')) os.close(fd) @@ -275,6 +389,9 @@ else: if font_added: # TODO: we should mask local fonts with the same name # too as explained in Behdad's blog entry + # What about pango_fc_font_map_config_changed() + # as suggested in Behdad's blog entry? + # Though it seems to work without... return filename else: LOGGER.error('Failed to load font at "%s"', url) @@ -284,5 +401,14 @@ else: def __del__(self): """Clean a font configuration for a document.""" + # Can't cleanup the temporary font files on Windows, + # library has still open file handles. + # On Unix `os.remove()` a file that is in use works fine, + # on Windows a PermissionError is raised. + # FcConfigAppFontClear() doesn't help + # pango_fc_font_map_shutdown() doesn't help for filename in self._filenames: - os.remove(filename) + try: + os.remove(filename) + except OSError: + continue From 23d7dac5b47d0cc38ad193f28ae7af7520688ba7 Mon Sep 17 00:00:00 2001 From: Tontyna <35614937+Tontyna@users.noreply.github.com> Date: Fri, 16 Mar 2018 00:33:31 +0100 Subject: [PATCH 2/6] Restrict altered behavior to win. Dont annoy. --- weasyprint/fonts.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/weasyprint/fonts.py b/weasyprint/fonts.py index 232b5bbd..a66b289e 100644 --- a/weasyprint/fonts.py +++ b/weasyprint/fonts.py @@ -46,9 +46,13 @@ else: pangoft2 = dlopen(ffi, 'pangoft2-1.0', 'libpangoft2-1.0-0', 'libpangoft2-1.0.so', 'libpangoft2-1.0.dylib') except Exception as err: - warnings.warn("'@font-face not supported: {0}".format(err)) - fontconfig = None - pangoft2 = None + # dont alter behavior on other platforms! + if not sys.platform.startswith('win'): + raise err + else: + warnings.warn("'@font-face not supported: {0}".format(err)) + fontconfig = None + pangoft2 = None # if both libraries are present: Use them From dca4746935260661f30310d6cc13878a4420836e Mon Sep 17 00:00:00 2001 From: Tontyna <35614937+Tontyna@users.noreply.github.com> Date: Fri, 16 Mar 2018 17:45:35 +0100 Subject: [PATCH 3/6] Remove superfluous dlopen --- weasyprint/fonts.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/weasyprint/fonts.py b/weasyprint/fonts.py index a66b289e..0e3d1530 100644 --- a/weasyprint/fonts.py +++ b/weasyprint/fonts.py @@ -132,12 +132,6 @@ if fontconfig and pangoft2: cairo_font_type_t fonttype); ''') - fontconfig = dlopen(ffi, 'fontconfig', 'libfontconfig', - 'libfontconfig-1.dll', - 'libfontconfig.so.1', 'libfontconfig-1.dylib') - pangoft2 = dlopen(ffi, 'pangoft2-1.0', 'libpangoft2-1.0-0', - 'libpangoft2-1.0.so', 'libpangoft2-1.0.dylib') - FONTCONFIG_WEIGHT_CONSTANTS = { 'normal': 'normal', 'bold': 'bold', @@ -170,6 +164,15 @@ if fontconfig and pangoft2: 'ultra-expanded': 'ultraexpanded', } + _warned_once = False + + def _warn_once(msg): + """don't annoy with warnings, one is enough""" + global _warned_once + if not _warned_once: + warnings.warn(msg) + _warned_once = True + def _checkfontconfiguration(font_config): """ Check whether the given font_config has fonts. @@ -203,10 +206,10 @@ if fontconfig and pangoft2: configfiles = fontconfig.FcConfigGetConfigFiles(font_config) file = fontconfig.FcStrListNext(configfiles) if file == ffi.NULL: - warnings.warn( + _warn_once( '@font-face not supported: Cannot load default config file') else: - warnings.warn('@font-face not supported: no fonts configured') + _warn_once('@font-face not supported: no fonts configured') # fall back to defaul @font-face-less behaviour return False # on Windows we could try to add the system fonts like that: From db401ac0cb268736393f16e008687ee80979c1ac Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sun, 18 Mar 2018 22:37:56 +0100 Subject: [PATCH 4/6] Don't create dummy cairo contexts each time a Layout is created Saves a loooooot of time when a lot of text is drawn. Related to #578. --- weasyprint/text.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/weasyprint/text.py b/weasyprint/text.py index 3ec4d95f..2f7586f7 100644 --- a/weasyprint/text.py +++ b/weasyprint/text.py @@ -22,6 +22,11 @@ if cairo.cairo_version() <= 11400: warnings.warn('There are known rendering problems with Cairo <= 1.14.0') +CAIRO_DUMMY_CONTEXT = { + True: cairo.Context(cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1)), + False: cairo.Context(cairo.PDFSurface(None, 1, 1))} + + PANGO_ATTR_FONT_FEATURES_CACHE = {} @@ -623,12 +628,9 @@ class Layout(object): def __init__(self, context, font_size, style): self.context = context hinting = context.enable_hinting if context else False - cairo_dummy_context = ( - cairo.Context(cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1)) - if hinting else cairo.Context(cairo.PDFSurface(None, 1, 1))) self.layout = ffi.gc( pangocairo.pango_cairo_create_layout(ffi.cast( - 'cairo_t *', cairo_dummy_context._pointer)), + 'cairo_t *', CAIRO_DUMMY_CONTEXT[hinting]._pointer)), gobject.g_object_unref) pango_context = pango.pango_layout_get_context(self.layout) if context and context.font_config.font_map: From 2bb329acc47ab4864ec30da38a2328dc19d597d8 Mon Sep 17 00:00:00 2001 From: Tontyna <35614937+Tontyna@users.noreply.github.com> Date: Tue, 20 Mar 2018 10:04:55 +0100 Subject: [PATCH 5/6] Ensure 'str' and trailing slash in path2url --- weasyprint/urls.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/weasyprint/urls.py b/weasyprint/urls.py index 16f420f5..98f22f1c 100644 --- a/weasyprint/urls.py +++ b/weasyprint/urls.py @@ -81,13 +81,24 @@ def iri_to_uri(url): def path2url(path): - """Return file URL of `path`""" + """Return file URL of `path`. + Accepts 'str' or 'bytes', returns 'str' + """ + # Ensure 'str' + if isinstance(path, bytes): + path = path.decode(sys.getfilesystemencoding()) + # if a trailing path.sep is given -- keep it + wants_trailing_slash = path.endswith(os.path.sep) or path.endswith('/') path = os.path.abspath(path) - if os.path.isdir(path): + if wants_trailing_slash or os.path.isdir(path): # Make sure directory names have a trailing slash. # Otherwise relative URIs are resolved from the parent directory. path += os.path.sep + wants_trailing_slash = True path = pathname2url(path) + # on Windows pathname2url cuts off trailing slash + if wants_trailing_slash and not path.endswith('/'): + path += '/' if path.startswith('///'): # On Windows pathname2url(r'C:\foo') is apparently '///C:/foo' # That enough slashes already. From 043dcc1365d68223a475ae4be4dfe942d6599209 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 20 Mar 2018 10:44:49 +0100 Subject: [PATCH 6/6] Ignore /.pytest_cache --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 55e5ad65..90e2ff01 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ /.cache # Tests +/.pytest_cache /weasyprint/tests/.cache /weasyprint/tests/test_results /weasyprint/tests/w3_test_suite/test_results