Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/basic/lynx.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default defineConfig({
'variable-flow': './src/variable-flow.tsx',
accuracy: './src/accuracy.tsx',
'hello-world': './src/hello-world.tsx',
'bidi-test': './src/bidi-test.tsx',
},
},
plugins: [
Expand Down
130 changes: 130 additions & 0 deletions examples/basic/src/bidi-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { root, useState, useMemo } from '@lynx-js/react'

import { prepareWithSegments, layoutWithLines } from 'lynx-pretext'
import { DevPanel, useDevPanelFPS, DevPanelFPS } from 'lynx-pretext-devtools'

const ARABIC_SHORT =
'مرحبا بالعالم، هذه تجربة لقياس النص العربي وكسر الأسطر بشكل صحيح'
const HEBREW_SHORT =
'שלום עולם, זוהי בדיקה למדידת טקסט עברי ושבירת שורות'
const MULTI_SCRIPT =
'Hello مرحبا שלום 你好 こんにちは 안녕하세요 สวัสดี — a greeting in seven scripts!'

const SAMPLES = [
{ label: 'Arabic', text: ARABIC_SHORT },
{ label: 'Hebrew', text: HEBREW_SHORT },
{ label: 'Multi-script', text: MULTI_SCRIPT },
] as const

const FONT_SIZE = 16
const LINE_HEIGHT = 24
const FONT = `${FONT_SIZE}px`

function BidiTestPage() {
const [maxWidth, setMaxWidth] = useState(360)

const { btsFpsTick, btsFpsDisplay } = useDevPanelFPS()

const contentWidth = Math.max(40, maxWidth - 32)

const results = useMemo(() => {
return SAMPLES.map(s => ({
...s,
prepared: prepareWithSegments(s.text, FONT),
}))
}, [])

const layouts = results.map(r => ({
...r,
layout: layoutWithLines(r.prepared, contentWidth, LINE_HEIGHT),
}))

btsFpsTick()

return (
<DevPanel.Root>
<view style={{ flex: 1, backgroundColor: '#fff' }}>
<view style={{ flex: 1, padding: '16px' }}>
<text style={{ fontSize: '20px', fontWeight: 'bold', color: '#333', marginBottom: '16px' }}>
Bidi Text Demo
</text>

{layouts.map((item, idx) => (
<view key={`sample-${idx}`} style={{ marginBottom: '20px' }}>
<text style={{ fontSize: '13px', fontWeight: 'bold', color: '#1565c0', marginBottom: '6px' }}>
{item.label}
</text>

{/* Pretext rendered lines */}
<view style={{
width: `${contentWidth}px`,
borderWidth: '1px',
borderColor: '#1976d2',
borderRadius: '4px',
backgroundColor: '#fafafa',
padding: '4px 8px',
}}>
{item.layout.lines.map((line, i) => (
<view
key={`line-${i}`}
style={{ height: `${LINE_HEIGHT}px`, justifyContent: 'center' }}
>
<text style={{ fontSize: `${FONT_SIZE}px`, color: '#333' }}>
{line.text}
</text>
</view>
))}
</view>

{/* Native comparison */}
<view style={{
width: `${contentWidth}px`,
marginTop: '6px',
padding: '4px 8px',
borderWidth: '1px',
borderColor: '#ddd',
borderRadius: '4px',
backgroundColor: '#f5f5f5',
}}>
<text style={{ fontSize: '11px', color: '#999', marginBottom: '2px' }}>
Native
</text>
<text style={{ fontSize: `${FONT_SIZE}px`, lineHeight: `${LINE_HEIGHT}px`, color: '#333' }}>
{item.text}
</text>
</view>

{/* Stats */}
<text style={{ fontSize: '11px', color: '#888', marginTop: '4px' }}>
{`${item.layout.lineCount} lines, ${item.layout.height}px`}
</text>
</view>
))}
</view>

<DevPanel.Trigger />
<DevPanel.Content title="Bidi">
<DevPanelFPS mtsFpsDisplay={0} btsFpsDisplay={btsFpsDisplay} />
<DevPanel.Stats>
<DevPanel.Stat label="width" value={`${contentWidth}px`} />
</DevPanel.Stats>
<DevPanel.Stepper
label="width"
value={maxWidth}
min={40}
max={1200}
step={20}
unit="px"
onChange={setMaxWidth}
/>
</DevPanel.Content>
</view>
</DevPanel.Root>
)
}

root.render(<BidiTestPage />)

if (import.meta.webpackHot) {
import.meta.webpackHot.accept()
}
173 changes: 173 additions & 0 deletions src/bidi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Simplified bidi metadata helper for the rich prepareWithSegments() path,
// forked from pdf.js via Sebastian's text-layout. It classifies characters
// into bidi types, computes embedding levels, and maps them onto prepared
// segments for custom rendering. The line-breaking engine does not consume
// these levels.

type BidiType = 'L' | 'R' | 'AL' | 'AN' | 'EN' | 'ES' | 'ET' | 'CS' |
'ON' | 'BN' | 'B' | 'S' | 'WS' | 'NSM'

const baseTypes: BidiType[] = [
'BN','BN','BN','BN','BN','BN','BN','BN','BN','S','B','S','WS',
'B','BN','BN','BN','BN','BN','BN','BN','BN','BN','BN','BN','BN',
'BN','BN','B','B','B','S','WS','ON','ON','ET','ET','ET','ON',
'ON','ON','ON','ON','ON','CS','ON','CS','ON','EN','EN','EN',
'EN','EN','EN','EN','EN','EN','EN','ON','ON','ON','ON','ON',
'ON','ON','L','L','L','L','L','L','L','L','L','L','L','L','L',
'L','L','L','L','L','L','L','L','L','L','L','L','L','ON','ON',
'ON','ON','ON','ON','L','L','L','L','L','L','L','L','L','L',
'L','L','L','L','L','L','L','L','L','L','L','L','L','L','L',
'L','ON','ON','ON','ON','BN','BN','BN','BN','BN','BN','B','BN',
'BN','BN','BN','BN','BN','BN','BN','BN','BN','BN','BN','BN',
'BN','BN','BN','BN','BN','BN','BN','BN','BN','BN','BN','BN',
'BN','CS','ON','ET','ET','ET','ET','ON','ON','ON','ON','L','ON',
'ON','ON','ON','ON','ET','ET','EN','EN','ON','L','ON','ON','ON',
'EN','L','ON','ON','ON','ON','ON','L','L','L','L','L','L','L',
'L','L','L','L','L','L','L','L','L','L','L','L','L','L','L',
'L','ON','L','L','L','L','L','L','L','L','L','L','L','L','L',
'L','L','L','L','L','L','L','L','L','L','L','L','L','L','L',
'L','L','L','ON','L','L','L','L','L','L','L','L'
]

const arabicTypes: BidiType[] = [
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'CS','AL','ON','ON','NSM','NSM','NSM','NSM','NSM','NSM','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','AL','AL','AL','NSM','NSM','NSM','NSM','NSM','NSM','NSM',
'NSM','NSM','NSM','NSM','NSM','NSM','NSM','AL','AL','AL','AL',
'AL','AL','AL','AN','AN','AN','AN','AN','AN','AN','AN','AN',
'AN','ET','AN','AN','AL','AL','AL','NSM','AL','AL','AL','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','NSM','NSM','NSM','NSM','NSM','NSM','NSM','NSM','NSM','NSM',
'NSM','NSM','NSM','NSM','NSM','NSM','NSM','NSM','NSM','ON','NSM',
'NSM','NSM','NSM','AL','AL','AL','AL','AL','AL','AL','AL','AL',
'AL','AL','AL','AL','AL','AL','AL','AL','AL'
]

function classifyChar(charCode: number): BidiType {
if (charCode <= 0x00ff) return baseTypes[charCode]!
if (0x0590 <= charCode && charCode <= 0x05f4) return 'R'
if (0x0600 <= charCode && charCode <= 0x06ff) return arabicTypes[charCode & 0xff]!
if (0x0700 <= charCode && charCode <= 0x08AC) return 'AL'
return 'L'
}

function computeBidiLevels(str: string): Int8Array | null {
const len = str.length
if (len === 0) return null

// eslint-disable-next-line unicorn/no-new-array
const types: BidiType[] = new Array(len)
let numBidi = 0

for (let i = 0; i < len; i++) {
const t = classifyChar(str.charCodeAt(i))
if (t === 'R' || t === 'AL' || t === 'AN') numBidi++
types[i] = t
}

if (numBidi === 0) return null

const startLevel = (len / numBidi) < 0.3 ? 0 : 1
const levels = new Int8Array(len)
for (let i = 0; i < len; i++) levels[i] = startLevel

const e: BidiType = (startLevel & 1) ? 'R' : 'L'
const sor = e

// W1-W7
let lastType: BidiType = sor
for (let i = 0; i < len; i++) {
if (types[i] === 'NSM') types[i] = lastType
else lastType = types[i]!
}
lastType = sor
for (let i = 0; i < len; i++) {
const t = types[i]!
if (t === 'EN') types[i] = lastType === 'AL' ? 'AN' : 'EN'
else if (t === 'R' || t === 'L' || t === 'AL') lastType = t
}
for (let i = 0; i < len; i++) {
if (types[i] === 'AL') types[i] = 'R'
}
for (let i = 1; i < len - 1; i++) {
if (types[i] === 'ES' && types[i - 1] === 'EN' && types[i + 1] === 'EN') {
types[i] = 'EN'
}
if (
types[i] === 'CS' &&
(types[i - 1] === 'EN' || types[i - 1] === 'AN') &&
types[i + 1] === types[i - 1]
) {
types[i] = types[i - 1]!
}
}
for (let i = 0; i < len; i++) {
if (types[i] !== 'EN') continue
let j
for (j = i - 1; j >= 0 && types[j] === 'ET'; j--) types[j] = 'EN'
for (j = i + 1; j < len && types[j] === 'ET'; j++) types[j] = 'EN'
}
for (let i = 0; i < len; i++) {
const t = types[i]!
if (t === 'WS' || t === 'ES' || t === 'ET' || t === 'CS') types[i] = 'ON'
}
lastType = sor
for (let i = 0; i < len; i++) {
const t = types[i]!
if (t === 'EN') types[i] = lastType === 'L' ? 'L' : 'EN'
else if (t === 'R' || t === 'L') lastType = t
}

// N1-N2
for (let i = 0; i < len; i++) {
if (types[i] !== 'ON') continue
let end = i + 1
while (end < len && types[end] === 'ON') end++
const before: BidiType = i > 0 ? types[i - 1]! : sor
const after: BidiType = end < len ? types[end]! : sor
const bDir: BidiType = before !== 'L' ? 'R' : 'L'
const aDir: BidiType = after !== 'L' ? 'R' : 'L'
if (bDir === aDir) {
for (let j = i; j < end; j++) types[j] = bDir
}
i = end - 1
}
for (let i = 0; i < len; i++) {
if (types[i] === 'ON') types[i] = e
}

// I1-I2
for (let i = 0; i < len; i++) {
const t = types[i]!
if ((levels[i]! & 1) === 0) {
if (t === 'R') levels[i]!++
else if (t === 'AN' || t === 'EN') levels[i]! += 2
} else if (t === 'L' || t === 'AN' || t === 'EN') {
levels[i]!++
}
}

return levels
}

export function computeSegmentLevels(normalized: string, segStarts: number[]): Int8Array | null {
const bidiLevels = computeBidiLevels(normalized)
if (bidiLevels === null) return null

const segLevels = new Int8Array(segStarts.length)
for (let i = 0; i < segStarts.length; i++) {
segLevels[i] = bidiLevels[segStarts[i]!]!
}
return segLevels
}
8 changes: 1 addition & 7 deletions src/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,7 @@ function getSharedGraphemeSegmenter(): Intl.Segmenter {
return sharedGraphemeSegmenter
}

// Bidi stub for MVP — returns null (no bidi metadata).
function computeSegmentLevels(
_normalized: string,
_segStarts: number[],
): Int8Array | null {
return null
}
import { computeSegmentLevels } from './bidi'

// --- Public types ---

Expand Down
Loading