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 000000000..2a1ade3de Binary files /dev/null and b/packages/textkit/tests/assets/noto-sans-devanagari.ttf differ diff --git a/packages/textkit/tests/index.test.ts b/packages/textkit/tests/index.test.ts index e359a63b1..90caed243 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,64 @@ 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); + const [[{ runs }]] = result; + for (const run of runs) { + expect(run.positions).toHaveLength(run.glyphs?.length ?? 0); + } +});