From 18f06b8b9a48ee1b6eea4831eae8e096d990b493 Mon Sep 17 00:00:00 2001 From: Andreas Hocevar Date: Mon, 21 Feb 2022 23:25:33 +0100 Subject: [PATCH] Rich text labels --- examples/rich-text-labels.html | 9 ++ examples/rich-text-labels.js | 64 +++++++++ examples/vector-label-decluttering.html | 2 - src/ol/render/canvas.js | 37 +++-- src/ol/render/canvas/Executor.js | 101 ++++++++++---- src/ol/render/canvas/Immediate.js | 7 +- src/ol/render/canvas/TextBuilder.js | 2 +- src/ol/style/Text.js | 10 +- .../cases/rich-text-style/expected.png | Bin 0 -> 12725 bytes test/rendering/cases/rich-text-style/main.js | 129 ++++++++++++++++++ 10 files changed, 313 insertions(+), 48 deletions(-) create mode 100644 examples/rich-text-labels.html create mode 100644 examples/rich-text-labels.js create mode 100644 test/rendering/cases/rich-text-style/expected.png create mode 100644 test/rendering/cases/rich-text-style/main.js diff --git a/examples/rich-text-labels.html b/examples/rich-text-labels.html new file mode 100644 index 0000000000..546ec3aff0 --- /dev/null +++ b/examples/rich-text-labels.html @@ -0,0 +1,9 @@ +--- +layout: example.html +title: Rich Text Labels +shortdesc: Rich text labels. +docs: > + The labels in this map use different fonts to create clear context - an alphabetic sort key prefixing the state name in bold, and the population density in an extra line with a smaller font and italic. +tags: "vector, rich-text, labels" +--- +
diff --git a/examples/rich-text-labels.js b/examples/rich-text-labels.js new file mode 100644 index 0000000000..f74c883a2f --- /dev/null +++ b/examples/rich-text-labels.js @@ -0,0 +1,64 @@ +import GeoJSON from '../src/ol/format/GeoJSON.js'; +import Map from '../src/ol/Map.js'; +import VectorLayer from '../src/ol/layer/Vector.js'; +import VectorSource from '../src/ol/source/Vector.js'; +import View from '../src/ol/View.js'; +import {Fill, Stroke, Style, Text} from '../src/ol/style.js'; + +const map = new Map({ + target: 'map', + view: new View({ + center: [0, 0], + zoom: 2, + extent: [-13882269, 2890586, -7456136, 6340207], + showFullExtent: true, + }), +}); + +const labelStyle = new Style({ + text: new Text({ + font: '13px Calibri,sans-serif', + fill: new Fill({ + color: '#000', + }), + stroke: new Stroke({ + color: '#fff', + width: 4, + }), + }), +}); +const countryStyle = new Style({ + fill: new Fill({ + color: 'rgba(255, 255, 255, 0.6)', + }), + stroke: new Stroke({ + color: '#319FD3', + width: 1, + }), +}); +const style = [countryStyle, labelStyle]; + +const vectorLayer = new VectorLayer({ + background: 'white', + source: new VectorSource({ + url: 'https://openlayers.org/data/vector/us-states.json', + format: new GeoJSON(), + }), + style: function (feature) { + labelStyle + .getText() + .setText([ + feature.getId(), + 'bold 13px Calibri,sans-serif', + ` ${feature.get('name')}`, + '', + '\n', + '', + `${feature.get('density')} people/mi²`, + 'italic 11px Calibri,sans-serif', + ]); + return style; + }, +}); + +map.addLayer(vectorLayer); diff --git a/examples/vector-label-decluttering.html b/examples/vector-label-decluttering.html index c453b3a2d2..e756ee797f 100644 --- a/examples/vector-label-decluttering.html +++ b/examples/vector-label-decluttering.html @@ -2,8 +2,6 @@ layout: example.html title: Vector Label Decluttering shortdesc: Label decluttering on polygons. -resources: - - https://cdn.polyfill.io/v2/polyfill.min.js?features=Set" docs: > Decluttering is used to avoid overlapping labels. The `overflow: true` setting on the text style makes it so labels that do not fit within the bounds of a polygon are also considered for decluttering. tags: "vector, decluttering, labels" diff --git a/src/ol/render/canvas.js b/src/ol/render/canvas.js index 6202e5f92d..6ec5947a58 100644 --- a/src/ol/render/canvas.js +++ b/src/ol/render/canvas.js @@ -367,21 +367,36 @@ export function measureAndCacheTextWidth(font, text, cache) { } /** - * @param {string} font Font to use for measuring. - * @param {Array} lines Lines to measure. - * @param {Array} widths Array will be populated with the widths of - * each line. - * @return {number} Width of the whole text. + * @param {TextState} baseStyle Base style. + * @param {Array} chunks Text chunks to measure. + * @return {{width: number, height: number, widths: Array, heights: Array, lineWidths: Array}}} Text metrics. */ -export function measureTextWidths(font, lines, widths) { - const numLines = lines.length; +export function getTextDimensions(baseStyle, chunks) { + const widths = []; + const heights = []; + const lineWidths = []; let width = 0; - for (let i = 0; i < numLines; ++i) { - const currentWidth = measureTextWidth(font, lines[i]); - width = Math.max(width, currentWidth); + let lineWidth = 0; + let height = 0; + let lineHeight = 0; + for (let i = 0, ii = chunks.length; i <= ii; i += 2) { + const text = chunks[i]; + if (text === '\n' || i === ii) { + width = Math.max(width, lineWidth); + lineWidths.push(lineWidth); + lineWidth = 0; + height += lineHeight; + continue; + } + const font = chunks[i + 1] || baseStyle.font; + const currentWidth = measureTextWidth(font, text); widths.push(currentWidth); + lineWidth += currentWidth; + const currentHeight = measureTextHeight(font); + heights.push(currentHeight); + lineHeight = Math.max(lineHeight, currentHeight); } - return width; + return {width, height, widths, heights, lineWidths}; } /** diff --git a/src/ol/render/canvas/Executor.js b/src/ol/render/canvas/Executor.js index 144eeeeb69..1a888b0d15 100644 --- a/src/ol/render/canvas/Executor.js +++ b/src/ol/render/canvas/Executor.js @@ -16,9 +16,8 @@ import { defaultTextAlign, defaultTextBaseline, drawImageOrLabel, + getTextDimensions, measureAndCacheTextWidth, - measureTextHeight, - measureTextWidths, } from '../canvas.js'; import {drawTextOnPath} from '../../geom/flat/textpath.js'; import {equals} from '../../array.js'; @@ -102,6 +101,20 @@ function horizontalTextAlign(text, align) { return TEXT_ALIGN[align]; } +/** + * @param {Array} acc Accumulator. + * @param {string} line Line of text. + * @param {number} i Index + * @return {Array} Accumulator. + */ +function createTextChunks(acc, line, i) { + if (i > 0) { + acc.push('\n', ''); + } + acc.push(line, ''); + return acc; +} + class Executor { /** * @param {number} resolution Resolution. @@ -206,7 +219,7 @@ class Executor { } /** - * @param {string} text Text. + * @param {string|Array} text Text. * @param {string} textKey Text style key. * @param {string} fillKey Fill style key. * @param {string} strokeKey Stroke style key. @@ -225,19 +238,22 @@ class Executor { textState.scale[0] * pixelRatio, textState.scale[1] * pixelRatio, ]; + const textIsArray = Array.isArray(text); const align = horizontalTextAlign( - text, + textIsArray ? text[0] : text, textState.textAlign || defaultTextAlign ); const strokeWidth = strokeKey && strokeState.lineWidth ? strokeState.lineWidth : 0; - const lines = text.split('\n'); - const numLines = lines.length; - const widths = []; - const width = measureTextWidths(textState.font, lines, widths); - const lineHeight = measureTextHeight(textState.font); - const height = lineHeight * numLines; + const chunks = textIsArray + ? text + : text.split('\n').reduce(createTextChunks, []); + + const {width, height, widths, heights, lineWidths} = getTextDimensions( + textState, + chunks + ); const renderWidth = width + strokeWidth; const contextInstructions = []; // make canvas 2 pixels wider to account for italic text width measurement errors @@ -252,7 +268,6 @@ class Executor { if (scale[0] != 1 || scale[1] != 1) { contextInstructions.push('scale', scale); } - contextInstructions.push('font', textState.font); if (strokeKey) { contextInstructions.push('strokeStyle', strokeState.strokeStyle); contextInstructions.push('lineWidth', strokeWidth); @@ -272,26 +287,52 @@ class Executor { contextInstructions.push('textBaseline', 'middle'); contextInstructions.push('textAlign', 'center'); const leftRight = 0.5 - align; - const x = align * renderWidth + leftRight * strokeWidth; - let i; - if (strokeKey) { - for (i = 0; i < numLines; ++i) { - contextInstructions.push('strokeText', [ - lines[i], - x + leftRight * widths[i], - 0.5 * (strokeWidth + lineHeight) + i * lineHeight, - ]); + let x = align * renderWidth + leftRight * strokeWidth; + const strokeInstructions = []; + const fillInstructions = []; + let lineHeight = 0; + let lineOffset = 0; + let widthHeightIndex = 0; + let lineWidthIndex = 0; + let previousFont; + for (let i = 0, ii = chunks.length; i < ii; i += 2) { + const text = chunks[i]; + if (text === '\n') { + lineOffset += lineHeight; + lineHeight = 0; + x = align * renderWidth + leftRight * strokeWidth; + ++lineWidthIndex; + continue; } - } - if (fillKey) { - for (i = 0; i < numLines; ++i) { - contextInstructions.push('fillText', [ - lines[i], - x + leftRight * widths[i], - 0.5 * (strokeWidth + lineHeight) + i * lineHeight, - ]); + const font = chunks[i + 1] || textState.font; + if (font !== previousFont) { + if (strokeKey) { + strokeInstructions.push('font', font); + } + if (fillKey) { + fillInstructions.push('font', font); + } + previousFont = font; } + lineHeight = Math.max(lineHeight, heights[widthHeightIndex]); + const fillStrokeArgs = [ + text, + x + + leftRight * widths[widthHeightIndex] + + align * (widths[widthHeightIndex] - lineWidths[lineWidthIndex]), + 0.5 * (strokeWidth + lineHeight) + lineOffset, + ]; + x += widths[widthHeightIndex]; + if (strokeKey) { + strokeInstructions.push('strokeText', fillStrokeArgs); + } + if (fillKey) { + fillInstructions.push('fillText', fillStrokeArgs); + } + ++widthHeightIndex; } + Array.prototype.push.apply(contextInstructions, strokeInstructions); + Array.prototype.push.apply(contextInstructions, fillInstructions); this.labels_[key] = label; return label; } @@ -550,7 +591,7 @@ class Executor { /** * @private - * @param {string} text The text to draw. + * @param {string|Array} text The text to draw. * @param {string} textKey The key of the text state. * @param {string} strokeKey The key for the stroke state. * @param {string} fillKey The key for the fill state. @@ -564,7 +605,7 @@ class Executor { const strokeState = this.strokeStates[strokeKey]; const pixelRatio = this.pixelRatio; const align = horizontalTextAlign( - text, + Array.isArray(text) ? text[0] : text, textState.textAlign || defaultTextAlign ); const baseline = TEXT_ALIGN[textState.textBaseline || defaultTextBaseline]; diff --git a/src/ol/render/canvas/Immediate.js b/src/ol/render/canvas/Immediate.js index de0aba4e5f..ab7a55397a 100644 --- a/src/ol/render/canvas/Immediate.js +++ b/src/ol/render/canvas/Immediate.js @@ -1146,7 +1146,12 @@ class CanvasImmediateRenderer extends VectorContext { ? textTextBaseline : defaultTextBaseline, }; - this.text_ = textText !== undefined ? textText : ''; + this.text_ = + textText !== undefined + ? Array.isArray(textText) + ? textText.reduce((acc, t, i) => (acc += i % 2 ? ' ' : t), '') + : textText + : ''; this.textOffsetX_ = textOffsetX !== undefined ? this.pixelRatio_ * textOffsetX : 0; this.textOffsetY_ = diff --git a/src/ol/render/canvas/TextBuilder.js b/src/ol/render/canvas/TextBuilder.js index 849cd872d5..b7bb31708e 100644 --- a/src/ol/render/canvas/TextBuilder.js +++ b/src/ol/render/canvas/TextBuilder.js @@ -60,7 +60,7 @@ class CanvasTextBuilder extends CanvasBuilder { /** * @private - * @type {string} + * @type {string|Array} */ this.text_ = ''; diff --git a/src/ol/style/Text.js b/src/ol/style/Text.js index 2c044ed8c0..c8fa321331 100644 --- a/src/ol/style/Text.js +++ b/src/ol/style/Text.js @@ -27,7 +27,11 @@ const DEFAULT_FILL_COLOR = '#333'; * @property {number|import("../size.js").Size} [scale] Scale. * @property {boolean} [rotateWithView=false] Whether to rotate the text with the view. * @property {number} [rotation=0] Rotation in radians (positive rotation clockwise). - * @property {string} [text] Text content. + * @property {string|Array} [text] Text content or rich text content. For plain text provide a string, which can + * contain line breaks (`\n`). For rich text provide an array of text/font tuples. A tuple consists of the text to + * render and the font to use (or `''` to use the text style's font). A line break has to be a separate tuple (i.e. `'\n', ''`). + * **Example:** `['foo', 'bold 10px sans-serif', ' bar', 'italic 10px sans-serif', ' baz', '']` will yield "**foo** *bar* baz". + * **Note:** Rich text is not supported for the immediate rendering API. * @property {string} [textAlign] Text alignment. Possible values: 'left', 'right', 'center', 'end' or 'start'. * Default is 'center' for `placement: 'point'`. For `placement: 'line'`, the default is to let the renderer choose a * placement where `maxAngle` is not exceeded. @@ -87,7 +91,7 @@ class Text { /** * @private - * @type {string|undefined} + * @type {string|Array|undefined} */ this.text_ = options.text; @@ -314,7 +318,7 @@ class Text { /** * Get the text to be rendered. - * @return {string|undefined} Text. + * @return {string|Array|undefined} Text. * @api */ getText() { diff --git a/test/rendering/cases/rich-text-style/expected.png b/test/rendering/cases/rich-text-style/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..c75627d500c9ccee50bba02ebafdd8e74bb7d538 GIT binary patch literal 12725 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

<*;^JKBJIs=Mq6U8N%B7L2o=$P2ov(mPkuo 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 P<9^ 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 G|9=AoDHBxy literal 0 HcmV?d00001 diff --git a/test/rendering/cases/rich-text-style/main.js b/test/rendering/cases/rich-text-style/main.js new file mode 100644 index 0000000000..68e387d865 --- /dev/null +++ b/test/rendering/cases/rich-text-style/main.js @@ -0,0 +1,129 @@ +import CircleStyle from '../../../../src/ol/style/Circle.js'; +import Feature from '../../../../src/ol/Feature.js'; +import Fill from '../../../../src/ol/style/Fill.js'; +import Map from '../../../../src/ol/Map.js'; +import Point from '../../../../src/ol/geom/Point.js'; +import Stroke from '../../../../src/ol/style/Stroke.js'; +import Style from '../../../../src/ol/style/Style.js'; +import Text from '../../../../src/ol/style/Text.js'; +import VectorLayer from '../../../../src/ol/layer/Vector.js'; +import VectorSource from '../../../../src/ol/source/Vector.js'; +import View from '../../../../src/ol/View.js'; + +const vectorSource = new VectorSource({ + features: [ + // inline right-bottom + new Feature({ + geometry: new Point([-10, 50]), + text: [ + 'in', + '', + 'line', + 'italic 20px/1.5 Ubuntu', + '\n', + '', + 'right-bottom', + '20px/1.2 Ubuntu', + ], + textAlign: 'right', + textBaseline: 'bottom', + }), + // multi-line - center-middle + new Feature({ + geometry: new Point([0, 0]), + text: ['multi-line', '', '\n', '', 'center-middle', 'italic 20px Ubuntu'], + textAlign: 'center', + textBaseline: 'middle', + }), + + // inline right-top + new Feature({ + geometry: new Point([-10, -50]), + text: [ + 'in', + '', + 'line', + 'italic 20px/1.5 Ubuntu', + '\n', + '', + 'right-top', + '28px/1 Ubuntu', + ], + textAlign: 'right', + textBaseline: 'top', + }), + + // inline left-bottom + new Feature({ + geometry: new Point([10, 50]), + text: [ + 'in', + '', + 'line', + 'italic 20px/1.5 Ubuntu', + '\n', + '', + 'left-bottom', + '20px/1.2 Ubuntu', + ], + textAlign: 'left', + textBaseline: 'bottom', + }), + + // inline left-top + new Feature({ + geometry: new Point([10, -50]), + text: [ + 'in', + '', + 'line', + 'italic 20px/1.5 Ubuntu', + '\n', + '', + 'left-top', + '28px/1 Ubuntu', + ], + textAlign: 'left', + textBaseline: 'top', + }), + ], +}); + +new Map({ + pixelRatio: 1, + layers: [ + new VectorLayer({ + source: vectorSource, + style: function (feature) { + return new Style({ + text: new Text({ + text: feature.get('text'), + font: '24px Ubuntu', + textAlign: feature.get('textAlign'), + textBaseline: feature.get('textBaseline'), + fill: new Fill({ + color: 'black', + }), + stroke: new Stroke({ + color: 'white', + }), + backgroundStroke: new Stroke({width: 1}), + }), + image: new CircleStyle({ + radius: 10, + fill: new Fill({ + color: 'cyan', + }), + }), + }); + }, + }), + ], + target: 'map', + view: new View({ + center: [0, 0], + resolution: 1, + }), +}); + +render({tolerance: 0.01});