Initial commit

This commit is contained in:
2026-08-30 22:02:02 +00:00
commit b6bd2277f5
2334 changed files with 646393 additions and 0 deletions
+549
View File
@@ -0,0 +1,549 @@
<?php
/**
* BidiTest.php
*
* @since 2011-05-23
* @category Library
* @package Unicode
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-unicode
*
* This file is part of tc-lib-unicode software library.
*/
namespace Test;
use Com\Tecnick\Unicode\Bidi;
use PHPUnit\Framework\Attributes\DataProvider;
/**
* Bidi Test
*
* @since 2011-05-23
* @category Library
* @package Unicode
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-unicode
*/
class BidiTest extends TestUtil
{
private static function decodeJsonString(string $json): string
{
/** @var string */
return \json_decode($json);
}
/**
* @throws \Com\Tecnick\Unicode\Exception
*/
public function testException(): void
{
$this->bcExpectException(\Com\Tecnick\Unicode\Exception::class);
new \Com\Tecnick\Unicode\Bidi();
}
/**
* @param ?string $str String to convert (if null it will be generated from $chrarr or $ordarr)
* @param ?array<string> $chrarr Array of UTF-8 chars (if empty it will be generated from $str or $ordarr)
* @param ?array<int> $ordarr Array of UTF-8 codepoints (if empty it will be generated from $str or $chrarr)
* @param string $forcedir If 'R' forces RTL, if 'L' forces LTR
* @param bool $shaping If true enable the shaping algorithm
*
* @throws \Com\Tecnick\Unicode\Exception
*/
#[DataProvider('inputDataProvider')]
public function testStr(
?string $str = null,
?array $chrarr = null,
?array $ordarr = null,
string $forcedir = '',
bool $shaping = true,
): void {
$bidi = new Bidi($str, $chrarr, $ordarr, $forcedir, $shaping);
$this->assertEquals('test', $bidi->getString());
$this->assertEquals(['t', 'e', 's', 't'], $bidi->getChrArray());
$this->assertEquals([116, 101, 115, 116], $bidi->getOrdArray());
$this->assertEquals(
[
116 => true,
101 => true,
115 => true,
],
$bidi->getCharKeys(),
);
$this->assertEquals(4, $bidi->getNumChars());
}
/**
* @return array<int, array{?string, ?array<string>, ?array<int>, string, bool}>
*/
public static function inputDataProvider(): array
{
return [
['test', null, null, '', true],
[null, ['t', 'e', 's', 't'], null, '', true],
[null, null, [116, 101, 115, 116], '', true],
['test', ['t', 'e', 's', 't'], null, '', true],
['test', null, [116, 101, 115, 116], '', true],
[null, ['t', 'e', 's', 't'], [116, 101, 115, 116], '', true],
['test', ['t', 'e', 's', 't'], [116, 101, 115, 116], '', true],
['test', null, null, 'L', true],
['test', null, null, 'R', true],
];
}
/**
* @throws \Com\Tecnick\Unicode\Exception
*/
#[DataProvider('bidiStrDataProvider')]
public function testBidiStr(string $str, mixed $expected, string $forcedir = ''): void
{
$bidi = new Bidi($str, null, null, $forcedir, true);
$this->assertEquals($expected, $bidi->getString());
}
/**
* @return array<int, array{string, string, string}>
*/
public static function bidiStrDataProvider(): array
{
return [
[
"\n\nABC\nEFG\n\nHIJ\n\n",
"\n\nABC\nEFG\n\nHIJ\n\n",
'L',
],
[
self::decodeJsonString('"\u202EABC\u202C"'),
'CBA',
'',
],
[
// All-L text in a forced-RTL paragraph: N1 resolves the spaces to L,
// so the whole phrase is a single LTR run and keeps its word order.
'left to right',
'left to right',
'R',
],
[
// Same, but L1.4 resets the trailing space to the paragraph level,
// moving it to the visual left.
'left to right ',
' left to right',
'R',
],
[
// U+0600..U+0602 are Arabic numbers (AN): I1 puts them on an even level,
// so they keep their logical order inside the right-to-left paragraph.
self::decodeJsonString('"smith (fabrikam \u0600\u0601\u0602) \u05de\u05d6\u05dc"'),
self::decodeJsonString('"\u05dc\u05d6\u05de (\u0600\u0601\u0602 fabrikam) smith"'),
'R',
],
[
self::decodeJsonString('"\u0600\u0601\u0602 book(s)"'),
self::decodeJsonString('"book(s) \u0600\u0601\u0602"'),
'R',
],
[
self::decodeJsonString('"\u0600\u0601(\u0602\u0603[&ef]!)gh"'),
self::decodeJsonString('"gh(![ef&]\u0602\u0603)\u0600\u0601"'),
'R',
],
[
'تشكيل اختبار',
'ﺭﺎﺒﺘﺧﺍ ﻞﻴﻜﺸﺗ',
'',
],
[
self::decodeJsonString('"\u05de\u05d6\u05dc \u05d8\u05d5\u05d1"'),
self::decodeJsonString('"\u05d1\u05d5\u05d8 \u05dc\u05d6\u05de"'),
'',
],
[
self::decodeJsonString(
'"\u0644\u0644\u0647 \u0600\u0601\u0602 \uFB50'
. ' \u0651\u064c\u0651\u064d\u0651\u064e\u0651\u064f\u0651\u0650'
. ' \u0644\u0622"',
),
// "\u0644\u0644\u0647" is not the word Allah: without the alef it shapes as
// lam initial, lam medial and heh final instead of the U+FDF2 ligature.
self::decodeJsonString(
'"\ufef5 \ufc62\ufc61\ufc60\ufc5f\ufc5e \ufb50 \u0600\u0601\u0602 \ufeea\ufee0\ufedf"',
),
'',
],
[
self::decodeJsonString('"A\u2067\u05d8\u2069B"'),
self::decodeJsonString('"A\u2067\u05d8\u2069B"'),
'',
],
[
// Unterminated isolate: RLI with no matching PDI. Exercises StepXten's
// findMatchingPdiStart() returning -1 and the eos-from-paragraph-level fallback.
self::decodeJsonString('"\u05d0\u2067\u05d1"'),
self::decodeJsonString('"\u05d1\u2067\u05d0"'),
'',
],
[
// Unterminated FSI wrapping LTR text: covers FSI auto-direction with no matching PDI.
self::decodeJsonString('"\u05d0\u2068ab\u05d1"'),
self::decodeJsonString('"ab\u05d1\u2068\u05d0"'),
'',
],
[
// RLI + PDI
self::decodeJsonString('"The words \"\u2067\u05de\u05d6\u05dc [mazel] \u05d8\u05d5\u05d1 [tov]\u2069\"'
. ' mean \"Congratulations!\""'),
'The words "[tov] בוט [mazel] לזמ⁩" mean "Congratulations!"',
'',
],
[
// RLE + PDF. The all-L phrase inside the embedding stays one LTR run,
// and the legacy embedding spills over: sos of the run after PDF is R, so N1
// resolves '" - ' between it and the number to R, pulling '" - $19.95' into
// the RTL context (the spillover problem that isolates were made to solve).
self::decodeJsonString('"it is called \"\u202bAN INTRODUCTION TO java\u202c\" - $19.95 in hardcover."'),
'it is called "$19.95 - "AN INTRODUCTION TO java in hardcover.',
'',
],
[
// RLI + PDI: the isolate shields the surrounding text, so unlike the RLE
// case above the quote and price stay in place and the phrase keeps its
// word order.
self::decodeJsonString('"it is called \"\u2067AN INTRODUCTION TO java\u2069\" - $19.95 in hardcover."'),
'it is called "AN INTRODUCTION TO java" - $19.95 in hardcover.',
'',
],
[
// Hebrew with embedded paragraph separator (covers getParagraphs() splitting and re-insertion)
self::decodeJsonString('"\u05de\u05d6\u05dc \u05d8\u05d5\u05d1"')
. "\n"
. self::decodeJsonString('"\u05de\u05d6\u05dc \u05d8\u05d5\u05d1"'),
self::decodeJsonString('"\u05d1\u05d5\u05d8 \u05dc\u05d6\u05de"')
. "\n"
. self::decodeJsonString('"\u05d1\u05d5\u05d8 \u05dc\u05d6\u05de"'),
'',
],
[
// Hebrew ending with paragraph separator (covers empty last paragraph handling)
self::decodeJsonString('"\u05de\u05d6\u05dc \u05d8\u05d5\u05d1"') . "\n",
self::decodeJsonString('"\u05d1\u05d5\u05d8 \u05dc\u05d6\u05de"') . "\n",
'',
],
[
// Arabic with forced LTR direction (covers getPel() returning 0 for forcedir='L').
// N1 resolves the space between the two R words, so the phrase is a single RTL
// run inside the LTR paragraph and is reversed as a unit (words swap places).
'تشكيل اختبار',
self::decodeJsonString('"\ufead\ufe8e\ufe92\ufe98\ufea7\ufe8d\u0020\ufede\ufef4\ufedc\ufeb8\ufe97"'),
'L',
],
];
}
/**
* Regression test for https://github.com/tecnickcom/tc-lib-unicode/issues/12
* Arabic shaping must replace a lam-alef pair with a single ligature glyph
* without deleting any other character of the run.
*
* @param array<int> $expected Shaped codepoints in visual order
*
* @throws \Com\Tecnick\Unicode\Exception
*/
#[DataProvider('lamAlefShapingDataProvider')]
public function testLamAlefShaping(string $str, array $expected): void
{
$bidi = new Bidi($str);
$this->assertSame($expected, $bidi->getOrdArray());
}
/**
* @return array<int, array{string, array<int>}>
*/
public static function lamAlefShapingDataProvider(): array
{
return [
// khah + lam + alef + lam: the pair merges and the khah keeps its initial form
['خلال', [0xFEDD, 0xFEFC, 0xFEA7]],
// nine letters with one lam-alef pair: one glyph less, leading alef preserved
['الاستخدام', [0xFEE1, 0xFE8D, 0xFEAA, 0xFEA8, 0xFE98, 0xFEB3, 0xFEFB, 0xFE8D]],
// two adjacent lam-alef pairs merge independently
['لالا', [0xFEFB, 0xFEFB]],
// both words shape identically (the second merge must not delete the first char)
['خلال خلال', [0xFEDD, 0xFEFC, 0xFEA7, 0x0020, 0xFEDD, 0xFEFC, 0xFEA7]],
// NSM between the pair: the ligature still forms around the shadda
['لّا', [0xFEFB, 0x0651]],
];
}
/**
* The Arabic shaper picks the presentation form from the Joining_Type of the
* surrounding characters: transparent marks are skipped, ZWJ joins, ZWNJ and every
* non-joining character break the connection.
*
* @param array<int> $expected Shaped codepoints in visual order
*
* @throws \Com\Tecnick\Unicode\Exception
*/
#[DataProvider('joiningShapingDataProvider')]
public function testJoiningShaping(string $str, array $expected): void
{
$bidi = new Bidi($str);
$this->assertSame($expected, $bidi->getOrdArray());
}
/**
* @return array<string, array{string, array<int>}>
*/
public static function joiningShapingDataProvider(): array
{
return [
// ZWNJ (U+200C) breaks the connection between two dual-joining letters
'beh ZWNJ teh' => ["\u{0628}\u{200C}\u{062A}", [0xFE95, 0xFE8F]],
// and it also blocks the lam-alef ligature
'lam ZWNJ alef' => ["\u{0644}\u{200C}\u{0627}", [0xFE8D, 0xFEDD]],
// ZWJ (U+200D) is join causing: the beh takes its initial form
'beh ZWJ' => ["\u{0628}\u{200D}", [0xFE91]],
// Persian needs ZWNJ inside a word
'persian mikhaham' => [
'می‌خواهم',
[0xFEE2, 0xFEEB, 0xFE8D, 0xFEEE, 0xFEA7, 0xFBFD, 0xFEE3],
],
// a non-joining character between two letters isolates both
'beh comma teh' => ['ب،ت', [0xFE95, 0x060C, 0xFE8F]],
'beh digit teh' => ['ب١ت', [0xFE95, 0x0661, 0xFE8F]],
// letters with an isolated and a final form only
'waw with hamza' => ['سؤال', [0xFEDD, 0xFE8D, 0xFE86, 0xFEB3]],
'teh marbuta' => ['بةب', [0xFE8F, 0xFE94, 0xFE91]],
// right joining letters do not connect to the following letter
'urdu rreh' => ['بڑا', [0xFE8D, 0xFB8D, 0xFE91]],
'alef wasla' => ['ٱلحمد', [0xFEAA, 0xFEE4, 0xFEA4, 0xFEDF, 0xFB50]],
'uyghur' => ['ئۇيغۇر', [0xFEAD, 0xFBD8, 0xFED0, 0xFEF3, 0xFBD8, 0xFE8B]],
// non-joining letters and punctuation are not a joining context
'hamza' => ['شيء', [0xFE80, 0xFEF2, 0xFEB7]],
'arabic semicolon' => ['ب؛', [0x061B, 0xFE8F]],
'urdu full stop' => ['اب۔', [0x06D4, 0xFE8F, 0xFE8D]],
'arabic question mark' => ['ب؟', [0x061F, 0xFE8F]],
'two beh and question mark' => ['بب؟', [0x061F, 0xFE90, 0xFE91]],
];
}
/**
* Shadda (U+0651) and a second mark are merged into a single glyph in both orders:
* canonical ordering puts the vowel first, as its combining class is lower.
*
* @throws \Com\Tecnick\Unicode\Exception
*/
public function testCombineShadda(): void
{
// BEH + SHADDA + FATHA and BEH + FATHA + SHADDA
$bidi = new Bidi(null, null, [0x0628, 0x0651, 0x064E]);
$this->assertSame([0xFC60, 0xFE8F], $bidi->getOrdArray());
$bidi = new Bidi(null, null, [0x0628, 0x064E, 0x0651]);
$this->assertSame([0xFC60, 0xFE8F], $bidi->getOrdArray());
// BEH + SHADDA + SUPERSCRIPT ALEF
$bidi = new Bidi(null, null, [0x0628, 0x0651, 0x0670]);
$this->assertSame([0xFC63, 0xFE8F], $bidi->getOrdArray());
}
/**
* The U+FDF2 ligature covers alef + lam + lam + heh and has an isolated form only.
*
* @param array<int> $expected Shaped codepoints in visual order
*
* @throws \Com\Tecnick\Unicode\Exception
*/
#[DataProvider('allahShapingDataProvider')]
public function testAllahShaping(string $str, array $expected): void
{
$bidi = new Bidi($str);
$this->assertSame($expected, $bidi->getOrdArray());
}
/**
* @return array<string, array{string, array<int>}>
*/
public static function allahShapingDataProvider(): array
{
return [
// the four characters become one glyph
'allah' => ['الله', [0xFDF2]],
// the combining marks are transparent and are kept after the ligature
'vocalized allah' => ['اللّٰه', [0xFDF2, 0xFC63]],
// without the alef the word is shaped letter by letter
'lillah' => ['لله', [0xFEEA, 0xFEE0, 0xFEDF]],
'three lam and heh' => ['للله', [0xFEEA, 0xFEE0, 0xFEE0, 0xFEDF]],
// the alef joins the beh, so the isolated ligature cannot be used
'billah' => ['بالله', [0xFEEA, 0xFEE0, 0xFEDF, 0xFE8E, 0xFE91]],
];
}
/**
* Regression test for https://github.com/tecnickcom/tc-lib-unicode/issues/13
* Rules N1/N2 must resolve ordinary neutrals (WS, ON, S), not only the literal
* 'NI'-typed isolate formatting characters: a neutral between two strong characters
* of the same direction takes that direction, keeping multi-word runs together.
*
* @throws \Com\Tecnick\Unicode\Exception
*/
#[DataProvider('neutralResolutionDataProvider')]
public function testNeutralResolution(string $str, string $expected, string $forcedir): void
{
$bidi = new Bidi($str, null, null, $forcedir, false);
$this->assertSame($expected, $bidi->getString());
}
/**
* @return array<int, array{string, string, string}>
*/
public static function neutralResolutionDataProvider(): array
{
// The issue's third case (all-Latin text forced RTL) is covered by
// bidiStrDataProvider: shaping is a no-op for ASCII input.
return [
// RTL paragraph with a two-word Latin phrase: the phrase keeps its word order
['تجربة - John Doe', 'John Doe - ةبرجت', ''],
// LTR paragraph with a two-word Arabic phrase: reversed as a single run
['John Doe - تجربة خاصة', 'John Doe - ةصاخ ةبرجت', ''],
];
}
/**
* Test Bidi with edge-case ordarr inputs: a negative codepoint and a Private Use Area
* codepoint as the last character of the paragraph.
*
* @throws \Com\Tecnick\Unicode\Exception
*/
public function testBidiWithSpecialOrdarr(): void
{
// Negative codepoint as last char: covers the $lastchar < 0 branch
$bidi1 = new \Com\Tecnick\Unicode\Bidi(null, null, [0x05D0, -1], 'R', false);
$this->assertEquals([-1, 1488], $bidi1->getOrdArray());
// Codepoint 0xE001 (Private Use Area): type L, so it forms its own left-to-right run
$bidi2 = new \Com\Tecnick\Unicode\Bidi(null, null, [0x05D0, 0xE001], 'R', false);
$this->assertEquals([57345, 1488], $bidi2->getOrdArray());
}
/**
* X9 removes the explicit formatting characters also when the text holds no
* right-to-left character, so the left-to-right fast path cannot return them.
*
* @throws \Com\Tecnick\Unicode\Exception
*/
public function testFormattingCharactersAreRemoved(): void
{
$bidi = new Bidi(self::decodeJsonString('"a\u202Ab\u202Cc"'));
$this->assertSame('abc', $bidi->getString());
$bidi = new Bidi(self::decodeJsonString('"a\u2066b\u2069c"'));
$this->assertSame(
self::decodeJsonString('"a\u2066b\u2069c"'),
$bidi->getString(),
'the isolate formatting characters are retained',
);
}
/**
* The string, char array and codepoint array forms of the input must describe the
* same text: a different number of characters is an input error.
*
* @throws \Com\Tecnick\Unicode\Exception
*/
public function testMismatchedInputForms(): void
{
$this->bcExpectException(\Com\Tecnick\Unicode\Exception::class);
new Bidi('test', null, [0x05D0]);
}
/**
* Conformance repros taken from the official BidiCharacterTest.txt suite, checked
* against the codepoint sequence the UBA produces (shaping disabled).
*
* @param array<int> $ordarr Input codepoints
* @param array<int> $expected Expected codepoints in visual order
*
* @throws \Com\Tecnick\Unicode\Exception
*/
#[DataProvider('ubaOrdArrDataProvider')]
public function testUbaOrdArr(array $ordarr, string $forcedir, array $expected): void
{
$bidi = new Bidi(null, null, $ordarr, $forcedir, false);
$this->assertSame($expected, \array_values($bidi->getOrdArray()));
}
/**
* @return array<string, array{array<int>, string, array<int>}>
*/
public static function ubaOrdArrDataProvider(): array
{
return [
// X5c: the direction of an FSI comes from the content between it and its
// matching PDI, so an FSI wrapping Hebrew text acts as an RLI.
'FSI with RTL content' => [
[0x2068, 0x05D0, 0x0021, 0x2069, 0x0061, 0x0062],
'',
[0x2068, 0x0021, 0x05D0, 0x2069, 0x0061, 0x0062],
],
// X5c with no matching PDI: the scan runs to the end of the paragraph.
'unterminated FSI with RTL content' => [
[0x2068, 0x05D0, 0x0061],
'',
[0x2068, 0x0061, 0x05D0],
],
// X6a: a PDI matching no isolate initiator keeps the level of the current stack
// entry and is retained by X9 instead of being deleted.
'unmatched PDI is retained' => [
[0x05D0, 0x2069, 0x05D1],
'',
[0x05D1, 0x2069, 0x05D0],
],
// W4: a European separator only joins two European numbers, so the plus sign
// between two Arabic-Indic digits stays a separator and N1 resolves it as R.
'ES does not join two Arabic numbers' => [
[0x0660, 0x002B, 0x0661],
'R',
[0x0661, 0x002B, 0x0660],
],
// Same rule with a hyphen between two European numbers resolved to AN by W2.
'ES after an Arabic letter' => [
[0x0028, 0x0627, 0x0029, 0x0020, 0x0031, 0x002D, 0x0032],
'L',
[0x0028, 0x0627, 0x0029, 0x0020, 0x0032, 0x002D, 0x0031],
],
// BD14/BD15: a bracket whose current type is not ON (here retyped by an
// override) is not part of a bracket pair.
'brackets retyped by an override do not pair' => [
[0x202E, 0x0028, 0x202C, 0x202B, 0x05D0, 0x0062, 0x0029, 0x0063, 0x202C],
'R',
[0x0062, 0x0029, 0x0063, 0x05D0, 0x0029],
],
];
}
/**
* BD16 uses a fixed 63 element stack: when a 64th opening bracket is found, bracket
* pairing stops for the remainder of the isolating run sequence and the pairs found
* so far are kept.
*
* @throws \Com\Tecnick\Unicode\Exception
*/
public function testBracketStackLimit(): void
{
$ordarr = \array_merge([0x0061], \array_fill(0, 64, 0x0028), [0x0062], \array_fill(0, 64, 0x0029));
// None of the 64 pairs is resolved to L by N0, so N1 leaves the brackets at the
// paragraph level: the whole line is reversed and every bracket is mirrored.
$expected = \array_merge(\array_fill(0, 64, 0x0028), [0x0061], \array_fill(0, 64, 0x0028), [0x0062]);
$bidi = new Bidi(null, null, $ordarr, 'R', false);
$this->assertSame($expected, \array_values($bidi->getOrdArray()));
}
}