diff --git a/CHANGELOG.md b/CHANGELOG.md index fa82a46fca..270e4fb553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### ✨ Features and improvements +- Add support for `vertical-align` in `format` expression ([specification](https://maplibre.org/maplibre-style-spec/expressions/#format))([#5043](https://github.com/maplibre/maplibre-gl-js/pull/5043)). - _...Add new stuff here..._ ### 🐞 Bug fixes diff --git a/src/symbol/shaping.ts b/src/symbol/shaping.ts index 567f7b526d..717a7da1a5 100644 --- a/src/symbol/shaping.ts +++ b/src/symbol/shaping.ts @@ -14,7 +14,7 @@ import {TextFit} from '../style/style_image'; import type {ImagePosition} from '../render/image_atlas'; import {IMAGE_PADDING} from '../render/image_atlas'; import type {Rect, GlyphPosition} from '../render/glyph_atlas'; -import {type Formatted, type FormattedSection} from '@maplibre/maplibre-gl-style-spec'; +import {type Formatted, type FormattedSection, type VerticalAlign} from '@maplibre/maplibre-gl-style-spec'; enum WritingMode { none = 0, @@ -58,6 +58,18 @@ export type Shaping = { verticalizable: boolean; }; +type ShapingSectionAttributes = { + rect: Rect | null; + metrics: GlyphMetrics; + baselineOffset: number; + imageOffset?: number; +}; + +type LineShapingSize = { + verticalLineContentWidth: number; + horizontalLineContentHeight: number; +}; + function isEmpty(positionedLines: Array) { for (const line of positionedLines) { if (line.positionedGlyphs.length !== 0) { @@ -81,23 +93,28 @@ class SectionOptions { fontStack: string; // Image options imageName: string | null; + // Common options + verticalAlign: VerticalAlign; constructor() { this.scale = 1.0; this.fontStack = ''; this.imageName = null; + this.verticalAlign = 'bottom'; } - static forText(scale: number | null, fontStack: string) { + static forText(scale: number | null, fontStack: string, verticalAlign: VerticalAlign | null) { const textOptions = new SectionOptions(); textOptions.scale = scale || 1; textOptions.fontStack = fontStack; + textOptions.verticalAlign = verticalAlign || 'bottom'; return textOptions; } - static forImage(imageName: string) { + static forImage(imageName: string, verticalAlign: VerticalAlign | null) { const imageOptions = new SectionOptions(); imageOptions.imageName = imageName; + imageOptions.verticalAlign = verticalAlign || 'bottom'; return imageOptions; } @@ -182,9 +199,28 @@ class TaggedString { return this.sectionIndex.reduce((max, index) => Math.max(max, this.sections[index].scale), 0); } + getMaxImageSize(imagePositions: {[_: string]: ImagePosition}): { + maxImageWidth: number; + maxImageHeight: number; + } { + let maxImageWidth = 0; + let maxImageHeight = 0; + for (let i = 0; i < this.length(); i++) { + const section = this.getSection(i); + if (section.imageName) { + const imagePosition = imagePositions[section.imageName]; + if (!imagePosition) continue; + const size = imagePosition.displaySize; + maxImageWidth = Math.max(maxImageWidth, size[0]); + maxImageHeight = Math.max(maxImageHeight, size[1]); + } + } + return {maxImageWidth, maxImageHeight}; + } + addTextSection(section: FormattedSection, defaultFontStack: string) { this.text += section.text; - this.sections.push(SectionOptions.forText(section.scale, section.fontStack || defaultFontStack)); + this.sections.push(SectionOptions.forText(section.scale, section.fontStack || defaultFontStack, section.verticalAlign)); const index = this.sections.length - 1; for (let i = 0; i < section.text.length; ++i) { this.sectionIndex.push(index); @@ -205,7 +241,7 @@ class TaggedString { } this.text += String.fromCharCode(nextImageSectionCharCode); - this.sections.push(SectionOptions.forImage(imageName)); + this.sections.push(SectionOptions.forImage(imageName, section.verticalAlign)); this.sectionIndex.push(this.sections.length - 1); } @@ -585,6 +621,68 @@ function getAnchorAlignment(anchor: SymbolAnchor) { return {horizontalAlign, verticalAlign}; } +function calculateLineContentSize( + imagePositions: {[_: string]: ImagePosition}, + line: TaggedString, + layoutTextSizeFactor: number +): LineShapingSize { + const maxGlyphSize = line.getMaxScale() * ONE_EM; + const {maxImageWidth, maxImageHeight} = line.getMaxImageSize(imagePositions); + + const horizontalLineContentHeight = Math.max(maxGlyphSize, maxImageHeight * layoutTextSizeFactor); + const verticalLineContentWidth = Math.max(maxGlyphSize, maxImageWidth * layoutTextSizeFactor); + + return {verticalLineContentWidth, horizontalLineContentHeight}; +} + +function getVerticalAlignFactor( + verticalAlign: VerticalAlign +) { + switch (verticalAlign) { + case 'top': + return 0; + case 'center': + return 0.5; + default: + return 1; + } +} + +function getRectAndMetrics( + glyphPosition: GlyphPosition, + glyphMap: { + [_: string]: { + [_: number]: StyleGlyph; + }; + }, + section: SectionOptions, + codePoint: number +): GlyphPosition | null { + if (glyphPosition && glyphPosition.rect) { + return glyphPosition; + } + + const glyphs = glyphMap[section.fontStack]; + const glyph = glyphs && glyphs[codePoint]; + if (!glyph) return null; + + const metrics = glyph.metrics; + return {rect: null, metrics}; +} + +function isLineVertical( + writingMode: WritingMode.horizontal | WritingMode.vertical, + allowVerticalPlacement: boolean, + codePoint: number +): boolean { + return !(writingMode === WritingMode.horizontal || + // Don't verticalize glyphs that have no upright orientation if vertical placement is disabled. + (!allowVerticalPlacement && !charHasUprightVerticalOrientation(codePoint)) || + // If vertical placement is enabled, don't verticalize glyphs that + // are from complex text layout script, or whitespaces. + (allowVerticalPlacement && (whitespace[codePoint] || charInComplexShapingScript(codePoint)))); +} + function shapeLines(shaping: Shaping, glyphMap: { [_: string]: { @@ -607,7 +705,7 @@ function shapeLines(shaping: Shaping, layoutTextSizeThisZoom: number) { let x = 0; - let y = SHAPING_DEFAULT_OFFSET; + let y = 0; let maxLineLength = 0; let maxLineHeight = 0; @@ -615,17 +713,17 @@ function shapeLines(shaping: Shaping, const justify = textJustify === 'right' ? 1 : textJustify === 'left' ? 0 : 0.5; + const layoutTextSizeFactor = ONE_EM / layoutTextSizeThisZoom; let lineIndex = 0; for (const line of lines) { line.trim(); const lineMaxScale = line.getMaxScale(); - const maxLineOffset = (lineMaxScale - 1) * ONE_EM; const positionedLine = {positionedGlyphs: [], lineOffset: 0}; shaping.positionedLines[lineIndex] = positionedLine; const positionedGlyphs = positionedLine.positionedGlyphs; - let lineOffset = 0.0; + let imageOffset = 0.0; if (!line.length()) { y += lineHeight; // Still need a line feed after empty line @@ -633,78 +731,50 @@ function shapeLines(shaping: Shaping, continue; } + const lineShapingSize = calculateLineContentSize(imagePositions, line, layoutTextSizeFactor); + for (let i = 0; i < line.length(); i++) { const section = line.getSection(i); const sectionIndex = line.getSectionIndex(i); const codePoint = line.getCharCode(i); - let baselineOffset = 0.0; - let metrics = null; - let rect = null; - let imageName = null; - let verticalAdvance = ONE_EM; - const vertical = !(writingMode === WritingMode.horizontal || - // Don't verticalize glyphs that have no upright orientation if vertical placement is disabled. - (!allowVerticalPlacement && !charHasUprightVerticalOrientation(codePoint)) || - // If vertical placement is enabled, don't verticalize glyphs that - // are from complex text layout script, or whitespaces. - (allowVerticalPlacement && (whitespace[codePoint] || charInComplexShapingScript(codePoint)))); + const vertical = isLineVertical(writingMode, allowVerticalPlacement, codePoint); + + let sectionAttributes: ShapingSectionAttributes; if (!section.imageName) { - const positions = glyphPositions[section.fontStack]; - const glyphPosition = positions && positions[codePoint]; - if (glyphPosition && glyphPosition.rect) { - rect = glyphPosition.rect; - metrics = glyphPosition.metrics; - } else { - const glyphs = glyphMap[section.fontStack]; - const glyph = glyphs && glyphs[codePoint]; - if (!glyph) continue; - metrics = glyph.metrics; - } - - // We don't know the baseline, but since we're laying out - // at 24 points, we can calculate how much it will move when - // we scale up or down. - baselineOffset = (lineMaxScale - section.scale) * ONE_EM; + sectionAttributes = shapeTextSection(section, codePoint, vertical, lineShapingSize, glyphMap, glyphPositions); + if (!sectionAttributes) continue; } else { - const imagePosition = imagePositions[section.imageName]; - if (!imagePosition) continue; - imageName = section.imageName; - shaping.iconsInText = shaping.iconsInText || true; - rect = imagePosition.paddedRect; - const size = imagePosition.displaySize; + shaping.iconsInText = true; // If needed, allow to set scale factor for an image using // alias "image-scale" that could be alias for "font-scale" // when FormattedSection is an image section. - section.scale = section.scale * ONE_EM / layoutTextSizeThisZoom; - - metrics = {width: size[0], - height: size[1], - left: IMAGE_PADDING, - top: -GLYPH_PBF_BORDER, - advance: vertical ? size[1] : size[0]}; - - // Difference between one EM and an image size. - // Aligns bottom of an image to a baseline level. - const imageOffset = ONE_EM - size[1] * section.scale; - baselineOffset = maxLineOffset + imageOffset; - verticalAdvance = metrics.advance; - - // Difference between height of an image and one EM at max line scale. - // Pushes current line down if an image size is over 1 EM at max line scale. - const offset = vertical ? size[0] * section.scale - ONE_EM * lineMaxScale : - size[1] * section.scale - ONE_EM * lineMaxScale; - if (offset > 0 && offset > lineOffset) { - lineOffset = offset; - } + section.scale = section.scale * layoutTextSizeFactor; + + sectionAttributes = shapeImageSection(section, vertical, lineMaxScale, lineShapingSize, imagePositions); + if (!sectionAttributes) continue; + imageOffset = Math.max(imageOffset, sectionAttributes.imageOffset); } + const {rect, metrics, baselineOffset} = sectionAttributes; + positionedGlyphs.push({ + glyph: codePoint, + imageName: section.imageName, + x, + y: y + baselineOffset + SHAPING_DEFAULT_OFFSET, + vertical, + scale: section.scale, + fontStack: section.fontStack, + sectionIndex, + metrics, + rect + }); + if (!vertical) { - positionedGlyphs.push({glyph: codePoint, imageName, x, y: y + baselineOffset, vertical, scale: section.scale, fontStack: section.fontStack, sectionIndex, metrics, rect}); x += metrics.advance * section.scale + spacing; } else { shaping.verticalizable = true; - positionedGlyphs.push({glyph: codePoint, imageName, x, y: y + baselineOffset, vertical, scale: section.scale, fontStack: section.fontStack, sectionIndex, metrics, rect}); + const verticalAdvance = section.imageName ? metrics.advance : ONE_EM; x += verticalAdvance * section.scale + spacing; } } @@ -713,35 +783,107 @@ function shapeLines(shaping: Shaping, if (positionedGlyphs.length !== 0) { const lineLength = x - spacing; maxLineLength = Math.max(lineLength, maxLineLength); - justifyLine(positionedGlyphs, 0, positionedGlyphs.length - 1, justify, lineOffset); + justifyLine(positionedGlyphs, 0, positionedGlyphs.length - 1, justify); } x = 0; - const currentLineHeight = lineHeight * lineMaxScale + lineOffset; - positionedLine.lineOffset = Math.max(lineOffset, maxLineOffset); + const maxLineOffset = (lineMaxScale - 1) * ONE_EM; + positionedLine.lineOffset = Math.max(imageOffset, maxLineOffset); + const currentLineHeight = lineHeight * lineMaxScale + imageOffset; y += currentLineHeight; maxLineHeight = Math.max(currentLineHeight, maxLineHeight); ++lineIndex; } // Calculate the bounding box and justify / align text block. - const height = y - SHAPING_DEFAULT_OFFSET; const {horizontalAlign, verticalAlign} = getAnchorAlignment(textAnchor); - align(shaping.positionedLines, justify, horizontalAlign, verticalAlign, maxLineLength, maxLineHeight, lineHeight, height, lines.length); + align(shaping.positionedLines, justify, horizontalAlign, verticalAlign, maxLineLength, maxLineHeight, lineHeight, y, lines.length); - shaping.top += -verticalAlign * height; - shaping.bottom = shaping.top + height; + // Calculate the bounding box + // shaping.top & shaping.left already include text offset (text-radial-offset or text-offset) + shaping.top += -verticalAlign * y; + shaping.bottom = shaping.top + y; shaping.left += -horizontalAlign * maxLineLength; shaping.right = shaping.left + maxLineLength; } +function shapeTextSection( + section: SectionOptions, + codePoint: number, + vertical: boolean, + lineShapingSize: LineShapingSize, + glyphMap: { + [_: string]: { + [_: number]: StyleGlyph; + }; + }, + glyphPositions: { + [_: string]: { + [_: number]: GlyphPosition; + }; + }, +): ShapingSectionAttributes | null { + const positions = glyphPositions[section.fontStack]; + const glyphPosition = positions && positions[codePoint]; + + const rectAndMetrics = getRectAndMetrics(glyphPosition, glyphMap, section, codePoint); + + if (rectAndMetrics === null) return null; + + let baselineOffset: number; + if (vertical) { + baselineOffset = lineShapingSize.verticalLineContentWidth - section.scale * ONE_EM; + } else { + const verticalAlignFactor = getVerticalAlignFactor(section.verticalAlign); + baselineOffset = (lineShapingSize.horizontalLineContentHeight - section.scale * ONE_EM) * verticalAlignFactor; + } + + return { + rect: rectAndMetrics.rect, + metrics: rectAndMetrics.metrics, + baselineOffset + }; +} + +function shapeImageSection( + section: SectionOptions, + vertical: boolean, + lineMaxScale: number, + lineShapingSize: LineShapingSize, + imagePositions: {[_: string]: ImagePosition}, +): ShapingSectionAttributes | null { + const imagePosition = imagePositions[section.imageName]; + if (!imagePosition) return null; + const rect = imagePosition.paddedRect; + const size = imagePosition.displaySize; + + const metrics = {width: size[0], + height: size[1], + left: IMAGE_PADDING, + top: -GLYPH_PBF_BORDER, + advance: vertical ? size[1] : size[0]}; + + let baselineOffset: number; + if (vertical) { + baselineOffset = lineShapingSize.verticalLineContentWidth - size[1] * section.scale; + } else { + const verticalAlignFactor = getVerticalAlignFactor(section.verticalAlign); + baselineOffset = (lineShapingSize.horizontalLineContentHeight - size[1] * section.scale) * verticalAlignFactor; + } + + // Difference between height of an image and one EM at max line scale. + // Pushes current line down if an image size is over 1 EM at max line scale. + const imageOffset = (vertical ? size[0] : size[1]) * section.scale - ONE_EM * lineMaxScale; + + return {rect, metrics, baselineOffset, imageOffset}; +} + // justify right = 1, left = 0, center = 0.5 function justifyLine(positionedGlyphs: Array, start: number, end: number, - justify: 1 | 0 | 0.5, - lineOffset: number) { - if (!justify && !lineOffset) + justify: 1 | 0 | 0.5) { + if (justify === 0) return; const lastPositionedGlyph = positionedGlyphs[end]; @@ -750,10 +892,12 @@ function justifyLine(positionedGlyphs: Array, for (let j = start; j <= end; j++) { positionedGlyphs[j].x -= lineIndent; - positionedGlyphs[j].y += lineOffset; } } +/** + * Aligns the lines based on horizontal and vertical alignment. + */ function align(positionedLines: Array, justify: number, horizontalAlign: number, @@ -769,7 +913,7 @@ function align(positionedLines: Array, if (maxLineHeight !== lineHeight) { shiftY = -blockHeight * verticalAlign - SHAPING_DEFAULT_OFFSET; } else { - shiftY = (-verticalAlign * lineCount + 0.5) * lineHeight; + shiftY = -verticalAlign * lineCount * lineHeight + 0.5 * lineHeight; } for (const line of positionedLines) { diff --git a/test/build/min.test.ts b/test/build/min.test.ts index 3d61808d9f..e2e31841fc 100644 --- a/test/build/min.test.ts +++ b/test/build/min.test.ts @@ -38,7 +38,7 @@ describe('test min build', () => { const decreaseQuota = 4096; // feel free to update this value after you've checked that it has changed on purpose :-) - const expectedBytes = 908628; + const expectedBytes = 909795; expect(actualBytes).toBeLessThan(expectedBytes + increaseQuota); expect(actualBytes).toBeGreaterThan(expectedBytes - decreaseQuota); diff --git a/test/integration/render/tests/text-field/formatted-vertical-align-line/expected.png b/test/integration/render/tests/text-field/formatted-vertical-align-line/expected.png new file mode 100644 index 0000000000..ce55a4688c Binary files /dev/null and b/test/integration/render/tests/text-field/formatted-vertical-align-line/expected.png differ diff --git a/test/integration/render/tests/text-field/formatted-vertical-align-line/style.json b/test/integration/render/tests/text-field/formatted-vertical-align-line/style.json new file mode 100644 index 0000000000..1ec254a3d5 --- /dev/null +++ b/test/integration/render/tests/text-field/formatted-vertical-align-line/style.json @@ -0,0 +1,452 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 512, + "width": 512 + } + }, + "center": [ 0, 0 ], + "zoom": 1, + "sources": { + "line-top": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -63, + 41 + ], + [ + -57, + 44 + ], + [ + -51, + 45.8 + ], + [ + -45, + 47 + ], + [ + -39, + 48.2 + ], + [ + -33, + 49.1 + ], + [ + -27, + 49.7 + ], + [ + -21, + 50 + ], + [ + -15, + 50.3 + ], + [ + -9, + 50.9 + ], + [ + -3, + 51.8 + ], + [ + 3, + 53 + ], + [ + 9, + 54.2 + ], + [ + 15, + 56 + ], + [ + 21, + 57 + ], + [ + 27, + 58 + ], + [ + 33, + 58 + ], + [ + 39, + 59 + ], + [ + 45, + 59 + ], + [ + 51, + 58 + ], + [ + 57, + 57 + ], + [ + 63, + 55 + ] + ] + }, + "properties": {} + } + ] + } + }, + "line-center": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -63, + -9 + ], + [ + -57, + -6 + ], + [ + -51, + -4.2 + ], + [ + -45, + -3 + ], + [ + -39, + -1.8 + ], + [ + -33, + -0.9 + ], + [ + -27, + -0.3 + ], + [ + -21, + 0 + ], + [ + -15, + 0.3 + ], + [ + -9, + 0.9 + ], + [ + -3, + 1.8 + ], + [ + 3, + 3 + ], + [ + 9, + 4.2 + ], + [ + 15, + 6 + ], + [ + 21, + 7 + ], + [ + 27, + 8 + ], + [ + 33, + 8 + ], + [ + 39, + 9 + ], + [ + 45, + 9 + ], + [ + 51, + 8 + ], + [ + 57, + 7 + ], + [ + 63, + 5 + ] + ] + }, + "properties": {} + } + ] + } + }, + "line-bottom": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -63, + -59 + ], + [ + -57, + -56 + ], + [ + -51, + -54.2 + ], + [ + -45, + -53 + ], + [ + -39, + -51.8 + ], + [ + -33, + -50.9 + ], + [ + -27, + -50.3 + ], + [ + -21, + -50 + ], + [ + -15, + -49.7 + ], + [ + -9, + -49.1 + ], + [ + -3, + -48.2 + ], + [ + 3, + -47 + ], + [ + 9, + -45.8 + ], + [ + 15, + -44 + ], + [ + 21, + -43 + ], + [ + 27, + -42 + ], + [ + 33, + -42 + ], + [ + 39, + -41 + ], + [ + 45, + -41 + ], + [ + 51, + -42 + ], + [ + 57, + -43 + ], + [ + 63, + -45 + ] + ] + }, + "properties": {} + } + ] + } + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "sprite": "local://sprites/emerald", + "layers": [ + { + "id": "line-top", + "type": "line", + "source": "line-top", + "layout": {}, + "paint": { + "line-color": "#000000", + "line-width": 2 + } + }, + { + "id": "line-center", + "type": "line", + "source": "line-center", + "layout": {}, + "paint": { + "line-color": "#000000", + "line-width": 2 + } + }, + { + "id": "line-bottom", + "type": "line", + "source": "line-bottom", + "layout": {}, + "paint": { + "line-color": "#000000", + "line-width": 2 + } + }, + { + "id": "line-label-top", + "type": "symbol", + "source": "line-top", + "layout": { + "text-field": [ + "format", + ["image", "government_icon"], + { "vertical-align": "top" }, + " ", + {}, + "Ag", + { "font-scale": 1.2, "vertical-align": "top" }, + " ", + {}, + "Top", + { "font-scale": 0.8, "vertical-align": "top" } + ], + "text-size": 48, + "symbol-placement": "line-center", + "symbol-spacing": 100, + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "line-label-center", + "type": "symbol", + "source": "line-center", + "layout": { + "text-field": [ + "format", + ["image", "government_icon"], + { "vertical-align": "center" }, + " ", + {}, + "Ag", + { "font-scale": 1.2, "vertical-align": "center" }, + " ", + {}, + "Center", + { "font-scale": 0.8, "vertical-align": "center" } + ], + "text-size": 48, + "symbol-placement": "line-center", + "symbol-spacing": 100, + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "line-label-bottom", + "type": "symbol", + "source": "line-bottom", + "layout": { + "text-field": [ + "format", + ["image", "government_icon"], + { "vertical-align": "bottom" }, + " ", + {}, + "Ag", + { "font-scale": 1.2, "vertical-align": "bottom" }, + " ", + {}, + "Bottom", + { "font-scale": 0.8, "vertical-align": "bottom" } + ], + "text-size": 48, + "symbol-placement": "line-center", + "symbol-spacing": 100, + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + }, + "paint": { + "text-color": "#000000" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/text-field/formatted-vertical-align-vertical-text/expected.png b/test/integration/render/tests/text-field/formatted-vertical-align-vertical-text/expected.png new file mode 100644 index 0000000000..3355890fe0 Binary files /dev/null and b/test/integration/render/tests/text-field/formatted-vertical-align-vertical-text/expected.png differ diff --git a/test/integration/render/tests/text-field/formatted-vertical-align-vertical-text/style.json b/test/integration/render/tests/text-field/formatted-vertical-align-vertical-text/style.json new file mode 100644 index 0000000000..af4e8491b7 --- /dev/null +++ b/test/integration/render/tests/text-field/formatted-vertical-align-vertical-text/style.json @@ -0,0 +1,45 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 512, + "width": 512 + } + }, + "center": [ 0, 0 ], + "zoom": 0, + "sources": { + "point": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [0, 0] + } + } + ] + } + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "sprite": "local://sprites/emerald", + "layers": [ + { + "id": "vertical", + "type": "symbol", + "source": "point", + "layout": { + "text-writing-mode": ["vertical"], + "text-field": ["format", "H", { "vertical-align": "top" }, "H", { "vertical-align": "center" }, "H", ["image", "interstate_1"], { "vertical-align": "center" }, ["image", "government_icon"], { "vertical-align": "top" }, ["image", "government_icon"], { "vertical-align": "center" }, ["image", "government_icon"], "ッ",{"font-scale": 1.8}], + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/text-field/formatted-vertical-align/expected.png b/test/integration/render/tests/text-field/formatted-vertical-align/expected.png new file mode 100644 index 0000000000..9e3223aca6 Binary files /dev/null and b/test/integration/render/tests/text-field/formatted-vertical-align/expected.png differ diff --git a/test/integration/render/tests/text-field/formatted-vertical-align/style.json b/test/integration/render/tests/text-field/formatted-vertical-align/style.json new file mode 100644 index 0000000000..563f582d2e --- /dev/null +++ b/test/integration/render/tests/text-field/formatted-vertical-align/style.json @@ -0,0 +1,119 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 512, + "width": 512 + } + }, + "center": [ 0, 0 ], + "zoom": 0, + "sources": { + "point": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [0, 0] + } + } + ] + } + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "sprite": "local://sprites/emerald", + "layers": [ + { + "id": "point-label-top", + "type": "symbol", + "source": "point", + "layout": { + "text-field": [ + "format", + ["image", "government_icon"], + { "vertical-align": "top" }, + " ", + {}, + "ÓÑt yg", + { "font-scale": 1.2, "vertical-align": "top" }, + " ", + {}, + "TOP", + { "font-scale": 0.8, "vertical-align": "top" } + ], + "text-size": 48, + "text-offset": [0, -2], + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "point-label-center", + "type": "symbol", + "source": "point", + "layout": { + "text-field": [ + "format", + ["image", "government_icon"], + { "vertical-align": "center" }, + " ", + {}, + "ÓÑt yg", + { "font-scale": 1.2, "vertical-align": "center" }, + " ", + {}, + "CENTER", + { "font-scale": 0.8, "vertical-align": "center" } + ], + "text-size": 48, + "text-offset": [0, 0], + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "point-label-bottom", + "type": "symbol", + "source": "point", + "layout": { + "text-field": [ + "format", + ["image", "government_icon"], + { "vertical-align": "bottom" }, + " ", + {}, + "ÓÑt yg", + { "font-scale": 1.2, "vertical-align": "bottom" }, + " ", + {}, + "BOTTOM", + { "font-scale": 0.8, "vertical-align": "bottom" } + ], + "text-size": 48, + "text-offset": [0, 2], + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + }, + "paint": { + "text-color": "#000000" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/symbol-shaping/shaping.test.ts b/test/integration/symbol-shaping/shaping.test.ts index e226edf4b9..090f993a03 100644 --- a/test/integration/symbol-shaping/shaping.test.ts +++ b/test/integration/symbol-shaping/shaping.test.ts @@ -2,7 +2,7 @@ import {describe, test, expect} from 'vitest'; import fs from 'fs'; import path from 'path'; import {WritingMode, shapeText, type Shaping} from '../../../src/symbol/shaping'; -import {ResolvedImage, Formatted, FormattedSection} from '@maplibre/maplibre-gl-style-spec'; +import {ResolvedImage, Formatted, FormattedSection, type VerticalAlign} from '@maplibre/maplibre-gl-style-spec'; import {ImagePosition} from '../../../src/render/image_atlas'; import type {StyleImage} from '../../../src/style/style_image'; import type {StyleGlyph} from '../../../src/style/style_glyph'; @@ -22,12 +22,12 @@ if (typeof process !== 'undefined' && process.env !== undefined) { UPDATE = !!process.env.UPDATE; } -function sectionForImage(name: string) { - return new FormattedSection('', ResolvedImage.fromString(name), null, null, null, null); +function sectionForImage(name: string, verticalAlign?: VerticalAlign) { + return new FormattedSection('', ResolvedImage.fromString(name), null, null, null, verticalAlign); } -function sectionForText(name: string, scale?: number) { - return new FormattedSection(name, null, scale, null, null, null); +function sectionForText(name: string, scale?: number, verticalAlign?: VerticalAlign) { + return new FormattedSection(name, null, scale, null, null, verticalAlign); } describe('shaping', () => { @@ -160,4 +160,32 @@ describe('shaping', () => { if (UPDATE) fs.writeFileSync(path.resolve(__dirname, './tests/text-shaping-images-vertical.json'), JSON.stringify(shaped, null, 2)); expect(shaped).toEqual(expectedImagesVertical); }); + + test('text vertical align', () => { + const shaped = shapeText(new Formatted([ + sectionForText('A', 3), + sectionForText('A', 1, 'top'), + sectionForText('A', 1, 'center'), + sectionForText('A', 1, 'bottom'), + ]), glyphs, glyphPositions, images, fontStack, 5 * oneEm, oneEm, 'center', 'center', 0, [0, 0], WritingMode.horizontal, false, layoutTextSize, layoutTextSizeThisZoom); + const positionedGlyphs = (shaped as Shaping).positionedLines[0].positionedGlyphs; + expect(positionedGlyphs[0].y).toBe(-36); + expect(positionedGlyphs[1].y).toBe(-36); + expect(positionedGlyphs[2].y).toBe(-12); + expect(positionedGlyphs[3].y).toBe(12); + }); + + test('image vertical align', () => { + const shaped = shapeText(new Formatted([ + sectionForText('A', 3), + sectionForImage('square', 'top'), + sectionForImage('square', 'center'), + sectionForImage('square', 'bottom'), + ]), glyphs, glyphPositions, images, fontStack, 5 * oneEm, oneEm, 'center', 'center', 0, [0, 0], WritingMode.horizontal, false, layoutTextSize, layoutTextSizeThisZoom); + const positionedGlyphs = (shaped as Shaping).positionedLines[0].positionedGlyphs; + expect(positionedGlyphs[0].y).toBe(-36); + expect(positionedGlyphs[1].y).toBe(-36); + expect(positionedGlyphs[2].y).toBe(-10.5); + expect(positionedGlyphs[3].y).toBe(15); + }); });