diff --git a/src/builder/shadow.ts b/src/builder/shadow.ts index f984e582..f199ca48 100644 --- a/src/builder/shadow.ts +++ b/src/builder/shadow.ts @@ -24,7 +24,14 @@ const SCALE = 1.1 export function buildDropShadow( { id, width, height }: { id: string; width: number; height: number }, - style: Record + style: { + shadowColor: string[] + shadowOffset: { + width: number + height: number + }[] + shadowRadius: number[] + } ) { if ( !style.shadowColor || diff --git a/src/font.ts b/src/font.ts index 34062eb3..cf03a395 100644 --- a/src/font.ts +++ b/src/font.ts @@ -5,15 +5,16 @@ import opentype from '@shuding/opentype.js' import { Locale, locales, isValidLocale } from './language.js' export type Weight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 -type WeightName = 'normal' | 'bold' -export type Style = 'normal' | 'italic' +export type WeightName = 'normal' | 'bold' +export type FontWeight = Weight | WeightName +export type FontStyle = 'normal' | 'italic' const SUFFIX_WHEN_LANG_NOT_SET = 'unknown' export interface FontOptions { data: Buffer | ArrayBuffer name: string weight?: Weight - style?: Style + style?: FontStyle lang?: string } @@ -21,8 +22,22 @@ export type FontEngine = { has: (s: string) => boolean baseline: (s?: string, resolvedFont?: any) => number height: (s?: string, resolvedFont?: any) => number - measure: (s: string, style: any) => number - getSVG: (s: string, style: any) => string + measure: ( + s: string, + style: { + fontSize: number + letterSpacing: number + } + ) => number + getSVG: ( + s: string, + style: { + fontSize: number + top: number + left: number + letterSpacing: number + } + ) => string } function compareFont( @@ -74,7 +89,7 @@ function compareFont( export default class FontLoader { defaultFont: opentype.Font - fonts = new Map() + fonts = new Map() constructor(fontOptions: FontOptions[]) { this.addFonts(fontOptions) } @@ -87,7 +102,7 @@ export default class FontLoader { }: { name: string weight: Weight | WeightName - style: Style + style: FontStyle }) { if (!this.fonts.has(name)) { return null @@ -175,8 +190,8 @@ export default class FontLoader { fontStyle = 'normal', }: { fontFamily?: string | string[] - fontWeight?: Weight | WeightName - fontStyle?: Style + fontWeight?: FontWeight + fontStyle?: FontStyle }, locale: Locale | undefined ): FontEngine { @@ -351,10 +366,24 @@ export default class FontLoader { (lineHeight / 1.2) ) }, - measure: (s: string, style: any) => { + measure: ( + s: string, + style: { + fontSize: number + letterSpacing: number + } + ) => { return this.measure(resolveFont, s, style) }, - getSVG: (s: string, style: any) => { + getSVG: ( + s: string, + style: { + fontSize: number + top: number + left: number + letterSpacing: number + } + ) => { return this.getSVG(resolveFont, s, style) }, } diff --git a/src/handler/expand.ts b/src/handler/expand.ts index 45be94b1..92ba9d9f 100644 --- a/src/handler/expand.ts +++ b/src/handler/expand.ts @@ -14,6 +14,7 @@ import parseTransformOrigin, { } from '../transform-origin.js' import { isString, lengthToNumber, v, splitEffects } from '../utils.js' import { MaskProperty, parseMask } from '../parser/mask.js' +import { FontWeight, FontStyle } from '../font.js' // https://react-cn.github.io/react/tips/style-props-value-px.html const optOutPx = new Set([ @@ -172,21 +173,22 @@ function handleSpecialCase( if (name === 'textShadow') { // Handle multiple text shadows if provided. value = value.toString().trim() - if (value.includes(',')) { - const shadows = splitEffects(value) - const result = {} - for (const shadow of shadows) { - const styles = getStylesForProperty('textShadow', shadow, true) - for (const k in styles) { - if (!result[k]) { - result[k] = [styles[k]] - } else { - result[k].push(styles[k]) - } + const result = {} + + const shadows = splitEffects(value) + + for (const shadow of shadows) { + const styles = getStylesForProperty('textShadow', shadow, true) + for (const k in styles) { + if (!result[k]) { + result[k] = [styles[k]] + } else { + result[k].push(styles[k]) } } - return result } + + return result } return @@ -232,6 +234,11 @@ type MainStyle = { wordBreak: string textAlign: string lineHeight: number + letterSpacing: number + + fontFamily: string | string[] + fontWeight: FontWeight + fontStyle: FontStyle borderTopWidth: number borderLeftWidth: number @@ -249,6 +256,13 @@ type MainStyle = { gap: number rowGap: number columnGap: number + + textShadowOffset: { + width: number + height: number + }[] + textShadowColor: string[] + textShadowRadius: number[] } type OtherStyle = Exclude, keyof MainStyle> @@ -391,6 +405,34 @@ export default function expand( transform[type] = len } } + + if (prop === 'textShadowRadius') { + const textShadowRadius = value as unknown as Array + + serializedStyle.textShadowRadius = textShadowRadius.map((_v) => + lengthToNumber(_v, baseFontSize, 0, inheritedStyle, false) + ) + } + + if (prop === 'textShadowOffset') { + const textShadowOffset = value as unknown as Array<{ + width: number | string + height: number | string + }> + + serializedStyle.textShadowOffset = textShadowOffset.map( + ({ height, width }) => ({ + height: lengthToNumber( + height, + baseFontSize, + 0, + inheritedStyle, + false + ), + width: lengthToNumber(width, baseFontSize, 0, inheritedStyle, false), + }) + ) + } } return serializedStyle diff --git a/src/index.ts b/src/index.ts index dc0f4afa..11161a6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ export type { FontOptions as Font, Weight as FontWeight, - Style as FontStyle, + FontStyle, } from './font.js' export type { Locale } from './language.js' diff --git a/src/layout.ts b/src/layout.ts index ae1404ff..70734036 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -16,7 +16,7 @@ import { import { SVGNodeToImage } from './handler/preprocess.js' import computeStyle from './handler/compute.js' import FontLoader from './font.js' -import buildTextNodes from './text.js' +import buildTextNodes from './text/index.js' import rect from './builder/rect.js' import { Locale, normalizeLocale } from './language.js' import { SerializedStyle } from './handler/expand.js' diff --git a/src/characters.ts b/src/text/characters.ts similarity index 100% rename from src/characters.ts rename to src/text/characters.ts diff --git a/src/text.ts b/src/text/index.ts similarity index 79% rename from src/text.ts rename to src/text/index.ts index f789c756..9204d787 100644 --- a/src/text.ts +++ b/src/text/index.ts @@ -2,26 +2,25 @@ * This module calculates the layout of a text string. Currently the only * supported inline node is text. All other nodes are using block layout. */ -import type { LayoutContext } from './layout.js' +import type { LayoutContext } from '../layout.js' import type { Yoga } from 'yoga-wasm-web' -import getYoga from './yoga/index.js' +import getYoga from '../yoga/index.js' import { v, segment, wordSeparators, buildXMLString, - splitByBreakOpportunities, isUndefined, isString, lengthToNumber, - isNumber, -} from './utils.js' -import buildText, { container } from './builder/text.js' -import { buildDropShadow } from './builder/shadow.js' -import buildDecoration from './builder/text-decoration.js' -import { Locale } from './language.js' -import { FontEngine } from './font.js' +} from '../utils.js' +import buildText, { container } from '../builder/text.js' +import { buildDropShadow } from '../builder/shadow.js' +import buildDecoration from '../builder/text-decoration.js' +import { Locale } from '../language.js' import { HorizontalEllipsis, Space, Tab } from './characters.js' +import { genMeasurer } from './measurer.js' +import { preprocess } from './processor.js' const skippedWordWhenFindingMissingFont = new Set([Tab]) @@ -51,49 +50,41 @@ export default async function* buildTextNodes( const { textAlign, - whiteSpace, - wordBreak, lineHeight, - textTransform, textWrap, fontSize, filter: cssFilter, tabSize = 8, + letterSpacing, _inheritedBackgroundClipTextPath, + flexShrink, } = parentStyle - content = processTextTransform(content, textTransform, locale) - const { - content: _content, - shouldCollapseTabsAndSpaces, + words, + requiredBreaks, allowSoftWrap, - } = processWhiteSpace(content, whiteSpace) - - const { words, requiredBreaks, allowBreakWord } = processWordBreak( - _content, - wordBreak - ) - - const [lineLimit, blockEllipsis] = processTextOverflow( - parentStyle, - allowSoftWrap - ) + allowBreakWord, + processedContent, + shouldCollapseTabsAndSpaces, + lineLimit, + blockEllipsis, + } = preprocess(content, parentStyle, locale) const textContainer = createTextContainerNode(Yoga, textAlign) parent.insertChild(textContainer, parent.getChildCount()) - if (isUndefined(parentStyle.flexShrink)) { + if (isUndefined(flexShrink)) { parent.setFlexShrink(1) } // Get the correct font according to the container style. // https://www.w3.org/TR/CSS2/visudet.html - let engine = font.getEngine(fontSize, lineHeight, parentStyle as any, locale) + let engine = font.getEngine(fontSize, lineHeight, parentStyle, locale) // Yield segments that are missing a font. const wordsMissingFont = canLoadAdditionalAssets - ? segment(_content, 'grapheme').filter( + ? segment(processedContent, 'grapheme').filter( (word) => !shouldSkipWhenFindingMissingFont(word) && !engine.has(word) ) : [] @@ -107,34 +98,21 @@ export default async function* buildTextNodes( if (wordsMissingFont.length) { // Reload the engine with additional fonts. - engine = font.getEngine(fontSize, lineHeight, parentStyle as any, locale) + engine = font.getEngine(fontSize, lineHeight, parentStyle, locale) } function isImage(s: string): boolean { return !!(graphemeImages && graphemeImages[s]) } - // We can cache the measured width of each word as the measure function will be - // called multiple times. - const measureGrapheme = genMeasureGrapheme(engine, parentStyle) - - function measureGraphemeArray(segments: string[]): number { - let width = 0 - - for (const s of segments) { - if (isImage(s)) { - width += fontSize - } else { - width += measureGrapheme(s) - } + const { measureGrapheme, measureGraphemeArray, measureText } = genMeasurer( + engine, + isImage, + { + fontSize, + letterSpacing, } - - return width - } - - function measureText(text: string): number { - return measureGraphemeArray(segment(text, 'grapheme')) - } + ) const tabWidth = isString(tabSize) ? lengthToNumber(tabSize, fontSize, 1, parentStyle) @@ -157,11 +135,11 @@ export default async function* buildTextNodes( } const { index, tabCount } = detectTabs(text) + let originWidth = 0 - let textBeforeTab = '' if (tabCount > 0) { - textBeforeTab = text.slice(0, index) + const textBeforeTab = text.slice(0, index) const textAfterTab = text.slice(index + tabCount) const textWidthBeforeTab = measureText(textBeforeTab) const offsetBeforeTab = textWidthBeforeTab + currentWidth @@ -189,7 +167,7 @@ export default async function* buildTextNodes( let lineWidths = [] let baselines = [] let lineSegmentNumber = [] - let texts = [] + let texts: string[] = [] let wordPositionInLayout: (null | { x: number y: number @@ -287,7 +265,7 @@ export default async function* buildTextNodes( if (forceBreak || willWrap) { // Start a new line, spaces can be ignored. // @TODO Lack of support for Japanese spacing - if (shouldCollapseTabsAndSpaces && word === ' ') { + if (shouldCollapseTabsAndSpaces && word === Space) { w = 0 } @@ -408,16 +386,18 @@ export default async function* buildTextNodes( } } flow(r) - measuredTextSize = { width: r, height } - return { width: Math.ceil(r), height } + const _width = Math.ceil(r) + measuredTextSize = { width: _width, height } + return { width: _width, height } } - measuredTextSize = { width, height } + const _width = Math.ceil(width) + measuredTextSize = { width: _width, height } // This may be a temporary fix, I didn't dig deep into yoga. // But when the return value of width here doesn't change (assuming the value of width is 216.9), // when we later get the width through `parent.getComputedWidth()`, sometimes it returns 216 and sometimes 217. // I'm not sure if this is a yoga bug, but it seems related to the entire page width. // So I use Math.ceil. - return { width: Math.ceil(width), height } + return { width: _width, height } }) const [x, y] = yield @@ -459,13 +439,7 @@ export default async function* buildTextNodes( let filter = '' if (parentStyle.textShadowOffset) { - let { textShadowColor, textShadowOffset, textShadowRadius } = - parentStyle as any - if (!Array.isArray(parentStyle.textShadowOffset)) { - textShadowColor = [textShadowColor] - textShadowOffset = [textShadowOffset] - textShadowRadius = [textShadowRadius] - } + const { textShadowColor, textShadowOffset, textShadowRadius } = parentStyle filter = buildDropShadow( { @@ -655,11 +629,11 @@ export default async function* buildTextNodes( const finalizedWidth = layout.width + leftOffset - finalizedLeftOffset path = engine.getSVG(finalizedSegment.replace(/(\t)+/g, ''), { - ...parentStyle, + fontSize, left: left + finalizedLeftOffset, // Since we need to pass the baseline position, add the ascender to the top. top: top + topOffset + baselineOfWord + baselineDelta, - letterSpacing: parentStyle.letterSpacing, + letterSpacing, }) wordBuffer = null @@ -790,104 +764,6 @@ export default async function* buildTextNodes( return result } -function processTextTransform( - content: string, - textTransform: string, - locale?: Locale -): string { - if (textTransform === 'uppercase') { - content = content.toLocaleUpperCase(locale) - } else if (textTransform === 'lowercase') { - content = content.toLocaleLowerCase(locale) - } else if (textTransform === 'capitalize') { - content = segment(content, 'word', locale) - // For each word... - .map((word) => { - // ...split into graphemes... - return segment(word, 'grapheme', locale) - .map((grapheme, index) => { - // ...and make the first grapheme uppercase - return index === 0 ? grapheme.toLocaleUpperCase(locale) : grapheme - }) - .join('') - }) - .join('') - } - - return content -} - -function processTextOverflow( - parentStyle: Record, - allowSoftWrap: boolean -): [number, string?] { - const { - textOverflow, - lineClamp, - WebkitLineClamp, - WebkitBoxOrient, - overflow, - display, - } = parentStyle - - if (display === 'block' && lineClamp) { - const [lineLimit, blockEllipsis = HorizontalEllipsis] = - parseLineClamp(lineClamp) - if (lineLimit) { - return [lineLimit, blockEllipsis] - } - } - - if ( - textOverflow === 'ellipsis' && - display === '-webkit-box' && - WebkitBoxOrient === 'vertical' && - isNumber(WebkitLineClamp) && - WebkitLineClamp > 0 - ) { - return [WebkitLineClamp, HorizontalEllipsis] - } - - if (textOverflow === 'ellipsis' && overflow === 'hidden' && !allowSoftWrap) { - return [1, HorizontalEllipsis] - } - - return [Infinity] -} - -function processWordBreak(content, wordBreak: string) { - const allowBreakWord = ['break-all', 'break-word'].includes(wordBreak) - - const { words, requiredBreaks } = splitByBreakOpportunities( - content, - wordBreak - ) - - return { words, requiredBreaks, allowBreakWord } -} - -function processWhiteSpace(content: string, whiteSpace: string) { - const shouldKeepLinebreak = ['pre', 'pre-wrap', 'pre-line'].includes( - whiteSpace - ) - - const shouldCollapseTabsAndSpaces = ['normal', 'nowrap', 'pre-line'].includes( - whiteSpace - ) - - const allowSoftWrap = !['pre', 'nowrap'].includes(whiteSpace) - - if (!shouldKeepLinebreak) { - content = content.replace(/\n/g, Space) - } - - if (shouldCollapseTabsAndSpaces) { - content = content.replace(/([ ]|\t)+/g, Space).trim() - } - - return { content, shouldCollapseTabsAndSpaces, allowSoftWrap } -} - function createTextContainerNode( Yoga: Yoga, textAlign: string @@ -915,24 +791,6 @@ function createTextContainerNode( return textContainer } -function genMeasureGrapheme( - engine: FontEngine, - parentStyle: any -): (s: string) => number { - const cache = new Map() - - return function measureGrapheme(s: string): number { - if (cache.has(s)) { - return cache.get(s) - } - - const width = engine.measure(s, parentStyle) - cache.set(s, width) - - return width - } -} - function detectTabs(text: string): | { index: null @@ -953,26 +811,3 @@ function detectTabs(text: string): tabCount: 0, } } - -function parseLineClamp(input: number | string): [number?, string?] { - if (typeof input === 'number') return [input] - - const regex1 = /^(\d+)\s*"(.*)"$/ - const regex2 = /^(\d+)\s*'(.*)'$/ - const match1 = regex1.exec(input) - const match2 = regex2.exec(input) - - if (match1) { - const number = +match1[1] - const text = match1[2] - - return [number, text] - } else if (match2) { - const number = +match2[1] - const text = match2[2] - - return [number, text] - } - - return [] -} diff --git a/src/text/measurer.ts b/src/text/measurer.ts new file mode 100644 index 00000000..09bf224d --- /dev/null +++ b/src/text/measurer.ts @@ -0,0 +1,54 @@ +import { FontEngine } from '../font.js' +import { segment } from '../utils.js' + +export function genMeasurer( + engine: FontEngine, + isImage: (grapheme: string) => boolean, + style: { + fontSize: number + letterSpacing: number + } +): { + measureGrapheme: (grapheme: string) => number + measureGraphemeArray: (graphemes: string[]) => number + measureText: (text: string) => number +} { + const { fontSize, letterSpacing } = style + + const cache = new Map() + + function measureGrapheme(grapheme: string): number { + if (cache.has(grapheme)) { + return cache.get(grapheme) + } + + const width = engine.measure(grapheme, { fontSize, letterSpacing }) + cache.set(grapheme, width) + + return width + } + + function measureGraphemeArray(graphemes: string[]): number { + let width = 0 + + for (const grapheme of graphemes) { + if (isImage(grapheme)) { + width += fontSize + } else { + width += measureGrapheme(grapheme) + } + } + + return width + } + + function measureText(text: string): number { + return measureGraphemeArray(segment(text, 'grapheme')) + } + + return { + measureGrapheme, + measureGraphemeArray, + measureText, + } +} diff --git a/src/text/processor.ts b/src/text/processor.ts new file mode 100644 index 00000000..17784155 --- /dev/null +++ b/src/text/processor.ts @@ -0,0 +1,178 @@ +import { Locale } from '../language.js' +import { isNumber, segment, splitByBreakOpportunities } from '../utils.js' +import { HorizontalEllipsis, Space } from './characters.js' +import { SerializedStyle } from '../handler/expand.js' + +export function preprocess( + content: string, + style: SerializedStyle, + locale?: Locale +): { + words: string[] + requiredBreaks: boolean[] + allowSoftWrap: boolean + allowBreakWord: boolean + processedContent: string + shouldCollapseTabsAndSpaces: boolean + lineLimit: number + blockEllipsis: string +} { + const { textTransform, whiteSpace, wordBreak } = style + + content = processTextTransform(content, textTransform, locale) + + const { + content: processedContent, + shouldCollapseTabsAndSpaces, + allowSoftWrap, + } = processWhiteSpace(content, whiteSpace) + + const { words, requiredBreaks, allowBreakWord } = processWordBreak( + processedContent, + wordBreak + ) + + const [lineLimit, blockEllipsis] = processTextOverflow(style, allowSoftWrap) + + return { + words, + requiredBreaks, + allowSoftWrap, + allowBreakWord, + processedContent, + shouldCollapseTabsAndSpaces, + lineLimit, + blockEllipsis, + } +} + +function processTextTransform( + content: string, + textTransform: string, + locale?: Locale +): string { + if (textTransform === 'uppercase') { + content = content.toLocaleUpperCase(locale) + } else if (textTransform === 'lowercase') { + content = content.toLocaleLowerCase(locale) + } else if (textTransform === 'capitalize') { + content = segment(content, 'word', locale) + // For each word... + .map((word) => { + // ...split into graphemes... + return segment(word, 'grapheme', locale) + .map((grapheme, index) => { + // ...and make the first grapheme uppercase + return index === 0 ? grapheme.toLocaleUpperCase(locale) : grapheme + }) + .join('') + }) + .join('') + } + + return content +} + +function processTextOverflow( + style: SerializedStyle, + allowSoftWrap: boolean +): [number, string?] { + const { + textOverflow, + lineClamp, + WebkitLineClamp, + WebkitBoxOrient, + overflow, + display, + } = style + + if (display === 'block' && lineClamp) { + const [lineLimit, blockEllipsis = HorizontalEllipsis] = + parseLineClamp(lineClamp) + if (lineLimit) { + return [lineLimit, blockEllipsis] + } + } + + if ( + textOverflow === 'ellipsis' && + display === '-webkit-box' && + WebkitBoxOrient === 'vertical' && + isNumber(WebkitLineClamp) && + WebkitLineClamp > 0 + ) { + return [WebkitLineClamp, HorizontalEllipsis] + } + + if (textOverflow === 'ellipsis' && overflow === 'hidden' && !allowSoftWrap) { + return [1, HorizontalEllipsis] + } + + return [Infinity] +} + +function processWordBreak( + content, + wordBreak: string +): { words: string[]; requiredBreaks: boolean[]; allowBreakWord: boolean } { + const allowBreakWord = ['break-all', 'break-word'].includes(wordBreak) + + const { words, requiredBreaks } = splitByBreakOpportunities( + content, + wordBreak + ) + + return { words, requiredBreaks, allowBreakWord } +} + +function processWhiteSpace( + content: string, + whiteSpace: string +): { + content: string + shouldCollapseTabsAndSpaces: boolean + allowSoftWrap: boolean +} { + const shouldKeepLinebreak = ['pre', 'pre-wrap', 'pre-line'].includes( + whiteSpace + ) + + const shouldCollapseTabsAndSpaces = ['normal', 'nowrap', 'pre-line'].includes( + whiteSpace + ) + + const allowSoftWrap = !['pre', 'nowrap'].includes(whiteSpace) + + if (!shouldKeepLinebreak) { + content = content.replace(/\n/g, Space) + } + + if (shouldCollapseTabsAndSpaces) { + content = content.replace(/([ ]|\t)+/g, Space).trim() + } + + return { content, shouldCollapseTabsAndSpaces, allowSoftWrap } +} + +function parseLineClamp(input: number | string): [number?, string?] { + if (typeof input === 'number') return [input] + + const regex1 = /^(\d+)\s*"(.*)"$/ + const regex2 = /^(\d+)\s*'(.*)'$/ + const match1 = regex1.exec(input) + const match2 = regex2.exec(input) + + if (match1) { + const number = +match1[1] + const text = match1[2] + + return [number, text] + } else if (match2) { + const number = +match2[1] + const text = match2[2] + + return [number, text] + } + + return [] +} diff --git a/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-shadow-box-shadow-should-support-multiple-box-shadows-1-snap.png b/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-shadow-box-shadow-should-support-multiple-box-shadows-1-snap.png deleted file mode 100644 index 4931a5bd..00000000 Binary files a/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-shadow-box-shadow-should-support-multiple-box-shadows-1-snap.png and /dev/null differ diff --git a/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-shadow-box-shadow-should-support-multiple-text-shadows-1-snap.png b/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-shadow-box-shadow-should-support-multiple-text-shadows-1-snap.png new file mode 100644 index 00000000..e796cab6 Binary files /dev/null and b/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-shadow-box-shadow-should-support-multiple-text-shadows-1-snap.png differ diff --git a/test/shadow.test.tsx b/test/shadow.test.tsx index df9cdb6a..95b2c856 100644 --- a/test/shadow.test.tsx +++ b/test/shadow.test.tsx @@ -195,7 +195,7 @@ describe('Shadow', () => { expect(toImage(svg, 100)).toMatchImageSnapshot() }) - it('should support multiple box shadows', async () => { + it('should support multiple text shadows', async () => { const svg = await satori(
{ width: 100, height: 100, fontSize: 40, - textShadow: '2px 2px red, 4px 4px blue', + textShadow: '2px 2px 2px red, 4px .25rem .25rem blue', }} > Hello