Skip to content

Commit

Permalink
feat(troika-three-text): simple bidi layout support, using explicit L…
Browse files Browse the repository at this point in the history
…RO/RLO/PDF chars only
  • Loading branch information
lojjic committed Apr 7, 2021
1 parent 3e98ca7 commit d511655
Show file tree
Hide file tree
Showing 3 changed files with 132 additions and 65 deletions.
6 changes: 5 additions & 1 deletion packages/troika-examples/text-rtl/TextExample.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const FONTS = {
'Cairo': 'https://fonts.gstatic.com/s/cairo/v10/SLXGc1nY6HkvamIl.woff',
'Lemonada': 'https://fonts.gstatic.com/s/lemonada/v12/0QI-MXFD9oygTWy_R-FFlwV-bgfR7QJGeut2mg.woff',
'Mirza': 'https://fonts.gstatic.com/s/mirza/v10/co3ImWlikiN5Eure.woff',
//'Reem Kufi': 'https://fonts.gstatic.com/s/reemkufi/v10/2sDcZGJLip7W2J7v7wQDbA.woff',
'Scheherazade': 'https://fonts.gstatic.com/s/scheherazade/v20/YA9Ur0yF4ETZN60keViq1kQgtA.woff',
'Roboto': 'https://fonts.gstatic.com/s/roboto/v18/KFOmCnqEu92Fr1Mu4mxM.woff',
'Noto Sans': 'https://fonts.gstatic.com/s/notosans/v7/o-0IIpQlx3QUlC5A4PNr5TRG.woff',
Expand All @@ -37,7 +38,10 @@ const TEXTS = {

Three: 'وللناس مذاهبهم المختلفة في التخفف من الهموم والتخلص من الأحزان، فمنهم من يتسلى عنها بالقراءة، ومنهم من يتسلى عنها بالرياضة، ومنهم من يتسلى عنها بالاستماع للموسيقى والغناء، ومنهم من يذهب غير هذه المذاهب كلها لينسى نفسه ويفر من حياته الحاضرة وما تثقله به من الأعباء.',

'BiDi 1': `ان عدة الشهور عند الله اثنا عشر شهرا في كتاب الله يوم خلق السماوات والارض \u202DSOME LATIN TEXT HERE\u202C منها اربعة حرم ذلك الدين القيم فلاتظلموا فيهن انفسكم وقاتلوا المشركين كافة كما يقاتلونكم كافة واعلموا ان الله مع المتقين `,

[CUSTOM_LBL]: 'نص'
//[CUSTOM_LBL]: 'abc def ghi jkl mno pqr stu vwx yz \u202EABC DEF GHI JKL MNO \u202Dabc def ghi jkl \u202E123 456 7890\u202C mno pqr stu vwx yz\u202C PQR STU VWX YZ\u202C abc def ghi jkl mno pqr stu vwx yz'
}

const TEXTURE = new TextureLoader().load('shader-anim/lava.jpg')
Expand Down Expand Up @@ -330,7 +334,7 @@ class TextExample extends React.Component {

{ state.text === CUSTOM_LBL ? (
<textarea
style={{position:'absolute', left:280, top:0, width:300, height: 120}}
style={{position:'absolute', left:280, top:0, width:300, height: 120, fontFamily: 'serif'}}
value={TEXTS[CUSTOM_LBL]}
onChange={e => {
TEXTS[CUSTOM_LBL] = e.target.value
Expand Down
43 changes: 29 additions & 14 deletions packages/troika-three-text/src/selectionUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,25 +69,40 @@ export function getSelectionRects(textRenderInfo, start, end) {
start = Math.max(start, 0)
end = Math.min(end, caretPositions.length + 1)

// Collect into one rect per row
let rows = new Map()
// Build list of rects, expanding the current rect for all characters in a run and starting
// a new rect whenever reaching a new line or a new bidi direction
rects = []
let currentRect = null
for (let i = start; i < end; i++) {
const x1 = caretPositions[i * 3]
const x2 = caretPositions[i * 3 + 1]
const y = caretPositions[i * 3 + 2]
let row = rows.get(y)
if (!row) {
row = {left: Math.min(x1, x2), right: Math.max(x1, x2), bottom: y, top: y + caretHeight}
rows.set(y, row)
} else {
row.left = Math.min(row.left, x1, x2)
row.right = Math.max(row.right, x2, x2)
const left = Math.min(x1, x2)
const right = Math.max(x1, x2)
const bottom = caretPositions[i * 3 + 2]
if (!currentRect || bottom !== currentRect.bottom || left > currentRect.right || right < currentRect.left) {
currentRect = {
left: Infinity,
right: -Infinity,
bottom: bottom,
top: bottom + caretHeight
}
rects.push(currentRect)
}
currentRect.left = Math.min(left, currentRect.left)
currentRect.right = Math.max(right, currentRect.right)
}

// Merge any overlapping rects, e.g. those formed by adjacent bidi runs
rects.sort((a, b) => b.bottom - a.bottom || a.left - b.left)
for (let i = rects.length - 1; i-- > 0;) {
const rectA = rects[i]
const rectB = rects[i + 1]
if (rectA.bottom === rectB.bottom && rectA.left <= rectB.right && rectA.right >= rectB.left) {
rectB.left = Math.min(rectB.left, rectA.left)
rectB.right = Math.max(rectB.right, rectA.right)
rects.splice(i, 1)
}
}
rects = []
rows.forEach(rect => {
rects.push(rect)
})

_rectsCache.set(textRenderInfo, {start, end, rects})
}
Expand Down
148 changes: 98 additions & 50 deletions packages/troika-three-text/src/worker/FontProcessor.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export function createFontProcessor(fontParser, sdfGenerator, config) {

const INF = Infinity

const CHAR_LRO = '\u202D'
const CHAR_RLO = '\u202E'
const CHAR_PDF = '\u202C'
const BIDI_CHARS = CHAR_LRO + CHAR_RLO + CHAR_PDF

/**
* Load a given font url
Expand Down Expand Up @@ -173,7 +177,7 @@ export function createFontProcessor(fontParser, sdfGenerator, config) {
letterSpacing=0,
lineHeight='normal',
maxWidth=INF,
direction='ltr',
direction,
textAlign='left',
textIndent=0,
whiteSpace='normal',
Expand Down Expand Up @@ -216,7 +220,6 @@ export function createFontProcessor(fontParser, sdfGenerator, config) {
let maxLineWidth = 0
let renderableGlyphCount = 0
let canWrap = whiteSpace !== 'nowrap'
const rtl = direction === 'rtl'
const {ascender, descender, unitsPerEm} = fontObj
timings.fontLoad = now() - mainStart
const layoutStart = now()
Expand All @@ -242,16 +245,38 @@ export function createFontProcessor(fontParser, sdfGenerator, config) {
let lineXOffset = textIndent
let currentLine = new TextLine()
const lines = [currentLine]
let rtl = direction === 'rtl'
let curBidiSpan = {rtl, start: 0, end: text.length}
const bidiSpans = [curBidiSpan]
fontObj.forEachGlyph(text, fontSize, letterSpacing, (glyphObj, glyphX, charIndex) => {
const char = text.charAt(charIndex)
const glyphWidth = glyphObj.advanceWidth * fontSizeMult
const curLineCount = currentLine.count
let nextLine

// Track bidi ranges
// TODO currently this only supports a few of the explicit command characters,
// need to expand that and handle implicit character directionality
const isBidiCommand = BIDI_CHARS.indexOf(char) !== -1
if (isBidiCommand) {
if (char === CHAR_PDF) {
if (curBidiSpan.parent) { //ignore PDF without opener
curBidiSpan.end = charIndex + 1
curBidiSpan = curBidiSpan.parent
}
} else {
rtl = char === CHAR_RLO
curBidiSpan = {rtl, parent: curBidiSpan, start: charIndex, end: text.length}
if (rtl !== curBidiSpan.parent.rtl) {
bidiSpans.push(curBidiSpan)
}
}
}

// Calc isWhitespace and isEmpty once per glyphObj
if (!('isEmpty' in glyphObj)) {
glyphObj.isWhitespace = !!char && /\s/.test(char)
glyphObj.isEmpty = glyphObj.xMin === glyphObj.xMax || glyphObj.yMin === glyphObj.yMax
glyphObj.isEmpty = glyphObj.xMin === glyphObj.xMax || glyphObj.yMin === glyphObj.yMax || isBidiCommand
}
if (!glyphObj.isWhitespace && !glyphObj.isEmpty) {
renderableGlyphCount++
Expand Down Expand Up @@ -297,6 +322,7 @@ export function createFontProcessor(fontParser, sdfGenerator, config) {
fly.x = glyphX + lineXOffset
fly.width = glyphWidth
fly.charIndex = charIndex
fly.rtl = rtl

// Handle hard line breaks
if (char === '\n') {
Expand Down Expand Up @@ -370,78 +396,99 @@ export function createFontProcessor(fontParser, sdfGenerator, config) {
let colorCharIndex = -1
let chunk
let currentColor
lines.forEach(line => {
const bidiSpansByStartIndex = bidiSpans.reduce((out, span) => {
out[span.start] = span
return out
}, {})
lines.forEach((line, lineIndex) => {
let {count:lineGlyphCount, width:lineWidth} = line

// Ignore empty lines
if (lineGlyphCount > 0) {
// Find x offset for horizontal alignment
// Count trailing whitespaces, we want to ignore these for certain things
let trailingWhitespaceCount = 0
for (let i = lineGlyphCount; i-- && line.glyphAt(i).glyphObj.isWhitespace;) {
trailingWhitespaceCount++
}

// Apply horizontal alignment adjustments
let lineXOffset = 0
let justifyAdjust = 0
if (textAlign === 'center') {
lineXOffset = (maxLineWidth - lineWidth) / 2
} else if (textAlign === 'right') {
lineXOffset = maxLineWidth - lineWidth
} else if (textAlign === 'justify' && line.isSoftWrapped) {
// just count the non-trailing whitespace characters, and we'll adjust the offsets per
// character in the next loop
// count non-trailing whitespace characters, and we'll adjust the offsets per character in the next loop
let whitespaceCount = 0
for (let i = lineGlyphCount; i--;) {
if (!line.glyphAt(i).glyphObj.isWhitespace) {
while (i--) {
if (!line.glyphAt(i).glyphObj) {
debugger
}
if (line.glyphAt(i).glyphObj.isWhitespace) {
whitespaceCount++
}
}
break
for (let i = lineGlyphCount - trailingWhitespaceCount; i--;) {
if (line.glyphAt(i).glyphObj.isWhitespace) {
whitespaceCount++
}
}
justifyAdjust = (maxLineWidth - lineWidth) / whitespaceCount
lineWidth = maxLineWidth
}

let justifyOffset = 0
for (let i = 0; i < lineGlyphCount; i++) {
let glyphInfo = line.glyphAt(i)
const glyphObj = glyphInfo.glyphObj

// Apply position adjustments
if (lineXOffset || justifyOffset) {
if (justifyAdjust || lineXOffset) {
let justifyOffset = 0
for (let i = 0; i < lineGlyphCount; i++) {
let glyphInfo = line.glyphAt(i)
const glyphObj = glyphInfo.glyphObj
glyphInfo.x += lineXOffset + justifyOffset
// Expand non-trailing whitespaces for justify alignment
if (justifyAdjust !== 0 && glyphObj.isWhitespace && i < lineGlyphCount - trailingWhitespaceCount) {
justifyOffset += justifyAdjust
glyphInfo.width += justifyAdjust
}
}
}

// Expand non-trailing whitespaces for justify alignment
if (justifyAdjust !== 0 && glyphObj.isWhitespace) {
let isTrailingWhitespace = true
for (let j = i + 1; j < lineGlyphCount; j++) {
glyphInfo = line.glyphAt(j)
if (!glyphInfo.glyphObj.isWhitespace) {
isTrailingWhitespace = false
break
// Perform bidi range flipping
if (bidiSpans.length > 1 || bidiSpans[0].rtl) {
rtl = false
const visitBidiSpan = function(start, span) {
if (span.rtl !== rtl) {
rtl = span.rtl
let end = 0
while (end < lineGlyphCount - trailingWhitespaceCount && line.glyphAt(end).charIndex < span.end) {end++}
const {x: startX, width: startW} = line.glyphAt(start)
const {x: endX, width: endW} = line.glyphAt(end - 1)
const left = Math.min(startX, endX)
const right = Math.max(startX + startW, endX + endW)
for (let i = start; i < end; i++) {
const glyphInfo = line.glyphAt(i)
glyphInfo.x = right - (glyphInfo.x + glyphInfo.width - left)
}
//console.log(`line ${lineIndex}: flipped ${start} - ${end}`)
}
glyphInfo = line.glyphAt(i) //restore flyweight
if (!isTrailingWhitespace) {
justifyOffset += justifyAdjust
glyphInfo.width += justifyAdjust
}
const lineFirstIndex = line.glyphAt(0).charIndex
bidiSpans.forEach(span => {
if (lineFirstIndex >= span.start && lineFirstIndex < span.end) {
// TODO this can result in redundant whole-line flips. Also iterates more
// members than strictly necessary, could optimize.
visitBidiSpan(0, span)
}
})
for (let i = 1; i < lineGlyphCount - trailingWhitespaceCount; i++) {
const span = bidiSpansByStartIndex[line.glyphAt(i).charIndex]
if (span) {
visitBidiSpan(i, span)
}
}
}

// Apply rtl flip
if (rtl) {
glyphInfo.x = (lineXOffset + lineWidth) - (glyphInfo.x - lineXOffset) - glyphInfo.width
}
// Assemble final data arrays
for (let i = 0; i < lineGlyphCount; i++) {
let glyphInfo = line.glyphAt(i)
const glyphObj = glyphInfo.glyphObj

// Add caret positions
if (includeCaretPositions) {
const {charIndex} = glyphInfo
const caretLeft = glyphInfo.x + anchorXOffset
const caretRight = glyphInfo.x + glyphInfo.width + anchorXOffset
caretPositions[charIndex * 3] = rtl ? caretRight : caretLeft //start edge x
caretPositions[charIndex * 3 + 1] = rtl ? caretLeft : caretRight //end edge x
caretPositions[charIndex * 3] = glyphInfo.rtl ? caretRight : caretLeft //start edge x
caretPositions[charIndex * 3 + 1] = glyphInfo.rtl ? caretLeft : caretRight //end edge x
caretPositions[charIndex * 3 + 2] = lineYOffset + caretBottomOffset + anchorYOffset //common bottom y

// If we skipped any chars from the previous glyph (due to ligature subs), copy the
Expand Down Expand Up @@ -606,11 +653,12 @@ export function createFontProcessor(fontParser, sdfGenerator, config) {
function TextLine() {
this.data = []
}
const textLineProps = ['glyphObj', 'x', 'width', 'charIndex', 'rtl']
TextLine.prototype = {
width: 0,
isSoftWrapped: false,
get count() {
return Math.ceil(this.data.length / 4)
return Math.ceil(this.data.length / textLineProps.length)
},
glyphAt(i) {
let fly = TextLine.flyweight
Expand All @@ -620,17 +668,17 @@ export function createFontProcessor(fontParser, sdfGenerator, config) {
},
splitAt(i) {
let newLine = new TextLine()
newLine.data = this.data.splice(i * 4)
newLine.data = this.data.splice(i * textLineProps.length)
return newLine
}
}
TextLine.flyweight = ['glyphObj', 'x', 'width', 'charIndex'].reduce((obj, prop, i, all) => {
TextLine.flyweight = textLineProps.reduce((obj, prop, i, all) => {
Object.defineProperty(obj, prop, {
get() {
return this.data[this.index * 4 + i]
return this.data[this.index * textLineProps.length + i]
},
set(val) {
this.data[this.index * 4 + i] = val
this.data[this.index * textLineProps.length + i] = val
}
})
return obj
Expand Down

0 comments on commit d511655

Please sign in to comment.