From a340702536aecad8d81f66c5f7e7eb1299f743bb Mon Sep 17 00:00:00 2001 From: Andreas Hocevar Date: Fri, 17 Mar 2023 22:26:38 +0100 Subject: [PATCH] Flexible font handling without Google Fonts default --- README.md | 23 ++++++++++++- examples/maptiler-hillshading.js | 4 +++ src/stylefunction.js | 12 +++++-- src/text.js | 33 +++++++++++-------- test/apply.test.js | 54 ++++++++++++++++++++++++++++++ test/text.test.js | 56 ++++++++++++++++++++++++-------- 6 files changed, 152 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 5324377e..3902602d 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,28 @@ Note that this low-level API does not create a source for the layer, and extra w ### Font handling -Only commonly available system fonts and [Google Fonts](https://developers.google.com/fonts/) will automatically be available for any `text-font` defined in the Mapbox Style object. It is the responsibility of the application to load other fonts. Because `ol-mapbox-style` uses system and web fonts instead of PBF/SDF glyphs, the [font stack](https://www.mapbox.com/help/manage-fontstacks/) is treated a little different: style and weight are taken from the primary font (i.e. the first one in the font stack). Subsequent fonts in the font stack are only used if the primary font is not available/loaded, and they will be used with the style and weight of the primary font. +`ol-mapbox-style` cannot use PBF/SDF glyphs for `text-font` layout property, as defined in the Mapbox Style specification. Instead, it relies on web fonts. A `ol-webfonts` metadata property can be set on the root of the Style object to specify a location for webfonts, e.g. +```js +{ + "version": 8, + "metadata": { + "ol-webfonts": "https://my.server/fonts/{font-family}/{fontweight}{-fontstyle}.css" + } + // ... +} +``` + +The following placeholders can be used in the `ol-webfonts` url: + +* `{font-family}`: CSS font family converted to lowercase, blanks replaced with -, e.g. noto-sans +* `{Font+Family}`: CSS font family in original case, blanks replaced with +, e.g. Noto+Sans +* `{fontweight}`: CSS font weight (numeric), e.g. 400, 700 +* `{fontstyle}`: CSS font style, e.g. normal, italic +* `{-fontstyle}`: CSS font style other than normal, e.g. -italic or empty string for normal + +If no `metadata['ol-webfonts']` property is available on the Style object, [Fontsource Fonts](https://fontsource.org/fonts) will be used. It is also possible for the application to load other fonts. If a font is already available in the browser, `ol-mapbox-style` will not load it. + +Because of this difference, the [font stack](https://www.mapbox.com/help/manage-fontstacks/) is treated a little different than defined in the spec: style and weight are taken from the primary font (i.e. the first one in the font stack). Subsequent fonts in the font stack are only used if the primary font is not available/loaded, and they will be used with the style and weight of the primary font. ## Building the library diff --git a/examples/maptiler-hillshading.js b/examples/maptiler-hillshading.js index 0a95b5e0..cd250747 100644 --- a/examples/maptiler-hillshading.js +++ b/examples/maptiler-hillshading.js @@ -19,6 +19,10 @@ fetch(`https://api.maptiler.com/maps/outdoor-v2/style.json?key=${key}`) Object.assign({}, style, { center: [13.783578, 47.609499], zoom: 11, + metadata: Object.assign(style.metadata, { + 'ol:webfonts': + 'https://fonts.googleapis.com/css?family={Font+Family}:{fontweight}{fontstyle}', + }), }) ); }); diff --git a/src/stylefunction.js b/src/stylefunction.js index 4ab47c0c..7e373ac4 100644 --- a/src/stylefunction.js +++ b/src/stylefunction.js @@ -320,8 +320,9 @@ export const styleFunctionArgs = {}; * @param {string} spriteImageUrl Sprite image url for the sprite * specified in the Mapbox Style object's `sprite` property. Only required if a * `sprite` property is specified in the Mapbox Style object. - * @param {function(Array):Array} getFonts Function that - * receives a font stack as arguments, and returns a (modified) font stack that + * @param {function(Array, string=):Array} getFonts Function that + * receives a font stack and the url template from the GL style's `metadata['ol:webfonts']` + * property (if set) as arguments, and returns a (modified) font stack that * is available. Font names are the names used in the Mapbox Style object. If * not provided, the font stack will be used as-is. This function can also be * used for loading web fonts. @@ -1145,7 +1146,12 @@ export function stylefunction( featureState ); font = mb2css( - getFonts ? getFonts(fontArray) : fontArray, + getFonts + ? getFonts( + fontArray, + glStyle.metadata ? glStyle.metadata['ol:webfonts'] : undefined + ) + : fontArray, textSize, textLineHeight ); diff --git a/src/text.js b/src/text.js index ce41f067..1386fb75 100644 --- a/src/text.js +++ b/src/text.js @@ -151,42 +151,49 @@ const processedFontFamilies = {}; /** * @param {Array} fonts Fonts. + * @param {string} [templateUrl] Template URL. * @return {Array} Processed fonts. * @private */ -export function getFonts(fonts) { +export function getFonts( + fonts, + templateUrl = 'https://cdn.jsdelivr.net/npm/@fontsource/{font-family}/{fontweight}{-fontstyle}.css' +) { const fontsKey = fonts.toString(); if (fontsKey in processedFontFamilies) { return processedFontFamilies[fontsKey]; } - const googleFontDescriptions = []; + const fontDescriptions = []; for (let i = 0, ii = fonts.length; i < ii; ++i) { fonts[i] = fonts[i].replace('Arial Unicode MS', 'Arial'); const font = fonts[i]; const cssFont = mb2css(font, 1); registerFont(cssFont); const parts = cssFont.split(' '); - googleFontDescriptions.push([ + fontDescriptions.push([ parts.slice(3).join(' ').replace(/"/g, ''), parts[1], parts[0], ]); } - for (let i = 0, ii = googleFontDescriptions.length; i < ii; ++i) { - const googleFontDescription = googleFontDescriptions[i]; - const family = googleFontDescription[0]; + for (let i = 0, ii = fontDescriptions.length; i < ii; ++i) { + const fontDescription = fontDescriptions[i]; + const family = fontDescription[0]; if (!hasFontFamily(family)) { if ( checkedFonts.get( - `${googleFontDescription[2]}\n${googleFontDescription[1]} \n${family}` + `${fontDescription[2]}\n${fontDescription[1]} \n${family}` ) !== 100 ) { - const fontUrl = - 'https://fonts.googleapis.com/css?family=' + - family.replace(/ /g, '+') + - ':' + - googleFontDescription[1] + - googleFontDescription[2]; + const fontUrl = templateUrl + .replace('{font-family}', family.replace(/ /g, '-').toLowerCase()) + .replace('{Font+Family}', family.replace(/ /g, '+')) + .replace('{fontweight}', fontDescription[1]) + .replace( + '{-fontstyle}', + fontDescription[2].replace('normal', '').replace(/(.+)/, '-$1') + ) + .replace('{fontstyle}', fontDescription[2]); if (!document.querySelector('link[href="' + fontUrl + '"]')) { const markup = document.createElement('link'); markup.href = fontUrl; diff --git a/test/apply.test.js b/test/apply.test.js index 34cdcdc3..25ee23e2 100644 --- a/test/apply.test.js +++ b/test/apply.test.js @@ -900,4 +900,58 @@ describe('ol-mapbox-style', function () { .catch(done); }); }); + + describe('Font loading', function () { + let target; + beforeEach(function () { + target = document.createElement('div'); + }); + + it('loads fonts from a style', function (done) { + const stylesheets = document.querySelectorAll('link[rel=stylesheet]'); + stylesheets.forEach(function (stylesheet) { + stylesheet.remove(); + }); + apply(target, { + version: 8, + metadata: { + 'ol:webfonts': + 'https://fonts.openmaptiles.org/{font-family}/{fontweight}{-fontstyle}.css', + }, + sources: { + test: { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [], + }, + }, + }, + layers: [ + { + id: 'test', + type: 'symbol', + source: 'test', + layout: { + 'text-field': 'test', + 'text-font': ['Open Sans Regular'], + }, + }, + ], + }) + .then(function (map) { + const getStyle = map.getAllLayers()[0].getStyle(); + getStyle(new Feature(new Point([0, 0])), 1); + const stylesheets = document.querySelectorAll('link[rel=stylesheet]'); + should(stylesheets.length).eql(1); + should(stylesheets.item(0).href).eql( + 'https://fonts.openmaptiles.org/open-sans/400.css' + ); + done(); + }) + .catch(function (err) { + done(err); + }); + }); + }); }); diff --git a/test/text.test.js b/test/text.test.js index 7fddc203..bdffe48f 100644 --- a/test/text.test.js +++ b/test/text.test.js @@ -41,7 +41,7 @@ describe('text', function () { }); describe('getFonts', function () { - beforeEach(function () { + before(function () { const stylesheets = document.querySelectorAll('link[rel=stylesheet]'); stylesheets.forEach(function (stylesheet) { stylesheet.remove(); @@ -54,14 +54,16 @@ describe('text', function () { should(stylesheets.length).eql(0); }); - it('loads fonts from fonts.google.com', function () { - let stylesheets; - getFonts([ - 'Noto Sans Bold', - 'Noto Sans Regular Italic', - 'Averia Sans Libre Bold', - ]); - stylesheets = document.querySelectorAll('link[rel=stylesheet]'); + it('loads fonts with a template using {Font+Family} and {fontstyle}', function () { + getFonts( + [ + 'Noto Sans Bold', + 'Noto Sans Regular Italic', + 'Averia Sans Libre Bold', + ], + 'https://fonts.googleapis.com/css?family={Font+Family}:{fontweight}{fontstyle}' + ); + const stylesheets = document.querySelectorAll('link[rel=stylesheet]'); should(stylesheets.length).eql(3); should(stylesheets.item(0).href).eql( 'https://fonts.googleapis.com/css?family=Noto+Sans:700normal' @@ -72,11 +74,39 @@ describe('text', function () { should(stylesheets.item(2).href).eql( 'https://fonts.googleapis.com/css?family=Averia+Sans+Libre:700normal' ); + }); - // already loaded family, no additional link - getFonts(['Noto Sans Bold']); - stylesheets = document.querySelectorAll('link[rel=stylesheet]'); - should(stylesheets.length).eql(3); + it('loads fonts with a template using {font-family} and {-fontstyle}', function () { + getFonts( + ['Noto Sans Regular', 'Averia Sans Libre Bold Italic'], + './fonts/{font-family}/{fontweight}{-fontstyle}.css' + ); + const stylesheets = document.querySelectorAll('link[rel=stylesheet]'); + should(stylesheets.length).eql(5); + should(stylesheets.item(3).href).eql( + location.origin + '/fonts/noto-sans/400.css' + ); + should(stylesheets.item(4).href).eql( + location.origin + '/fonts/averia-sans-libre/700-italic.css' + ); + }); + + it('does not load fonts twice', function () { + getFonts( + ['Noto Sans Bold'], + 'https://fonts.googleapis.com/css?family={Font+Family}:{fontweight}{fontstyle}' + ); + const stylesheets = document.querySelectorAll('link[rel=stylesheet]'); + should(stylesheets.length).eql(5); + }); + + it('uses the default template if none is provided', function () { + getFonts(['Averia Sans Libre']); + const stylesheets = document.querySelectorAll('link[rel=stylesheet]'); + should(stylesheets.length).eql(6); + should(stylesheets.item(5).href).eql( + 'https://cdn.jsdelivr.net/npm/@fontsource/averia-sans-libre/400.css' + ); }); }); });