postext-pdf 1.29.1 → 1.29.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/shapingLanguageRuns.test.d.ts +2 -0
- package/dist/__tests__/shapingLanguageRuns.test.d.ts.map +1 -0
- package/dist/__tests__/shapingLanguageRuns.test.js +250 -0
- package/dist/__tests__/shapingLanguageRuns.test.js.map +1 -0
- package/dist/__tests__/shapingLanguageRunsDocument.test.d.ts +2 -0
- package/dist/__tests__/shapingLanguageRunsDocument.test.d.ts.map +1 -0
- package/dist/__tests__/shapingLanguageRunsDocument.test.js +327 -0
- package/dist/__tests__/shapingLanguageRunsDocument.test.js.map +1 -0
- package/dist/faceFiles.d.ts +9 -0
- package/dist/faceFiles.d.ts.map +1 -1
- package/dist/faceFiles.js +16 -1
- package/dist/faceFiles.js.map +1 -1
- package/dist/pdf-backend/blockRender.d.ts.map +1 -1
- package/dist/pdf-backend/blockRender.js +11 -1
- package/dist/pdf-backend/blockRender.js.map +1 -1
- package/dist/pdf-backend/primitives.js +7 -7
- package/dist/pdf-backend/primitives.js.map +1 -1
- package/dist/pdf-backend/renderResourceBlock.d.ts.map +1 -1
- package/dist/pdf-backend/renderResourceBlock.js +8 -2
- package/dist/pdf-backend/renderResourceBlock.js.map +1 -1
- package/dist/pdf-backend/shapedText.d.ts.map +1 -1
- package/dist/pdf-backend/shapedText.js +16 -6
- package/dist/pdf-backend/shapedText.js.map +1 -1
- package/dist/pdf-backend/wordPieces.d.ts +48 -0
- package/dist/pdf-backend/wordPieces.d.ts.map +1 -0
- package/dist/pdf-backend/wordPieces.js +72 -0
- package/dist/pdf-backend/wordPieces.js.map +1 -0
- package/dist/shapingLanguage.d.ts +40 -3
- package/dist/shapingLanguage.d.ts.map +1 -1
- package/dist/shapingLanguage.js +168 -5
- package/dist/shapingLanguage.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shapingLanguageRuns.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/shapingLanguageRuns.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import fontkit from '@pdf-lib/fontkit';
|
|
4
|
+
import { PDFDocument } from 'pdf-lib';
|
|
5
|
+
import { loadComplexShaper, needsComplexShaping, shapeRun } from '../complexShaping';
|
|
6
|
+
import { fileRuns, registerFaceFiles } from '../faceFiles';
|
|
7
|
+
import { besideLetterOf, shapeInCurrentLanguage, shapingKey, shapingLanguage, shapingScriptOf, withBesideLetter, withDocumentLanguage, withShapingScript, withinRightToLeftText } from '../shapingLanguage';
|
|
8
|
+
import { textAdvancePx } from '../pdf-backend/primitives';
|
|
9
|
+
import { shapedSvgText } from '../pdf-backend/shapedText';
|
|
10
|
+
// #764, right-to-left runs and faces in several files. The widths below
|
|
11
|
+
// are the ones Chrome 154 gives these very font files on a canvas, loaded
|
|
12
|
+
// as the Cookbook loads them (the `latin` file for U+0000-00FF and
|
|
13
|
+
// U+2000-206F, the `arabic` file for the Arabic blocks), `measureText` at
|
|
14
|
+
// about 1000 px with `ctx.lang` and `ctx.direction` set, each string on a
|
|
15
|
+
// font size of its own (Chrome keeps the words it has shaped by font, and
|
|
16
|
+
// answers a word met before from there): thousandths of the em.
|
|
17
|
+
//
|
|
18
|
+
// Chrome cuts a string into runs of one direction and each run into words
|
|
19
|
+
// at its spaces, and shapes each word in the script of its letters: a mark
|
|
20
|
+
// takes the script of the letter before it in its word, else of the first
|
|
21
|
+
// after it, whatever file draws it. A word with no letter has no script,
|
|
22
|
+
// unless the whole string has no character above U+00FF, which makes all
|
|
23
|
+
// of it Latin text.
|
|
24
|
+
const fixture = (name) => new Uint8Array(fs.readFileSync(new URL(`./fixtures/fontsource/${name}`, import.meta.url)));
|
|
25
|
+
const FILES = {
|
|
26
|
+
markazi: [fixture('markazi-text-latin.ttf'), fixture('markazi-text-arabic.ttf')],
|
|
27
|
+
amiri: [fixture('amiri-latin.ttf'), fixture('amiri-arabic.ttf')],
|
|
28
|
+
crimson: [fixture('crimson-pro-latin.ttf')],
|
|
29
|
+
};
|
|
30
|
+
const ARABIC_FACES = ['markazi', 'amiri'];
|
|
31
|
+
/** The languages measured: Arabic, and no language. */
|
|
32
|
+
const LANGUAGES = ['ar', ''];
|
|
33
|
+
/** Right-to-left words as the layout cuts and measures them (`«أنا`,
|
|
34
|
+
* `خالة.»`, `«يوسف؟`, `(ويقال`, `كتاب.`, `كتاب)`, `كتاب:`, `[كتاب]`,
|
|
35
|
+
* `كتاب!`, `“كتاب”`, `كتاب،`, `كتاب`): in a right-to-left context. */
|
|
36
|
+
const WORDS = ['\u00AB\u0623\u0646\u0627', '\u062E\u0627\u0644\u0629.\u00BB', '\u00AB\u064A\u0648\u0633\u0641\u061F', '(\u0648\u064A\u0642\u0627\u0644', '\u0643\u062A\u0627\u0628.', '\u0643\u062A\u0627\u0628)', '\u0643\u062A\u0627\u0628:', '[\u0643\u062A\u0627\u0628]', '\u0643\u062A\u0627\u0628!', '\u201C\u0643\u062A\u0627\u0628\u201D', '\u0643\u062A\u0627\u0628\u060C', '\u0643\u062A\u0627\u0628'];
|
|
37
|
+
const WORD_WIDTHS = {
|
|
38
|
+
// Markazi Text draws the guillemets, the stop and the exclamation mark
|
|
39
|
+
// of Arabic text otherwise in Arabic.
|
|
40
|
+
markazi: {
|
|
41
|
+
ar: [939.4, 1892.1, 2703.6, 1928.7, 1708.0, 1749.5, 1724.6, 2002.0, 1718.3, 2141.6, 1762.7, 1525.4],
|
|
42
|
+
'': [1043.4, 1988.3, 2807.6, 1928.7, 1714.8, 1749.5, 1724.6, 2002.0, 1734.4, 2141.6, 1762.7, 1525.4],
|
|
43
|
+
},
|
|
44
|
+
// Amiri the same in every language: the stop of Arabic text is 0.317 em
|
|
45
|
+
// (0.202 as Latin text), a bracket 0.458 (0.255), a guillemet 0.415
|
|
46
|
+
// (0.47).
|
|
47
|
+
amiri: {
|
|
48
|
+
ar: [1068, 2112, 2936, 2380, 2040, 2181, 1943, 2445, 1958, 2463, 2092, 1723],
|
|
49
|
+
'': [1068, 2112, 2936, 2380, 2040, 2181, 1943, 2445, 1958, 2463, 2092, 1723],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
/** Words with no letter that the layout measured in the Arabic script
|
|
53
|
+
* (`scriptContext`, #725), right to left: beside `ب` and a no-break
|
|
54
|
+
* space, the width of those taken off. */
|
|
55
|
+
const SCRIPTED = ['\u00BB.', '(', '.', '\u00AB', ')'];
|
|
56
|
+
const SCRIPTED_WIDTHS = {
|
|
57
|
+
markazi: { ar: [485.8, 224.1, 182.6, 303.2, 224.1], '': [596.7, 224.1, 189.4, 407.2, 224.1] },
|
|
58
|
+
amiri: { ar: [732, 458, 317, 415, 458], '': [732, 458, 317, 415, 458] },
|
|
59
|
+
};
|
|
60
|
+
/** Strings of several words, as a title or a running head is measured
|
|
61
|
+
* and set, in a right-to-left and in a left-to-right context. */
|
|
62
|
+
const MIXED = ['\u0643\u062A\u0627\u0628 (1)', '\u0643\u062A\u0627\u0628 . \u0643\u062A\u0627\u0628', '\u0643\u062A\u0627\u0628 \u00ABabc\u00BB', '\u00ABabc\u00BB \u0643\u062A\u0627\u0628', '\u0643\u062A\u0627\u0628 \u00AB\u0643\u062A\u0627\u0628\u00BB', '\u0643\u062A\u0627\u0628 46.', '(1) \u0643\u062A\u0627\u0628'];
|
|
63
|
+
const MIXED_WIDTHS = {
|
|
64
|
+
markazi: {
|
|
65
|
+
ar: { rtl: [2467.8, 3572.2, 3646.5, 3646.5, 3823.2, 2694.3, 2467.8], ltr: [2467.8, 3572.2, 3646.5, 3646.5, 3927.2, 2694.3, 2467.8] },
|
|
66
|
+
'': { rtl: [2467.8, 3572.2, 3646.5, 3646.5, 4031.2, 2694.3, 2467.8], ltr: [2467.8, 3572.2, 3646.5, 3646.5, 4031.2, 2694.3, 2467.8] },
|
|
67
|
+
},
|
|
68
|
+
amiri: {
|
|
69
|
+
ar: { rtl: [3463, 4347, 4164, 4164, 4568, 3396, 3463], ltr: [3463, 4347, 4274, 4274, 4568, 3396, 3463] },
|
|
70
|
+
'': { rtl: [3463, 4347, 4164, 4164, 4568, 3396, 3463], ltr: [3463, 4347, 4274, 4274, 4568, 3396, 3463] },
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
/** Strings with no character above U+00FF, alone: Latin text. */
|
|
74
|
+
const LATIN = ['\u00AB', '\u00BB.', '(1)', '.', '46.', '(d.', '\u00ABabc\u00BB'];
|
|
75
|
+
const LATIN_WIDTHS = {
|
|
76
|
+
markazi: [407.2, 596.7, 776.4, 189.4, 1002.9, 835.4, 1955.1],
|
|
77
|
+
amiri: [470, 672, 1042, 202, 1266, 959, 2259],
|
|
78
|
+
};
|
|
79
|
+
/** Crimson Pro: the Catalan `l·l`, whose dot is 600 units of 1024 wide
|
|
80
|
+
* between two `l` in Catalan and 246 anywhere else; and the pieces a
|
|
81
|
+
* line breaker cuts `instal·lació` into. */
|
|
82
|
+
const CATALAN = ['l\u00B7l', 'L\u00B7L', 'col\u00B7legi', 'instal\u00B7laci\u00F3', 'il\u00B7lusi\u00F3', 'COL\u00B7LEGI', 'tal\u00B7', 'la', 'l\u00B7', '\u00B7l', '\u00B7', 'a\u00B7b', 'l\u00B7L', 'paral\u00B7lela'];
|
|
83
|
+
const CATALAN_WIDTHS = {
|
|
84
|
+
ca: [1111.3, 1584.0, 3202.1, 4727.5, 3053.7, 4312.5, 1292.0, 725.6, 502.9, 502.9, 240.2, 1216.8, 1002.0, 4062.5],
|
|
85
|
+
es: [765.6, 1238.3, 2856.4, 4381.8, 2708.0, 3966.8, 1292.0, 725.6, 502.9, 502.9, 240.2, 1216.8, 1002.0, 3716.8],
|
|
86
|
+
'': [765.6, 1238.3, 2856.4, 4381.8, 2708.0, 3966.8, 1292.0, 725.6, 502.9, 502.9, 240.2, 1216.8, 1002.0, 3716.8],
|
|
87
|
+
};
|
|
88
|
+
const fonts = {};
|
|
89
|
+
const bytesOf = new Map();
|
|
90
|
+
beforeAll(async () => {
|
|
91
|
+
await loadComplexShaper();
|
|
92
|
+
const pdf = await PDFDocument.create();
|
|
93
|
+
pdf.registerFontkit(fontkit);
|
|
94
|
+
for (const face of Object.keys(FILES)) {
|
|
95
|
+
const files = [];
|
|
96
|
+
for (const bytes of FILES[face]) {
|
|
97
|
+
const file = await pdf.embedFont(bytes, { subset: true });
|
|
98
|
+
shapeInCurrentLanguage(file);
|
|
99
|
+
bytesOf.set(file, bytes);
|
|
100
|
+
files.push(file);
|
|
101
|
+
}
|
|
102
|
+
// The Cookbook kit's order: the `latin` file first.
|
|
103
|
+
if (files.length > 1)
|
|
104
|
+
registerFaceFiles(files[0], files.slice(1));
|
|
105
|
+
fonts[face] = files[0];
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
/** In a document shaped in the language `tag`. */
|
|
109
|
+
const inLanguage = (tag, fn) => withDocumentLanguage(tag, tag || undefined, fn);
|
|
110
|
+
/** HarfBuzz's advance of `text` through the PDF's painter of shaped text,
|
|
111
|
+
* in a context of direction `base`. */
|
|
112
|
+
const painted = (face, text, base) => shapedSvgText(fonts[face], text, 1000, base).advance;
|
|
113
|
+
/** fontkit's, as pdf-lib sets plain text. */
|
|
114
|
+
const plain = (face, text) => textAdvancePx(fonts[face], text, 1000).advance;
|
|
115
|
+
/** `shapeRun`'s, asked part by part in the script and the language the
|
|
116
|
+
* painter asks in: `text` is one run of direction `direction`. */
|
|
117
|
+
function shapedRun(face, text, direction) {
|
|
118
|
+
const language = shapingLanguage() || undefined;
|
|
119
|
+
return withinRightToLeftText(needsComplexShaping(text), () => fileRuns(fonts[face], text, direction === 'rtl').reduce((sum, part) => sum + withBesideLetter(part.beside, () => {
|
|
120
|
+
const script = shapingScriptOf(part.text);
|
|
121
|
+
const run = shapeRun(bytesOf.get(part.font), part.text, { direction, ...(language ? { language } : {}), ...(script ? { script } : {}) });
|
|
122
|
+
return (run.glyphs.reduce((advance, g) => advance + g.xAdvance, 0) * 1000) / run.upem;
|
|
123
|
+
}), 0));
|
|
124
|
+
}
|
|
125
|
+
/** Within half a thousandth of the em of Chrome's width. */
|
|
126
|
+
const expectWidth = (got, chrome, what) => expect(Math.abs(got - chrome), `${what}: ${got.toFixed(1)}, Chrome ${chrome}`).toBeLessThan(0.5);
|
|
127
|
+
describe('a right-to-left word of a face in several files (#764)', () => {
|
|
128
|
+
it('a mark is shaped in the script of the letter beside it, whatever file draws it', () => {
|
|
129
|
+
for (const face of ARABIC_FACES) {
|
|
130
|
+
for (const tag of LANGUAGES) {
|
|
131
|
+
inLanguage(tag, () => WORDS.forEach((word, i) => {
|
|
132
|
+
const chrome = WORD_WIDTHS[face][tag][i];
|
|
133
|
+
expectWidth(painted(face, word, 'rtl'), chrome, `${face} ${tag} ${word} painted`);
|
|
134
|
+
expectWidth(shapedRun(face, word, 'rtl'), chrome, `${face} ${tag} ${word} shapeRun`);
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
it('Markazi Text sets the guillemet of Arabic text 0.1 em narrower in Arabic, and Amiri no Latin stop in an Arabic word', () => {
|
|
140
|
+
const latinFile = (face, text, script, language) => {
|
|
141
|
+
const run = shapeRun(FILES[face][0], text, { direction: 'rtl', ...(script ? { script } : {}), ...(language ? { language } : {}) });
|
|
142
|
+
return Math.round((run.glyphs.reduce((sum, g) => sum + g.xAdvance, 0) * 1000) / run.upem);
|
|
143
|
+
};
|
|
144
|
+
// The `latin` file of Markazi Text: `arab` has a language system for
|
|
145
|
+
// Arabic, `latn` none.
|
|
146
|
+
expect(latinFile('markazi', '\u00AB', 'Arab', 'ar')).toBe(303);
|
|
147
|
+
expect(latinFile('markazi', '\u00AB', 'Arab')).toBe(407);
|
|
148
|
+
expect(latinFile('markazi', '\u00AB', 'Latn', 'ar')).toBe(407);
|
|
149
|
+
// Amiri's: the Latin forms under `latn` only.
|
|
150
|
+
expect([latinFile('amiri', '.', 'Arab'), latinFile('amiri', '.', undefined), latinFile('amiri', '.', 'Latn')]).toEqual([317, 317, 202]);
|
|
151
|
+
expect([latinFile('amiri', '(', 'Arab'), latinFile('amiri', '(', 'Latn')]).toEqual([458, 255]);
|
|
152
|
+
expect([latinFile('amiri', '\u00AB', 'Arab'), latinFile('amiri', '\u00AB', 'Latn')]).toEqual([415, 470]);
|
|
153
|
+
});
|
|
154
|
+
it('the parts say the letter beside them; a part with a letter, and any part of Latin text, none', () => {
|
|
155
|
+
const parts = (face, text, rtl = true) => fileRuns(fonts[face], text, rtl).map((part) => [part.text, part.beside === undefined ? undefined : String.fromCodePoint(part.beside)]);
|
|
156
|
+
inLanguage('ar', () => {
|
|
157
|
+
expect(parts('amiri', '\u062E\u0627\u0644\u0629.\u00BB')).toEqual([['\u062E\u0627\u0644\u0629', undefined], ['.\u00BB', '\u0629']]);
|
|
158
|
+
expect(parts('amiri', '\u00AB\u0623\u0646\u0627')).toEqual([['\u00AB', '\u0623'], ['\u0623\u0646\u0627', undefined]]);
|
|
159
|
+
expect(parts('markazi', '(\u0648\u064A\u0642\u0627\u0644')).toEqual([['(', '\u0648'], ['\u0648\u064A\u0642\u0627\u0644', undefined]]);
|
|
160
|
+
// Latin text: no character above U+00FF.
|
|
161
|
+
expect(parts('amiri', '\u00ABabc\u00BB', false)).toEqual([['\u00ABabc\u00BB', undefined]]);
|
|
162
|
+
// A word ends at a space: the stop between two takes nothing.
|
|
163
|
+
expect(parts('amiri', '\u0643\u062A\u0627\u0628 . \u0643\u062A\u0627\u0628').map(([, beside]) => beside)).toEqual([undefined, undefined, undefined]);
|
|
164
|
+
expect(besideLetterOf('\u0643\u062A\u0627\u0628 . \u0643\u062A\u0627\u0628', 5, 7)).toBeUndefined();
|
|
165
|
+
expect(besideLetterOf('\u0643\u062A\u0627\u0628 .\u0643\u062A\u0627\u0628', 5, 6)).toBe('\u0643'.codePointAt(0));
|
|
166
|
+
expect(besideLetterOf('\u0643\u062A\u0627\u0628. \u0643\u062A\u0627\u0628', 4, 6)).toBe('\u0628'.codePointAt(0));
|
|
167
|
+
// Not beside an ideograph: the canvas shapes each apart.
|
|
168
|
+
expect(besideLetterOf('\u4E002026', 1, 5)).toBeUndefined();
|
|
169
|
+
// A script in force (#725) is the word's.
|
|
170
|
+
expect(withShapingScript('Arab', () => besideLetterOf('\u062E\u0627\u0644\u0629.\u00BB', 4, 6))).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
// A document that keeps the host's language: as before.
|
|
173
|
+
withDocumentLanguage(undefined, 'ar', () => {
|
|
174
|
+
expect(parts('amiri', '\u062E\u0627\u0644\u0629.\u00BB')).toEqual([['\u062E\u0627\u0644\u0629', undefined], ['.\u00BB', undefined]]);
|
|
175
|
+
expect(withBesideLetter(0x629, () => shapingScriptOf('.\u00BB'))).toBeUndefined();
|
|
176
|
+
expect(withBesideLetter(0x629, () => shapingKey('.\u00BB'))).toBe('.\u00BB');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
it('the script of the letter beside a part is in the key of every cache of shaped text', () => {
|
|
180
|
+
inLanguage('ar', () => {
|
|
181
|
+
expect(shapingScriptOf('.\u00BB')).toBe('Latn');
|
|
182
|
+
expect(withBesideLetter(0x629, () => shapingScriptOf('.\u00BB'))).toBe('Arab');
|
|
183
|
+
expect(withBesideLetter(0x5D0, () => shapingScriptOf('.\u00BB'))).toBe('Hebr');
|
|
184
|
+
expect(withBesideLetter(0x629, () => shapingKey('.\u00BB'))).not.toBe(shapingKey('.\u00BB'));
|
|
185
|
+
expect(withBesideLetter(0x629, () => shapingKey('.\u00BB'))).not.toBe(withBesideLetter(0x5D0, () => shapingKey('.\u00BB')));
|
|
186
|
+
// A piece with no letter of a string with right-to-left letters.
|
|
187
|
+
expect(withinRightToLeftText(true, () => shapingScriptOf('(1)'))).toBeUndefined();
|
|
188
|
+
expect(withinRightToLeftText(true, () => shapingScriptOf('abc'))).toBe('Latn');
|
|
189
|
+
expect(withinRightToLeftText(true, () => shapingKey('(1)'))).not.toBe(shapingKey('(1)'));
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('a word with no letter measured in the Arabic script (#725, #764)', () => {
|
|
194
|
+
it('is as wide as beside an Arabic letter, in the document\u2019s language', () => {
|
|
195
|
+
for (const face of ARABIC_FACES) {
|
|
196
|
+
for (const tag of LANGUAGES) {
|
|
197
|
+
inLanguage(tag, () => withShapingScript('Arab', () => SCRIPTED.forEach((word, i) => {
|
|
198
|
+
const chrome = SCRIPTED_WIDTHS[face][tag][i];
|
|
199
|
+
expectWidth(painted(face, word, 'rtl'), chrome, `${face} ${tag} ${word} painted`);
|
|
200
|
+
expectWidth(shapedRun(face, word, 'rtl'), chrome, `${face} ${tag} ${word} shapeRun`);
|
|
201
|
+
})));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe('a string of several words with right-to-left letters (#764)', () => {
|
|
207
|
+
it('each word in the script of its letters, a word with none in no script', () => {
|
|
208
|
+
for (const face of ARABIC_FACES) {
|
|
209
|
+
for (const tag of LANGUAGES) {
|
|
210
|
+
inLanguage(tag, () => MIXED.forEach((text, i) => {
|
|
211
|
+
for (const base of ['rtl', 'ltr'])
|
|
212
|
+
expectWidth(painted(face, text, base), MIXED_WIDTHS[face][tag][base][i], `${face} ${tag} ${base} ${text}`);
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
it('Latin text alone keeps its Latin forms, by both shapers', () => {
|
|
218
|
+
for (const face of ARABIC_FACES) {
|
|
219
|
+
for (const tag of LANGUAGES) {
|
|
220
|
+
inLanguage(tag, () => LATIN.forEach((text, i) => {
|
|
221
|
+
const chrome = LATIN_WIDTHS[face][i];
|
|
222
|
+
expectWidth(plain(face, text), chrome, `${face} ${tag} ${text} fontkit`);
|
|
223
|
+
expectWidth(painted(face, text, 'ltr'), chrome, `${face} ${tag} ${text} painted`);
|
|
224
|
+
expectWidth(shapedRun(face, text, 'ltr'), chrome, `${face} ${tag} ${text} shapeRun`);
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
describe('the Catalan l\u00B7l of Crimson Pro (#764)', () => {
|
|
231
|
+
it('takes its wide dot between two l in Catalan, by the three shapers', () => {
|
|
232
|
+
for (const tag of ['ca', 'es', '']) {
|
|
233
|
+
inLanguage(tag, () => CATALAN.forEach((text, i) => {
|
|
234
|
+
const chrome = CATALAN_WIDTHS[tag][i];
|
|
235
|
+
expectWidth(plain('crimson', text), chrome, `${tag} ${text} fontkit`);
|
|
236
|
+
expectWidth(painted('crimson', text, 'ltr'), chrome, `${tag} ${text} painted`);
|
|
237
|
+
expectWidth(shapedRun('crimson', text, 'ltr'), chrome, `${tag} ${text} shapeRun`);
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
it('the pieces of a word shaped apart are not the word: the dot needs both its neighbours', () => {
|
|
242
|
+
inLanguage('ca', () => {
|
|
243
|
+
const whole = plain('crimson', 'instal\u00B7laci\u00F3');
|
|
244
|
+
const pieces = ['ins', 'tal\u00B7', 'la', 'ci\u00F3'].reduce((sum, piece) => sum + plain('crimson', piece), 0);
|
|
245
|
+
// 600 - 246 units of 1024.
|
|
246
|
+
expect(whole - pieces).toBeCloseTo((354 / 1024) * 1000, 0);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
//# sourceMappingURL=shapingLanguageRuns.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shapingLanguageRuns.test.js","sourceRoot":"","sources":["../../src/__tests__/shapingLanguageRuns.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AACzD,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,OAAO,MAAM,kBAAkB,CAAC;AACvC,OAAO,EAAE,WAAW,EAAgB,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EAAE,QAAQ,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,sBAAsB,EAAE,UAAU,EAAE,eAAe,EAAE,eAAe,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAC5M,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE1D,wEAAwE;AACxE,0EAA0E;AAC1E,mEAAmE;AACnE,0EAA0E;AAC1E,0EAA0E;AAC1E,0EAA0E;AAC1E,gEAAgE;AAChE,EAAE;AACF,0EAA0E;AAC1E,2EAA2E;AAC3E,0EAA0E;AAC1E,yEAAyE;AACzE,yEAAyE;AACzE,oBAAoB;AAEpB,MAAM,OAAO,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,IAAI,UAAU,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,GAAG,CAAC,yBAAyB,IAAI,EAAE,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;AAC7H,MAAM,KAAK,GAAG;IACZ,OAAO,EAAE,CAAC,OAAO,CAAC,wBAAwB,CAAC,EAAE,OAAO,CAAC,yBAAyB,CAAC,CAAC;IAChF,KAAK,EAAE,CAAC,OAAO,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAChE,OAAO,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC,CAAC;CAC5C,CAAC;AAGF,MAAM,YAAY,GAAa,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AACpD,uDAAuD;AACvD,MAAM,SAAS,GAAG,CAAC,IAAI,EAAE,EAAE,CAAU,CAAC;AAEtC;;uEAEuE;AACvE,MAAM,KAAK,GAAG,CAAC,0BAA0B,EAAE,iCAAiC,EAAE,sCAAsC,EAAE,iCAAiC,EAAE,2BAA2B,EAAE,2BAA2B,EAAE,2BAA2B,EAAE,4BAA4B,EAAE,2BAA2B,EAAE,sCAAsC,EAAE,gCAAgC,EAAE,0BAA0B,CAAC,CAAC;AACjZ,MAAM,WAAW,GAA6C;IAC5D,uEAAuE;IACvE,sCAAsC;IACtC,OAAO,EAAE;QACP,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;QACnG,EAAE,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;KACrG;IACD,wEAAwE;IACxE,oEAAoE;IACpE,UAAU;IACV,KAAK,EAAE;QACL,EAAE,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC;QAC5E,EAAE,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC;KAC7E;CACF,CAAC;AAEF;;2CAE2C;AAC3C,MAAM,QAAQ,GAAG,CAAC,SAAS,EAAE,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,GAAG,CAAC,CAAC;AACtD,MAAM,eAAe,GAA6C;IAChE,OAAO,EAAE,EAAE,EAAE,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,EAAE;IAC7F,KAAK,EAAE,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,EAAE;CACxE,CAAC;AAEF;kEACkE;AAClE,MAAM,KAAK,GAAG,CAAC,8BAA8B,EAAE,qDAAqD,EAAE,0CAA0C,EAAE,0CAA0C,EAAE,+DAA+D,EAAE,8BAA8B,EAAE,8BAA8B,CAAC,CAAC;AAC/T,MAAM,YAAY,GAAqE;IACrF,OAAO,EAAE;QACP,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE;QACpI,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE;KACrI;IACD,KAAK,EAAE;QACL,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE;QACxG,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE;KACzG;CACF,CAAC;AAEF,iEAAiE;AACjE,MAAM,KAAK,GAAG,CAAC,QAAQ,EAAE,SAAS,EAAE,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,iBAAiB,CAAC,CAAC;AACjF,MAAM,YAAY,GAA6B;IAC7C,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC;IAC5D,KAAK,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,CAAC;CAC9C,CAAC;AAEF;;6CAE6C;AAC7C,MAAM,OAAO,GAAG,CAAC,UAAU,EAAE,UAAU,EAAE,eAAe,EAAE,wBAAwB,EAAE,oBAAoB,EAAE,eAAe,EAAE,WAAW,EAAE,IAAI,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAAE,iBAAiB,CAAC,CAAC;AACzN,MAAM,cAAc,GAA6B;IAC/C,EAAE,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;IAChH,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;IAC/G,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;CAChH,CAAC;AAEF,MAAM,KAAK,GAAG,EAA2B,CAAC;AAC1C,MAAM,OAAO,GAAG,IAAI,GAAG,EAAuB,CAAC;AAC/C,SAAS,CAAC,KAAK,IAAI,EAAE;IACnB,MAAM,iBAAiB,EAAE,CAAC;IAC1B,MAAM,GAAG,GAAG,MAAM,WAAW,CAAC,MAAM,EAAE,CAAC;IACvC,GAAG,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC;IAC7B,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAW,EAAE,CAAC;QAChD,MAAM,KAAK,GAAc,EAAE,CAAC;QAC5B,KAAK,MAAM,KAAK,IAAI,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YAChC,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,SAAS,CAAC,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;YAC1D,sBAAsB,CAAC,IAAI,CAAC,CAAC;YAC7B,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;YACzB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACnB,CAAC;QACD,oDAAoD;QACpD,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;YAAE,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAE,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QACnE,KAAK,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC;IAC1B,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,kDAAkD;AAClD,MAAM,UAAU,GAAG,CAAK,GAAW,EAAE,EAAW,EAAK,EAAE,CAAC,oBAAoB,CAAC,GAAG,EAAE,GAAG,IAAI,SAAS,EAAE,EAAE,CAAC,CAAC;AACxG;wCACwC;AACxC,MAAM,OAAO,GAAG,CAAC,IAAU,EAAE,IAAY,EAAE,IAAmB,EAAU,EAAE,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAE,CAAC,OAAO,CAAC;AACjI,6CAA6C;AAC7C,MAAM,KAAK,GAAG,CAAC,IAAU,EAAE,IAAY,EAAU,EAAE,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,OAAO,CAAC;AACnG;mEACmE;AACnE,SAAS,SAAS,CAAC,IAAU,EAAE,IAAY,EAAE,SAAwB;IACnE,MAAM,QAAQ,GAAG,eAAe,EAAE,IAAI,SAAS,CAAC;IAChD,OAAO,qBAAqB,CAAC,mBAAmB,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,KAAK,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,CAAC,GAAG,GAAG,gBAAgB,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE;QAC5K,MAAM,MAAM,GAAG,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC1C,MAAM,GAAG,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAE,EAAE,IAAI,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAE,CAAC;QAC3I,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,CAAC,EAAE,EAAE,CAAC,OAAO,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,GAAG,CAAC,IAAI,CAAC;IACxF,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AACV,CAAC;AACD,4DAA4D;AAC5D,MAAM,WAAW,GAAG,CAAC,GAAW,EAAE,MAAc,EAAE,IAAY,EAAQ,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,MAAM,CAAC,EAAE,GAAG,IAAI,KAAK,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,YAAY,MAAM,EAAE,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC;AAE1K,QAAQ,CAAC,wDAAwD,EAAE,GAAG,EAAE;IACtE,EAAE,CAAC,gFAAgF,EAAE,GAAG,EAAE;QACxF,KAAK,MAAM,IAAI,IAAI,YAAY,EAAE,CAAC;YAChC,KAAK,MAAM,GAAG,IAAI,SAAS,EAAE,CAAC;gBAC5B,UAAU,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;oBAC9C,MAAM,MAAM,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC,GAAG,CAAE,CAAC,CAAC,CAAE,CAAC;oBAC3C,WAAW,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,GAAG,IAAI,IAAI,UAAU,CAAC,CAAC;oBAClF,WAAW,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,GAAG,IAAI,IAAI,WAAW,CAAC,CAAC;gBACvF,CAAC,CAAC,CAAC,CAAC;YACN,CAAC;QACH,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qHAAqH,EAAE,GAAG,EAAE;QAC7H,MAAM,SAAS,GAAG,CAAC,IAAY,EAAE,IAAY,EAAE,MAA0B,EAAE,QAAiB,EAAU,EAAE;YACtG,MAAM,GAAG,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAE,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAE,CAAC;YACrI,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,GAAG,CAAC,IAAI,CAAC,CAAC;QAC5F,CAAC,CAAC;QACF,qEAAqE;QACrE,uBAAuB;QACvB,MAAM,CAAC,SAAS,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC/D,MAAM,CAAC,SAAS,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACzD,MAAM,CAAC,SAAS,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC/D,8CAA8C;QAC9C,MAAM,CAAC,CAAC,SAAS,CAAC,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,SAAS,CAAC,OAAO,EAAE,GAAG,EAAE,SAAS,CAAC,EAAE,SAAS,CAAC,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;QACxI,MAAM,CAAC,CAAC,SAAS,CAAC,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,SAAS,CAAC,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;QAC/F,MAAM,CAAC,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,SAAS,CAAC,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;IAC3G,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,8FAA8F,EAAE,GAAG,EAAE;QACtG,MAAM,KAAK,GAAG,CAAC,IAAU,EAAE,IAAY,EAAE,GAAG,GAAG,IAAI,EAAkC,EAAE,CACrF,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QACzI,UAAU,CAAC,IAAI,EAAE,GAAG,EAAE;YACpB,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,iCAAiC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,0BAA0B,EAAE,SAAS,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC;YACpI,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,0BAA0B,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,EAAE,QAAQ,CAAC,EAAE,CAAC,oBAAoB,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;YACtH,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,iCAAiC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,EAAE,QAAQ,CAAC,EAAE,CAAC,gCAAgC,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;YACtI,yCAAyC;YACzC,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,iBAAiB,EAAE,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,iBAAiB,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;YAC3F,8DAA8D;YAC9D,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,qDAAqD,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,SAAS,EAAE,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC;YACrJ,MAAM,CAAC,cAAc,CAAC,qDAAqD,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;YACpG,MAAM,CAAC,cAAc,CAAC,oDAAoD,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC;YACjH,MAAM,CAAC,cAAc,CAAC,oDAAoD,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC;YACjH,yDAAyD;YACzD,MAAM,CAAC,cAAc,CAAC,YAAY,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;YAC3D,0CAA0C;YAC1C,MAAM,CAAC,iBAAiB,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,cAAc,CAAC,iCAAiC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;QACnH,CAAC,CAAC,CAAC;QACH,wDAAwD;QACxD,oBAAoB,CAAC,SAAS,EAAE,IAAI,EAAE,GAAG,EAAE;YACzC,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,iCAAiC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,0BAA0B,EAAE,SAAS,CAAC,EAAE,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;YACrI,MAAM,CAAC,gBAAgB,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,eAAe,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;YAClF,MAAM,CAAC,gBAAgB,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAC/E,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oFAAoF,EAAE,GAAG,EAAE;QAC5F,UAAU,CAAC,IAAI,EAAE,GAAG,EAAE;YACpB,MAAM,CAAC,eAAe,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YAChD,MAAM,CAAC,gBAAgB,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,eAAe,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YAC/E,MAAM,CAAC,gBAAgB,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,eAAe,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YAC/E,MAAM,CAAC,gBAAgB,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC;YAC7F,MAAM,CAAC,gBAAgB,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,gBAAgB,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;YAC5H,iEAAiE;YACjE,MAAM,CAAC,qBAAqB,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;YAClF,MAAM,CAAC,qBAAqB,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YAC/E,MAAM,CAAC,qBAAqB,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;QAC3F,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,kEAAkE,EAAE,GAAG,EAAE;IAChF,EAAE,CAAC,wEAAwE,EAAE,GAAG,EAAE;QAChF,KAAK,MAAM,IAAI,IAAI,YAAY,EAAE,CAAC;YAChC,KAAK,MAAM,GAAG,IAAI,SAAS,EAAE,CAAC;gBAC5B,UAAU,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,iBAAiB,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;oBACjF,MAAM,MAAM,GAAG,eAAe,CAAC,IAAI,CAAC,CAAC,GAAG,CAAE,CAAC,CAAC,CAAE,CAAC;oBAC/C,WAAW,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,GAAG,IAAI,IAAI,UAAU,CAAC,CAAC;oBAClF,WAAW,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,GAAG,IAAI,IAAI,WAAW,CAAC,CAAC;gBACvF,CAAC,CAAC,CAAC,CAAC,CAAC;YACP,CAAC;QACH,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,6DAA6D,EAAE,GAAG,EAAE;IAC3E,EAAE,CAAC,uEAAuE,EAAE,GAAG,EAAE;QAC/E,KAAK,MAAM,IAAI,IAAI,YAAY,EAAE,CAAC;YAChC,KAAK,MAAM,GAAG,IAAI,SAAS,EAAE,CAAC;gBAC5B,UAAU,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;oBAC9C,KAAK,MAAM,IAAI,IAAI,CAAC,KAAK,EAAE,KAAK,CAAU;wBAAE,WAAW,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC,GAAG,CAAE,CAAC,IAAI,CAAC,CAAC,CAAC,CAAE,EAAE,GAAG,IAAI,IAAI,GAAG,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC,CAAC;gBAC3J,CAAC,CAAC,CAAC,CAAC;YACN,CAAC;QACH,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE;QACjE,KAAK,MAAM,IAAI,IAAI,YAAY,EAAE,CAAC;YAChC,KAAK,MAAM,GAAG,IAAI,SAAS,EAAE,CAAC;gBAC5B,UAAU,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;oBAC9C,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,CAAE,CAAC;oBACtC,WAAW,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,GAAG,IAAI,IAAI,UAAU,CAAC,CAAC;oBACzE,WAAW,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,GAAG,IAAI,IAAI,UAAU,CAAC,CAAC;oBAClF,WAAW,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,GAAG,IAAI,IAAI,WAAW,CAAC,CAAC;gBACvF,CAAC,CAAC,CAAC,CAAC;YACN,CAAC;QACH,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,4CAA4C,EAAE,GAAG,EAAE;IAC1D,EAAE,CAAC,mEAAmE,EAAE,GAAG,EAAE;QAC3E,KAAK,MAAM,GAAG,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC;YACnC,UAAU,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;gBAChD,MAAM,MAAM,GAAG,cAAc,CAAC,GAAG,CAAE,CAAC,CAAC,CAAE,CAAC;gBACxC,WAAW,CAAC,KAAK,CAAC,SAAS,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,GAAG,IAAI,IAAI,UAAU,CAAC,CAAC;gBACtE,WAAW,CAAC,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,GAAG,IAAI,IAAI,UAAU,CAAC,CAAC;gBAC/E,WAAW,CAAC,SAAS,CAAC,SAAS,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,GAAG,IAAI,IAAI,WAAW,CAAC,CAAC;YACpF,CAAC,CAAC,CAAC,CAAC;QACN,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uFAAuF,EAAE,GAAG,EAAE;QAC/F,UAAU,CAAC,IAAI,EAAE,GAAG,EAAE;YACpB,MAAM,KAAK,GAAG,KAAK,CAAC,SAAS,EAAE,wBAAwB,CAAC,CAAC;YACzD,MAAM,MAAM,GAAG,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,CAAC,GAAG,GAAG,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;YAC/G,2BAA2B;YAC3B,MAAM,CAAC,KAAK,GAAG,MAAM,CAAC,CAAC,WAAW,CAAC,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,CAAC;QAC7D,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shapingLanguageRunsDocument.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/shapingLanguageRunsDocument.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { buildDocument, lineRuns, resolveParagraph } from 'postext';
|
|
7
|
+
import { renderToPdf } from '../pdf-backend';
|
|
8
|
+
import { parseFontString } from '../fontString';
|
|
9
|
+
import { loadComplexShaper, shapeRun } from '../complexShaping';
|
|
10
|
+
// #764, end to end, in the faces the page review found off: an Arabic
|
|
11
|
+
// paragraph with quotations in guillemets, brackets and full stops in
|
|
12
|
+
// Markazi Text and in Amiri, and a justified Catalan paragraph with `l·l`
|
|
13
|
+
// words in Crimson Pro. The document is laid out by a measurer that
|
|
14
|
+
// shapes as Chrome 154's canvas does (see `shapingLanguageRuns.test.ts`,
|
|
15
|
+
// which holds Chrome's own widths of these files), in the language and
|
|
16
|
+
// the direction the engine sets on it, and its PDF prints every word
|
|
17
|
+
// where the layout put it.
|
|
18
|
+
const fixture = (name) => new Uint8Array(fs.readFileSync(new URL(`./fixtures/fontsource/${name}`, import.meta.url)));
|
|
19
|
+
/** The files of each family, as Fontsource cuts it: `latin`, then
|
|
20
|
+
* `arabic`. */
|
|
21
|
+
const FACES = {
|
|
22
|
+
'Markazi Text': [fixture('markazi-text-latin.ttf'), fixture('markazi-text-arabic.ttf')],
|
|
23
|
+
Amiri: [fixture('amiri-latin.ttf'), fixture('amiri-arabic.ttf')],
|
|
24
|
+
'Crimson Pro': [fixture('crimson-pro-latin.ttf')],
|
|
25
|
+
};
|
|
26
|
+
const fontProvider = async (family) => FACES[family] ?? FACES.Amiri;
|
|
27
|
+
/** The file the browser takes a character from: the `arabic` one for the
|
|
28
|
+
* Arabic blocks (its `unicode-range`), the `latin` one for the rest. */
|
|
29
|
+
const fileOf = (files, cp) => (files.length > 1 && cp >= 0x0600 && cp <= 0x08FF ? files[1] : files[0]);
|
|
30
|
+
const SCRIPTS = [['Arab', /\p{Script=Arabic}/u], ['Latn', /\p{Script=Latin}/u], ['Grek', /\p{Script=Greek}/u], ['Cyrl', /\p{Script=Cyrillic}/u]];
|
|
31
|
+
const scriptOfLetter = (ch) => SCRIPTS.find(([, re]) => re.test(ch))?.[0];
|
|
32
|
+
/** The advance (em) of one word of a run, as Chrome shapes it: each
|
|
33
|
+
* character in the script of the letter before it in the word, else of
|
|
34
|
+
* the first after it (none for a word with no letter; `latin`: the whole
|
|
35
|
+
* string had no character above U+00FF, and is Latin text), then cut by
|
|
36
|
+
* the file that draws it. */
|
|
37
|
+
function wordAdvance(files, word, rtl, language, latin) {
|
|
38
|
+
const chars = [...word];
|
|
39
|
+
const own = chars.map((ch) => (latin ? 'Latn' : scriptOfLetter(ch)));
|
|
40
|
+
const first = own.find((s) => s !== undefined);
|
|
41
|
+
let before;
|
|
42
|
+
const scripts = own.map((s) => (before = s ?? before) ?? first);
|
|
43
|
+
let advance = 0;
|
|
44
|
+
for (let a = 0; a < chars.length;) {
|
|
45
|
+
const file = fileOf(files, chars[a].codePointAt(0));
|
|
46
|
+
let b = a + 1;
|
|
47
|
+
while (b < chars.length && scripts[b] === scripts[a] && fileOf(files, chars[b].codePointAt(0)) === file)
|
|
48
|
+
b++;
|
|
49
|
+
const script = scripts[a];
|
|
50
|
+
const run = shapeRun(file, chars.slice(a, b).join(''), { direction: rtl ? 'rtl' : 'ltr', ...(script ? { script } : {}), ...(language ? { language } : {}) });
|
|
51
|
+
advance += run ? run.glyphs.reduce((sum, g) => sum + g.xAdvance, 0) / run.upem : (b - a) * 0.5;
|
|
52
|
+
a = b;
|
|
53
|
+
}
|
|
54
|
+
return advance;
|
|
55
|
+
}
|
|
56
|
+
/** The strings the engine measured, each with the context's direction. */
|
|
57
|
+
const measured = new Set();
|
|
58
|
+
/**
|
|
59
|
+
* Measures as Chrome 154's canvas does, with HarfBuzz: the string cut
|
|
60
|
+
* into runs of one direction (UAX #9, in the context's `direction`), each
|
|
61
|
+
* run into words at its spaces, each word shaped in the script of its
|
|
62
|
+
* letters and the context's language.
|
|
63
|
+
*/
|
|
64
|
+
class CanvasCtx {
|
|
65
|
+
direction = 'ltr';
|
|
66
|
+
letterSpacing = '0px';
|
|
67
|
+
fontString = '';
|
|
68
|
+
language = 'inherit';
|
|
69
|
+
get font() {
|
|
70
|
+
return this.fontString;
|
|
71
|
+
}
|
|
72
|
+
set font(value) {
|
|
73
|
+
this.fontString = value;
|
|
74
|
+
}
|
|
75
|
+
get lang() {
|
|
76
|
+
return this.language;
|
|
77
|
+
}
|
|
78
|
+
set lang(value) {
|
|
79
|
+
this.language = value;
|
|
80
|
+
}
|
|
81
|
+
measureText(s) {
|
|
82
|
+
const parsed = parseFontString(this.fontString);
|
|
83
|
+
const files = FACES[parsed?.family ?? ''] ?? FACES.Amiri;
|
|
84
|
+
const language = this.language === 'inherit' || this.language === '' ? undefined : this.language;
|
|
85
|
+
const latin = /^[\u0000-\u00FF]*$/.test(s);
|
|
86
|
+
measured.add(`${this.direction} ${s}`);
|
|
87
|
+
let advance = 0;
|
|
88
|
+
for (const run of lineRuns(resolveParagraph(s, this.direction === 'rtl' ? 'rtl' : 'ltr'))) {
|
|
89
|
+
for (const word of s.slice(run.start, run.end).split(/( )/)) {
|
|
90
|
+
if (word !== '')
|
|
91
|
+
advance += wordAdvance(files, word, (run.level & 1) === 1, language, latin);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { width: advance * (parsed?.sizePx ?? 16) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const host = globalThis;
|
|
98
|
+
host.OffscreenCanvasRenderingContext2D = CanvasCtx;
|
|
99
|
+
host.OffscreenCanvas = class {
|
|
100
|
+
getContext() {
|
|
101
|
+
return new CanvasCtx();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const pt = (value) => ({ value, unit: 'pt' });
|
|
105
|
+
const SIZE = 20;
|
|
106
|
+
const EM = SIZE / 1000;
|
|
107
|
+
const PAGE = 420;
|
|
108
|
+
const MARGIN = 20;
|
|
109
|
+
function config(locale, fontFamily, bodyText = {}) {
|
|
110
|
+
return {
|
|
111
|
+
page: { width: pt(PAGE), height: pt(400), dpi: 72, margins: { top: pt(MARGIN), bottom: pt(MARGIN), left: pt(MARGIN), right: pt(MARGIN) } },
|
|
112
|
+
locale,
|
|
113
|
+
layout: { layoutType: 'single' },
|
|
114
|
+
header: { elements: [] },
|
|
115
|
+
footer: { elements: [] },
|
|
116
|
+
bodyText: { fontFamily, fontSize: pt(SIZE), lineHeight: pt(36), firstLineIndent: pt(0), textAlign: 'justify', hyphenation: { enabled: false }, shapingLanguage: 'document', ...bodyText },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const hasPdftotext = (() => {
|
|
120
|
+
try {
|
|
121
|
+
execFileSync('pdftotext', ['-v'], { stdio: 'ignore' });
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
})();
|
|
128
|
+
/** What pdftotext reads as words on each line of the PDF's first page,
|
|
129
|
+
* px at 72 dpi. It cuts a word where its font changes, and at a letter
|
|
130
|
+
* that leaves air before the next: a word may come in several boxes. */
|
|
131
|
+
function wordBoxes(bytes) {
|
|
132
|
+
const file = path.join(os.tmpdir(), `postext-language-runs-${process.pid}-${Math.random().toString(36).slice(2)}.pdf`);
|
|
133
|
+
fs.writeFileSync(file, bytes);
|
|
134
|
+
const xml = execFileSync('pdftotext', ['-bbox', '-l', '1', file, '-'], { encoding: 'utf8' });
|
|
135
|
+
fs.unlinkSync(file);
|
|
136
|
+
const lines = new Map();
|
|
137
|
+
for (const m of xml.matchAll(/<word xMin="([\d.]+)" yMin="([\d.]+)" xMax="([\d.]+)" yMax="[\d.]+">/g)) {
|
|
138
|
+
const y = Math.round(Number(m[2]));
|
|
139
|
+
const key = [...lines.keys()].find((k) => Math.abs(k - y) < 8) ?? y;
|
|
140
|
+
if (!lines.has(key))
|
|
141
|
+
lines.set(key, []);
|
|
142
|
+
lines.get(key).push({ left: Number(m[1]), right: Number(m[3]) });
|
|
143
|
+
}
|
|
144
|
+
return [...lines.entries()].sort((a, b) => a[0] - b[0]).map(([, boxes]) => boxes);
|
|
145
|
+
}
|
|
146
|
+
const firstBlock = (doc) => doc.pages[0].columns[0].blocks[0];
|
|
147
|
+
/**
|
|
148
|
+
* The words of a line as the layout placed them, on the sheet: its
|
|
149
|
+
* segments in the order the line gives (flow order), from the line's
|
|
150
|
+
* start, the spaces of a justified line that is not its paragraph's last
|
|
151
|
+
* stretched to fill the measure; what stands between two spaces is one
|
|
152
|
+
* word. A right-to-left page is the mirror of its flow.
|
|
153
|
+
*/
|
|
154
|
+
function wordPlaces(page, block, line) {
|
|
155
|
+
const segments = line.segments;
|
|
156
|
+
const measure = PAGE - 2 * MARGIN - (line.bbox.x - MARGIN);
|
|
157
|
+
const spaces = segments.filter((s) => s.kind === 'space').length;
|
|
158
|
+
const words = segments.reduce((sum, s) => sum + (s.kind === 'space' ? 0 : s.width), 0);
|
|
159
|
+
const space = block.textAlign === 'justify' && !line.isLastLine && spaces > 0 ? (measure - words) / spaces : undefined;
|
|
160
|
+
const places = [];
|
|
161
|
+
let x = line.bbox.x;
|
|
162
|
+
let open;
|
|
163
|
+
for (const i of line.order ?? segments.map((_, k) => k)) {
|
|
164
|
+
const seg = segments[i];
|
|
165
|
+
const width = seg.kind === 'space' ? space ?? seg.width : seg.width;
|
|
166
|
+
if (seg.kind === 'space')
|
|
167
|
+
open = undefined;
|
|
168
|
+
else if (open)
|
|
169
|
+
open.right = x + width;
|
|
170
|
+
else {
|
|
171
|
+
open = { left: x, right: x + width };
|
|
172
|
+
places.push(open);
|
|
173
|
+
}
|
|
174
|
+
x += width;
|
|
175
|
+
}
|
|
176
|
+
const flow = page.flow;
|
|
177
|
+
const originX = flow && flow.writingMode === 'horizontal-tb' && flow.direction === 'rtl' ? flow.mirror.originX : undefined;
|
|
178
|
+
return originX === undefined ? places : places.map((p) => ({ left: originX - p.right, right: originX - p.left }));
|
|
179
|
+
}
|
|
180
|
+
/** Every word of the PDF against its place: the boxes pdftotext read
|
|
181
|
+
* inside a place start where it starts and end where it ends, to a
|
|
182
|
+
* twentieth of a pixel, and no box lies outside every place. With
|
|
183
|
+
* `whole`, a place holds one box: pdftotext cuts no Latin word that has
|
|
184
|
+
* no hole in it. */
|
|
185
|
+
async function expectInPlace(doc, what, whole = false) {
|
|
186
|
+
const boxes = wordBoxes(await renderToPdf(doc, { fontProvider, accessible: false }));
|
|
187
|
+
const block = firstBlock(doc);
|
|
188
|
+
expect(boxes.length, what).toBe(block.lines.length);
|
|
189
|
+
block.lines.forEach((line, n) => {
|
|
190
|
+
const places = wordPlaces(doc.pages[0], block, line);
|
|
191
|
+
const inside = places.map((place) => boxes[n].filter((box) => (box.left + box.right) / 2 > place.left && (box.left + box.right) / 2 < place.right));
|
|
192
|
+
expect(inside.reduce((sum, list) => sum + list.length, 0), `${what} line ${n + 1}: ${line.text}`).toBe(boxes[n].length);
|
|
193
|
+
places.forEach((place, i) => {
|
|
194
|
+
expect(inside[i].length, `${what} line ${n + 1} word ${i + 1}`).toBeGreaterThan(0);
|
|
195
|
+
if (whole)
|
|
196
|
+
expect(inside[i].length, `${what} line ${n + 1} word ${i + 1} in one piece`).toBe(1);
|
|
197
|
+
expect(Math.min(...inside[i].map((box) => box.left)), `${what} line ${n + 1} word ${i + 1} left`).toBeCloseTo(place.left, 1);
|
|
198
|
+
expect(Math.max(...inside[i].map((box) => box.right)), `${what} line ${n + 1} word ${i + 1} right`).toBeCloseTo(place.right, 1);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/** The width the layout gave each text, thousandths of the em. */
|
|
203
|
+
const widthsOf = (doc) => Object.fromEntries(firstBlock(doc).lines.flatMap((line) => line.segments.filter((s) => s.kind !== 'space').map((s) => [s.text, Math.round((s.width / EM) * 10) / 10])));
|
|
204
|
+
beforeAll(async () => {
|
|
205
|
+
await loadComplexShaper();
|
|
206
|
+
});
|
|
207
|
+
// An Arabic paragraph: «…» quotations, a bracketed aside, full stops, a
|
|
208
|
+
// question mark inside a quotation.
|
|
209
|
+
const ARABIC = '\u0642\u0644\u062A: \u00AB\u0623\u0646\u0627 \u064A\u0627 \u062E\u0627\u0644\u0629.\u00BB \u062B\u0645 \u0645\u0634\u064A\u062A (\u0648\u064A\u0642\u0627\u0644 \u0633\u0627\u0641\u0631\u062A) \u0625\u0644\u0649 \u0627\u0644\u0628\u064A\u0648\u062A. \u00AB\u064A\u0648\u0633\u0641\u061F\u00BB \u0643\u062A\u0627\u0628.';
|
|
210
|
+
// Set from its start side: a justified Arabic line takes kashidas.
|
|
211
|
+
const fromStart = { textAlign: 'left' };
|
|
212
|
+
describe('an Arabic paragraph with quotations, brackets and full stops (#764)', () => {
|
|
213
|
+
it('Markazi Text: the layout measures the guillemets and the stops of Arabic text in Arabic', () => {
|
|
214
|
+
measured.clear();
|
|
215
|
+
const widths = widthsOf(buildDocument({ markdown: ARABIC }, config('ar', 'Markazi Text', fromStart)));
|
|
216
|
+
// Each word as a right-to-left run, as it is painted.
|
|
217
|
+
expect([...measured]).toContain('rtl \u00AB\u0623\u0646\u0627');
|
|
218
|
+
// Chrome's widths of these words in Arabic.
|
|
219
|
+
expect(widths).toMatchObject({ '\u00AB\u0623\u0646\u0627': 939.5, '\u062E\u0627\u0644\u0629.\u00BB': 1892.1, '\u0643\u062A\u0627\u0628.': 1708 });
|
|
220
|
+
// In Persian the face has no forms of its own and draws its marks
|
|
221
|
+
// the same under every script, so the engine measures the word as any
|
|
222
|
+
// text, in a left-to-right context: the stop and the guillemet are a
|
|
223
|
+
// run of their own there, set in the order they are written (0.587 em;
|
|
224
|
+
// 0.582 in the right-to-left run every output paints them in).
|
|
225
|
+
const persian = widthsOf(buildDocument({ markdown: ARABIC }, config('fa', 'Markazi Text', fromStart)));
|
|
226
|
+
expect([...measured]).toContain('ltr \u00AB\u0623\u0646\u0627');
|
|
227
|
+
expect(persian).toMatchObject({ '\u00AB\u0623\u0646\u0627': 1043.5, '\u062E\u0627\u0644\u0629.\u00BB': 1993.2, '\u0643\u062A\u0627\u0628.': 1714.8 });
|
|
228
|
+
});
|
|
229
|
+
it('Amiri: the stop, the brackets and the guillemets of an Arabic word are the Arabic ones', () => {
|
|
230
|
+
const widths = widthsOf(buildDocument({ markdown: ARABIC }, config('ar', 'Amiri', fromStart)));
|
|
231
|
+
expect(widths).toMatchObject({ '\u00AB\u0623\u0646\u0627': 1068, '\u062E\u0627\u0644\u0629.\u00BB': 2112, '(\u0648\u064A\u0642\u0627\u0644': 2380, '\u0643\u062A\u0627\u0628.': 2040 });
|
|
232
|
+
});
|
|
233
|
+
it.skipIf(!hasPdftotext)('the PDF prints every word where the layout put it, in both faces', async () => {
|
|
234
|
+
for (const family of ['Markazi Text', 'Amiri']) {
|
|
235
|
+
// Not Markazi Text in Persian: its words were measured left to
|
|
236
|
+
// right (see above), five thousandths of the em wider than any
|
|
237
|
+
// output sets `.»`.
|
|
238
|
+
for (const locale of family === 'Amiri' ? ['ar', 'ar-EG', 'fa'] : ['ar', 'ar-EG']) {
|
|
239
|
+
const doc = buildDocument({ markdown: ARABIC }, config(locale, family, fromStart));
|
|
240
|
+
expect(firstBlock(doc).lines.length, `${family} ${locale}`).toBeGreaterThan(1);
|
|
241
|
+
await expectInPlace(doc, `${family} ${locale}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}, 120_000);
|
|
245
|
+
it.skipIf(!hasPdftotext)('and in a document that keeps the host\u2019s language, measured in none', async () => {
|
|
246
|
+
const doc = buildDocument({ markdown: ARABIC }, config('ar', 'Amiri', { ...fromStart, shapingLanguage: 'host' }));
|
|
247
|
+
expect(widthsOf(doc)).toMatchObject({ '\u00AB\u0623\u0646\u0627': 1068, '\u062E\u0627\u0644\u0629.\u00BB': 2112, '\u0643\u062A\u0627\u0628.': 2040 });
|
|
248
|
+
await expectInPlace(doc, 'Amiri host');
|
|
249
|
+
// Markazi Text has no forms of its own in no language.
|
|
250
|
+
expect(widthsOf(buildDocument({ markdown: ARABIC }, config('ar', 'Markazi Text', { ...fromStart, shapingLanguage: 'host' })))['\u00AB\u0623\u0646\u0627']).toBe(1043.5);
|
|
251
|
+
}, 120_000);
|
|
252
|
+
});
|
|
253
|
+
describe('a justified Catalan paragraph in Crimson Pro (#764)', () => {
|
|
254
|
+
// A paragraph with a word in italics is cut word by word: the pieces of
|
|
255
|
+
// a word are as wide as the word grew by each. A plain one goes to the
|
|
256
|
+
// text library, which measures each piece alone.
|
|
257
|
+
const STYLED = 'La instal\u00B7laci\u00F3 de la il\u00B7luminaci\u00F3 col\u00B7lectiva *fou* una il\u00B7lusi\u00F3 paral\u00B7lela del col\u00B7legi.';
|
|
258
|
+
const PLAIN = STYLED.replace(/\*/g, '');
|
|
259
|
+
const hyphenated = (locale) => ({ hyphenation: { enabled: true, locale } });
|
|
260
|
+
/** The words of a paragraph: the pieces of each joined, with the width
|
|
261
|
+
* the layout gave them together. */
|
|
262
|
+
const wordWidths = (doc) => {
|
|
263
|
+
const words = {};
|
|
264
|
+
for (const line of firstBlock(doc).lines) {
|
|
265
|
+
let text = '';
|
|
266
|
+
let width = 0;
|
|
267
|
+
for (const seg of [...line.segments, { kind: 'space', text: ' ', width: 0 }]) {
|
|
268
|
+
if (seg.kind !== 'space') {
|
|
269
|
+
text += seg.text;
|
|
270
|
+
width += seg.width;
|
|
271
|
+
}
|
|
272
|
+
else if (text !== '') {
|
|
273
|
+
words[text] = Math.round((width / EM) * 10) / 10;
|
|
274
|
+
text = '';
|
|
275
|
+
width = 0;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return words;
|
|
280
|
+
};
|
|
281
|
+
/** Each line ends between two words: the piece that opens a line after
|
|
282
|
+
* a break inside a word keeps the width the word had beyond the break,
|
|
283
|
+
* whatever stood before it. */
|
|
284
|
+
const expectNoWordBroken = (doc) => {
|
|
285
|
+
for (const line of firstBlock(doc).lines)
|
|
286
|
+
expect(line.text).not.toMatch(/-$/);
|
|
287
|
+
};
|
|
288
|
+
it('a word cut at its syllables is measured whole in a styled paragraph: the Catalan dot is in the piece after it', () => {
|
|
289
|
+
const doc = buildDocument({ markdown: STYLED }, config('ca', 'Crimson Pro', hyphenated('ca')));
|
|
290
|
+
expectNoWordBroken(doc);
|
|
291
|
+
// Chrome's widths in Catalan (4381.8, 2708 and 2856.4 in any other
|
|
292
|
+
// language).
|
|
293
|
+
expect(wordWidths(doc)).toMatchObject({ 'instal\u00B7laci\u00F3': 4727.5, 'il\u00B7lusi\u00F3': 3053.7, 'col\u00B7legi.': 3430.7 });
|
|
294
|
+
// `tal·` alone has the narrow dot (1292); the 354 units of 1024 the
|
|
295
|
+
// Catalan one adds went to the piece after it, 725.6 wide alone.
|
|
296
|
+
const pieces = firstBlock(doc).lines[0].segments.filter((s) => s.kind !== 'space').map((s) => [s.text, Math.round((s.width / EM) * 10) / 10]);
|
|
297
|
+
expect(pieces.slice(1, 5)).toEqual([['ins', 1191.4], ['tal\u00B7', 1292], ['la', 1071.3], ['ci\u00F3', 1172.9]]);
|
|
298
|
+
const spanish = buildDocument({ markdown: STYLED }, config('es', 'Crimson Pro', hyphenated('es')));
|
|
299
|
+
expect(wordWidths(spanish)).toMatchObject({ 'instal\u00B7laci\u00F3': 4381.8, 'il\u00B7lusi\u00F3': 2708, 'col\u00B7legi.': 3085 });
|
|
300
|
+
});
|
|
301
|
+
it('and piece by piece in a plain paragraph: each as wide as it is alone', () => {
|
|
302
|
+
const doc = buildDocument({ markdown: PLAIN }, config('ca', 'Crimson Pro', hyphenated('ca')));
|
|
303
|
+
expectNoWordBroken(doc);
|
|
304
|
+
const pieces = firstBlock(doc).lines[0].segments.filter((s) => s.kind !== 'space').map((s) => [s.text, Math.round((s.width / EM) * 10) / 10]);
|
|
305
|
+
expect(pieces.slice(1, 5)).toEqual([['ins', 1191.4], ['tal\u00B7', 1292], ['la', 725.6], ['ci\u00F3', 1172.9]]);
|
|
306
|
+
});
|
|
307
|
+
it.skipIf(!hasPdftotext)('the PDF prints each word where the layout put it, as wide as measured: no hole after the dot', async () => {
|
|
308
|
+
for (const markdown of [STYLED, PLAIN]) {
|
|
309
|
+
for (const locale of ['ca', 'es']) {
|
|
310
|
+
const doc = buildDocument({ markdown }, config(locale, 'Crimson Pro', hyphenated(locale)));
|
|
311
|
+
expect(firstBlock(doc).lines.length, locale).toBeGreaterThan(1);
|
|
312
|
+
expectNoWordBroken(doc);
|
|
313
|
+
await expectInPlace(doc, `justified ${locale} ${markdown === STYLED ? 'styled' : 'plain'}`, true);
|
|
314
|
+
}
|
|
315
|
+
// Set ragged, and with no syllables: the words come whole.
|
|
316
|
+
await expectInPlace(buildDocument({ markdown }, config('ca', 'Crimson Pro', { textAlign: 'left' })), 'ragged ca', true);
|
|
317
|
+
await expectInPlace(buildDocument({ markdown }, config('ca', 'Crimson Pro')), 'whole words ca', true);
|
|
318
|
+
}
|
|
319
|
+
}, 120_000);
|
|
320
|
+
it.skipIf(!hasPdftotext)('a document that keeps the host\u2019s language is measured with the plain dot and printed piece by piece', async () => {
|
|
321
|
+
const doc = buildDocument({ markdown: STYLED }, config('ca', 'Crimson Pro', { ...hyphenated('ca'), shapingLanguage: 'host' }));
|
|
322
|
+
expectNoWordBroken(doc);
|
|
323
|
+
expect(wordWidths(doc)).toMatchObject({ 'instal\u00B7laci\u00F3': 4381.8, 'il\u00B7lusi\u00F3': 2708 });
|
|
324
|
+
await expectInPlace(doc, 'host ca', true);
|
|
325
|
+
}, 120_000);
|
|
326
|
+
});
|
|
327
|
+
//# sourceMappingURL=shapingLanguageRunsDocument.test.js.map
|