-
-
Notifications
You must be signed in to change notification settings - Fork 696
/
Copy pathfonts.py
353 lines (302 loc) · 14.6 KB
/
fonts.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
"""Interface with external libraries managing fonts installed on the system."""
from hashlib import sha1
from io import BytesIO
from pathlib import Path
from shutil import rmtree
from tempfile import mkdtemp
from warnings import warn
from fontTools.ttLib import TTFont, woff2
from ..logger import LOGGER
from ..urls import FILESYSTEM_ENCODING, fetch
from .constants import (
CAPS_KEYS, EAST_ASIAN_KEYS, FONTCONFIG_STRETCH, FONTCONFIG_STYLE,
FONTCONFIG_WEIGHT, LIGATURE_KEYS, NUMERIC_KEYS, PANGO_STRETCH, PANGO_STYLE)
from .ffi import (
ffi, fontconfig, gobject, pango, pangoft2, unicode_to_char_p,
units_from_double)
def _check_font_configuration(font_config): # pragma: no cover
"""Check whether the given font_config has fonts.
The default fontconfig configuration file may be missing (particularly
on Windows or macOS, where installation of fontconfig isn't as
standardized as on Linux), resulting in "Fontconfig error: Cannot load
default config file".
Fontconfig tries to retrieve the system fonts as fallback, which may or
may not work, especially on macOS, where fonts can be installed at
various loactions. On Windows (at least since fontconfig 2.13) the
fallback seems to work.
If there’s no default configuration and the system fonts fallback
fails, or if the configuration file exists but doesn’t provide fonts,
output will be ugly.
If you happen to have no fonts and an HTML document without a valid
@font-face, all letters turn into rectangles.
If you happen to have an HTML document with at least one valid
@font-face, all text is styled with that font.
On Windows and macOS we can cause Pango to use native font rendering
instead of rendering fonts with FreeType. But then we must do without
@font-face. Expect other missing features and ugly output.
"""
# Having fonts means: fontconfig's config file returns fonts or
# fontconfig managed to retrieve system fallback-fonts. On Windows the
# fallback stragegy seems to work since fontconfig >= 2.13
fonts = fontconfig.FcConfigGetFonts(font_config, fontconfig.FcSetSystem)
# Of course, with nfont == 1 the user wont be happy, too…
if fonts.nfont > 0:
return
# Find the reason why we have no fonts
config_files = fontconfig.FcConfigGetConfigFiles(font_config)
config_file = fontconfig.FcStrListNext(config_files)
if config_file == ffi.NULL:
warn('FontConfig cannot load default config file. Expect ugly output.')
else:
# Useless config file, or indeed no fonts.
warn('No fonts configured in FontConfig. Expect ugly output.')
_check_font_configuration(ffi.gc(
fontconfig.FcInitLoadConfigAndFonts(), fontconfig.FcConfigDestroy))
class FontConfiguration:
"""A FreeType font configuration.
Keep a list of fonts, including fonts installed on the system, fonts
installed for the current user, and fonts referenced by cascading
stylesheets.
When created, an instance of this class gathers available fonts. It can
then be given to :class:`weasyprint.HTML` methods or to
:class:`weasyprint.CSS` to find fonts in ``@font-face`` rules.
"""
def __init__(self):
"""Create a FreeType font configuration.
See Behdad's blog:
https://mces.blogspot.fr/2015/05/
how-to-use-custom-application-fonts.html
"""
# Load the main config file and the fonts.
self._fontconfig_config = ffi.gc(
fontconfig.FcInitLoadConfigAndFonts(),
fontconfig.FcConfigDestroy)
self.font_map = ffi.gc(
pangoft2.pango_ft2_font_map_new(), 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)
# Temporary folder storing fonts and Fontconfig config files
self._folder = Path(mkdtemp(prefix='weasyprint-'))
def add_font_face(self, rule_descriptors, url_fetcher):
features = {
rules[0][0].replace('-', '_'): rules[0][1] for rules in
rule_descriptors.get('font_variant', [])}
key = 'font_feature_settings'
if key in rule_descriptors:
features[key] = rule_descriptors[key]
features_string = ''.join(
f'<string>{key} {value}</string>'
for key, value in font_features(**features).items())
fontconfig_style = FONTCONFIG_STYLE[
rule_descriptors.get('font_style', 'normal')]
fontconfig_weight = FONTCONFIG_WEIGHT[
rule_descriptors.get('font_weight', 'normal')]
fontconfig_stretch = FONTCONFIG_STRETCH[
rule_descriptors.get('font_stretch', 'normal')]
config_key = sha1((
f'{rule_descriptors["font_family"]}-{fontconfig_style}-'
f'{fontconfig_weight}-{features_string}').encode()).hexdigest()
font_path = self._folder / config_key
if font_path.exists():
return
for font_type, url in rule_descriptors['src']:
if url is None:
continue
if font_type in ('external', 'local'):
config = self._fontconfig_config
if font_type == 'local':
font_name = url.encode()
pattern = ffi.gc(
fontconfig.FcPatternCreate(),
fontconfig.FcPatternDestroy)
fontconfig.FcConfigSubstitute(
config, pattern, fontconfig.FcMatchFont)
fontconfig.FcDefaultSubstitute(pattern)
fontconfig.FcPatternAddString(
pattern, b'fullname', font_name)
fontconfig.FcPatternAddString(
pattern, b'postscriptname', font_name)
family = ffi.new('FcChar8 **')
postscript = ffi.new('FcChar8 **')
result = ffi.new('FcResult *')
matching_pattern = fontconfig.FcFontMatch(
config, pattern, result)
# prevent RuntimeError, see issue #677
if matching_pattern == ffi.NULL:
LOGGER.debug(
'Failed to get matching local font for %r',
font_name.decode())
continue
# TODO: do many fonts have multiple family values?
fontconfig.FcPatternGetString(
matching_pattern, b'fullname', 0, family)
fontconfig.FcPatternGetString(
matching_pattern, b'postscriptname', 0, postscript)
family = ffi.string(family[0])
postscript = ffi.string(postscript[0])
if font_name.lower() in (
family.lower(), postscript.lower()):
filename = ffi.new('FcChar8 **')
fontconfig.FcPatternGetString(
matching_pattern, b'file', 0, filename)
path = ffi.string(filename[0]).decode(
FILESYSTEM_ENCODING)
url = Path(path).as_uri()
else:
LOGGER.debug(
'Failed to load local font %r', font_name.decode())
continue
# Get font content
try:
with fetch(url_fetcher, url) as result:
if 'string' in result:
font = result['string']
else:
font = result['file_obj'].read()
except Exception as exc:
LOGGER.debug('Failed to load font at %r (%s)', url, exc)
continue
# Store font content
try:
# Decode woff and woff2 fonts
if font[:3] == b'wOF':
out = BytesIO()
woff_version_byte = font[3:4]
if woff_version_byte == b'F':
# woff font
ttfont = TTFont(BytesIO(font))
ttfont.flavor = ttfont.flavorData = None
ttfont.save(out)
elif woff_version_byte == b'2':
# woff2 font
woff2.decompress(BytesIO(font), out)
font = out.getvalue()
except Exception as exc:
LOGGER.debug(
'Failed to handle woff font at %r (%s)', url, exc)
continue
font_path.write_bytes(font)
xml_path = self._folder / f'{config_key}.xml'
xml_path.write_text(f'''<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<match target="scan">
<test name="file" compare="eq">
<string>{font_path}</string>
</test>
<edit name="family" mode="assign_replace">
<string>{rule_descriptors['font_family']}</string>
</edit>
<edit name="slant" mode="assign_replace">
<const>{fontconfig_style}</const>
</edit>
<edit name="weight" mode="assign_replace">
<int>{fontconfig_weight}</int>
</edit>
<edit name="width" mode="assign_replace">
<const>{fontconfig_stretch}</const>
</edit>
</match>
<match target="font">
<test name="file" compare="eq">
<string>{font_path}</string>
</test>
<edit name="fontfeatures"
mode="assign_replace">{features_string}</edit>
</match>
</fontconfig>''')
# TODO: We should mask local fonts with the same name
# too as explained in Behdad's blog entry.
fontconfig.FcConfigParseAndLoad(
config, str(xml_path).encode(FILESYSTEM_ENCODING),
True)
font_added = fontconfig.FcConfigAppFontAddFile(
config, str(font_path).encode(FILESYSTEM_ENCODING))
if font_added:
return pangoft2.pango_fc_font_map_config_changed(
ffi.cast('PangoFcFontMap *', self.font_map))
LOGGER.debug('Failed to load font at %r', url)
LOGGER.warning(
'Font-face %r cannot be loaded', rule_descriptors['font_family'])
def __del__(self):
"""Clean a font configuration for a document."""
rmtree(self._folder, ignore_errors=True)
def font_features(font_kerning='normal', font_variant_ligatures='normal',
font_variant_position='normal', font_variant_caps='normal',
font_variant_numeric='normal',
font_variant_alternates='normal',
font_variant_east_asian='normal',
font_feature_settings='normal'):
"""Get the font features from the different properties in style.
See https://www.w3.org/TR/css-fonts-3/#feature-precedence
"""
features = {}
# Step 1: getting the default, we rely on Pango for this
# Step 2: @font-face font-variant, done in fonts.add_font_face
# Step 3: @font-face font-feature-settings, done in fonts.add_font_face
# Step 4: font-variant and OpenType features
if font_kerning != 'auto':
features['kern'] = int(font_kerning == 'normal')
if font_variant_ligatures == 'none':
for keys in LIGATURE_KEYS.values():
for key in keys:
features[key] = 0
elif font_variant_ligatures != 'normal':
for ligature_type in font_variant_ligatures:
value = 1
if ligature_type.startswith('no-'):
value = 0
ligature_type = ligature_type[3:]
for key in LIGATURE_KEYS[ligature_type]:
features[key] = value
if font_variant_position == 'sub':
# TODO: the specification asks for additional checks
# https://www.w3.org/TR/css-fonts-3/#font-variant-position-prop
features['subs'] = 1
elif font_variant_position == 'super':
features['sups'] = 1
if font_variant_caps != 'normal':
# TODO: the specification asks for additional checks
# https://www.w3.org/TR/css-fonts-3/#font-variant-caps-prop
for key in CAPS_KEYS[font_variant_caps]:
features[key] = 1
if font_variant_numeric != 'normal':
for key in font_variant_numeric:
features[NUMERIC_KEYS[key]] = 1
if font_variant_alternates != 'normal':
# TODO: support other values
# See https://www.w3.org/TR/css-fonts-3/#font-variant-caps-prop
if font_variant_alternates == 'historical-forms':
features['hist'] = 1
if font_variant_east_asian != 'normal':
for key in font_variant_east_asian:
features[EAST_ASIAN_KEYS[key]] = 1
# Step 5: incompatible non-OpenType features, already handled by Pango
# Step 6: font-feature-settings
if font_feature_settings != 'normal':
features.update(dict(font_feature_settings))
return features
def get_font_description(style):
font_description = ffi.gc(
pango.pango_font_description_new(),
pango.pango_font_description_free)
family_p, family = unicode_to_char_p(','.join(style['font_family']))
pango.pango_font_description_set_family(font_description, family_p)
pango.pango_font_description_set_style(
font_description, PANGO_STYLE[style['font_style']])
pango.pango_font_description_set_stretch(
font_description, PANGO_STRETCH[style['font_stretch']])
pango.pango_font_description_set_weight(
font_description, style['font_weight'])
pango.pango_font_description_set_absolute_size(
font_description, units_from_double(style['font_size']))
if style['font_variation_settings'] != 'normal':
string = ','.join(
f'{key}={value}' for key, value in
style['font_variation_settings']).encode()
pango.pango_font_description_set_variations(
font_description, string)
return font_description