postext-pdf 1.14.0 → 1.15.0

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.
Files changed (100) hide show
  1. package/README.md +13 -1
  2. package/dist/__bench__/arabicShaping.bench.test.d.ts +2 -0
  3. package/dist/__bench__/arabicShaping.bench.test.d.ts.map +1 -0
  4. package/dist/__bench__/arabicShaping.bench.test.js +53 -0
  5. package/dist/__bench__/arabicShaping.bench.test.js.map +1 -0
  6. package/dist/__tests__/arabicShaping.test.d.ts +2 -0
  7. package/dist/__tests__/arabicShaping.test.d.ts.map +1 -0
  8. package/dist/__tests__/arabicShaping.test.js +261 -0
  9. package/dist/__tests__/arabicShaping.test.js.map +1 -0
  10. package/dist/__tests__/complexShaping.test.d.ts +2 -0
  11. package/dist/__tests__/complexShaping.test.d.ts.map +1 -0
  12. package/dist/__tests__/complexShaping.test.js +145 -0
  13. package/dist/__tests__/complexShaping.test.js.map +1 -0
  14. package/dist/__tests__/designTextRtl.test.d.ts +2 -0
  15. package/dist/__tests__/designTextRtl.test.d.ts.map +1 -0
  16. package/dist/__tests__/designTextRtl.test.js +136 -0
  17. package/dist/__tests__/designTextRtl.test.js.map +1 -0
  18. package/dist/__tests__/harfbuzz.test.d.ts +2 -0
  19. package/dist/__tests__/harfbuzz.test.d.ts.map +1 -0
  20. package/dist/__tests__/harfbuzz.test.js +103 -0
  21. package/dist/__tests__/harfbuzz.test.js.map +1 -0
  22. package/dist/__tests__/measuredLines.test.js +13 -5
  23. package/dist/__tests__/measuredLines.test.js.map +1 -1
  24. package/dist/__tests__/mirroredPdf.test.d.ts +2 -0
  25. package/dist/__tests__/mirroredPdf.test.d.ts.map +1 -0
  26. package/dist/__tests__/mirroredPdf.test.js +104 -0
  27. package/dist/__tests__/mirroredPdf.test.js.map +1 -0
  28. package/dist/__tests__/rtlPdf.test.d.ts +2 -0
  29. package/dist/__tests__/rtlPdf.test.d.ts.map +1 -0
  30. package/dist/__tests__/rtlPdf.test.js +312 -0
  31. package/dist/__tests__/rtlPdf.test.js.map +1 -0
  32. package/dist/__tests__/verticalText.test.js +4 -4
  33. package/dist/__tests__/verticalText.test.js.map +1 -1
  34. package/dist/complexShaping.d.ts +113 -0
  35. package/dist/complexShaping.d.ts.map +1 -0
  36. package/dist/complexShaping.js +188 -0
  37. package/dist/complexShaping.js.map +1 -0
  38. package/dist/faceFiles.d.ts +9 -1
  39. package/dist/faceFiles.d.ts.map +1 -1
  40. package/dist/faceFiles.js +47 -5
  41. package/dist/faceFiles.js.map +1 -1
  42. package/dist/harfbuzz.d.ts +119 -0
  43. package/dist/harfbuzz.d.ts.map +1 -0
  44. package/dist/harfbuzz.js +280 -0
  45. package/dist/harfbuzz.js.map +1 -0
  46. package/dist/harfbuzz.wasm +0 -0
  47. package/dist/index.d.ts +2 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/pdf-backend/blockRender.d.ts.map +1 -1
  51. package/dist/pdf-backend/blockRender.js +113 -40
  52. package/dist/pdf-backend/blockRender.js.map +1 -1
  53. package/dist/pdf-backend/chip.d.ts.map +1 -1
  54. package/dist/pdf-backend/chip.js +11 -2
  55. package/dist/pdf-backend/chip.js.map +1 -1
  56. package/dist/pdf-backend/directedLine.d.ts +38 -0
  57. package/dist/pdf-backend/directedLine.d.ts.map +1 -0
  58. package/dist/pdf-backend/directedLine.js +103 -0
  59. package/dist/pdf-backend/directedLine.js.map +1 -0
  60. package/dist/pdf-backend/fontHelpers.d.ts.map +1 -1
  61. package/dist/pdf-backend/fontHelpers.js +6 -0
  62. package/dist/pdf-backend/fontHelpers.js.map +1 -1
  63. package/dist/pdf-backend/headerFooter.d.ts.map +1 -1
  64. package/dist/pdf-backend/headerFooter.js +11 -3
  65. package/dist/pdf-backend/headerFooter.js.map +1 -1
  66. package/dist/pdf-backend/index.d.ts +32 -3
  67. package/dist/pdf-backend/index.d.ts.map +1 -1
  68. package/dist/pdf-backend/index.js +40 -6
  69. package/dist/pdf-backend/index.js.map +1 -1
  70. package/dist/pdf-backend/links.d.ts +7 -2
  71. package/dist/pdf-backend/links.d.ts.map +1 -1
  72. package/dist/pdf-backend/links.js +9 -3
  73. package/dist/pdf-backend/links.js.map +1 -1
  74. package/dist/pdf-backend/outlines.js +2 -2
  75. package/dist/pdf-backend/outlines.js.map +1 -1
  76. package/dist/pdf-backend/primitives.d.ts +46 -2
  77. package/dist/pdf-backend/primitives.d.ts.map +1 -1
  78. package/dist/pdf-backend/primitives.js +67 -5
  79. package/dist/pdf-backend/primitives.js.map +1 -1
  80. package/dist/pdf-backend/renderResourceBlock.d.ts.map +1 -1
  81. package/dist/pdf-backend/renderResourceBlock.js +48 -14
  82. package/dist/pdf-backend/renderResourceBlock.js.map +1 -1
  83. package/dist/pdf-backend/rtlRun.d.ts +12 -0
  84. package/dist/pdf-backend/rtlRun.d.ts.map +1 -0
  85. package/dist/pdf-backend/rtlRun.js +31 -0
  86. package/dist/pdf-backend/rtlRun.js.map +1 -0
  87. package/dist/pdf-backend/shapedText.d.ts +122 -0
  88. package/dist/pdf-backend/shapedText.d.ts.map +1 -0
  89. package/dist/pdf-backend/shapedText.js +635 -0
  90. package/dist/pdf-backend/shapedText.js.map +1 -0
  91. package/dist/pdf-backend/svgVector.d.ts +9 -2
  92. package/dist/pdf-backend/svgVector.d.ts.map +1 -1
  93. package/dist/pdf-backend/svgVector.js +42 -18
  94. package/dist/pdf-backend/svgVector.js.map +1 -1
  95. package/dist/worker/client.d.ts.map +1 -1
  96. package/dist/worker/client.js +10 -1
  97. package/dist/worker/client.js.map +1 -1
  98. package/dist/worker/protocol.d.ts +5 -1
  99. package/dist/worker/protocol.d.ts.map +1 -1
  100. package/package.json +6 -5
@@ -0,0 +1,635 @@
1
+ /**
2
+ * Text shaped with HarfBuzz, painted through glyph ids (issue #380).
3
+ *
4
+ * pdf-lib's own text path asks fontkit for the glyphs (`encodeText`). For
5
+ * a run HarfBuzz shapes (`complexShaping.ts`) the glyphs are registered
6
+ * with the font's subset here instead, as the vertical twins register
7
+ * theirs (`verticalFonts.ts`): the subset keeps every glyph a run shows and
8
+ * its `W` width, and the hex codes are the subset's ids (or the face's own,
9
+ * for a font embedded whole).
10
+ *
11
+ * Positions: a run is one `TJ` per stretch of glyphs at one height. Its
12
+ * numbers move the pen to each glyph's origin (`pen + xOffset`) and, after
13
+ * the last, to where HarfBuzz's advances end, so a run is exactly as wide
14
+ * as the layout measured it. A mark HarfBuzz raises or lowers (`yOffset`:
15
+ * a fatha on a shadda, a kasra under a dotted letter) is shown with the
16
+ * text rise (`Ts`) set to it, which moves no pen. Glyphs are shown left to
17
+ * right, a right-to-left run in visual order, as every right-to-left PDF
18
+ * shows them: readers put the line back in logical order from their
19
+ * positions.
20
+ *
21
+ * Reading the text back (`/ToUnicode`): clusters are graphemes, a letter
22
+ * with its marks. Each glyph newly added to the font is given the
23
+ * characters of its cluster it stands for ({@link clusterCodePoints}), and
24
+ * the font's map is written from them. That map is global to the font, so
25
+ * a glyph keeps the characters it was first given: a mirrored bracket
26
+ * shares its glyph with the other bracket, a contextual form can stand for
27
+ * other text elsewhere. A cluster whose glyphs do not read back as its own
28
+ * characters, a vocalised letter (readers set a raised or lowered mark on
29
+ * a line of its own, or after the next letter) and a cluster holding
30
+ * kashidas justification inserted are shown under an `/ActualText` span
31
+ * with the cluster's text as written, in logical order (PDF 1.7 §14.9.4).
32
+ * pdf.js 5 ignores the span and reads the glyphs; Poppler reads it, but
33
+ * turns a span of several characters around in a right-to-left line.
34
+ */
35
+ import { PDFArray, PDFHexString, PDFName, PDFNumber, PDFOperator, PDFOperatorNames, moveText, setCharacterSpacing, setCharacterSqueeze, setFillingColor, setTextRise } from 'pdf-lib';
36
+ import { lineRuns, resolveParagraph } from 'postext';
37
+ import { complexShaperReady, joinsLetters, needsComplexShaping, shapeRun, clusterTexts } from '../complexShaping';
38
+ import { fileRuns, noteMissingGlyphs } from '../faceFiles';
39
+ import { toUnicodeCmap } from '../fontCache';
40
+ import { pushTextObject, registerComplexPainter, textAdvancePx, textShows } from './primitives';
41
+ function embedderOf(font) {
42
+ const e = font.embedder;
43
+ return e && e.fontData && e.font && typeof e.font.getGlyph === 'function' ? e : undefined;
44
+ }
45
+ const shapingsByFont = new WeakMap();
46
+ const showsByFont = new WeakMap();
47
+ const SHOW_CACHE_SLOTS = 50_000;
48
+ /** Glyph ids whose characters are settled, per embedder: given once, never
49
+ * changed, so a cluster found to read back right stays so. */
50
+ const settledByEmbedder = new WeakMap();
51
+ /** The width a viewer gives a glyph with no `W` entry (no `DW` is written). */
52
+ const MISSING_WIDTH = 1000;
53
+ function tjNumber(n) {
54
+ return Math.round(n * 100) / 100;
55
+ }
56
+ function sameCodePoints(a, b) {
57
+ if (a.length !== b.length)
58
+ return false;
59
+ for (let i = 0; i < a.length; i++)
60
+ if (a[i] !== b[i])
61
+ return false;
62
+ return true;
63
+ }
64
+ function codePointsOf(text) {
65
+ const out = [];
66
+ for (const ch of text)
67
+ out.push(ch.codePointAt(0));
68
+ return out;
69
+ }
70
+ /** pdf-lib's font features in the form the shaper takes. */
71
+ function shapeFeatures(features) {
72
+ if (Array.isArray(features))
73
+ return features.filter((f) => typeof f === 'string');
74
+ if (features && typeof features === 'object')
75
+ return features;
76
+ return undefined;
77
+ }
78
+ function cached(byFont, font, key, make) {
79
+ let cache = byFont.get(font);
80
+ if (!cache) {
81
+ cache = new Map();
82
+ byFont.set(font, cache);
83
+ }
84
+ if (cache.has(key))
85
+ return cache.get(key);
86
+ const value = make();
87
+ if (cache.size >= SHOW_CACHE_SLOTS)
88
+ cache.clear();
89
+ cache.set(key, value);
90
+ return value;
91
+ }
92
+ /**
93
+ * `text` (one bidi run, set in one file) shaped by HarfBuzz in `font`:
94
+ * cached per font, direction and language. Null when the font has no file
95
+ * bytes to shape from (a standard font) or HarfBuzz is not loaded.
96
+ */
97
+ function shapingOf(font, text, direction, language) {
98
+ return cached(shapingsByFont, font, `${direction}|${language ?? ''}|${text}`, () => buildShaping(font, text, direction, language));
99
+ }
100
+ /**
101
+ * A stretch of {@link shapingOf}, glyphs `[from, to)` in visual order (by
102
+ * default all of them), encoded in `font`: cached per font, direction,
103
+ * language, whether tatweels are read, and stretch.
104
+ */
105
+ /** `ShapedTextOptions.hideTatweel` as a count (`Infinity`: all). */
106
+ function tatweelsToHide(hide) {
107
+ return hide === true ? Number.POSITIVE_INFINITY : typeof hide === 'number' && hide > 0 ? hide : 0;
108
+ }
109
+ function shapedShow(font, text, direction, language, hideTatweel, from, to) {
110
+ const key = `${direction}|${language ?? ''}|${hideTatweel}|${from ?? ''}|${to ?? ''}|${text}`;
111
+ return cached(showsByFont, font, key, () => {
112
+ const shaping = shapingOf(font, text, direction, language);
113
+ return shaping ? encodeStretch(font, shaping, from ?? 0, to ?? shaping.run.glyphs.length, hideTatweel) : null;
114
+ });
115
+ }
116
+ /**
117
+ * The characters each glyph of one cluster is given in the ToUnicode map,
118
+ * the glyphs listed in logical order. A glyph that is the font's own glyph
119
+ * for one of the cluster's characters (a mark, a letter in its isolated
120
+ * form) takes that character; the glyphs left (contextual forms, a mark
121
+ * set on a mark) take the characters left one each, in order, the last
122
+ * of them any still left (a ligature); a glyph with none left gets none.
123
+ * A glyph of a
124
+ * right-to-left run that stands for several characters lists them right to
125
+ * left: Poppler and pdf.js spread such a glyph's characters across its
126
+ * width left to right, then read the right-to-left line from its right
127
+ * end.
128
+ */
129
+ function clusterCodePoints(face, gids, chars, rtl) {
130
+ const left = chars.slice();
131
+ const out = gids.map(() => []);
132
+ const claimed = gids.map(() => false);
133
+ if (gids.length > 1 && face.glyphForCodePoint) {
134
+ gids.forEach((gid, i) => {
135
+ const k = left.findIndex((cp) => face.glyphForCodePoint(cp)?.id === gid);
136
+ if (k < 0)
137
+ return;
138
+ out[i] = [left[k]];
139
+ claimed[i] = true;
140
+ left.splice(k, 1);
141
+ });
142
+ }
143
+ const free = claimed.flatMap((c, i) => (c ? [] : [i]));
144
+ if (free.length === 0 && left.length > 0)
145
+ free.push(0);
146
+ free.forEach((i, k) => {
147
+ const take = k === free.length - 1 ? left.splice(0) : left.splice(0, 1);
148
+ if (take.length > 1 && rtl)
149
+ take.reverse();
150
+ out[i] = [...out[i], ...take];
151
+ });
152
+ return out;
153
+ }
154
+ /** The offset in a cluster's text of the character a glyph stands for:
155
+ * the first of `want` not yet taken by another glyph of the cluster (a
156
+ * glyph given nothing stands for the cluster's first character). */
157
+ function ownerOffset(clusterText, want, taken) {
158
+ const first = want.length > 0 ? Math.min(...want.map((cp) => {
159
+ for (let k = 0; k < clusterText.length;) {
160
+ const c = clusterText.codePointAt(k);
161
+ if (c === cp && !taken.has(k))
162
+ return k;
163
+ k += c > 0xffff ? 2 : 1;
164
+ }
165
+ return clusterText.length;
166
+ })) : 0;
167
+ const at = first < clusterText.length ? first : 0;
168
+ taken.add(at);
169
+ return at;
170
+ }
171
+ function buildShaping(font, text, direction, language) {
172
+ const embedder = embedderOf(font);
173
+ if (!embedder)
174
+ return null;
175
+ const run = shapeRun(embedder.fontData, text, { direction, language, features: shapeFeatures(embedder.fontFeatures) });
176
+ if (!run)
177
+ return null;
178
+ noteMissingGlyphs(font, text);
179
+ const face = embedder.font;
180
+ const rtl = direction === 'rtl';
181
+ const toText = 1000 / run.upem;
182
+ let settled = settledByEmbedder.get(embedder);
183
+ if (!settled) {
184
+ settled = new Set();
185
+ settledByEmbedder.set(embedder, settled);
186
+ }
187
+ const texts = clusterTexts(run, text);
188
+ const glyphs = run.glyphs;
189
+ const n = glyphs.length;
190
+ const wants = new Array(n);
191
+ const owner = new Array(n);
192
+ const clusterOf = new Array(n);
193
+ const clusters = [];
194
+ // The order glyphs are shown in: cluster by cluster, left to right, and
195
+ // inside a cluster read through a span its glyphs on the baseline first:
196
+ // a reader places the span's text where its first glyph is, and a raised
197
+ // mark there would put it on a line of its own.
198
+ const showOrder = [];
199
+ for (let a = 0; a < n;) {
200
+ let b = a + 1;
201
+ while (b < n && glyphs[b].cluster === glyphs[a].cluster)
202
+ b++;
203
+ // The cluster's glyphs, logical order; its text is on the first met.
204
+ const indices = Array.from({ length: b - a }, (_, k) => a + k);
205
+ if (rtl)
206
+ indices.reverse();
207
+ const clusterText = texts[a];
208
+ const start = glyphs[a].cluster;
209
+ const cps = clusterCodePoints(face, indices.map((i) => glyphs[i].gid), codePointsOf(clusterText), rtl);
210
+ const taken = new Set();
211
+ let readsBack = true;
212
+ indices.forEach((i, k) => {
213
+ const g = glyphs[i];
214
+ const want = cps[k];
215
+ wants[i] = want;
216
+ owner[i] = start + ownerOffset(clusterText, want, taken);
217
+ clusterOf[i] = clusters.length;
218
+ const glyph = face.getGlyph(g.gid, want);
219
+ if (!glyph)
220
+ return;
221
+ // A glyph met for the first time reads as given here; one already in
222
+ // the font keeps what it was given.
223
+ const known = settled.has(g.gid) || (embedder.glyphIdMap?.has(g.gid) ?? false) || glyph.codePoints.length > 0;
224
+ if (!known)
225
+ glyph.codePoints = want;
226
+ settled.add(g.gid);
227
+ if (!sameCodePoints(glyph.codePoints, want))
228
+ readsBack = false;
229
+ });
230
+ // A vocalised letter (marks raised or lowered, which readers would set
231
+ // on a line of their own) and a cluster whose glyphs read as other
232
+ // text: read as the cluster's text.
233
+ const vocalised = indices.some((i) => glyphs[i].yOffset !== 0);
234
+ const visual = Array.from({ length: b - a }, (_, k) => a + k);
235
+ if (vocalised)
236
+ visual.sort((p, q) => Number(glyphs[p].yOffset !== 0) - Number(glyphs[q].yOffset !== 0));
237
+ clusters.push({ text: clusterText, glyphs: indices, span: !readsBack || vocalised });
238
+ showOrder.push(...visual);
239
+ a = b;
240
+ }
241
+ const at = new Array(n);
242
+ const pen = new Array(n + 1);
243
+ let end = 0;
244
+ for (let i = 0; i < n; i++) {
245
+ pen[i] = end;
246
+ at[i] = end + glyphs[i].xOffset * toText;
247
+ end += glyphs[i].xAdvance * toText;
248
+ }
249
+ pen[n] = end;
250
+ return { run, text, rtl, wants, owner, at, pen, clusterOf, clusters, showOrder };
251
+ }
252
+ /**
253
+ * Glyphs `[from, to)` (visual order) of `shaping`, encoded in `font`, the
254
+ * pen starting where glyph `from` is. A cluster whose glyphs all fall in
255
+ * the stretch reads through its span as written; one cut by the stretch
256
+ * (a vowel sign in another colour than its letter) reads, in each
257
+ * stretch, as the characters of the glyphs shown there. With
258
+ * `hideTatweel` (how many, `Infinity` for all), a cluster holding a tatweel
259
+ * reads through a span without that many of its tatweels: justification
260
+ * inserted them. A tatweel the author typed next to them stays read.
261
+ */
262
+ function encodeStretch(font, shaping, from, to, hideTatweel) {
263
+ const embedder = embedderOf(font);
264
+ if (!embedder)
265
+ return null;
266
+ const face = embedder.font;
267
+ const glyphs = shaping.run.glyphs;
268
+ const toText = 1000 / shaping.run.upem;
269
+ const subset = embedder.subset && embedder.glyphs && embedder.glyphIdMap ? embedder : undefined;
270
+ const inside = (i) => i >= from && i < to;
271
+ // The span each cluster of the stretch reads through, by cluster.
272
+ const spans = [];
273
+ const spanOfCluster = new Map();
274
+ let toHide = hideTatweel;
275
+ const spanOf = (i) => {
276
+ const c = shaping.clusterOf[i];
277
+ if (spanOfCluster.has(c))
278
+ return spanOfCluster.get(c);
279
+ const cluster = shaping.clusters[c];
280
+ const tatweel = toHide > 0 && cluster.text.includes('ـ');
281
+ let span;
282
+ if (cluster.span || tatweel) {
283
+ const whole = cluster.glyphs.every(inside);
284
+ let text = whole ? cluster.text : cluster.glyphs.filter(inside).map((g) => String.fromCodePoint(...shaping.wants[g])).join('');
285
+ if (tatweel)
286
+ text = text.replace(/ـ/g, (t) => (toHide-- > 0 ? '' : t));
287
+ spans.push(text);
288
+ span = spans.length - 1;
289
+ }
290
+ spanOfCluster.set(c, span);
291
+ return span;
292
+ };
293
+ const origin = shaping.pen[from];
294
+ const end = shaping.pen[to] - origin;
295
+ const pieces = [];
296
+ let parts = [];
297
+ let rise = 0;
298
+ let span;
299
+ let added = false;
300
+ let shown = 0;
301
+ // Where the pen is, thousandths of the em from the stretch's start.
302
+ let pen = 0;
303
+ const flush = () => {
304
+ if (parts.length > 0)
305
+ pieces.push(span === undefined ? { rise, op: tjOperator(font, parts) } : { rise, op: tjOperator(font, parts), span });
306
+ parts = [];
307
+ };
308
+ const push = (part) => {
309
+ const last = parts[parts.length - 1];
310
+ if (typeof part === 'number') {
311
+ if (part === 0)
312
+ return;
313
+ if (typeof last === 'number')
314
+ parts[parts.length - 1] = tjNumber(last + part);
315
+ else
316
+ parts.push(part);
317
+ }
318
+ else if (typeof last === 'string')
319
+ parts[parts.length - 1] = last + part;
320
+ else
321
+ parts.push(part);
322
+ };
323
+ for (const i of shaping.showOrder) {
324
+ if (!inside(i))
325
+ continue;
326
+ const g = glyphs[i];
327
+ const glyph = face.getGlyph(g.gid, shaping.wants[i]);
328
+ if (!glyph)
329
+ return null;
330
+ let id = g.gid;
331
+ if (subset) {
332
+ if (!subset.glyphIdMap.has(g.gid))
333
+ added = true;
334
+ id = subset.subset.includeGlyph(glyph);
335
+ if (id > 0) {
336
+ subset.glyphs[id - 1] = glyph;
337
+ subset.glyphIdMap.set(g.gid, id);
338
+ }
339
+ }
340
+ const glyphRise = g.yOffset * toText;
341
+ const glyphSpan = spanOf(i);
342
+ if (glyphRise !== rise || glyphSpan !== span) {
343
+ // The pen move into the glyph opens the next piece, inside its span.
344
+ flush();
345
+ rise = glyphRise;
346
+ span = glyphSpan;
347
+ }
348
+ // A TJ number moves the pen back by thousandths of the em: to the
349
+ // glyph's origin, then the viewer advances it by its `W` width (notdef
350
+ // in a subset has none and takes the default).
351
+ const move = tjNumber(pen - (shaping.at[i] - origin));
352
+ push(move);
353
+ pen -= move;
354
+ push(id.toString(16).padStart(4, '0'));
355
+ pen += id === 0 && subset ? MISSING_WIDTH : glyph.advanceWidth * toText;
356
+ shown++;
357
+ }
358
+ // The pen ends where HarfBuzz's advances put it, as the canvas measured.
359
+ const last = tjNumber(pen - end);
360
+ if (span !== undefined && last !== 0) {
361
+ flush();
362
+ span = undefined;
363
+ }
364
+ push(last);
365
+ flush();
366
+ if (subset)
367
+ omitEmptyUnicode(subset);
368
+ if (added) {
369
+ embedder.glyphCache?.invalidate();
370
+ // pdf-lib writes a font only once text was encoded with it.
371
+ font.modified = true;
372
+ }
373
+ return { pieces, spans, advance: end, glyphs: shown };
374
+ }
375
+ const patchedCmaps = new WeakSet();
376
+ /** pdf-lib writes a glyph with no characters into the ToUnicode map as
377
+ * `<…> <>`, which pdf.js reads as the glyph's code: leave such glyphs out
378
+ * (the second glyph of a decomposed letter; its cluster's `/ActualText`
379
+ * reads for it). */
380
+ function omitEmptyUnicode(embedder) {
381
+ if (patchedCmaps.has(embedder))
382
+ return;
383
+ patchedCmaps.add(embedder);
384
+ const e = embedder;
385
+ e.embedUnicodeCmap = (context) => context.register(context.flateStream(toUnicodeCmap(e.glyphCache.access().map((g) => ({ id: e.glyphId(g), codePoints: g.codePoints })))));
386
+ }
387
+ function tjOperator(font, parts) {
388
+ const array = PDFArray.withContext(font.doc.context);
389
+ for (const part of parts)
390
+ array.push(typeof part === 'number' ? PDFNumber.of(part) : PDFHexString.of(part));
391
+ return PDFOperator.of(PDFOperatorNames.ShowTextAdjusted, [array]);
392
+ }
393
+ /** The runs of `text` HarfBuzz shapes, in visual order: one in the given
394
+ * direction, else the text's bidi runs of one level each (UAX #9 with
395
+ * rules L1 and L2, the engine's `bidi.ts`, as the layout resolves a
396
+ * paragraph). */
397
+ function runsOf(text, options) {
398
+ if (options.direction)
399
+ return [{ start: 0, end: text.length, rtl: options.direction === 'rtl' }];
400
+ return lineRuns(resolveParagraph(text, options.base ?? 'auto')).map((r) => ({ start: r.start, end: r.end, rtl: (r.level & 1) === 1 }));
401
+ }
402
+ /** Appends the pieces of `shown` to `shows`, each at its rise (text space
403
+ * units are thousandths of the em times `fontSize`) and, with `spanOp`,
404
+ * inside the span its cluster reads through. `state` carries the rise set
405
+ * and the span open from one call to the next. */
406
+ function appendShown(shows, file, shown, fontSize, state, spanOp) {
407
+ let span;
408
+ let open = false;
409
+ for (const piece of shown.pieces) {
410
+ if (spanOp && piece.span !== span) {
411
+ if (open)
412
+ shows.push({ font: file, op: PDFOperator.of(PDFOperatorNames.EndMarkedContent) });
413
+ open = false;
414
+ if (piece.span !== undefined) {
415
+ shows.push({ font: file, op: spanOp(shown.spans[piece.span]) });
416
+ open = true;
417
+ }
418
+ span = piece.span;
419
+ }
420
+ if (piece.rise !== state.rise) {
421
+ shows.push({ font: file, op: setTextRise(tjNumber((piece.rise / 1000) * fontSize)) });
422
+ state.rise = piece.rise;
423
+ }
424
+ shows.push({ font: file, op: piece.op });
425
+ }
426
+ if (open)
427
+ shows.push({ font: file, op: PDFOperator.of(PDFOperatorNames.EndMarkedContent) });
428
+ }
429
+ /**
430
+ * `text` shaped by HarfBuzz as the show operators of one text object: each
431
+ * bidi run in its direction, the runs left to right, each run cut by the
432
+ * file of the face that has its characters. `fontSize` is the font size in
433
+ * the text space the shows are painted in (for the rise of marks).
434
+ * `spanOp` opens the `/ActualText` span of a cluster; without it no span
435
+ * is written. Null when HarfBuzz is not loaded.
436
+ */
437
+ function layoutShapedText(font, text, fontSize, options, spanOp) {
438
+ if (!complexShaperReady())
439
+ return null;
440
+ const shows = [];
441
+ const state = { rise: 0 };
442
+ let advance = 0;
443
+ let glyphs = 0;
444
+ for (const run of runsOf(text, options)) {
445
+ const files = fileRuns(font, text.slice(run.start, run.end), run.rtl);
446
+ // The files of a right-to-left run follow each other leftwards.
447
+ if (run.rtl)
448
+ files.reverse();
449
+ for (const { font: file, text: part } of files) {
450
+ const shown = shapedShow(file, part, run.rtl ? 'rtl' : 'ltr', options.language, tatweelsToHide(options.hideTatweel));
451
+ if (!shown) {
452
+ // A file HarfBuzz cannot read (a standard font): fontkit's glyphs.
453
+ if (state.rise !== 0)
454
+ shows.push({ font: file, op: setTextRise(0) });
455
+ state.rise = 0;
456
+ shows.push(...textShows(file, part));
457
+ const plain = textAdvancePx(file, part, 1000);
458
+ advance += plain.advance;
459
+ glyphs += plain.glyphs;
460
+ continue;
461
+ }
462
+ appendShown(shows, file, shown, fontSize, state, spanOp);
463
+ advance += shown.advance;
464
+ glyphs += shown.glyphs;
465
+ }
466
+ }
467
+ return { shows, advance, glyphs };
468
+ }
469
+ /**
470
+ * Paint `text` shaped by HarfBuzz from `xPx` on the baseline, in one text
471
+ * object: each bidi run in its direction, the runs left to right, each run
472
+ * cut by the file of the face that has its characters. Character spacing
473
+ * is turned off for a text with joining letters, whose joins it would
474
+ * break. On a mirrored page the text object is turned back about the box
475
+ * HarfBuzz's own advances give the text (`pushTextObject`'s `advancePx`),
476
+ * so it lands exactly where it was measured. Returns false, painting
477
+ * nothing, when HarfBuzz is not loaded: the caller paints the text the
478
+ * fontkit way.
479
+ */
480
+ export function drawShapedTextPx(ctx, text, xPx, baselinePx, font, sizePx, color, options = {}) {
481
+ if (!text)
482
+ return true;
483
+ if (!(sizePx > 0))
484
+ return false;
485
+ const sizePt = sizePx * ctx.scale;
486
+ // Cluster spans, unless the caller reads the text some other way.
487
+ const layout = layoutShapedText(font, text, sizePt, options, options.actualText === undefined ? (t) => spanOperator(ctx, t) : undefined);
488
+ if (!layout)
489
+ return false;
490
+ const shows = layout.shows;
491
+ // Joining letters are never spaced apart (the canvas ignores
492
+ // `letterSpacing` on them too); the graphics state the text object is
493
+ // wrapped in restores the line's spacing after it.
494
+ const untracked = (ctx.trackingPx ?? 0) !== 0 && joinsLetters(text);
495
+ if (untracked)
496
+ shows.unshift({ font: shows[0]?.font ?? font, op: setCharacterSpacing(0) });
497
+ const advancePx = (layout.advance / 1000) * sizePx + layout.glyphs * (untracked ? 0 : ctx.trackingPx ?? 0);
498
+ pushTextObject(ctx, shows, xPx, baselinePx, font, sizePx, color, options.outline, options.actualText ?? undefined, advancePx);
499
+ return true;
500
+ }
501
+ /**
502
+ * Text of an SVG drawing (`svgVector.ts`) shaped by HarfBuzz: the show
503
+ * operators of one text object in `font`'s face at `size` (the drawing's
504
+ * units), and its advance in those units. No `/ActualText` spans: the
505
+ * drawing is a figure, read through its alternate text. Null when
506
+ * HarfBuzz is not loaded.
507
+ */
508
+ export function shapedSvgText(font, text, size, base) {
509
+ const layout = layoutShapedText(font, text, size, { base }, undefined);
510
+ return layout ? { shows: layout.shows, advance: (layout.advance / 1000) * size } : null;
511
+ }
512
+ /** The one file of `font`'s face that sets all of `text`, if there is one. */
513
+ function singleFile(font, text, rtl = false) {
514
+ const files = fileRuns(font, text, rtl);
515
+ return files.length === 1 ? files[0].font : undefined;
516
+ }
517
+ /**
518
+ * Paint a word set in several styles (`VDTLineSegment.runs`, `كتا**ب**`)
519
+ * as one shaped run, so its letters keep the forms they take in the whole
520
+ * word, each glyph in the style of the run its character belongs to.
521
+ *
522
+ * The word is shaped once in its own face (`font`, the style it was
523
+ * measured in): that shaping gives every glyph's place and the word's
524
+ * width. Walking its glyphs left to right, each stretch of glyphs of one
525
+ * run is shown in that run's colour:
526
+ * - in the same face, the stretch's own glyphs, where they are;
527
+ * - in another face (a bold letter in a regular word), the word is shaped
528
+ * whole in that face too, so the letters at the cut take the joining
529
+ * forms of the whole word, and the glyphs of the stretch's characters are
530
+ * shown over the box the stretch has in the word's own shaping,
531
+ * horizontally scaled (`Tz`) to its width: the joins at both ends meet
532
+ * their neighbours, and the word keeps the width it was measured with.
533
+ * A stretch of vowel signs alone (no advance) keeps the word's face and
534
+ * takes only the colour, since a mark's place depends on its letter's
535
+ * glyph.
536
+ * A vowel sign in a run of its own (a red fatha) is a glyph of its own,
537
+ * so it is coloured apart from its letter. All the stretches are one text
538
+ * object, glyphs left to right (each stretch placed with `Td`), so the
539
+ * word reads back as one, as an unstyled word does.
540
+ *
541
+ * Returns false, painting nothing, when HarfBuzz is not loaded or a face
542
+ * sets the word from several files: the caller then paints the word in
543
+ * one style.
544
+ */
545
+ export function drawStyledWordPx(ctx, text, xPx, baselinePx, font, sizePx, color, parts, options = {}) {
546
+ if (!text)
547
+ return true;
548
+ if (!complexShaperReady() || !(sizePx > 0))
549
+ return false;
550
+ const direction = options.direction ?? 'ltr';
551
+ const file = singleFile(font, text, direction === 'rtl');
552
+ if (!file)
553
+ return false;
554
+ const base = shapingOf(file, text, direction, options.language);
555
+ if (!base)
556
+ return false;
557
+ const hide = tatweelsToHide(options.hideTatweel);
558
+ const sizePt = sizePx * ctx.scale;
559
+ const n = base.run.glyphs.length;
560
+ const partOf = base.owner.map((offset) => parts.findIndex((p) => offset >= p.start && offset < p.end));
561
+ const spanOp = options.actualText === undefined ? (t) => spanOperator(ctx, t) : undefined;
562
+ const shows = [];
563
+ const state = { rise: 0 };
564
+ let fill = color;
565
+ let squeeze = 100;
566
+ // Where the last `Td` put the line, thousandths of the em.
567
+ let lineAt = 0;
568
+ for (let a = 0; a < n;) {
569
+ let b = a + 1;
570
+ while (b < n && partOf[b] === partOf[a])
571
+ b++;
572
+ const part = parts[partOf[a]];
573
+ let showFile = file;
574
+ let shown = null;
575
+ let scale = 1;
576
+ const other = part && part.font !== font ? singleFile(part.font, text, direction === 'rtl') : undefined;
577
+ const width = base.pen[b] - base.pen[a];
578
+ if (other && other !== file && width > 0) {
579
+ // The other face's glyphs of the same characters, in its own shaping
580
+ // of the whole word.
581
+ const shaping = shapingOf(other, text, direction, options.language);
582
+ const owners = new Set(base.owner.slice(a, b));
583
+ let from = -1;
584
+ let to = -1;
585
+ shaping?.owner.forEach((o, i) => {
586
+ if (!owners.has(o))
587
+ return;
588
+ if (from < 0)
589
+ from = i;
590
+ to = i + 1;
591
+ });
592
+ if (shaping && from >= 0) {
593
+ const otherWidth = shaping.pen[to] - shaping.pen[from];
594
+ shown = otherWidth > 0 ? shapedShow(other, text, direction, options.language, hide, from, to) : null;
595
+ if (shown) {
596
+ scale = width / otherWidth;
597
+ showFile = other;
598
+ }
599
+ }
600
+ }
601
+ shown ??= shapedShow(file, text, direction, options.language, hide, a, b);
602
+ if (!shown)
603
+ return false;
604
+ const partColor = part?.color ?? color;
605
+ if (partColor !== fill) {
606
+ shows.push({ font: showFile, op: setFillingColor(partColor) });
607
+ fill = partColor;
608
+ }
609
+ const percent = tjNumber(scale * 100);
610
+ if (percent !== squeeze) {
611
+ shows.push({ font: showFile, op: setCharacterSqueeze(percent) });
612
+ squeeze = percent;
613
+ }
614
+ // `Td` moves the line start, in unscaled text space (points).
615
+ const move = tjNumber(((base.pen[a] - lineAt) / 1000) * sizePt);
616
+ if (move !== 0) {
617
+ shows.push({ font: showFile, op: moveText(move, 0) });
618
+ lineAt = base.pen[a];
619
+ }
620
+ appendShown(shows, showFile, shown, sizePt, state, spanOp);
621
+ a = b;
622
+ }
623
+ if ((ctx.trackingPx ?? 0) !== 0 && joinsLetters(text))
624
+ shows.unshift({ font: shows[0]?.font ?? file, op: setCharacterSpacing(0) });
625
+ pushTextObject(ctx, shows, xPx, baselinePx, file, sizePx, color, undefined, options.actualText ?? undefined, (base.pen[n] / 1000) * sizePx);
626
+ return true;
627
+ }
628
+ /** `BDC` of a `/Span` whose `/ActualText` is `text`. */
629
+ function spanOperator(ctx, text) {
630
+ const props = ctx.page.doc.context.obj({ ActualText: PDFHexString.fromText(text) });
631
+ return PDFOperator.of(PDFOperatorNames.BeginMarkedContentSequence, [PDFName.of('Span'), props]);
632
+ }
633
+ // `drawTextPx` hands complex text over (it cannot import this module back).
634
+ registerComplexPainter((ctx, text, xPx, baselinePx, font, sizePx, color, outline, actualText, direction) => needsComplexShaping(text) && drawShapedTextPx(ctx, text, xPx, baselinePx, font, sizePx, color, { outline, actualText, ...(direction ? { direction } : {}) }));
635
+ //# sourceMappingURL=shapedText.js.map