From cd09da7aaa18a07f2603cbaff6ad31f8f4f54023 Mon Sep 17 00:00:00 2001 From: loaiAladdien Date: Fri, 24 Jul 2026 13:31:38 +0300 Subject: [PATCH 1/3] Fix bidi reordering for indices absorbed into glyph clusters --- packages/textkit/src/layout/bidiReordering.ts | 1 + packages/textkit/tests/assets/OFL-Noto.txt | 94 ++++++++++++++++++ .../tests/assets/noto-sans-devanagari.ttf | Bin 0 -> 11456 bytes packages/textkit/tests/index.test.ts | 84 +++++++++++++++- 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 packages/textkit/tests/assets/OFL-Noto.txt create mode 100644 packages/textkit/tests/assets/noto-sans-devanagari.ttf diff --git a/packages/textkit/src/layout/bidiReordering.ts b/packages/textkit/src/layout/bidiReordering.ts index 9f85e7ddf..7a2f072fc 100644 --- a/packages/textkit/src/layout/bidiReordering.ts +++ b/packages/textkit/src/layout/bidiReordering.ts @@ -72,6 +72,7 @@ const reorderLine = (line: AttributedString) => { const glyph = getItemAtIndex(line.runs, 'glyphs', index); + if (glyph === undefined) continue; if (addedGlyphs.has(glyph.id)) continue; updatedGlyphs.push(glyph); diff --git a/packages/textkit/tests/assets/OFL-Noto.txt b/packages/textkit/tests/assets/OFL-Noto.txt new file mode 100644 index 000000000..c82d72e42 --- /dev/null +++ b/packages/textkit/tests/assets/OFL-Noto.txt @@ -0,0 +1,94 @@ +Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts) + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/textkit/tests/assets/noto-sans-devanagari.ttf b/packages/textkit/tests/assets/noto-sans-devanagari.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2a1ade3ded8abe13e11c2c6354d81b6df8b409d2 GIT binary patch literal 11456 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 M$I0PW8h_aEKZS=}A^-pY literal 0 HcmV?d00001 diff --git a/packages/textkit/tests/index.test.ts b/packages/textkit/tests/index.test.ts index e359a63b1..e7d4297a3 100644 --- a/packages/textkit/tests/index.test.ts +++ b/packages/textkit/tests/index.test.ts @@ -18,6 +18,14 @@ const __dirname = path.dirname(__filename); const LATIN_FONT_PATH = path.resolve(__dirname, './assets/latin.ttf'); const BENGALI_FONT_PATH = path.resolve(__dirname, './assets/bengali.ttf'); const THAI_FONT_PATH = path.resolve(__dirname, './assets/thai.ttf'); +const DEVANAGARI_FONT_PATH = path.resolve( + __dirname, + './assets/noto-sans-devanagari.ttf', +); +const ARABIC_FONT_PATH = path.resolve( + __dirname, + '../../examples/vite/public/NotoSansArabic-Regular.ttf', +); const engines = { bidi, @@ -31,19 +39,26 @@ const engines = { let font: Font; let bengaliFont: Font; let thaiFont: Font; +let devanagariFont: Font; +let arabicFont: Font; beforeAll(async () => { font = (await fontkit.open(LATIN_FONT_PATH)) as unknown as Font; bengaliFont = (await fontkit.open(BENGALI_FONT_PATH)) as unknown as Font; thaiFont = (await fontkit.open(THAI_FONT_PATH)) as unknown as Font; + devanagariFont = (await fontkit.open( + DEVANAGARI_FONT_PATH, + )) as unknown as Font; + arabicFont = (await fontkit.open(ARABIC_FONT_PATH)) as unknown as Font; }); const layout = ( string: string, - opts: { fontSize?: number; width?: number; font?: Font } = {}, + opts: { fontSize?: number; width?: number; font?: Font | Font[] } = {}, ) => { const { fontSize = 12, width = 500, font: f } = opts; const engine = layoutEngine(engines); + const fonts = Array.isArray(f) ? f : [f ?? font]; const attributedString = { string, @@ -51,7 +66,7 @@ const layout = ( { start: 0, end: string.length, - attributes: { font: [f ?? font], fontSize }, + attributes: { font: fonts, fontSize }, }, ], }; @@ -255,3 +270,68 @@ test('should produce positive advances for all Thai glyphs', () => { expect(pos.xAdvance).toBeGreaterThanOrEqual(0); } }); + +test('should preserve shaped glyph clusters during bidi reordering', () => { + const fonts = [font, devanagariFont, arabicFont]; + const fontLabels = new Map([ + [font, 'latin'], + [devanagariFont, 'devanagari'], + [arabicFont, 'arabic'], + ]); + const shape = (font: Font, string: string, label: string) => + font + .layout(string, undefined, undefined, undefined, 'ltr') + .glyphs.map((glyph) => `${label}:${glyph.id}`); + + layout('रृ', { font: fonts }); + + const cluster = devanagariFont.layout( + 'र्क', + undefined, + undefined, + undefined, + 'ltr', + ); + + expect('र्क').toHaveLength(3); + expect(cluster.glyphs).toHaveLength(2); + expect(cluster.glyphs.flatMap((glyph) => glyph.codePoints)).toHaveLength(2); + + expect(getGlyphs(layout('abc र्क def', { font: fonts }))).toHaveLength(10); + expect(getGlyphs(layout('abc مصر def', { font: fonts }))).toHaveLength(11); + expect(getGlyphs(layout('abc किंतु مصر def', { font: fonts }))).toHaveLength( + 17, + ); + + const expectedGlyphs = [ + ...shape(font, 'abc ', 'latin'), + ...shape(devanagariFont, 'र्क', 'devanagari'), + ...shape(font, ' ', 'latin'), + ...shape(arabicFont, 'مصر', 'arabic'), + ...shape(font, ' def', 'latin'), + ].sort(); + + const result = layout('abc र्क مصر def', { font: fonts }); + const glyphs = getGlyphs(result); + const positions = getPositions(result); + const actualGlyphs = result + .flatMap((lines) => lines) + .flatMap((line) => line.runs) + .flatMap((run) => + (run.glyphs ?? []).map( + (glyph) => `${fontLabels.get(run.attributes.font?.[0])}:${glyph.id}`, + ), + ) + .sort(); + + expect(actualGlyphs).toEqual(expectedGlyphs); + expect(positions).toHaveLength(glyphs.length); + + for (const lines of result) { + for (const line of lines) { + for (const run of line.runs) { + expect(run.positions).toHaveLength(run.glyphs?.length ?? 0); + } + } + } +}); From 391af921bfed430824d7164a5ecddab5cf6861b0 Mon Sep 17 00:00:00 2001 From: loaiAladdien Date: Fri, 24 Jul 2026 13:59:24 +0300 Subject: [PATCH 2/3] refactor tests for simplicity --- packages/textkit/tests/index.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/textkit/tests/index.test.ts b/packages/textkit/tests/index.test.ts index e7d4297a3..e595b161f 100644 --- a/packages/textkit/tests/index.test.ts +++ b/packages/textkit/tests/index.test.ts @@ -312,6 +312,7 @@ test('should preserve shaped glyph clusters during bidi reordering', () => { ].sort(); const result = layout('abc र्क مصر def', { font: fonts }); + const glyphs = getGlyphs(result); const positions = getPositions(result); const actualGlyphs = result @@ -326,12 +327,8 @@ test('should preserve shaped glyph clusters during bidi reordering', () => { expect(actualGlyphs).toEqual(expectedGlyphs); expect(positions).toHaveLength(glyphs.length); - - for (const lines of result) { - for (const line of lines) { - for (const run of line.runs) { - expect(run.positions).toHaveLength(run.glyphs?.length ?? 0); - } - } + const [[{ runs }]] = result; + for (const run of runs) { + expect(run.positions).toHaveLength(run.glyphs?.length ?? 0); } }); From 0e259e505c29f0c4d514dceea69a38deb8d0431e Mon Sep 17 00:00:00 2001 From: loaiAladdien Date: Fri, 24 Jul 2026 13:59:49 +0300 Subject: [PATCH 3/3] remove breakline --- packages/textkit/tests/index.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/textkit/tests/index.test.ts b/packages/textkit/tests/index.test.ts index e595b161f..90caed243 100644 --- a/packages/textkit/tests/index.test.ts +++ b/packages/textkit/tests/index.test.ts @@ -312,7 +312,6 @@ test('should preserve shaped glyph clusters during bidi reordering', () => { ].sort(); const result = layout('abc र्क مصر def', { font: fonts }); - const glyphs = getGlyphs(result); const positions = getPositions(result); const actualGlyphs = result