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.
- package/README.md +13 -1
- package/dist/__bench__/arabicShaping.bench.test.d.ts +2 -0
- package/dist/__bench__/arabicShaping.bench.test.d.ts.map +1 -0
- package/dist/__bench__/arabicShaping.bench.test.js +53 -0
- package/dist/__bench__/arabicShaping.bench.test.js.map +1 -0
- package/dist/__tests__/arabicShaping.test.d.ts +2 -0
- package/dist/__tests__/arabicShaping.test.d.ts.map +1 -0
- package/dist/__tests__/arabicShaping.test.js +261 -0
- package/dist/__tests__/arabicShaping.test.js.map +1 -0
- package/dist/__tests__/complexShaping.test.d.ts +2 -0
- package/dist/__tests__/complexShaping.test.d.ts.map +1 -0
- package/dist/__tests__/complexShaping.test.js +145 -0
- package/dist/__tests__/complexShaping.test.js.map +1 -0
- package/dist/__tests__/designTextRtl.test.d.ts +2 -0
- package/dist/__tests__/designTextRtl.test.d.ts.map +1 -0
- package/dist/__tests__/designTextRtl.test.js +136 -0
- package/dist/__tests__/designTextRtl.test.js.map +1 -0
- package/dist/__tests__/harfbuzz.test.d.ts +2 -0
- package/dist/__tests__/harfbuzz.test.d.ts.map +1 -0
- package/dist/__tests__/harfbuzz.test.js +103 -0
- package/dist/__tests__/harfbuzz.test.js.map +1 -0
- package/dist/__tests__/measuredLines.test.js +13 -5
- package/dist/__tests__/measuredLines.test.js.map +1 -1
- package/dist/__tests__/mirroredPdf.test.d.ts +2 -0
- package/dist/__tests__/mirroredPdf.test.d.ts.map +1 -0
- package/dist/__tests__/mirroredPdf.test.js +104 -0
- package/dist/__tests__/mirroredPdf.test.js.map +1 -0
- package/dist/__tests__/rtlPdf.test.d.ts +2 -0
- package/dist/__tests__/rtlPdf.test.d.ts.map +1 -0
- package/dist/__tests__/rtlPdf.test.js +312 -0
- package/dist/__tests__/rtlPdf.test.js.map +1 -0
- package/dist/__tests__/verticalText.test.js +4 -4
- package/dist/__tests__/verticalText.test.js.map +1 -1
- package/dist/complexShaping.d.ts +113 -0
- package/dist/complexShaping.d.ts.map +1 -0
- package/dist/complexShaping.js +188 -0
- package/dist/complexShaping.js.map +1 -0
- package/dist/faceFiles.d.ts +9 -1
- package/dist/faceFiles.d.ts.map +1 -1
- package/dist/faceFiles.js +47 -5
- package/dist/faceFiles.js.map +1 -1
- package/dist/harfbuzz.d.ts +119 -0
- package/dist/harfbuzz.d.ts.map +1 -0
- package/dist/harfbuzz.js +280 -0
- package/dist/harfbuzz.js.map +1 -0
- package/dist/harfbuzz.wasm +0 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pdf-backend/blockRender.d.ts.map +1 -1
- package/dist/pdf-backend/blockRender.js +113 -40
- package/dist/pdf-backend/blockRender.js.map +1 -1
- package/dist/pdf-backend/chip.d.ts.map +1 -1
- package/dist/pdf-backend/chip.js +11 -2
- package/dist/pdf-backend/chip.js.map +1 -1
- package/dist/pdf-backend/directedLine.d.ts +38 -0
- package/dist/pdf-backend/directedLine.d.ts.map +1 -0
- package/dist/pdf-backend/directedLine.js +103 -0
- package/dist/pdf-backend/directedLine.js.map +1 -0
- package/dist/pdf-backend/fontHelpers.d.ts.map +1 -1
- package/dist/pdf-backend/fontHelpers.js +6 -0
- package/dist/pdf-backend/fontHelpers.js.map +1 -1
- package/dist/pdf-backend/headerFooter.d.ts.map +1 -1
- package/dist/pdf-backend/headerFooter.js +11 -3
- package/dist/pdf-backend/headerFooter.js.map +1 -1
- package/dist/pdf-backend/index.d.ts +32 -3
- package/dist/pdf-backend/index.d.ts.map +1 -1
- package/dist/pdf-backend/index.js +40 -6
- package/dist/pdf-backend/index.js.map +1 -1
- package/dist/pdf-backend/links.d.ts +7 -2
- package/dist/pdf-backend/links.d.ts.map +1 -1
- package/dist/pdf-backend/links.js +9 -3
- package/dist/pdf-backend/links.js.map +1 -1
- package/dist/pdf-backend/outlines.js +2 -2
- package/dist/pdf-backend/outlines.js.map +1 -1
- package/dist/pdf-backend/primitives.d.ts +46 -2
- package/dist/pdf-backend/primitives.d.ts.map +1 -1
- package/dist/pdf-backend/primitives.js +67 -5
- 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 +48 -14
- package/dist/pdf-backend/renderResourceBlock.js.map +1 -1
- package/dist/pdf-backend/rtlRun.d.ts +12 -0
- package/dist/pdf-backend/rtlRun.d.ts.map +1 -0
- package/dist/pdf-backend/rtlRun.js +31 -0
- package/dist/pdf-backend/rtlRun.js.map +1 -0
- package/dist/pdf-backend/shapedText.d.ts +122 -0
- package/dist/pdf-backend/shapedText.d.ts.map +1 -0
- package/dist/pdf-backend/shapedText.js +635 -0
- package/dist/pdf-backend/shapedText.js.map +1 -0
- package/dist/pdf-backend/svgVector.d.ts +9 -2
- package/dist/pdf-backend/svgVector.d.ts.map +1 -1
- package/dist/pdf-backend/svgVector.js +42 -18
- package/dist/pdf-backend/svgVector.js.map +1 -1
- package/dist/worker/client.d.ts.map +1 -1
- package/dist/worker/client.js +10 -1
- package/dist/worker/client.js.map +1 -1
- package/dist/worker/protocol.d.ts +5 -1
- package/dist/worker/protocol.d.ts.map +1 -1
- 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
|