diff --git a/examples/basic/lynx.config.ts b/examples/basic/lynx.config.ts
index eeeb8c5..ead7666 100644
--- a/examples/basic/lynx.config.ts
+++ b/examples/basic/lynx.config.ts
@@ -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: [
diff --git a/examples/basic/src/bidi-test.tsx b/examples/basic/src/bidi-test.tsx
new file mode 100644
index 0000000..e54d339
--- /dev/null
+++ b/examples/basic/src/bidi-test.tsx
@@ -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 (
+
+
+
+
+ Bidi Text Demo
+
+
+ {layouts.map((item, idx) => (
+
+
+ {item.label}
+
+
+ {/* Pretext rendered lines */}
+
+ {item.layout.lines.map((line, i) => (
+
+
+ {line.text}
+
+
+ ))}
+
+
+ {/* Native comparison */}
+
+
+ Native
+
+
+ {item.text}
+
+
+
+ {/* Stats */}
+
+ {`${item.layout.lineCount} lines, ${item.layout.height}px`}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+root.render()
+
+if (import.meta.webpackHot) {
+ import.meta.webpackHot.accept()
+}
diff --git a/src/bidi.ts b/src/bidi.ts
new file mode 100644
index 0000000..f530ff9
--- /dev/null
+++ b/src/bidi.ts
@@ -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
+}
diff --git a/src/layout.ts b/src/layout.ts
index 51af429..9b026d3 100644
--- a/src/layout.ts
+++ b/src/layout.ts
@@ -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 ---