Skip to content

Commit

Permalink
Introduce "format" expression.
Browse files Browse the repository at this point in the history
Returns "formatted" type with formatting annotations applied to subsections.
"text-field" now accepts formatted text, allowing symbols to use multiple fonts within the same label.
  • Loading branch information
ChrisLoer committed Jul 27, 2018
1 parent 22a0345 commit 07583cc
Show file tree
Hide file tree
Showing 44 changed files with 863 additions and 147 deletions.
2 changes: 2 additions & 0 deletions build/generate-flow-typed-style-spec.js
Expand Up @@ -114,6 +114,8 @@ fs.writeFileSync('flow-typed/style-spec.js', `// Generated code; do not edit. Ed
declare type ColorSpecification = string;
declare type FormattedSpecification = string;
declare type FilterSpecification =
| ['has', string]
| ['!has', string]
Expand Down
6 changes: 5 additions & 1 deletion build/generate-style-code.js
Expand Up @@ -5,6 +5,7 @@ const fs = require('fs');
const ejs = require('ejs');
const spec = require('../src/style-spec/reference/v8');
const Color = require('../src/style-spec/util/color');
const {Formatted} = require('../src/style-spec/expression/definitions/formatted');

global.camelize = function (str) {
return str.replace(/(?:^|-)(.)/g, function (_, x) {
Expand All @@ -24,6 +25,8 @@ global.flowType = function (property) {
return Object.keys(property.values).map(JSON.stringify).join(' | ');
case 'color':
return `Color`;
case 'formatted':
return `string | Formatted`;
case 'array':
if (property.length) {
return `[${new Array(property.length).fill(flowType({type: property.value})).join(', ')}]`;
Expand Down Expand Up @@ -61,6 +64,8 @@ global.runtimeType = function (property) {
return 'StringType';
case 'color':
return `ColorType`;
case 'formatted':
return `FormattedType`;
case 'array':
if (property.length) {
return `array(${runtimeType({type: property.value})}, ${property.length})`;
Expand Down Expand Up @@ -131,4 +136,3 @@ const layers = Object.keys(spec.layer.type.values).map((type) => {
for (const layer of layers) {
fs.writeFileSync(`src/style/style_layer/${layer.type.replace('-', '_')}_style_layer_properties.js`, propertiesJs(layer))
}

8 changes: 8 additions & 0 deletions docs/components/expression-metadata.js
Expand Up @@ -134,6 +134,14 @@ const types = {
collator: [{
type: 'collator',
parameters: [ '{ "case-sensitive": boolean, "diacritic-sensitive": boolean, "locale": string }' ]
}],
format: [{
type: 'formatted',
parameters: [
'input_1: string, options_1: { "font-scale": number, "text-font": array<string> }',
'...',
'input_n: string, options_n: { "font-scale": number, "text-font": array<string> }'
]
}]
};

Expand Down
16 changes: 16 additions & 0 deletions docs/pages/style-spec.js
Expand Up @@ -159,6 +159,9 @@ const navigation = [
{
"title": "String"
},
{
"title": "Formatted"
},
{
"title": "Boolean"
},
Expand Down Expand Up @@ -948,6 +951,19 @@ export default class extends React.Component {
<p>Especially of note is the support for hsl, which can be <a href='http://mothereffinghsl.com/'>easier to reason about than rgb()</a>.</p>
</div>

<div className='pad2 keyline-bottom'>
<a id='types-formatted' className='anchor'/>
<h3 className='space-bottom1'><a href='#types-formatted' title='link to formatted'>Formatted</a></h3>
<p>The <code>formatted</code> type represents a string broken into sections annotated with separate formatting options.</p>
{highlightJSON(`
{
"text-field": ["format",
"foo", { "font-scale": 1.2 },
"bar", { "font-scale": 0.8 }
]
}`)}
</div>

<div className='pad2 keyline-bottom'>
<a id='types-string' className='anchor'/>
<h3 className='space-bottom1'><a href='#types-string' title='link to string'>String</a></h3>
Expand Down
5 changes: 4 additions & 1 deletion flow-typed/style-spec.js
Expand Up @@ -2,6 +2,8 @@

declare type ColorSpecification = string;

declare type FormattedSpecification = string;

declare type FilterSpecification =
| ['has', string]
| ['!has', string]
Expand Down Expand Up @@ -77,6 +79,7 @@ declare type VectorSourceSpecification = {
"url"?: string,
"tiles"?: Array<string>,
"bounds"?: [number, number, number, number],
"scheme"?: "xyz" | "tms",
"minzoom"?: number,
"maxzoom"?: number,
"attribution"?: string
Expand Down Expand Up @@ -222,7 +225,7 @@ declare type SymbolLayerSpecification = {|
"icon-pitch-alignment"?: PropertyValueSpecification<"map" | "viewport" | "auto">,
"text-pitch-alignment"?: PropertyValueSpecification<"map" | "viewport" | "auto">,
"text-rotation-alignment"?: PropertyValueSpecification<"map" | "viewport" | "auto">,
"text-field"?: DataDrivenPropertyValueSpecification<string>,
"text-field"?: DataDrivenPropertyValueSpecification<FormattedSpecification>,
"text-font"?: DataDrivenPropertyValueSpecification<Array<string>>,
"text-size"?: DataDrivenPropertyValueSpecification<number>,
"text-max-width"?: DataDrivenPropertyValueSpecification<number>,
Expand Down
35 changes: 25 additions & 10 deletions src/data/bucket/symbol_bucket.js
Expand Up @@ -18,6 +18,7 @@ import Anchor from '../../symbol/anchor';
import { getSizeData } from '../../symbol/symbol_size';
import { register } from '../../util/web_worker_transfer';
import EvaluationParameters from '../../style/evaluation_parameters';
import {Formatted} from '../../style-spec/expression/definitions/formatted';

import type {
Bucket,
Expand Down Expand Up @@ -53,7 +54,7 @@ export type CollisionArrays = {
};

export type SymbolFeature = {|
text: string | void,
text: string | Formatted | void,
icon: string | void,
index: number,
sourceLayerIndex: number,
Expand Down Expand Up @@ -328,6 +329,18 @@ class SymbolBucket implements Bucket {
this.lineVertexArray = new SymbolLineVertexArray();
}

calculateGlyphDependencies(text: string, stack: {[number]: boolean}, textAlongLine: boolean, doesAllowVerticalWritingMode: boolean) {
for (let i = 0; i < text.length; i++) {
stack[text.charCodeAt(i)] = true;
if (textAlongLine && doesAllowVerticalWritingMode) {
const verticalChar = verticalizedCharacterMap[text.charAt(i)];
if (verticalChar) {
stack[verticalChar.charCodeAt(0)] = true;
}
}
}
}

populate(features: Array<IndexedFeature>, options: PopulateParameters) {
const layer = this.layers[0];
const layout = layer.layout;
Expand All @@ -336,7 +349,7 @@ class SymbolBucket implements Bucket {
const textField = layout.get('text-field');
const iconImage = layout.get('icon-image');
const hasText =
(textField.value.kind !== 'constant' || textField.value.value.length > 0) &&
(textField.value.kind !== 'constant' || textField.value.value.toString().length > 0) &&
(textFont.value.kind !== 'constant' || textFont.value.value.length > 0);
const hasIcon = iconImage.value.kind !== 'constant' || iconImage.value.value && iconImage.value.value.length > 0;

Expand Down Expand Up @@ -392,16 +405,18 @@ class SymbolBucket implements Bucket {
const fontStack = textFont.evaluate(feature, {}).join(',');
const stack = stacks[fontStack] = stacks[fontStack] || {};
const textAlongLine = layout.get('text-rotation-alignment') === 'map' && layout.get('symbol-placement') !== 'point';
const doesAllowVerticalWritingMode = allowsVerticalWritingMode(text);
for (let i = 0; i < text.length; i++) {
stack[text.charCodeAt(i)] = true;
if (textAlongLine && doesAllowVerticalWritingMode) {
const verticalChar = verticalizedCharacterMap[text.charAt(i)];
if (verticalChar) {
stack[verticalChar.charCodeAt(0)] = true;
}
if (text instanceof Formatted) {
for (const section of text.sections) {
const doesAllowVerticalWritingMode = allowsVerticalWritingMode(text.toString());
const sectionFont = section.fontStack || fontStack;
const sectionStack = stacks[sectionFont] = stacks[sectionFont] || {};
this.calculateGlyphDependencies(section.text, sectionStack, textAlongLine, doesAllowVerticalWritingMode);
}
} else {
const doesAllowVerticalWritingMode = allowsVerticalWritingMode(text);
this.calculateGlyphDependencies(text, stack, textAlongLine, doesAllowVerticalWritingMode);
}

}
}

Expand Down
2 changes: 2 additions & 0 deletions src/source/rtl_text_plugin.js
Expand Up @@ -54,10 +54,12 @@ export const setRTLTextPlugin = function(url: string, callback: ErrorCallback) {
export const plugin: {
applyArabicShaping: ?Function,
processBidirectionalText: ?(string, Array<number>) => Array<string>,
processStyledBidirectionalText: ?(string, Array<number>, Array<number>) => Array<[string, Array<number>]>,
isLoaded: () => boolean
} = {
applyArabicShaping: null,
processBidirectionalText: null,
processStyledBidirectionalText: null,
isLoaded: function() {
return foregroundLoadComplete || // Foreground: loaded if the completion callback returned successfully
plugin.applyArabicShaping != null; // Background: loaded if the plugin functions have been compiled
Expand Down
4 changes: 2 additions & 2 deletions src/source/worker.js
Expand Up @@ -54,12 +54,13 @@ export default class Worker {
this.workerSourceTypes[name] = WorkerSource;
};

this.self.registerRTLTextPlugin = (rtlTextPlugin: {applyArabicShaping: Function, processBidirectionalText: Function}) => {
this.self.registerRTLTextPlugin = (rtlTextPlugin: {applyArabicShaping: Function, processBidirectionalText: Function, processStyledBidirectionalText?: Function}) => {
if (globalRTLTextPlugin.isLoaded()) {
throw new Error('RTL text plugin already registered.');
}
globalRTLTextPlugin['applyArabicShaping'] = rtlTextPlugin.applyArabicShaping;
globalRTLTextPlugin['processBidirectionalText'] = rtlTextPlugin.processBidirectionalText;
globalRTLTextPlugin['processStyledBidirectionalText'] = rtlTextPlugin.processStyledBidirectionalText;
};
}

Expand Down Expand Up @@ -196,4 +197,3 @@ if (typeof WorkerGlobalScope !== 'undefined' &&
self instanceof WorkerGlobalScope) {
new Worker(self);
}

145 changes: 145 additions & 0 deletions src/style-spec/expression/definitions/formatted.js
@@ -0,0 +1,145 @@
// @flow

import { NumberType, ValueType, FormattedType } from '../types';


import type { Expression } from '../expression';
import type EvaluationContext from '../evaluation_context';
import type ParsingContext from '../parsing_context';
import type { Type } from '../types';

export class FormattedSection {
text: string
scale: number | null
fontStack: string | null

constructor(text: string, scale: number | null, fontStack: string | null) {
this.text = text;
this.scale = scale;
this.fontStack = fontStack;
}
}

export class Formatted {
sections: Array<FormattedSection>

constructor(sections: Array<FormattedSection>) {
this.sections = sections;
}

toString(): string {
return this.sections.map(section => section.text).join('');
}

serialize() {
const serialized = ["format"];
for (const section of this.sections) {
serialized.push(section.text);
const fontStack = section.fontStack ?
["literal", section.fontStack.split(',')] :
null;
serialized.push({ "text-font": fontStack, "font-scale": section.scale });
}
return serialized;
}
}

type FormattedSectionExpression = {
text: Expression,
scale: Expression | null;
font: Expression | null;
}

export class FormattedExpression implements Expression {
type: Type;
sections: Array<FormattedSectionExpression>;

constructor(sections: Array<FormattedSectionExpression>) {
this.type = FormattedType;
this.sections = sections;
}

static parse(args: Array<mixed>, context: ParsingContext): ?Expression {
if (args.length < 3) {
return context.error(`Expected at least two arguments.`);
}

if ((args.length - 1) % 2 !== 0) {
return context.error(`Expected an even number of arguments.`);
}

const sections: Array<FormattedSectionExpression> = [];
for (let i = 1; i < args.length - 1; i += 2) {
const text = context.parse(args[i], 1, ValueType);
if (!text) return null;
const kind = text.type.kind;
if (kind !== 'string' && kind !== 'value' && kind !== 'null')
return context.error(`Formatted text type must be 'string', 'value', or 'null'.`);

const options = (args[i + 1]: any);
if (typeof options !== "object" || Array.isArray(options))
return context.error(`Format options argument must be an object.`);

let scale = null;
if (options['font-scale']) {
scale = context.parse(options['font-scale'], 1, NumberType);
if (!scale) return null;
}

let font = null;
if (options['text-font']) {
font = context.parse(options['text-font'], 1, ValueType); // Require array of strings?
if (!font) return null;
}
sections.push({text, scale, font});
}

return new FormattedExpression(sections);
}

evaluate(ctx: EvaluationContext) {
return new Formatted(
this.sections.map(section =>
new FormattedSection(
section.text.evaluate(ctx) || "",
section.scale ? section.scale.evaluate(ctx) : null,
section.font ? section.font.evaluate(ctx).join(',') : null
)
)
);
}

eachChild(fn: (Expression) => void) {
for (const section of this.sections) {
fn(section.text);
if (section.scale) {
fn(section.scale);
}
if (section.font) {
fn(section.font);
}
}
}

possibleOutputs() {
// Technically the combinatoric set of all children
// Usually, this.text will be undefined anyway
return [undefined];
}

serialize() {
const serialized = ["format"];
for (const section of this.sections) {
serialized.push(section.text.serialize());
const options = {};
if (section.scale) {
options['font-scale'] = section.scale.serialize();
}
if (section.font) {
options['text-font'] = section.font.serialize();
}
serialized.push(options);
}
return serialized;
}
}
4 changes: 3 additions & 1 deletion src/style-spec/expression/definitions/index.js
Expand Up @@ -26,6 +26,7 @@ import {
GreaterThanOrEqual
} from './comparison';
import { CollatorExpression } from './collator';
import { Formatted, FormattedExpression } from './formatted';
import Length from './length';

import type { Type } from '../types';
Expand All @@ -46,6 +47,7 @@ const expressions: ExpressionRegistry = {
'case': Case,
'coalesce': Coalesce,
'collator': CollatorExpression,
'format': FormattedExpression,
'interpolate': Interpolate,
'length': Length,
'let': Let,
Expand Down Expand Up @@ -117,7 +119,7 @@ CompoundExpression.register(expressions, {
return '';
} else if (type === 'string' || type === 'number' || type === 'boolean') {
return String(v);
} else if (v instanceof Color) {
} else if (v instanceof Color || v instanceof Formatted) {
return v.toString();
} else {
return JSON.stringify(v);
Expand Down

0 comments on commit 07583cc

Please sign in to comment.