Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Format semicolon-delimited value lists in labels #666

Merged
merged 4 commits into from
Jan 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 139 additions & 6 deletions src/constants/label.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,134 @@ export function localizeLayers(layers, locales) {
}

/**
* The name in the user's preferred language.
* Returns an expression that replaces a finite number of occurrences of a
* substring expression withing a larger string expression, starting at a given
* index.
*
* This expression nests recursively by the maximum number of replacements. Take
* special care to minimize this limit, which exponentially increases the length
* of a property value in JSON. Excessive nesting causes acute performance
* problems when loading the style.
*
* The returned expression can be complex, so use it only once within a property
* value. To reuse the evaluated value, bind it to a variable in a let
* expression.
*
* @param haystack The overall string expression to search within.
* @param needle The string to search for, or an expression that evaluates to
* this string.
*/
export function replaceExpression(
haystack,
needle,
replacement,
haystackStart,
numReplacements = 1
) {
let asIs = ["slice", haystack, haystackStart];
if (numReplacements <= 0) {
return asIs;
}

let needleStart = ["index-of", needle, haystack, haystackStart];
let needleLength =
typeof needle === "object" ? ["length", needle] : needle.length;
let needleEnd = ["+", needleStart, needleLength];
return [
"case",
[">=", needleStart, 0],
[
"concat",
["slice", haystack, haystackStart, needleStart],
replacement,
replaceExpression(
haystack,
needle,
replacement,
needleEnd,
numReplacements - 1
),
],
asIs,
];
}

/**
* Maximum number of values in a semicolon-delimited list of values.
*
* Increasing this constant deepens recursion for replacing delimiters in the
* list, potentially affecting style loading performance.
*/
const maxValueListLength = 3;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three values separated by two semicolons is plenty enough for the names currently using semicolons. In the future, with a more efficient string replacement implementation, we can add support for more semicolons, which will make this option more attractive to mappers currently using informal delimiters like spaces.


/**
* Returns an expression interpreting the given string as a list of tag values,
* pretty-printing the standard semicolon delimiter with the given separator.
*
* https://wiki.openstreetmap.org/wiki/Semi-colon_value_separator
*
* The returned expression can be complex, so use it only once within a property
* value. To reuse the evaluated value, bind it to a variable in a let
* expression.
*
* @param valueList A semicolon-delimited list of values.
* @param separator A string to insert between each value, or an expression that
* evaluates to this string.
*/
export function listValuesExpression(valueList, separator) {
let maxSeparators = maxValueListLength - 1;
// Replace the ;; escape sequence with a placeholder sequence unlikely to
// legitimately occur inside a value or separator.
const objReplacementChar = "\x91\ufffc\x92"; // https://overpass-turbo.eu/s/1pJx
Comment on lines +196 to +198
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s amazing how much junk there is in name that can impersonate a strip marker, but this sequence does the trick for now. If someone opens a pub for text processing geeks and names it “The �� & ;”, I’ll kindly ask someone to offer up a prized password as sacrifice.

let safeValueList = replaceExpression(
valueList,
";;",
objReplacementChar,
0,
maxSeparators
);
// Pretty-print the ; delimiter.
let prettyValueList = replaceExpression(
["var", "safeValueList"],
";",
separator,
0,
maxSeparators
);
// Replace the placeholder sequence with an unescaped semicolon.
let prettySafeValueList = replaceExpression(
["var", "prettyValueList"],
objReplacementChar,
";",
0,
maxSeparators
);
return [
"let",
"safeValueList",
safeValueList,
["let", "prettyValueList", prettyValueList, prettySafeValueList],
];
}

/**
* The names in the user's preferred language, each on a separate line.
*/
export const localizedName = [
"let",
"localizedName",
"",
["var", "localizedName"],
listValuesExpression(["var", "localizedName"], "\n"),
];

/**
* The names in the user's preferred language, all on the same line.
*/
export const localizedNameInline = [
"let",
"localizedName",
"",
listValuesExpression(["var", "localizedName"], " \u2022 "),
];

/**
Expand Down Expand Up @@ -217,7 +338,7 @@ export const localizedNameWithLocalGloss = [
["var", "localizedCollator"],
],
// ...just pick one.
["var", "localizedName"],
["format", listValuesExpression(["var", "localizedName"], "\n")],
Copy link
Member Author

@1ec5 1ec5 Jan 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return value of this method is so complex that the style-spec package (used by the unit tests) is apparently unable to upcast it to a format expression, leading to an error when it sees the direct format expression in the else slot below. Interestingly, GL JS has no such problem at runtime. This explicit cast reassures the expression parser that each branch of the case expression will resolve to a format expression.

// If the name in the preferred language is the same as the name in the
// local language except for the omission of diacritics and/or the addition
// of a suffix (e.g., "City" in English)...
Expand All @@ -227,7 +348,13 @@ export const localizedNameWithLocalGloss = [
["var", "diacriticInsensitiveCollator"]
),
// ...then replace the common prefix with the local name.
overwritePrefixExpression(["var", "localizedName"], ["get", "name"]),
[
"format",
overwritePrefixExpression(
["var", "localizedName"],
listValuesExpression(["get", "name"], "\n")
),
],
// If the name in the preferred language is the same as the name in the
// local language except for the omission of diacritics and/or the addition
// of a prefix (e.g., "City of" in English or "Ciudad de" in Spanish)...
Expand All @@ -237,7 +364,13 @@ export const localizedNameWithLocalGloss = [
["var", "diacriticInsensitiveCollator"]
),
// ...then replace the common suffix with the local name.
overwriteSuffixExpression(["var", "localizedName"], ["get", "name"]),
[
"format",
overwriteSuffixExpression(
["var", "localizedName"],
listValuesExpression(["get", "name"], "\n")
),
],
// Otherwise, gloss the name in the local language if it differs from the
// localized name.
[
Expand All @@ -252,7 +385,7 @@ export const localizedNameWithLocalGloss = [
// bother rendering it.
["concat", ["slice", ["var", "localizedName"], 0, 1], " "],
{ "font-scale": 0.001 },
["get", "name"],
listValuesExpression(["get", "name"], " \u2022 "),
{ "font-scale": 0.8 },
["concat", " ", ["slice", ["var", "localizedName"], 0, 1]],
{ "font-scale": 0.001 },
Expand Down
3 changes: 3 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
width: 0;
text-align: center;
}
.legend-row > .label {
white-space: pre-line;
}
.legend-row > .icon {
white-space: nowrap;
}
Expand Down
4 changes: 3 additions & 1 deletion src/layer/highway_exit.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use strict";

import * as Label from "../constants/label.js";

export const exits = {
id: "highway_exit",
type: "symbol",
Expand All @@ -12,7 +14,7 @@ export const exits = {
"source-layer": "transportation_name",
minzoom: 14,
layout: {
"text-field": ["get", "ref"],
"text-field": Label.listValuesExpression(["get", "ref"], "\n"),
"text-font": ["OpenHistorical Bold"],
"text-size": 9,
"text-line-height": 1,
Expand Down
2 changes: 1 addition & 1 deletion src/layer/transportation_label.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const label = {
["literal", ["OpenHistorical Italic"]],
["literal", ["OpenHistorical"]],
],
"text-field": [...Label.localizedName],
"text-field": [...Label.localizedNameInline],
"text-max-angle": 20,
"symbol-placement": "line",
"text-size": [
Expand Down
Loading