Skip to content

Commit

Permalink
feat: Multiline text overflow ellipsis (#480)
Browse files Browse the repository at this point in the history
### Description
1. Support multiline text overflow ellipsis(webkit-line-clamp +
webkit-box-orient).

2. Fixed a bug where overflow: 'hidden' should not be inherited by child
elements.

3. Support the 'line-clamp' property, including custom block ellipsis.

I added display: block here because whether it's required by the CSS
draft or needed to implement this feature (must be different from
display: flex), it seems I have to do so. One thing I want to discuss
here is whether we can set the default display to flex, because this is
more in line with the original behavior of the browser and does not seem
to cause any problems (can pass all unit tests).



https://github.com/vercel/satori/assets/22126563/5ec006a7-9cfb-4fc4-9000-adc9fb042c17


https://github.com/vercel/satori/assets/22126563/d672942e-e2c9-423c-8bcf-23a932e7cfb3


Closes: #253

---------

Co-authored-by: Shu Ding <g@shud.in>
  • Loading branch information
LuciNyan and shuding authored May 24, 2023
1 parent 341bfab commit e479559
Show file tree
Hide file tree
Showing 12 changed files with 374 additions and 33 deletions.
1 change: 1 addition & 0 deletions src/characters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export function stringFromCode(code: string): string {

export const Space = stringFromCode('U+0020')
export const Tab = stringFromCode('U+0009')
export const HorizontalEllipsis = stringFromCode('U+2026')
2 changes: 2 additions & 0 deletions src/handler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,9 @@ export default async function handler(
style.display,
{
flex: Yoga.DISPLAY_FLEX,
block: Yoga.DISPLAY_FLEX,
none: Yoga.DISPLAY_NONE,
'-webkit-box': Yoga.DISPLAY_FLEX,
},
Yoga.DISPLAY_FLEX,
'display'
Expand Down
182 changes: 150 additions & 32 deletions src/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@ import {
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'
import { Space, Tab } from './characters.js'
import { HorizontalEllipsis, Space, Tab } from './characters.js'

const skippedWordWhenFindingMissingFont = new Set([Tab])

function shouldSkipWhenFindingMissingFont(word: string): boolean {
return skippedWordWhenFindingMissingFont.has(word)
}
Expand All @@ -49,7 +51,6 @@ export default async function* buildTextNodes(

const {
textAlign,
textOverflow,
whiteSpace,
wordBreak,
lineHeight,
Expand All @@ -74,6 +75,11 @@ export default async function* buildTextNodes(
wordBreak as string
)

const [lineLimit, blockEllipsis] = processTextOverflow(
parentStyle,
allowSoftWrap
)

const textContainer = createTextContainerNode(Yoga, textAlign as string)
parent.insertChild(textContainer, parent.getChildCount())

Expand Down Expand Up @@ -222,7 +228,7 @@ export default async function* buildTextNodes(
// @TODO: Support different writing modes.
// @TODO: Support RTL languages.
let i = 0
while (i < words.length) {
while (i < words.length && lines < lineLimit) {
let word = words[i]
const forceBreak = requiredBreaks[i]

Expand Down Expand Up @@ -373,10 +379,12 @@ export default async function* buildTextNodes(
}

if (currentWidth) {
if (lines < lineLimit) {
height += currentLineHeight
}
lines++
lineWidths.push(currentWidth)
baselines.push(currentBaselineOffset)
height += currentLineHeight
}

// @TODO: Support `line-height`.
Expand Down Expand Up @@ -482,15 +490,14 @@ export default async function* buildTextNodes(
let mergedPath = ''
let extra = ''
let skippedLine = -1
let ellipsisWidth = textOverflow === 'ellipsis' ? measureGrapheme('…') : 0
let spaceWidth = textOverflow === 'ellipsis' ? measureGrapheme(' ') : 0
let decorationLines: Record<number, null | number[]> = {}
let wordBuffer: string | null = null
let bufferedOffset = 0

for (let i = 0; i < texts.length; i++) {
// Skip whitespace and empty characters.
const layout = wordPositionInLayout[i]
const nextLayout = wordPositionInLayout[i + 1]

if (!layout) continue

Expand Down Expand Up @@ -538,34 +545,80 @@ export default async function* buildTextNodes(
]
}

if (textOverflow === 'ellipsis') {
if (lineWidths[line] > parentContainerInnerWidth) {
if (lineLimit !== Infinity) {
let _blockEllipsis = blockEllipsis
let ellipsisWidth = measureGrapheme(blockEllipsis)
if (ellipsisWidth > parentContainerInnerWidth) {
_blockEllipsis = HorizontalEllipsis
ellipsisWidth = measureGrapheme(_blockEllipsis)
}
const spaceWidth = measureGrapheme(Space)
const isNotLastLine = line < lineWidths.length - 1
const isLastAllowedLine = line + 1 === lineLimit

function calcEllipsis(baseWidth: number, _text: string) {
const chars = segment(_text, 'grapheme', locale)

let subset = ''
let resolvedWidth = 0

for (const char of chars) {
const w = baseWidth + measureGraphemeArray([subset + char])
if (
// Keep at least one character:
// > The first character or atomic inline-level element on a line
// must be clipped rather than ellipsed.
// https://drafts.csswg.org/css-overflow/#text-overflow
subset &&
w + ellipsisWidth > parentContainerInnerWidth
) {
break
}
subset += char
resolvedWidth = w
}

return {
subset,
resolvedWidth,
}
}

if (
isLastAllowedLine &&
(isNotLastLine || lineWidths[line] > parentContainerInnerWidth)
) {
if (
layout.x + width + ellipsisWidth + spaceWidth >
leftOffset + width + ellipsisWidth + spaceWidth >
parentContainerInnerWidth
) {
const chars = segment(text, 'grapheme', locale)
let subset = ''
let resolvedWidth = 0
for (const char of chars) {
const w = layout.x + measureGraphemeArray([subset + char])
if (
// Keep at least one character:
// > The first character or atomic inline-level element on a line
// must be clipped rather than ellipsed.
// https://drafts.csswg.org/css-overflow/#text-overflow
subset &&
w + ellipsisWidth > parentContainerInnerWidth
) {
break
}
subset += char
resolvedWidth = w
}
text = subset + '…'
const { subset, resolvedWidth } = calcEllipsis(leftOffset, text)

text = subset + _blockEllipsis
skippedLine = line
decorationLines[line][1] = resolvedWidth
isLastDisplayedBeforeEllipsis = true
} else if (nextLayout && nextLayout.line !== line) {
if (textAlign === 'center') {
const { subset, resolvedWidth } = calcEllipsis(leftOffset, text)

text = subset + _blockEllipsis
skippedLine = line
decorationLines[line][1] = resolvedWidth
isLastDisplayedBeforeEllipsis = true
} else {
const nextLineText = texts[i + 1]

const { subset, resolvedWidth } = calcEllipsis(
width + leftOffset,
nextLineText
)

text = text + subset + _blockEllipsis
skippedLine = line
decorationLines[line][1] = resolvedWidth
isLastDisplayedBeforeEllipsis = true
}
}
}
}
Expand All @@ -585,9 +638,9 @@ export default async function* buildTextNodes(
!text.includes(Tab) &&
!wordSeparators.includes(text) &&
texts[i + 1] &&
wordPositionInLayout[i + 1] &&
!wordPositionInLayout[i + 1].isImage &&
topOffset === wordPositionInLayout[i + 1].y &&
nextLayout &&
!nextLayout.isImage &&
topOffset === nextLayout.y &&
!isLastDisplayedBeforeEllipsis
) {
if (wordBuffer === null) {
Expand Down Expand Up @@ -648,7 +701,7 @@ export default async function* buildTextNodes(
// Get the decoration shape.
if (parentStyle.textDecorationLine) {
// If it's the last word in the current line.
if (line !== wordPositionInLayout[i + 1]?.line || skippedLine === line) {
if (line !== nextLayout?.line || skippedLine === line) {
const deco = decorationLines[line]
if (deco && !deco[2]) {
decorationShape += buildDecoration(
Expand Down Expand Up @@ -692,6 +745,10 @@ export default async function* buildTextNodes(
backgroundClipDef += shape
decorationShape = ''
}

if (isLastDisplayedBeforeEllipsis) {
break
}
}

// Embed the font as path.
Expand Down Expand Up @@ -764,6 +821,44 @@ function processTextTransform(
return content
}

function processTextOverflow(
parentStyle: Record<string, string | number>,
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)

Expand Down Expand Up @@ -863,3 +958,26 @@ 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 []
}
4 changes: 4 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,10 @@ export function isString(x: unknown): x is string {
return typeof x === 'string'
}

export function isNumber(x: unknown): x is number {
return typeof x === 'number'
}

export function isUndefined(x: unknown): x is undefined {
return toString(x) === '[object Undefined]'
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/error.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('Error', () => {
}
)
expect(result).rejects.toThrowError(
`Invalid value for CSS property "display". Allowed values: "flex" | "none". Received: "inline-block".`
`Invalid value for CSS property "display". Allowed values: "flex" | "block" | "none" | "-webkit-box". Received: "inline-block".`
)
})

Expand Down
Loading

1 comment on commit e479559

@vercel
Copy link

@vercel vercel bot commented on e479559 May 24, 2023

Choose a reason for hiding this comment

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

Please sign in to comment.