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 0000000000..c75627d500
Binary files /dev/null and b/test/rendering/cases/rich-text-style/expected.png differ
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});