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 ---