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
29 changes: 29 additions & 0 deletions .changeset/natural-language-evidence-bias.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@bidilens/core': patch
---

Stop treating ordinary English words as technical tokens, which biased mixed
blocks toward RTL.

`isTechnicalIdentifier` excluded any word containing a hyphen and any all-capital
word from natural-language evidence. Because only tokens beginning with an ASCII
letter reach that test, every false exclusion removed LTR evidence and never RTL
evidence, so English-majority prose could resolve RTL — the mirror of the
`dir="auto"` failure this project exists to fix. `The well-known
state-of-the-art open-source کتابخانه` resolved `rtl` with zero LTR evidence
counted, and `PLEASE READ THIS IMPORTANT WARNING کتاب` resolved `rtl` after every
English word was discarded.

A hyphen is now ordinary English compounding: a hyphenated token is technical
only when one of its segments is itself a known technical word, so
`react-markdown` and `web-app` stay excluded while `well-known`,
`state-of-the-art`, and `e-mail` remain direction evidence. Capitals are read per
block: a short all-capital token is an acronym inside mixed-case prose (`HTTP`,
`API`), but when capitals are the block's prose style the words stay evidence.
Digits, underscores, and dots remain structural identifier syntax.

The incremental streaming classifier mirrors the same rules, and defers to the
exact batch policy when a block contains an all-capital word, since that decision
depends on the whole block. The Kotlin, Swift, .NET, and Rust cores carried the
same biased rule and are fixed in step. Two exact cross-platform fixtures keep
all five implementations aligned.
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ is published under the public `@bidilens` npm scope.

## Unreleased

### Direction correctness

- Kept ordinary hyphenated English compounds and block-level ALL-CAPS prose as
natural-language direction evidence while continuing to exclude technical
identifiers such as `react-markdown`, `GPT-5`, and mixed-case acronyms.
- Added shared cross-platform regressions for both evidence classes, bringing
the canonical direction corpus to 932 cases.
- Kept caller-supplied .NET technical identifiers case-insensitive regardless
of the comparer used by the caller's set.

### Release and verification

- Pinned the supported CI runtimes to exact Node 22.12.0 and 24.15.0 releases
Expand Down Expand Up @@ -44,7 +54,7 @@ is published under the public `@bidilens` npm scope.
- Kept paragraph direction independent from physical alignment in Android
Views, including immediate restoration of the caller's original gravity
when content-driven alignment is disabled.
- Expanded Kotlin parity to the 930-case canonical corpus and added Views and
- Expanded Kotlin parity to the 932-case canonical corpus and added Views and
Compose regressions for physical-left RTL rendering and pure-LTR no-op
behavior.
- Aligned the root and standalone-consumer Compose compiler plugins on Kotlin
Expand Down
2 changes: 1 addition & 1 deletion IMPACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

- 12 public JavaScript packages with implementations, declarations, README,
license, runnable example, and package-local assertion coverage;
- 930 schema-validated direction fixtures with numbered logical words;
- 932 schema-validated direction fixtures with numbered logical words;
- 0 fixtures currently certified by a native-language reviewer;
- property-based stream/source/range checks;
- 396 unit/property/action tests with 94.80% overall and 95.81% core line coverage,
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,16 @@ reordering and shaping; BidiLens supplies the application structure they need.
- safe HTML, DOM, unified/remark/rehype, markdown-it, React, Vue, Svelte, and
Web Component adapters;
- a conservative terminal adapter and a scriptable CLI;
- 930 schema-validated direction fixtures plus property-based random chunking;
- 932 schema-validated direction fixtures plus property-based random chunking;
- native Android pure-Kotlin core, non-destructive Views adapter, Compose
display/editable components, and a runnable photographed-case sample;
- Android JVM, Robolectric, lint, APK/AAR, and API 35/36 device gates using the
same generated 930-case corpus;
same generated 932-case corpus;
- a Swift Package with UIKit `UILabel`, `UITextView`, and `UITextField`
adapters, a UIKit-backed SwiftUI `BidiText` view, and a .NET 8 core with WPF
`TextBlock`/`TextBox` adapters;
- a native Rust core with generated Unicode 17 tables, byte/UTF-16/code-point
ranges, all 930 direction fixtures, declared isolation/security conformance,
ranges, all 932 direction fixtures, declared isolation/security conformance,
and Linux/macOS/Windows CI;
- explicit alignment policy on native adapters, allowing an RTL paragraph to
remain physically left-aligned without changing its base direction;
Expand Down
29 changes: 25 additions & 4 deletions action/dist/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6305,9 +6305,29 @@ function customTechnicalIdentifiers(values) {
if (cacheable) CUSTOM_TECHNICAL_IDENTIFIER_CACHE.set(values, identifiers);
return identifiers;
}
function isTechnicalIdentifier(token, custom) {
const normalized = token.toLowerCase();
return KNOWN_TECHNICAL_TOKENS.has(normalized) || custom.has(normalized) || /[0-9_.-]/u.test(token) || /^[A-Z]{2,}$/u.test(token) || /[a-z][A-Z]/u.test(token);
var ACRONYM_MAXIMUM_LENGTH = 5;
function isKnownTechnicalWord(value, custom) {
const normalized = value.toLowerCase();
return KNOWN_TECHNICAL_TOKENS.has(normalized) || custom.has(normalized);
}
function usesUppercaseProse(text) {
const words = text.match(/\b[A-Za-z]{2,}\b/gu);
if (words === null || words.length < 2) return false;
let capitalized = 0;
let hasLongCapitalizedWord = false;
for (const word of words) {
if (/[a-z]/u.test(word)) continue;
capitalized += 1;
if (word.length > ACRONYM_MAXIMUM_LENGTH) hasLongCapitalizedWord = true;
}
return hasLongCapitalizedWord && capitalized * 2 > words.length;
}
function isTechnicalIdentifier(token, custom, uppercaseProse) {
if (isKnownTechnicalWord(token, custom)) return true;
if (token.includes("-") && token.split("-").some((segment) => segment !== "" && isKnownTechnicalWord(segment, custom))) {
return true;
}
return /[0-9_.]/u.test(token) || /[a-z][A-Z]/u.test(token) || !uppercaseProse && token.length <= ACRONYM_MAXIMUM_LENGTH && /^[A-Z]{2,}$/u.test(token);
}
function findTechnicalTokenRanges(text, technicalIdentifiers = []) {
const ranges = [];
Expand Down Expand Up @@ -6364,10 +6384,11 @@ function findTechnicalTokenRanges(text, technicalIdentifiers = []) {
addMatches(text, ranges, /(?<![\p{L}\p{N}_])[+-]?(?:\d+(?:[.,]\d+)?|[\u0660-\u0669]+(?:[\u066B\u066C][\u0660-\u0669]+)?|[\u06F0-\u06F9]+(?:[.,][\u06F0-\u06F9]+)?)(?![\p{L}\p{N}_])/gu, "number");
const words = /\b[A-Za-z][A-Za-z0-9_.-]*\b/gu;
const customIdentifiers = customTechnicalIdentifiers(technicalIdentifiers);
const uppercaseProse = usesUppercaseProse(text);
let match;
while ((match = words.exec(text)) !== null) {
const token = match[0];
if (isTechnicalIdentifier(token, customIdentifiers)) {
if (isTechnicalIdentifier(token, customIdentifiers, uppercaseProse)) {
addRange(ranges, text, match.index, match.index + token.length, "identifier");
}
}
Expand Down
4 changes: 2 additions & 2 deletions action/dist/index.cjs.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

BidiLens Android fixes mixed Persian/Arabic/Hebrew and English text at the
native rendering boundary. It uses the same Unicode 17 data, content-majority
policy, technical-token rules, security scanner, and 930-case canonical corpus
policy, technical-token rules, security scanner, and 932-case canonical corpus
as the JavaScript packages.

The Android implementation has three small libraries:
Expand Down Expand Up @@ -175,7 +175,7 @@ pnpm run android:check

Current executable evidence includes:

- all 930 canonical direction fixtures and declared isolation plans in Kotlin;
- all 932 canonical direction fixtures and declared isolation plans in Kotlin;
- 23 core, 9 Views/Robolectric, and 8 Compose JVM tests;
- 3 Views and 3 Compose UI tests on an Android 16/API 36.1 emulator;
- release AAR assembly, sample APK assembly, and Android lint;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,60 @@ private fun addCodeRanges(source: String, ranges: MutableList<TechnicalTokenRang
}
}

private fun isTechnicalIdentifier(token: String, custom: Set<String>): Boolean {
val normalized = token.lowercase()
return normalized in DEFAULT_TECHNICAL_IDENTIFIERS ||
normalized in custom ||
token.any { it.isDigit() || it == '_' || it == '.' || it == '-' } ||
token.matches(Regex("^[A-Z]{2,}$")) ||
Regex("[a-z][A-Z]").containsMatchIn(token)
/**
* Acronyms are short. A longer all-capital word is emphasized prose, not an
* identifier, and must keep deciding the natural-language base direction.
*/
private const val ACRONYM_MAXIMUM_LENGTH = 5

private fun isKnownTechnicalWord(value: String, custom: Set<String>): Boolean {
val normalized = value.lowercase()
return normalized in DEFAULT_TECHNICAL_IDENTIFIERS || normalized in custom
}

/**
* Reports whether capitals are the block's prose style rather than an
* identifier signal. `PLEASE READ THIS WARNING` is emphasized natural language;
* the same `API` token inside mixed-case prose is an acronym.
*/
private fun usesUppercaseProse(text: String): Boolean {
var total = 0
var capitalized = 0
var hasLongCapitalizedWord = false
for (match in Regex("\\b[A-Za-z]{2,}\\b").findAll(text)) {
total += 1
if (match.value.all { it in 'A'..'Z' }) {
capitalized += 1
if (match.value.length > ACRONYM_MAXIMUM_LENGTH) hasLongCapitalizedWord = true
}
}
// `HTTP API` is an acronym sequence, not proof of an uppercase prose style.
return total >= 2 && hasLongCapitalizedWord && capitalized * 2 > total
}

private fun isTechnicalIdentifier(
token: String,
custom: Set<String>,
uppercaseProse: Boolean,
): Boolean {
if (isKnownTechnicalWord(token, custom)) return true
// A hyphenated token is technical when a segment is itself a known technical
// word (`react-markdown`), not merely because it is hyphenated. `well-known`
// and `state-of-the-art` are ordinary English and stay direction evidence.
if (token.contains('-') &&
token.split('-').any { it.isNotEmpty() && isKnownTechnicalWord(it, custom) }
) {
return true
}
// The hyphen is deliberately absent here: only digits, underscores, and dots
// are structural identifier syntax.
return token.any { it.isDigit() || it == '_' || it == '.' } ||
Regex("[a-z][A-Z]").containsMatchIn(token) ||
(
!uppercaseProse &&
token.length <= ACRONYM_MAXIMUM_LENGTH &&
token.matches(Regex("^[A-Z]{2,}$"))
)
}

/** Finds technical spans that should not decide natural-language direction. */
Expand Down Expand Up @@ -294,8 +341,9 @@ fun findTechnicalTokenRanges(
.filter { it.matches(Regex("^[A-Za-z][A-Za-z0-9_.-]*$")) }
.map(String::lowercase)
.toSet()
val uppercaseProse = usesUppercaseProse(text)
for (match in Regex("\\b[A-Za-z][A-Za-z0-9_.-]*\\b").findAll(text)) {
if (isTechnicalIdentifier(match.value, custom)) {
if (isTechnicalIdentifier(match.value, custom, uppercaseProse)) {
ranges.addRange(text, match.range.first, match.range.last + 1, TechnicalTokenKind.IDENTIFIER)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ class BidiCoreTest {
)
}

@Test
fun hyphenatedEnglishCompoundsStayNaturalLanguageEvidence() {
// A hyphen is ordinary English compounding. Excluding these tokens
// removes only LTR evidence, biasing mixed blocks toward RTL.
val source = "The well-known state-of-the-art open-source کتابخانه"
assertEquals(BidiDirection.LTR, detectDirection(source))
assertTrue(findTechnicalTokenRanges(source).isEmpty())
// A segment that is itself a known technical word still excludes it.
assertEquals(1, findTechnicalTokenRanges("react-markdown").size)
}

@Test
fun emphasizedUppercaseProseIsEvidenceButAcronymsAreTechnical() {
val shouted = "PLEASE READ THIS IMPORTANT WARNING کتاب"
assertEquals(BidiDirection.LTR, detectDirection(shouted))
assertTrue(findTechnicalTokenRanges(shouted).isEmpty())
// Inside mixed-case prose the same shape is an acronym again.
assertEquals(2, findTechnicalTokenRanges("Use the HTTP API for this").size)
// Short all-capital phrases remain acronym-shaped by default.
assertEquals(2, findTechnicalTokenRanges("HTTP API").size)
}

@Test
fun photographedPersianTitleResolvesRtl() {
val analysis = analyzeBidi("آپاندیسیت")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ internal val generatedCorpusFixtures = listOf(
ExpectedIsolation("React", BidiDirection.LTR, BidiIsolationKind.IDENTIFIER)
),
),
CorpusFixture(
id = "en-hyphenated-compounds-001",
text = "The well-known state-of-the-art open-source کتابخانه",
expected = BidiDirection.LTR,
isolations = listOf(
ExpectedIsolation("کتابخانه", BidiDirection.RTL, BidiIsolationKind.OPPOSITE_DIRECTION_RUN)
),
),
CorpusFixture(
id = "en-uppercase-emphasis-001",
text = "PLEASE READ THIS IMPORTANT WARNING کتاب",
expected = BidiDirection.LTR,
isolations = listOf(
ExpectedIsolation("کتاب", BidiDirection.RTL, BidiIsolationKind.OPPOSITE_DIRECTION_RUN)
),
),
CorpusFixture(
id = "fa-adjacent-url-001",
text = "یکhttps://example.com",
Expand Down
56 changes: 49 additions & 7 deletions apple/Sources/BidiLens/BidiAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,47 @@ public enum BidiAnalyzer {
)
}

/// Acronyms are short. A longer all-capital word is emphasized prose, not an
/// identifier, and must keep deciding the natural-language base direction.
private static let acronymMaximumLength = 5

private static func isKnownTechnicalWord(_ value: String, _ normalizedCustom: Set<String>) -> Bool {
let lower = value.lowercased()
return defaultTechnicalIdentifiers.contains(lower)
|| normalizedCustom.contains(lower)
}

/// Reports whether capitals are the block's prose style rather than an
/// identifier signal. `PLEASE READ THIS WARNING` is emphasized natural
/// language; the same `API` token inside mixed-case prose is an acronym.
private static func usesUppercaseProse(_ text: String) -> Bool {
guard let regex = try? NSRegularExpression(
pattern: #"(?<![A-Za-z0-9_])[A-Za-z]{2,}(?![A-Za-z0-9_])"#,
options: []
) else { return false }
let string = text as NSString
let matches = regex.matches(in: text, range: NSRange(location: 0, length: string.length))
var capitalized = 0
var hasLongCapitalizedWord = false
for match in matches {
let word = string.substring(with: match.range)
guard word.allSatisfy({ $0.isUppercase && $0.isASCII }) else { continue }
capitalized += 1
if word.count > acronymMaximumLength { hasLongCapitalizedWord = true }
}
// `HTTP API` is an acronym sequence, not proof of uppercase prose.
return matches.count >= 2
&& hasLongCapitalizedWord
&& capitalized * 2 > matches.count
}

public static func findTechnicalTokenRanges(
_ text: String,
customIdentifiers: Set<String> = []
) -> [TechnicalTokenRange] {
let fullRange = NSRange(location: 0, length: (text as NSString).length)
var ranges: [TechnicalTokenRange] = []
let normalizedCustomIdentifiers = Set(customIdentifiers.map { $0.lowercased() })
for (pattern, kind, options) in technicalPatterns {
guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else { continue }
for match in regex.matches(in: text, range: fullRange) {
Expand Down Expand Up @@ -153,20 +188,27 @@ public enum BidiAnalyzer {
}
}

let uppercaseProse = usesUppercaseProse(text)
if let regex = try? NSRegularExpression(
pattern: #"(?<![A-Za-z0-9_])[A-Za-z][A-Za-z0-9_.-]*(?<=[A-Za-z0-9_])(?![A-Za-z0-9_])"#,
options: []
) {
for match in regex.matches(in: text, range: fullRange) {
let token = (text as NSString).substring(with: match.range)
let lower = token.lowercased()
let technical = defaultTechnicalIdentifiers.contains(lower)
|| customIdentifiers.map { $0.lowercased() }.contains(lower)
|| token.contains(where: { $0.isNumber || $0 == "_" || $0 == "." || $0 == "-" })
|| token.unicodeScalars.allSatisfy {
!$0.properties.isAlphabetic || CharacterSet.uppercaseLetters.contains($0)
}
let technical = isKnownTechnicalWord(token, normalizedCustomIdentifiers)
// A hyphenated token is technical when a segment is itself a
// known technical word ("react-markdown"), not merely because
// it is hyphenated: "well-known" is ordinary English evidence.
|| (token.contains("-") && token.split(separator: "-").contains {
!$0.isEmpty && isKnownTechnicalWord(String($0), normalizedCustomIdentifiers)
})
// Only digits, underscores, and dots are identifier syntax.
|| token.contains(where: { $0.isNumber || $0 == "_" || $0 == "." })
|| token.range(of: #"[a-z][A-Z]"#, options: .regularExpression) != nil
|| (!uppercaseProse
&& token.count >= 2
&& token.count <= acronymMaximumLength
&& token.allSatisfy { $0.isUppercase && $0.isASCII })
if technical {
ranges.append(TechnicalTokenRange(
text: token,
Expand Down
23 changes: 22 additions & 1 deletion apple/Tests/BidiLensTests/BidiLensTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,27 @@ final class BidiLensTests: XCTestCase {
XCTAssertTrue(BidiAnalyzer.analyze(flagship).isolations.contains { $0.text == "React" })
}

func testNaturalLanguageEvidenceIsNotMistakenForIdentifiers() {
let compounds = "The well-known state-of-the-art open-source کتابخانه"
XCTAssertEqual(BidiAnalyzer.detectDirection(compounds), .leftToRight)
XCTAssertTrue(BidiAnalyzer.findTechnicalTokenRanges(compounds).isEmpty)

let emphasized = "PLEASE READ THIS IMPORTANT WARNING کتاب"
XCTAssertEqual(BidiAnalyzer.detectDirection(emphasized), .leftToRight)
XCTAssertTrue(BidiAnalyzer.findTechnicalTokenRanges(emphasized).isEmpty)

let acronyms = BidiAnalyzer.findTechnicalTokenRanges("Use the HTTP API for this")
XCTAssertEqual(acronyms.map(\.text), ["HTTP", "API"])
XCTAssertEqual(
BidiAnalyzer.findTechnicalTokenRanges("HTTP API").map(\.text),
["HTTP", "API"]
)
XCTAssertEqual(
BidiAnalyzer.findTechnicalTokenRanges("react-markdown").map(\.text),
["react-markdown"]
)
}

func testPhysicalLeftDoesNotChangeRTLDirection() {
let presentation = BidiAnalyzer.presentation(
"این متن فارسی در سمت چپ باقی می‌ماند.",
Expand Down Expand Up @@ -51,7 +72,7 @@ final class BidiLensTests: XCTestCase {
}
let url = try XCTUnwrap(Bundle.module.url(forResource: "cases", withExtension: "json"))
let cases = try JSONDecoder().decode([CorpusCase].self, from: Data(contentsOf: url))
XCTAssertEqual(cases.count, 930)
XCTAssertEqual(cases.count, 932)
for item in cases {
let expected: BidiDirection = switch item.expected {
case "rtl": .rightToLeft
Expand Down
Loading