modern-pdf-lib 0.34.0 → 0.36.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 +3 -3
- package/dist/browser.cjs +14 -3
- package/dist/browser.d.cts +2 -2
- package/dist/browser.d.mts +2 -2
- package/dist/browser.mjs +4 -4
- package/dist/create.cjs +2 -2
- package/dist/create.mjs +2 -2
- package/dist/{index-Z4U1Jx0K.d.cts → index-06FgCx99.d.cts} +540 -3
- package/dist/index-06FgCx99.d.cts.map +1 -0
- package/dist/{index-C240uDcb.d.mts → index-Bg3y1BTk.d.mts} +540 -3
- package/dist/index-Bg3y1BTk.d.mts.map +1 -0
- package/dist/index.cjs +14 -3
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +4 -4
- package/dist/{layout-XgQY_Uba.mjs → layout-BZ9InKTl.mjs} +2 -2
- package/dist/{layout-ClbkITM3.cjs → layout-DvciSXsr.cjs} +2 -2
- package/dist/parse.cjs +1 -1
- package/dist/parse.mjs +1 -1
- package/dist/{pdfDocument-Dyj4AmT1.cjs → pdfDocument-D5qz7duP.cjs} +19 -1
- package/dist/{pdfDocument-EcHxUnR6.mjs → pdfDocument-Q0wrwKEZ.mjs} +2 -2
- package/dist/{src-CiUkSS81.mjs → src-4wfgzZvz.mjs} +2455 -9
- package/dist/{src-PxZwqIKQ.cjs → src-MIBq9xGq.cjs} +2518 -6
- package/package.json +1 -1
- package/dist/index-C240uDcb.d.mts.map +0 -1
- package/dist/index-Z4U1Jx0K.d.cts.map +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { c as PdfRef, i as PdfName, l as PdfStream, n as PdfBool, o as PdfNumber, r as PdfDict, t as PdfArray, u as PdfString } from "./pdfObjects-CB7TEdbW.mjs";
|
|
1
|
+
import { $r as beginText, A as encodeUTCTime, C as encodeInteger, Dn as formatPdfDate, E as encodeOctetString, F as parseDerTlv, Gr as rectangle, Gt as PageSizes, I as toBuffer, Kn as saveState, Kr as setDashPattern, M as extractIssuerAndSerial, N as extractSubjectPublicKeyInfo, O as encodeSequence, P as getSubtle, Qn as applyStrokeColor, Qr as stroke, S as encodeContextTag, T as encodeOID, Tn as buildInfoDict, Un as restoreState, Ur as lineTo, Ut as serializePdf, V as findSignatures, W as mergePdfs, Wr as moveTo, Xr as setLineWidth, Zn as applyFillColor, ai as setFont, b as detectKeyAlgorithm, bt as PdfLexer, di as setWordSpacing, ei as endText, er as cmykToRgb, fi as showText$1, k as encodeSet, n as StandardFonts, nt as loadPdf, r as createPdf, rt as PdfEncryptionHandler, s as setPageLabels, st as sha256, t as PdfDocument, ti as moveText, vt as XrefParser, x as detectNamedCurve, xn as buildAnnotationDict, xt as PdfParseError, y as decodeOidBytes, yn as PdfAnnotation, yt as PdfObjectParser, z as computeSignatureHash, zr as fill } from "./pdfDocument-Q0wrwKEZ.mjs";
|
|
2
|
+
import { c as PdfRef, i as PdfName, l as PdfStream, n as PdfBool, o as PdfNumber, r as PdfDict, s as PdfObjectRegistry, t as PdfArray, u as PdfString } from "./pdfObjects-CB7TEdbW.mjs";
|
|
3
3
|
import { n as decodeStreamData, r as getStreamFilters } from "./streamDecode-DCpgTocN.mjs";
|
|
4
4
|
import { g as FieldFlags, v as numVal } from "./pdfForm-CfLnlsj-.mjs";
|
|
5
|
-
import { i as parseContentStream } from "./compressionAnalysis-BEd_745I.mjs";
|
|
5
|
+
import { i as parseContentStream, o as extractTextWithPositions } from "./compressionAnalysis-BEd_745I.mjs";
|
|
6
6
|
import { n as decodeJpegWasm } from "./bridge-7dhURZj7.mjs";
|
|
7
7
|
import { r as detectRuntime } from "./loader-DkrXz-h4.mjs";
|
|
8
8
|
import { deflateSync, inflateSync, unzlibSync } from "fflate";
|
|
@@ -277,7 +277,7 @@ function compressStream(stream, level) {
|
|
|
277
277
|
* @returns The incremental save result.
|
|
278
278
|
*/
|
|
279
279
|
async function saveDocumentIncremental(originalBytes, doc, options) {
|
|
280
|
-
const { buildDocumentStructure } = await import("./pdfDocument-
|
|
280
|
+
const { buildDocumentStructure } = await import("./pdfDocument-Q0wrwKEZ.mjs").then((n) => n.On);
|
|
281
281
|
const registry = doc.getRegistry();
|
|
282
282
|
const structure = buildDocumentStructure(doc.getInternalPages().map((p) => p.finalize()), {
|
|
283
283
|
producer: doc.getProducer(),
|
|
@@ -28081,6 +28081,2452 @@ function buildCertPath(leafCertDer, intermediates, anchors) {
|
|
|
28081
28081
|
};
|
|
28082
28082
|
}
|
|
28083
28083
|
//#endregion
|
|
28084
|
+
//#region src/security/threatScanner.ts
|
|
28085
|
+
/** Numeric rank for severities so we can take a maximum. */
|
|
28086
|
+
const SEVERITY_RANK = {
|
|
28087
|
+
low: 1,
|
|
28088
|
+
medium: 2,
|
|
28089
|
+
high: 3
|
|
28090
|
+
};
|
|
28091
|
+
/** Reverse lookup from rank back to severity. */
|
|
28092
|
+
const RANK_SEVERITY = [
|
|
28093
|
+
"low",
|
|
28094
|
+
"medium",
|
|
28095
|
+
"high"
|
|
28096
|
+
];
|
|
28097
|
+
/**
|
|
28098
|
+
* File-name extensions that denote directly executable / script payloads.
|
|
28099
|
+
* An attachment ending in one of these is treated as high severity because a
|
|
28100
|
+
* viewer's "open attachment" (or a `/Launch` action targeting it) can run it.
|
|
28101
|
+
*/
|
|
28102
|
+
const EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
28103
|
+
"exe",
|
|
28104
|
+
"com",
|
|
28105
|
+
"scr",
|
|
28106
|
+
"pif",
|
|
28107
|
+
"cpl",
|
|
28108
|
+
"msi",
|
|
28109
|
+
"msp",
|
|
28110
|
+
"mst",
|
|
28111
|
+
"dll",
|
|
28112
|
+
"sys",
|
|
28113
|
+
"drv",
|
|
28114
|
+
"ocx",
|
|
28115
|
+
"bat",
|
|
28116
|
+
"cmd",
|
|
28117
|
+
"ps1",
|
|
28118
|
+
"psm1",
|
|
28119
|
+
"vbs",
|
|
28120
|
+
"vbe",
|
|
28121
|
+
"vb",
|
|
28122
|
+
"wsf",
|
|
28123
|
+
"wsh",
|
|
28124
|
+
"js",
|
|
28125
|
+
"jse",
|
|
28126
|
+
"jar",
|
|
28127
|
+
"class",
|
|
28128
|
+
"sh",
|
|
28129
|
+
"bash",
|
|
28130
|
+
"zsh",
|
|
28131
|
+
"ksh",
|
|
28132
|
+
"csh",
|
|
28133
|
+
"command",
|
|
28134
|
+
"app",
|
|
28135
|
+
"dmg",
|
|
28136
|
+
"pkg",
|
|
28137
|
+
"lnk",
|
|
28138
|
+
"url",
|
|
28139
|
+
"reg",
|
|
28140
|
+
"hta",
|
|
28141
|
+
"gadget",
|
|
28142
|
+
"inf",
|
|
28143
|
+
"scf",
|
|
28144
|
+
"apk",
|
|
28145
|
+
"deb",
|
|
28146
|
+
"rpm",
|
|
28147
|
+
"run",
|
|
28148
|
+
"bin",
|
|
28149
|
+
"elf",
|
|
28150
|
+
"so",
|
|
28151
|
+
"dylib",
|
|
28152
|
+
"py",
|
|
28153
|
+
"pyc",
|
|
28154
|
+
"pyw",
|
|
28155
|
+
"rb",
|
|
28156
|
+
"pl",
|
|
28157
|
+
"php"
|
|
28158
|
+
]);
|
|
28159
|
+
/** Read a `PdfName` value (without the leading `/`) from a dict entry. */
|
|
28160
|
+
function nameValue$1(obj) {
|
|
28161
|
+
if (obj !== void 0 && obj.kind === "name") {
|
|
28162
|
+
const v = obj.value;
|
|
28163
|
+
return v.startsWith("/") ? v.slice(1) : v;
|
|
28164
|
+
}
|
|
28165
|
+
}
|
|
28166
|
+
/** Read a `PdfString` value from a dict entry. */
|
|
28167
|
+
function stringValue(obj) {
|
|
28168
|
+
if (obj !== void 0 && obj.kind === "string") return obj.value;
|
|
28169
|
+
}
|
|
28170
|
+
/** Format a `PdfRef` as the canonical `"N G R"` string, if available. */
|
|
28171
|
+
function refString(ref) {
|
|
28172
|
+
return ref ? `${ref.objectNumber} ${ref.generationNumber} R` : void 0;
|
|
28173
|
+
}
|
|
28174
|
+
/** Lower-case file extension of a name, or `''` when there is none. */
|
|
28175
|
+
function extensionOf(name) {
|
|
28176
|
+
const base = name.replace(/[\\/]+/g, "/").split("/").pop() ?? name;
|
|
28177
|
+
const dot = base.lastIndexOf(".");
|
|
28178
|
+
if (dot < 0 || dot === base.length - 1) return "";
|
|
28179
|
+
return base.slice(dot + 1).toLowerCase();
|
|
28180
|
+
}
|
|
28181
|
+
/**
|
|
28182
|
+
* Scan a PDF for hostile constructs and return a {@link ThreatReport}.
|
|
28183
|
+
*
|
|
28184
|
+
* The scan never executes any document content. It parses the PDF and walks
|
|
28185
|
+
* the parsed object graph (see the module header for the precise method and
|
|
28186
|
+
* ISO 32000 references). When parsing fails, a guarded raw-byte fallback runs.
|
|
28187
|
+
*
|
|
28188
|
+
* @param pdf Raw PDF bytes.
|
|
28189
|
+
* @returns A report of findings plus the aggregate `riskLevel`.
|
|
28190
|
+
*/
|
|
28191
|
+
async function scanPdfThreats(pdf) {
|
|
28192
|
+
const findings = [];
|
|
28193
|
+
let scannedGraph = false;
|
|
28194
|
+
try {
|
|
28195
|
+
scanObjectGraph((await loadPdf(pdf, { ignoreEncryption: true })).getRegistry(), findings);
|
|
28196
|
+
scannedGraph = true;
|
|
28197
|
+
} catch {
|
|
28198
|
+
scannedGraph = false;
|
|
28199
|
+
}
|
|
28200
|
+
if (!scannedGraph) scanRawBytesFallback(pdf, findings);
|
|
28201
|
+
return {
|
|
28202
|
+
findings,
|
|
28203
|
+
riskLevel: aggregateRisk(findings)
|
|
28204
|
+
};
|
|
28205
|
+
}
|
|
28206
|
+
/**
|
|
28207
|
+
* Walk every registered object and emit findings.
|
|
28208
|
+
*
|
|
28209
|
+
* We iterate the registry (every resolved indirect object). For each
|
|
28210
|
+
* dictionary (including stream dictionaries) we inspect that dict's *own
|
|
28211
|
+
* keys* and, where relevant, the resolved action-subtype `/S` *name*. Content
|
|
28212
|
+
* streams' raw bytes are never substring-scanned here, which is what makes the
|
|
28213
|
+
* scan precise (drawn text that merely says "JavaScript" is not a finding).
|
|
28214
|
+
*/
|
|
28215
|
+
function scanObjectGraph(registry, findings) {
|
|
28216
|
+
for (const entry of registry) {
|
|
28217
|
+
const obj = entry.object;
|
|
28218
|
+
const refStr = refString(entry.ref);
|
|
28219
|
+
let dict;
|
|
28220
|
+
if (obj.kind === "dict") dict = obj;
|
|
28221
|
+
else if (obj.kind === "stream") dict = obj.dict;
|
|
28222
|
+
if (dict === void 0) continue;
|
|
28223
|
+
inspectDict(dict, refStr, findings);
|
|
28224
|
+
}
|
|
28225
|
+
}
|
|
28226
|
+
/**
|
|
28227
|
+
* Inspect a single dictionary's keys/values for dangerous constructs.
|
|
28228
|
+
*
|
|
28229
|
+
* @param dict The dictionary (or stream dictionary).
|
|
28230
|
+
* @param refStr The owning object's `"N G R"` string, if known.
|
|
28231
|
+
* @param findings Accumulator.
|
|
28232
|
+
*/
|
|
28233
|
+
function inspectDict(dict, refStr, findings) {
|
|
28234
|
+
if (dict.has("/OpenAction")) findings.push({
|
|
28235
|
+
category: "OpenAction",
|
|
28236
|
+
severity: "medium",
|
|
28237
|
+
detail: "Document defines an /OpenAction that runs automatically when the PDF is opened (ISO 32000 §12.6.3).",
|
|
28238
|
+
objectRef: refStr
|
|
28239
|
+
});
|
|
28240
|
+
if (dict.has("/AA")) findings.push({
|
|
28241
|
+
category: "AA",
|
|
28242
|
+
severity: "medium",
|
|
28243
|
+
detail: "Document or object defines /AA additional actions that fire on viewer events such as open/close/print/save (ISO 32000 §12.6.2).",
|
|
28244
|
+
objectRef: refStr
|
|
28245
|
+
});
|
|
28246
|
+
if (dict.has("/JavaScript")) findings.push({
|
|
28247
|
+
category: "JavaScript",
|
|
28248
|
+
severity: "high",
|
|
28249
|
+
detail: "A /JavaScript name tree (catalog /Names → /JavaScript) is present; these scripts execute automatically on open (ISO 32000 §12.6.4.16, §7.7.4).",
|
|
28250
|
+
objectRef: refStr
|
|
28251
|
+
});
|
|
28252
|
+
const subtype = nameValue$1(dict.get("/S"));
|
|
28253
|
+
if (subtype !== void 0) inspectActionSubtype(subtype, dict, refStr, findings);
|
|
28254
|
+
if (dict.has("/XFA")) findings.push({
|
|
28255
|
+
category: "XFA",
|
|
28256
|
+
severity: "low",
|
|
28257
|
+
detail: "Document contains an XFA form (/AcroForm → /XFA, ISO 32000 §12.7.8); XFA carries its own scripting and data-binding model.",
|
|
28258
|
+
objectRef: refStr
|
|
28259
|
+
});
|
|
28260
|
+
const annotSubtype = nameValue$1(dict.get("/Subtype"));
|
|
28261
|
+
if (annotSubtype === "RichMedia") findings.push({
|
|
28262
|
+
category: "RichMedia",
|
|
28263
|
+
severity: "low",
|
|
28264
|
+
detail: "Rich-media annotation (/Subtype /RichMedia, ISO 32000-2 §13.6) embeds interactive multimedia (e.g. Flash/video).",
|
|
28265
|
+
objectRef: refStr
|
|
28266
|
+
});
|
|
28267
|
+
else if (annotSubtype === "Movie") findings.push({
|
|
28268
|
+
category: "Movie",
|
|
28269
|
+
severity: "low",
|
|
28270
|
+
detail: "Movie annotation (/Subtype /Movie, ISO 32000 §13.4) embeds video/animation content.",
|
|
28271
|
+
objectRef: refStr
|
|
28272
|
+
});
|
|
28273
|
+
else if (annotSubtype === "Sound") findings.push({
|
|
28274
|
+
category: "Sound",
|
|
28275
|
+
severity: "low",
|
|
28276
|
+
detail: "Sound annotation (/Subtype /Sound, ISO 32000 §13.3) embeds audio content.",
|
|
28277
|
+
objectRef: refStr
|
|
28278
|
+
});
|
|
28279
|
+
if (dict.has("/RichMedia") && annotSubtype !== "RichMedia") findings.push({
|
|
28280
|
+
category: "RichMedia",
|
|
28281
|
+
severity: "low",
|
|
28282
|
+
detail: "Object references rich-media content via a /RichMedia entry (ISO 32000-2 §13.6).",
|
|
28283
|
+
objectRef: refStr
|
|
28284
|
+
});
|
|
28285
|
+
if (dict.has("/Movie") && annotSubtype !== "Movie") findings.push({
|
|
28286
|
+
category: "Movie",
|
|
28287
|
+
severity: "low",
|
|
28288
|
+
detail: "Object references movie content via a /Movie entry (ISO 32000 §13.4).",
|
|
28289
|
+
objectRef: refStr
|
|
28290
|
+
});
|
|
28291
|
+
if (dict.has("/Sound") && annotSubtype !== "Sound" && subtype !== "Sound") findings.push({
|
|
28292
|
+
category: "Sound",
|
|
28293
|
+
severity: "low",
|
|
28294
|
+
detail: "Object references sound content via a /Sound entry (ISO 32000 §13.3).",
|
|
28295
|
+
objectRef: refStr
|
|
28296
|
+
});
|
|
28297
|
+
if (nameValue$1(dict.get("/Type")) === "Filespec" || dict.has("/EF")) {
|
|
28298
|
+
const fileName = stringValue(dict.get("/UF")) ?? stringValue(dict.get("/F"));
|
|
28299
|
+
if (fileName !== void 0) {
|
|
28300
|
+
const ext = extensionOf(fileName);
|
|
28301
|
+
if (ext !== "" && EXECUTABLE_EXTENSIONS.has(ext)) findings.push({
|
|
28302
|
+
category: "EmbeddedFile",
|
|
28303
|
+
severity: "high",
|
|
28304
|
+
detail: `Embedded file "${fileName}" has an executable/script extension ".${ext}" (ISO 32000 §7.11.3/§7.11.4); opening it can run code.`,
|
|
28305
|
+
objectRef: refStr
|
|
28306
|
+
});
|
|
28307
|
+
}
|
|
28308
|
+
}
|
|
28309
|
+
}
|
|
28310
|
+
/**
|
|
28311
|
+
* Flag a dangerous action `/S` subtype. Subtypes are ISO 32000 §12.6.4 /
|
|
28312
|
+
* §12.7.5 action types.
|
|
28313
|
+
*/
|
|
28314
|
+
function inspectActionSubtype(subtype, dict, refStr, findings) {
|
|
28315
|
+
switch (subtype) {
|
|
28316
|
+
case "JavaScript":
|
|
28317
|
+
findings.push({
|
|
28318
|
+
category: "JavaScript",
|
|
28319
|
+
severity: "high",
|
|
28320
|
+
detail: "JavaScript action (/S /JavaScript, ISO 32000 §12.6.4.16) executes script in the viewer.",
|
|
28321
|
+
objectRef: refStr
|
|
28322
|
+
});
|
|
28323
|
+
break;
|
|
28324
|
+
case "Launch":
|
|
28325
|
+
findings.push({
|
|
28326
|
+
category: "Launch",
|
|
28327
|
+
severity: "high",
|
|
28328
|
+
detail: "Launch action (/S /Launch, ISO 32000 §12.6.4.5) can run an external application or open a file.",
|
|
28329
|
+
objectRef: refStr
|
|
28330
|
+
});
|
|
28331
|
+
break;
|
|
28332
|
+
case "URI":
|
|
28333
|
+
findings.push({
|
|
28334
|
+
category: "URI",
|
|
28335
|
+
severity: "medium",
|
|
28336
|
+
detail: `URI action (/S /URI, ISO 32000 §12.6.4.7) opens a remote URL ${describeUri(dict)}.`,
|
|
28337
|
+
objectRef: refStr
|
|
28338
|
+
});
|
|
28339
|
+
break;
|
|
28340
|
+
case "SubmitForm":
|
|
28341
|
+
findings.push({
|
|
28342
|
+
category: "SubmitForm",
|
|
28343
|
+
severity: "medium",
|
|
28344
|
+
detail: "SubmitForm action (/S /SubmitForm, ISO 32000 §12.7.5.2) sends form data to a URL (potential data exfiltration).",
|
|
28345
|
+
objectRef: refStr
|
|
28346
|
+
});
|
|
28347
|
+
break;
|
|
28348
|
+
case "ImportData":
|
|
28349
|
+
findings.push({
|
|
28350
|
+
category: "ImportData",
|
|
28351
|
+
severity: "low",
|
|
28352
|
+
detail: "ImportData action (/S /ImportData, ISO 32000 §12.7.5.4) imports external form data.",
|
|
28353
|
+
objectRef: refStr
|
|
28354
|
+
});
|
|
28355
|
+
break;
|
|
28356
|
+
case "GoToR":
|
|
28357
|
+
findings.push({
|
|
28358
|
+
category: "GoToR",
|
|
28359
|
+
severity: "low",
|
|
28360
|
+
detail: "Remote Go-To action (/S /GoToR, ISO 32000 §12.6.4.3) targets an external document.",
|
|
28361
|
+
objectRef: refStr
|
|
28362
|
+
});
|
|
28363
|
+
break;
|
|
28364
|
+
case "Movie":
|
|
28365
|
+
findings.push({
|
|
28366
|
+
category: "Movie",
|
|
28367
|
+
severity: "low",
|
|
28368
|
+
detail: "Movie action (/S /Movie, ISO 32000 §12.6.4.10) plays embedded video.",
|
|
28369
|
+
objectRef: refStr
|
|
28370
|
+
});
|
|
28371
|
+
break;
|
|
28372
|
+
case "Sound":
|
|
28373
|
+
findings.push({
|
|
28374
|
+
category: "Sound",
|
|
28375
|
+
severity: "low",
|
|
28376
|
+
detail: "Sound action (/S /Sound, ISO 32000 §12.6.4.8) plays embedded audio.",
|
|
28377
|
+
objectRef: refStr
|
|
28378
|
+
});
|
|
28379
|
+
break;
|
|
28380
|
+
default: break;
|
|
28381
|
+
}
|
|
28382
|
+
}
|
|
28383
|
+
/** Append the target URL of a /URI action to the detail string, if present. */
|
|
28384
|
+
function describeUri(dict) {
|
|
28385
|
+
const uri = stringValue(dict.get("/URI"));
|
|
28386
|
+
return uri !== void 0 ? `→ "${uri}"` : "";
|
|
28387
|
+
}
|
|
28388
|
+
/**
|
|
28389
|
+
* Best-effort raw-byte fallback used only when the PDF cannot be parsed.
|
|
28390
|
+
*
|
|
28391
|
+
* To stay precise we match constructs only in their *structural* dictionary
|
|
28392
|
+
* form — action subtypes as `/S /Type` (optionally without the inner space)
|
|
28393
|
+
* and `/OpenAction`/`/AA`/`/JavaScript` as dictionary keys (a `/` immediately
|
|
28394
|
+
* followed by the token and then a PDF delimiter). This avoids flagging the
|
|
28395
|
+
* same words when they appear as content-stream text or inside a literal
|
|
28396
|
+
* string. It is heuristic and clearly less reliable than the graph walk.
|
|
28397
|
+
*/
|
|
28398
|
+
function scanRawBytesFallback(pdf, findings) {
|
|
28399
|
+
const text = new TextDecoder("latin1").decode(pdf);
|
|
28400
|
+
const keyForm = (key) => new RegExp(`/${key}(?=[\\s/(<\\[\\]>]|$)`);
|
|
28401
|
+
if (keyForm("OpenAction").test(text)) findings.push({
|
|
28402
|
+
category: "OpenAction",
|
|
28403
|
+
severity: "medium",
|
|
28404
|
+
detail: "Raw scan: /OpenAction key present (ISO 32000 §12.6.3)."
|
|
28405
|
+
});
|
|
28406
|
+
if (keyForm("AA").test(text)) findings.push({
|
|
28407
|
+
category: "AA",
|
|
28408
|
+
severity: "medium",
|
|
28409
|
+
detail: "Raw scan: /AA additional-actions key present (ISO 32000 §12.6.2)."
|
|
28410
|
+
});
|
|
28411
|
+
if (keyForm("JavaScript").test(text) || /\/S\s*\/JavaScript\b/.test(text)) findings.push({
|
|
28412
|
+
category: "JavaScript",
|
|
28413
|
+
severity: "high",
|
|
28414
|
+
detail: "Raw scan: JavaScript action/name tree present (ISO 32000 §12.6.4.16)."
|
|
28415
|
+
});
|
|
28416
|
+
if (/\/S\s*\/Launch\b/.test(text)) findings.push({
|
|
28417
|
+
category: "Launch",
|
|
28418
|
+
severity: "high",
|
|
28419
|
+
detail: "Raw scan: /S /Launch action present (ISO 32000 §12.6.4.5)."
|
|
28420
|
+
});
|
|
28421
|
+
if (/\/S\s*\/URI\b/.test(text)) findings.push({
|
|
28422
|
+
category: "URI",
|
|
28423
|
+
severity: "medium",
|
|
28424
|
+
detail: "Raw scan: /S /URI action present (ISO 32000 §12.6.4.7)."
|
|
28425
|
+
});
|
|
28426
|
+
if (/\/S\s*\/SubmitForm\b/.test(text)) findings.push({
|
|
28427
|
+
category: "SubmitForm",
|
|
28428
|
+
severity: "medium",
|
|
28429
|
+
detail: "Raw scan: /S /SubmitForm action present (ISO 32000 §12.7.5.2)."
|
|
28430
|
+
});
|
|
28431
|
+
if (/\/S\s*\/ImportData\b/.test(text)) findings.push({
|
|
28432
|
+
category: "ImportData",
|
|
28433
|
+
severity: "low",
|
|
28434
|
+
detail: "Raw scan: /S /ImportData action present (ISO 32000 §12.7.5.4)."
|
|
28435
|
+
});
|
|
28436
|
+
if (/\/S\s*\/GoToR\b/.test(text)) findings.push({
|
|
28437
|
+
category: "GoToR",
|
|
28438
|
+
severity: "low",
|
|
28439
|
+
detail: "Raw scan: /S /GoToR remote go-to present (ISO 32000 §12.6.4.3)."
|
|
28440
|
+
});
|
|
28441
|
+
if (/\/Subtype\s*\/RichMedia\b/.test(text) || keyForm("RichMedia").test(text)) findings.push({
|
|
28442
|
+
category: "RichMedia",
|
|
28443
|
+
severity: "low",
|
|
28444
|
+
detail: "Raw scan: RichMedia annotation present (ISO 32000-2 §13.6)."
|
|
28445
|
+
});
|
|
28446
|
+
if (/\/Subtype\s*\/Movie\b/.test(text) || /\/S\s*\/Movie\b/.test(text)) findings.push({
|
|
28447
|
+
category: "Movie",
|
|
28448
|
+
severity: "low",
|
|
28449
|
+
detail: "Raw scan: Movie content/action present (ISO 32000 §13.4)."
|
|
28450
|
+
});
|
|
28451
|
+
if (/\/Subtype\s*\/Sound\b/.test(text) || /\/S\s*\/Sound\b/.test(text)) findings.push({
|
|
28452
|
+
category: "Sound",
|
|
28453
|
+
severity: "low",
|
|
28454
|
+
detail: "Raw scan: Sound content/action present (ISO 32000 §13.3)."
|
|
28455
|
+
});
|
|
28456
|
+
if (keyForm("XFA").test(text)) findings.push({
|
|
28457
|
+
category: "XFA",
|
|
28458
|
+
severity: "low",
|
|
28459
|
+
detail: "Raw scan: /XFA form present (ISO 32000 §12.7.8)."
|
|
28460
|
+
});
|
|
28461
|
+
}
|
|
28462
|
+
/** Maximum severity across findings, or `'none'` when there are none. */
|
|
28463
|
+
function aggregateRisk(findings) {
|
|
28464
|
+
let maxRank = 0;
|
|
28465
|
+
for (const f of findings) {
|
|
28466
|
+
const rank = SEVERITY_RANK[f.severity];
|
|
28467
|
+
if (rank > maxRank) maxRank = rank;
|
|
28468
|
+
}
|
|
28469
|
+
if (maxRank === 0) return "none";
|
|
28470
|
+
return RANK_SEVERITY[maxRank - 1];
|
|
28471
|
+
}
|
|
28472
|
+
//#endregion
|
|
28473
|
+
//#region src/security/sanitize.ts
|
|
28474
|
+
/**
|
|
28475
|
+
* @module security/sanitize
|
|
28476
|
+
*
|
|
28477
|
+
* PDF sanitizer — produce a cleaned copy of a PDF with active / hidden
|
|
28478
|
+
* content neutralised.
|
|
28479
|
+
*
|
|
28480
|
+
* The sanitizer removes four classes of potentially dangerous or
|
|
28481
|
+
* privacy-leaking content from a document, each independently toggleable:
|
|
28482
|
+
*
|
|
28483
|
+
* - **javascript** — document-level JavaScript: the catalog's
|
|
28484
|
+
* `/Names → /JavaScript` name tree (ISO 32000-1 §7.7.4 / §12.6.4.16)
|
|
28485
|
+
* and the catalog's `/AA` (document additional-actions) dictionary
|
|
28486
|
+
* (§12.6.3) plus any page-level `/AA`.
|
|
28487
|
+
* - **openActions** — the catalog's auto-run `/OpenAction` (§12.3.4 / §12.6.4).
|
|
28488
|
+
* - **embeddedFiles** — embedded file attachments: the catalog's
|
|
28489
|
+
* `/Names → /EmbeddedFiles` name tree (§7.7.4 / §7.11.4) and the
|
|
28490
|
+
* PDF 2.0 catalog `/AF` (associated files) array (§7.11.4).
|
|
28491
|
+
* - **metadata** — the XMP `/Metadata` stream (§14.3.2) and the document
|
|
28492
|
+
* information `/Info` dictionary (§14.3.3).
|
|
28493
|
+
*
|
|
28494
|
+
* Implementation strategy (verified against this library's parser/writer):
|
|
28495
|
+
* the cleaned bytes are produced by mutating the *parsed object graph* in
|
|
28496
|
+
* place and re-serializing the registry directly through
|
|
28497
|
+
* {@link serializePdf}, **without** rebuilding the catalog. This is what
|
|
28498
|
+
* lets a disabled option genuinely preserve its content — the original
|
|
28499
|
+
* catalog (minus the removed references) is written verbatim.
|
|
28500
|
+
*
|
|
28501
|
+
* Removing a reference from the catalog is not sufficient on its own: the
|
|
28502
|
+
* underlying object (a JavaScript action dictionary, an `/EmbeddedFile`
|
|
28503
|
+
* stream, an XMP metadata stream, …) would otherwise remain physically
|
|
28504
|
+
* present in the output as an orphan. To guarantee the payload bytes are
|
|
28505
|
+
* actually gone, after deleting references this module runs the registry's
|
|
28506
|
+
* reachability filter from the catalog + info roots, dropping every object
|
|
28507
|
+
* no longer referenced.
|
|
28508
|
+
*
|
|
28509
|
+
* @packageDocumentation
|
|
28510
|
+
*/
|
|
28511
|
+
/**
|
|
28512
|
+
* Resolve a {@link PdfObject} that may be an indirect reference to its
|
|
28513
|
+
* underlying value using the registry. Non-refs are returned as-is.
|
|
28514
|
+
* Returns `undefined` if a ref cannot be resolved.
|
|
28515
|
+
*/
|
|
28516
|
+
function resolve$2(obj, registry) {
|
|
28517
|
+
if (obj === void 0) return void 0;
|
|
28518
|
+
if (obj instanceof PdfRef) return registry.resolve(obj);
|
|
28519
|
+
return obj;
|
|
28520
|
+
}
|
|
28521
|
+
/** Resolve `obj` and return it only if it is a {@link PdfDict}. */
|
|
28522
|
+
function asDict(obj, registry) {
|
|
28523
|
+
const resolved = resolve$2(obj, registry);
|
|
28524
|
+
return resolved instanceof PdfDict ? resolved : void 0;
|
|
28525
|
+
}
|
|
28526
|
+
/** Read a {@link PdfString} value, resolving an indirect reference if needed. */
|
|
28527
|
+
function asStringValue(obj, registry) {
|
|
28528
|
+
const resolved = resolve$2(obj, registry);
|
|
28529
|
+
return resolved instanceof PdfString ? resolved.value : void 0;
|
|
28530
|
+
}
|
|
28531
|
+
/**
|
|
28532
|
+
* Collect every distinct `/Type /Catalog` dictionary registered in the
|
|
28533
|
+
* document, paired with its reference. A well-formed PDF has exactly one,
|
|
28534
|
+
* but malformed or incrementally-updated files can register more than one;
|
|
28535
|
+
* sanitizing all of them is the conservative, correct choice. The first
|
|
28536
|
+
* entry is treated as the document root.
|
|
28537
|
+
*/
|
|
28538
|
+
function findCatalogEntries(registry) {
|
|
28539
|
+
const byObjNum = /* @__PURE__ */ new Map();
|
|
28540
|
+
for (const { ref, object } of registry) if (object instanceof PdfDict) {
|
|
28541
|
+
const type = object.get("/Type");
|
|
28542
|
+
if (type instanceof PdfName && type.value === "/Catalog") {
|
|
28543
|
+
if (!byObjNum.has(ref.objectNumber)) byObjNum.set(ref.objectNumber, {
|
|
28544
|
+
ref,
|
|
28545
|
+
dict: object
|
|
28546
|
+
});
|
|
28547
|
+
}
|
|
28548
|
+
}
|
|
28549
|
+
return [...byObjNum.values()];
|
|
28550
|
+
}
|
|
28551
|
+
/** Collect every distinct `/Type /Page` dictionary in the registry. */
|
|
28552
|
+
function findPages(registry) {
|
|
28553
|
+
const pages = /* @__PURE__ */ new Set();
|
|
28554
|
+
for (const { object } of registry) if (object instanceof PdfDict) {
|
|
28555
|
+
const type = object.get("/Type");
|
|
28556
|
+
if (type instanceof PdfName && type.value === "/Page") pages.add(object);
|
|
28557
|
+
}
|
|
28558
|
+
return [...pages];
|
|
28559
|
+
}
|
|
28560
|
+
/**
|
|
28561
|
+
* Determine whether a PDF name tree (ISO 32000-1 §7.7.4) contains at least
|
|
28562
|
+
* one entry. A name-tree node holds either a `/Names` array of
|
|
28563
|
+
* `[key value key value …]` pairs (leaf) or a `/Kids` array of child node
|
|
28564
|
+
* references (intermediate). The tree is walked recursively with a visited
|
|
28565
|
+
* guard against cyclic references in malformed files.
|
|
28566
|
+
*/
|
|
28567
|
+
function nameTreeHasEntries(node, registry, visited = /* @__PURE__ */ new Set()) {
|
|
28568
|
+
const dict = asDict(node, registry);
|
|
28569
|
+
if (dict === void 0 || visited.has(dict)) return false;
|
|
28570
|
+
visited.add(dict);
|
|
28571
|
+
const names = resolve$2(dict.get("/Names"), registry);
|
|
28572
|
+
if (names instanceof PdfArray && names.length >= 2) return true;
|
|
28573
|
+
const kids = resolve$2(dict.get("/Kids"), registry);
|
|
28574
|
+
if (kids instanceof PdfArray) {
|
|
28575
|
+
for (const kid of kids.items) if (nameTreeHasEntries(kid, registry, visited)) return true;
|
|
28576
|
+
}
|
|
28577
|
+
return false;
|
|
28578
|
+
}
|
|
28579
|
+
/** Return the catalog's resolved `/Names` sub-dictionary, if present. */
|
|
28580
|
+
function getNamesDict(catalog, registry) {
|
|
28581
|
+
return asDict(catalog.get("/Names"), registry);
|
|
28582
|
+
}
|
|
28583
|
+
/**
|
|
28584
|
+
* Determine whether an additional-actions (`/AA`) dictionary contains any
|
|
28585
|
+
* action whose `/S` (action type) is `/JavaScript`. Each value in an `/AA`
|
|
28586
|
+
* dictionary is itself an action dictionary (§12.6.3).
|
|
28587
|
+
*/
|
|
28588
|
+
function aaHasJavaScript(aa, registry) {
|
|
28589
|
+
if (aa === void 0) return false;
|
|
28590
|
+
for (const [, value] of aa) {
|
|
28591
|
+
const action = asDict(value, registry);
|
|
28592
|
+
if (action === void 0) continue;
|
|
28593
|
+
const s = action.get("/S");
|
|
28594
|
+
if (s instanceof PdfName && s.value === "/JavaScript") return true;
|
|
28595
|
+
}
|
|
28596
|
+
return false;
|
|
28597
|
+
}
|
|
28598
|
+
/**
|
|
28599
|
+
* Descriptive `/Info` keys that constitute identifying metadata. The
|
|
28600
|
+
* auto-generated `/Producer`, `/CreationDate`, and `/ModDate` are NOT
|
|
28601
|
+
* treated as "present" for reporting purposes: a freshly created document
|
|
28602
|
+
* carries those by default and must still be considered clean. (When
|
|
28603
|
+
* metadata removal runs, the entire `/Info` dict is dropped regardless.)
|
|
28604
|
+
*/
|
|
28605
|
+
const DESCRIPTIVE_INFO_KEYS = [
|
|
28606
|
+
"/Title",
|
|
28607
|
+
"/Author",
|
|
28608
|
+
"/Subject",
|
|
28609
|
+
"/Keywords",
|
|
28610
|
+
"/Creator"
|
|
28611
|
+
];
|
|
28612
|
+
/**
|
|
28613
|
+
* Locate the trailer's `/Info` dictionary in the registry. The parser does
|
|
28614
|
+
* not expose the trailer publicly, so the Info dict is identified
|
|
28615
|
+
* structurally: it is a dictionary that is NOT a catalog/page/pages node
|
|
28616
|
+
* and carries at least one recognised document-information key.
|
|
28617
|
+
*/
|
|
28618
|
+
function findInfoDict(registry, catalogs) {
|
|
28619
|
+
const catalogDicts = new Set(catalogs.map((c) => c.dict));
|
|
28620
|
+
const INFO_KEYS = [
|
|
28621
|
+
"/Title",
|
|
28622
|
+
"/Author",
|
|
28623
|
+
"/Subject",
|
|
28624
|
+
"/Keywords",
|
|
28625
|
+
"/Creator",
|
|
28626
|
+
"/Producer",
|
|
28627
|
+
"/CreationDate",
|
|
28628
|
+
"/ModDate",
|
|
28629
|
+
"/Trapped"
|
|
28630
|
+
];
|
|
28631
|
+
for (const { object } of registry) {
|
|
28632
|
+
if (!(object instanceof PdfDict) || catalogDicts.has(object)) continue;
|
|
28633
|
+
const type = object.get("/Type");
|
|
28634
|
+
if (type instanceof PdfName) {
|
|
28635
|
+
const v = type.value;
|
|
28636
|
+
if (v === "/Catalog" || v === "/Pages" || v === "/Page" || v === "/Font") continue;
|
|
28637
|
+
}
|
|
28638
|
+
if (INFO_KEYS.some((k) => object.has(k))) return object;
|
|
28639
|
+
}
|
|
28640
|
+
}
|
|
28641
|
+
/** Read descriptive metadata from an `/Info` dict into a typed bag. */
|
|
28642
|
+
function readInfoMetadata(info, registry) {
|
|
28643
|
+
const meta = {};
|
|
28644
|
+
if (info === void 0) return meta;
|
|
28645
|
+
const title = asStringValue(info.get("/Title"), registry);
|
|
28646
|
+
if (title !== void 0) meta.title = title;
|
|
28647
|
+
const author = asStringValue(info.get("/Author"), registry);
|
|
28648
|
+
if (author !== void 0) meta.author = author;
|
|
28649
|
+
const subject = asStringValue(info.get("/Subject"), registry);
|
|
28650
|
+
if (subject !== void 0) meta.subject = subject;
|
|
28651
|
+
const keywords = asStringValue(info.get("/Keywords"), registry);
|
|
28652
|
+
if (keywords !== void 0) meta.keywords = keywords;
|
|
28653
|
+
const creator = asStringValue(info.get("/Creator"), registry);
|
|
28654
|
+
if (creator !== void 0) meta.creator = creator;
|
|
28655
|
+
const producer = asStringValue(info.get("/Producer"), registry);
|
|
28656
|
+
if (producer !== void 0) meta.producer = producer;
|
|
28657
|
+
return meta;
|
|
28658
|
+
}
|
|
28659
|
+
/**
|
|
28660
|
+
* Produce a cleaned copy of a PDF with active / hidden content neutralised.
|
|
28661
|
+
*
|
|
28662
|
+
* Loads the PDF, removes each enabled class of content from the parsed
|
|
28663
|
+
* object graph, prunes the orphaned objects, re-serializes the cleaned
|
|
28664
|
+
* document, and returns the new bytes alongside a report listing the
|
|
28665
|
+
* classes that were actually present and removed.
|
|
28666
|
+
*
|
|
28667
|
+
* @param pdf The source PDF bytes.
|
|
28668
|
+
* @param options Which classes to remove (each defaults to `true`).
|
|
28669
|
+
* @returns The cleaned PDF bytes and a {@link SanitizeReport}.
|
|
28670
|
+
*
|
|
28671
|
+
* @example
|
|
28672
|
+
* ```ts
|
|
28673
|
+
* const { pdf, report } = await sanitizePdf(bytes);
|
|
28674
|
+
* // report.removed === ['javascript', 'embeddedFiles', 'metadata']
|
|
28675
|
+
* ```
|
|
28676
|
+
*/
|
|
28677
|
+
async function sanitizePdf(pdf, options) {
|
|
28678
|
+
const removeJavaScript = options?.javascript ?? true;
|
|
28679
|
+
const removeOpenActions = options?.openActions ?? true;
|
|
28680
|
+
const removeEmbeddedFiles = options?.embeddedFiles ?? true;
|
|
28681
|
+
const removeMetadata = options?.metadata ?? true;
|
|
28682
|
+
const registry = (await loadPdf(pdf)).getRegistry();
|
|
28683
|
+
const catalogs = findCatalogEntries(registry);
|
|
28684
|
+
if (catalogs.length === 0) return {
|
|
28685
|
+
pdf,
|
|
28686
|
+
report: { removed: [] }
|
|
28687
|
+
};
|
|
28688
|
+
const pages = findPages(registry);
|
|
28689
|
+
const infoDict = findInfoDict(registry, catalogs);
|
|
28690
|
+
const removed = /* @__PURE__ */ new Set();
|
|
28691
|
+
if (removeJavaScript) {
|
|
28692
|
+
for (const { dict: catalog } of catalogs) {
|
|
28693
|
+
const names = getNamesDict(catalog, registry);
|
|
28694
|
+
if (names !== void 0 && nameTreeHasEntries(names.get("/JavaScript"), registry)) removed.add("javascript");
|
|
28695
|
+
if (aaHasJavaScript(asDict(catalog.get("/AA"), registry), registry)) removed.add("javascript");
|
|
28696
|
+
}
|
|
28697
|
+
for (const page of pages) if (aaHasJavaScript(asDict(page.get("/AA"), registry), registry)) removed.add("javascript");
|
|
28698
|
+
for (const { dict: catalog } of catalogs) {
|
|
28699
|
+
const names = getNamesDict(catalog, registry);
|
|
28700
|
+
if (names !== void 0) {
|
|
28701
|
+
names.delete("/JavaScript");
|
|
28702
|
+
if (names.size === 0) catalog.delete("/Names");
|
|
28703
|
+
}
|
|
28704
|
+
catalog.delete("/AA");
|
|
28705
|
+
}
|
|
28706
|
+
for (const page of pages) page.delete("/AA");
|
|
28707
|
+
}
|
|
28708
|
+
if (removeOpenActions) {
|
|
28709
|
+
for (const { dict: catalog } of catalogs) if (catalog.has("/OpenAction")) {
|
|
28710
|
+
removed.add("openActions");
|
|
28711
|
+
catalog.delete("/OpenAction");
|
|
28712
|
+
}
|
|
28713
|
+
}
|
|
28714
|
+
if (removeEmbeddedFiles) {
|
|
28715
|
+
for (const { dict: catalog } of catalogs) {
|
|
28716
|
+
const names = getNamesDict(catalog, registry);
|
|
28717
|
+
if (names !== void 0 && nameTreeHasEntries(names.get("/EmbeddedFiles"), registry)) removed.add("embeddedFiles");
|
|
28718
|
+
const af = resolve$2(catalog.get("/AF"), registry);
|
|
28719
|
+
if (af instanceof PdfArray && af.length > 0) removed.add("embeddedFiles");
|
|
28720
|
+
}
|
|
28721
|
+
for (const { dict: catalog } of catalogs) {
|
|
28722
|
+
const names = getNamesDict(catalog, registry);
|
|
28723
|
+
if (names !== void 0) {
|
|
28724
|
+
names.delete("/EmbeddedFiles");
|
|
28725
|
+
if (names.size === 0) catalog.delete("/Names");
|
|
28726
|
+
}
|
|
28727
|
+
catalog.delete("/AF");
|
|
28728
|
+
}
|
|
28729
|
+
}
|
|
28730
|
+
const preservedMeta = readInfoMetadata(infoDict, registry);
|
|
28731
|
+
if (removeMetadata) {
|
|
28732
|
+
let metadataPresent = false;
|
|
28733
|
+
for (const { dict: catalog } of catalogs) if (resolve$2(catalog.get("/Metadata"), registry) instanceof PdfStream) metadataPresent = true;
|
|
28734
|
+
if (infoDict !== void 0 && DESCRIPTIVE_INFO_KEYS.some((k) => infoDict.has(k))) metadataPresent = true;
|
|
28735
|
+
if (metadataPresent) removed.add("metadata");
|
|
28736
|
+
for (const { dict: catalog } of catalogs) catalog.delete("/Metadata");
|
|
28737
|
+
if (infoDict !== void 0) for (const [key] of [...infoDict]) infoDict.delete(key);
|
|
28738
|
+
}
|
|
28739
|
+
const root = catalogs[0];
|
|
28740
|
+
const pagesRef = root.dict.get("/Pages");
|
|
28741
|
+
let infoRef;
|
|
28742
|
+
if (removeMetadata) infoRef = buildInfoDict({}, registry);
|
|
28743
|
+
else infoRef = buildInfoDict(preservedMeta, registry);
|
|
28744
|
+
const structure = {
|
|
28745
|
+
catalogRef: root.ref,
|
|
28746
|
+
infoRef,
|
|
28747
|
+
pagesRef: pagesRef instanceof PdfRef ? pagesRef : root.ref
|
|
28748
|
+
};
|
|
28749
|
+
registry.filterReachable([structure.catalogRef, structure.infoRef]);
|
|
28750
|
+
return {
|
|
28751
|
+
pdf: await serializePdf(registry, structure, { compress: true }),
|
|
28752
|
+
report: { removed: [...removed] }
|
|
28753
|
+
};
|
|
28754
|
+
}
|
|
28755
|
+
//#endregion
|
|
28756
|
+
//#region src/security/redactionVerifier.ts
|
|
28757
|
+
/**
|
|
28758
|
+
* @module security/redactionVerifier
|
|
28759
|
+
*
|
|
28760
|
+
* Detect FAILED / fake redactions — text that is still extractable underneath
|
|
28761
|
+
* a redaction region.
|
|
28762
|
+
*
|
|
28763
|
+
* A *secure* redaction physically removes the underlying text-showing
|
|
28764
|
+
* operators from the page content stream (see `redactRegions` in
|
|
28765
|
+
* `../render/redactContent.js`), so the redacted text cannot be recovered by
|
|
28766
|
+
* copy/paste, text extraction, or raw stream inspection. A *fake* redaction
|
|
28767
|
+
* merely paints an opaque black box on top of the page while the original text
|
|
28768
|
+
* remains in the byte stream — a genuine, well-documented security failure
|
|
28769
|
+
* (the "select-all-and-copy" leak). This verifier finds those leaks.
|
|
28770
|
+
*
|
|
28771
|
+
* ## How it works
|
|
28772
|
+
*
|
|
28773
|
+
* For each page named by a region:
|
|
28774
|
+
* 1. The page's content stream is decoded
|
|
28775
|
+
* (`PdfPage.getContentStream()`) and parsed (`parseContentStream`).
|
|
28776
|
+
* 2. `extractTextWithPositions` replays the text/graphics-state machine and
|
|
28777
|
+
* yields one positioned {@link import('../parser/textExtractor.js').TextItem}
|
|
28778
|
+
* per shown text run, with its page-space origin and size.
|
|
28779
|
+
* 3. Every text item whose bounding box intersects a region rectangle is
|
|
28780
|
+
* reported as a leak. `clean` is true iff there are no leaks.
|
|
28781
|
+
*
|
|
28782
|
+
* ## Coordinate / encoding conventions (confirmed, not assumed)
|
|
28783
|
+
*
|
|
28784
|
+
* - **Space:** PDF user space — the coordinate space produced by
|
|
28785
|
+
* `extractTextWithPositions`. Origin is **bottom-left**, the y-axis points
|
|
28786
|
+
* **up**, and units are **PDF points** (1/72 inch). This matches
|
|
28787
|
+
* `RedactRect` in `../render/redactContent.js` ("page space, PDF points,
|
|
28788
|
+
* y-up, lower-left origin"), so a {@link RedactionRegion} uses exactly the
|
|
28789
|
+
* same convention: `(x, y)` is the lower-left corner and `width` / `height`
|
|
28790
|
+
* extend in the +x / +y directions.
|
|
28791
|
+
* - **Glyph box:** a `TextItem` reports `(x, y)` = the text origin
|
|
28792
|
+
* (baseline-left), `width` extending +x, and `height` (equal to the font
|
|
28793
|
+
* size) extending +y. The run's axis-aligned box is therefore
|
|
28794
|
+
* `[x, y, x + width, y + height]`. Note `width`/`height` are heuristic
|
|
28795
|
+
* estimates from `extractTextWithPositions` (0.5·fontSize per character;
|
|
28796
|
+
* height = fontSize) because per-glyph font metrics are not available there;
|
|
28797
|
+
* intersection testing tolerates this by treating any overlap as a leak.
|
|
28798
|
+
* - **Text decoding:** strings are decoded by `extractTextWithPositions` using
|
|
28799
|
+
* the font's `/ToUnicode` CMap when present, else WinAnsiEncoding (1-byte) or
|
|
28800
|
+
* 2-byte Identity-H for CID fonts — see `../parser/textExtractor.js`.
|
|
28801
|
+
*
|
|
28802
|
+
* ## Region auto-detection is intentionally NOT performed
|
|
28803
|
+
*
|
|
28804
|
+
* The `regions` argument is **required**. Deriving candidate regions from the
|
|
28805
|
+
* PDF itself is not reliable through the public API and is not fabricated here:
|
|
28806
|
+
* - Filled black rectangles in the content stream are indistinguishable from
|
|
28807
|
+
* legitimate black-filled graphics (chart bars, rules, design elements), so
|
|
28808
|
+
* a heuristic would produce false positives/negatives.
|
|
28809
|
+
* - `/Redact` annotations (ISO 32000-2 §12.5.6.23) *do* carry explicit
|
|
28810
|
+
* `/Rect` / `/QuadPoints` regions, but a loaded page's original annotation
|
|
28811
|
+
* dictionaries are not exposed through a stable public `PdfPage` accessor,
|
|
28812
|
+
* so reading them here is not possible without reaching into private state.
|
|
28813
|
+
* Callers must therefore pass the regions they redacted (or believe were
|
|
28814
|
+
* redacted). When `regions` is omitted or empty, {@link verifyRedactions}
|
|
28815
|
+
* throws.
|
|
28816
|
+
*
|
|
28817
|
+
* @packageDocumentation
|
|
28818
|
+
*/
|
|
28819
|
+
/**
|
|
28820
|
+
* Axis-aligned bounding box of a text run in page space (bottom-left origin).
|
|
28821
|
+
* `width`/`height` may be zero (empty text); such runs never intersect.
|
|
28822
|
+
*/
|
|
28823
|
+
function itemBox(item) {
|
|
28824
|
+
const x0 = item.x;
|
|
28825
|
+
const y0 = item.y;
|
|
28826
|
+
const x1 = item.x + item.width;
|
|
28827
|
+
const y1 = item.y + item.height;
|
|
28828
|
+
return {
|
|
28829
|
+
minX: Math.min(x0, x1),
|
|
28830
|
+
minY: Math.min(y0, y1),
|
|
28831
|
+
maxX: Math.max(x0, x1),
|
|
28832
|
+
maxY: Math.max(y0, y1)
|
|
28833
|
+
};
|
|
28834
|
+
}
|
|
28835
|
+
/**
|
|
28836
|
+
* Whether a text run's box overlaps a region rectangle.
|
|
28837
|
+
*
|
|
28838
|
+
* Uses the standard separating-axis test for axis-aligned rectangles: they
|
|
28839
|
+
* overlap unless one is entirely to the left/right/above/below the other.
|
|
28840
|
+
* Touching edges count as overlap (inclusive bounds), matching the intersection
|
|
28841
|
+
* test in `../render/redactContent.js`.
|
|
28842
|
+
*/
|
|
28843
|
+
function intersects$1(box, region) {
|
|
28844
|
+
const rMinX = region.x;
|
|
28845
|
+
const rMinY = region.y;
|
|
28846
|
+
const rMaxX = region.x + region.width;
|
|
28847
|
+
const rMaxY = region.y + region.height;
|
|
28848
|
+
return box.minX <= rMaxX && box.maxX >= rMinX && box.minY <= rMaxY && box.maxY >= rMinY;
|
|
28849
|
+
}
|
|
28850
|
+
/**
|
|
28851
|
+
* Verify that the given regions of a PDF contain no still-extractable text,
|
|
28852
|
+
* detecting fake / failed redactions.
|
|
28853
|
+
*
|
|
28854
|
+
* For each region, the corresponding page's text is extracted with positions
|
|
28855
|
+
* and any text run whose bounding box intersects the region is reported as a
|
|
28856
|
+
* {@link RedactionLeak}. A clean result means no text was found under any
|
|
28857
|
+
* region (the redactions truly removed the content, or there was none to begin
|
|
28858
|
+
* with).
|
|
28859
|
+
*
|
|
28860
|
+
* @param pdf The PDF file bytes to inspect.
|
|
28861
|
+
* @param regions The regions to check (REQUIRED — see the module docs for why
|
|
28862
|
+
* automatic region detection is not performed). Coordinates are
|
|
28863
|
+
* in PDF user space: origin bottom-left, y-up, units in points;
|
|
28864
|
+
* `(x, y)` is the lower-left corner.
|
|
28865
|
+
* @returns A report listing every leak; `clean` is true iff there are
|
|
28866
|
+
* none.
|
|
28867
|
+
* @throws `TypeError` if `regions` is omitted or empty.
|
|
28868
|
+
*
|
|
28869
|
+
* @example
|
|
28870
|
+
* ```ts
|
|
28871
|
+
* const report = await verifyRedactions(pdfBytes, [
|
|
28872
|
+
* { page: 0, x: 45, y: 545, width: 120, height: 25 },
|
|
28873
|
+
* ]);
|
|
28874
|
+
* if (!report.clean) {
|
|
28875
|
+
* for (const leak of report.leaks) {
|
|
28876
|
+
* console.warn(`Leak on page ${leak.page}: "${leak.text}"`);
|
|
28877
|
+
* }
|
|
28878
|
+
* }
|
|
28879
|
+
* ```
|
|
28880
|
+
*/
|
|
28881
|
+
async function verifyRedactions(pdf, regions) {
|
|
28882
|
+
if (regions === void 0 || regions.length === 0) throw new TypeError("verifyRedactions requires an explicit, non-empty `regions` array. Automatic redaction-region detection is not performed: black-filled rectangles are indistinguishable from legitimate graphics, and a loaded page's original /Redact annotations are not exposed through a stable public API. Pass the regions you redacted (PDF points, bottom-left origin, y-up).");
|
|
28883
|
+
const doc = await loadPdf(pdf);
|
|
28884
|
+
const pageCount = doc.getPageCount();
|
|
28885
|
+
const itemsByPage = /* @__PURE__ */ new Map();
|
|
28886
|
+
const getItems = (pageIndex) => {
|
|
28887
|
+
const cached = itemsByPage.get(pageIndex);
|
|
28888
|
+
if (cached !== void 0) return cached;
|
|
28889
|
+
let items = [];
|
|
28890
|
+
if (pageIndex >= 0 && pageIndex < pageCount) {
|
|
28891
|
+
const page = doc.getPage(pageIndex);
|
|
28892
|
+
items = extractTextWithPositions(parseContentStream(page.getContentStream()), page.getOriginalResources());
|
|
28893
|
+
}
|
|
28894
|
+
itemsByPage.set(pageIndex, items);
|
|
28895
|
+
return items;
|
|
28896
|
+
};
|
|
28897
|
+
const leaks = [];
|
|
28898
|
+
for (const region of regions) {
|
|
28899
|
+
if (region.width <= 0 || region.height <= 0) continue;
|
|
28900
|
+
const items = getItems(region.page);
|
|
28901
|
+
for (const item of items) {
|
|
28902
|
+
if (item.text.length === 0) continue;
|
|
28903
|
+
const box = itemBox(item);
|
|
28904
|
+
if (box.maxX === box.minX && box.maxY === box.minY) continue;
|
|
28905
|
+
if (intersects$1(box, region)) leaks.push({
|
|
28906
|
+
page: region.page,
|
|
28907
|
+
text: item.text,
|
|
28908
|
+
x: item.x,
|
|
28909
|
+
y: item.y
|
|
28910
|
+
});
|
|
28911
|
+
}
|
|
28912
|
+
}
|
|
28913
|
+
return {
|
|
28914
|
+
leaks,
|
|
28915
|
+
clean: leaks.length === 0,
|
|
28916
|
+
regionsChecked: regions.length
|
|
28917
|
+
};
|
|
28918
|
+
}
|
|
28919
|
+
//#endregion
|
|
28920
|
+
//#region src/security/encryptionInspector.ts
|
|
28921
|
+
const BIT_PRINT = 4;
|
|
28922
|
+
const BIT_MODIFY = 8;
|
|
28923
|
+
const BIT_COPY = 16;
|
|
28924
|
+
const BIT_ANNOTATE = 32;
|
|
28925
|
+
const BIT_FILL_FORMS = 256;
|
|
28926
|
+
const BIT_ASSEMBLE = 1024;
|
|
28927
|
+
const BIT_PRINT_HIGH_RES = 2048;
|
|
28928
|
+
/**
|
|
28929
|
+
* Inspect a PDF's encryption + permission posture.
|
|
28930
|
+
*
|
|
28931
|
+
* @param pdf The raw PDF file bytes.
|
|
28932
|
+
* @returns A {@link EncryptionReport}. If the trailer has no
|
|
28933
|
+
* `/Encrypt` entry (or the file is unparseable) the report is
|
|
28934
|
+
* `{ encrypted: false }`.
|
|
28935
|
+
*/
|
|
28936
|
+
async function inspectEncryption(pdf) {
|
|
28937
|
+
const located = await locateEncryptDict(pdf);
|
|
28938
|
+
if (located === void 0) return { encrypted: false };
|
|
28939
|
+
const { encryptDict, fileId, encryptRef } = located;
|
|
28940
|
+
const filterName = getName(encryptDict, "/Filter");
|
|
28941
|
+
let handler;
|
|
28942
|
+
if (filterName === "/Standard") handler = "password";
|
|
28943
|
+
else if (filterName === "/Adobe.PubSec") handler = "publicKey";
|
|
28944
|
+
const version = getNumber(encryptDict, "/V");
|
|
28945
|
+
const revision = getNumber(encryptDict, "/R");
|
|
28946
|
+
const { method, keyBits } = decodeAlgorithm(encryptDict, version);
|
|
28947
|
+
let permissions;
|
|
28948
|
+
const pValue = getNumber(encryptDict, "/P");
|
|
28949
|
+
if (handler === "password" && pValue !== void 0) permissions = decodePermissionBits(pValue);
|
|
28950
|
+
let emptyUserPassword;
|
|
28951
|
+
if (handler === "password") emptyUserPassword = await testEmptyUserPassword(encryptDict, fileId);
|
|
28952
|
+
const report = { encrypted: true };
|
|
28953
|
+
if (method !== void 0) report.method = method;
|
|
28954
|
+
if (keyBits !== void 0) report.keyBits = keyBits;
|
|
28955
|
+
if (version !== void 0) report.version = version;
|
|
28956
|
+
if (revision !== void 0) report.revision = revision;
|
|
28957
|
+
if (handler !== void 0) report.handler = handler;
|
|
28958
|
+
if (emptyUserPassword !== void 0) report.emptyUserPassword = emptyUserPassword;
|
|
28959
|
+
if (permissions !== void 0) report.permissions = permissions;
|
|
28960
|
+
return report;
|
|
28961
|
+
}
|
|
28962
|
+
/**
|
|
28963
|
+
* Parse just enough of the PDF (header + xref + trailer) to resolve the
|
|
28964
|
+
* `/Encrypt` dictionary and the first element of the `/ID` array.
|
|
28965
|
+
*
|
|
28966
|
+
* Returns `undefined` when the file has no `/Encrypt` entry or when it
|
|
28967
|
+
* cannot be parsed far enough to find one.
|
|
28968
|
+
*/
|
|
28969
|
+
async function locateEncryptDict(pdf) {
|
|
28970
|
+
if (pdf.length < 8) return void 0;
|
|
28971
|
+
let encryptRef;
|
|
28972
|
+
let fileIdFirst;
|
|
28973
|
+
let entry;
|
|
28974
|
+
let objectParser;
|
|
28975
|
+
try {
|
|
28976
|
+
objectParser = new PdfObjectParser(new PdfLexer(pdf), new PdfObjectRegistry());
|
|
28977
|
+
const { entries, trailer } = await new XrefParser(pdf, objectParser).parseXref();
|
|
28978
|
+
encryptRef = trailer.encryptRef;
|
|
28979
|
+
if (encryptRef === void 0) return void 0;
|
|
28980
|
+
fileIdFirst = trailer.id?.[0] ?? /* @__PURE__ */ new Uint8Array(0);
|
|
28981
|
+
entry = entries.get(encryptRef.objectNumber);
|
|
28982
|
+
} catch {
|
|
28983
|
+
return;
|
|
28984
|
+
}
|
|
28985
|
+
if (entry === void 0 || entry.type !== "in-use") return void 0;
|
|
28986
|
+
let encryptObj;
|
|
28987
|
+
try {
|
|
28988
|
+
const { object } = objectParser.parseIndirectObjectAt(entry.offset);
|
|
28989
|
+
encryptObj = object;
|
|
28990
|
+
} catch {
|
|
28991
|
+
return;
|
|
28992
|
+
}
|
|
28993
|
+
if (encryptObj.kind !== "dict") return void 0;
|
|
28994
|
+
return {
|
|
28995
|
+
encryptDict: encryptObj,
|
|
28996
|
+
encryptRef,
|
|
28997
|
+
fileId: fileIdFirst
|
|
28998
|
+
};
|
|
28999
|
+
}
|
|
29000
|
+
/**
|
|
29001
|
+
* Decode the bulk cipher and key length from an encryption dictionary.
|
|
29002
|
+
*
|
|
29003
|
+
* - V=1: 40-bit RC4 (Table 20/21).
|
|
29004
|
+
* - V=2: RC4 with `/Length` bits.
|
|
29005
|
+
* - V=4: inspect the crypt filter `/CFM` of the filter named by `/StmF`
|
|
29006
|
+
* (falling back to `/StdCF`): `/V2` → RC4, `/AESV2` → 128-bit AES,
|
|
29007
|
+
* `/AESV3` → 256-bit AES, `/Identity`/`/None` → no encryption.
|
|
29008
|
+
* - V=5: 256-bit AES (`/AESV3`).
|
|
29009
|
+
*/
|
|
29010
|
+
function decodeAlgorithm(dict, version) {
|
|
29011
|
+
const lengthBits = getNumber(dict, "/Length");
|
|
29012
|
+
if (version === 1) return {
|
|
29013
|
+
method: "rc4",
|
|
29014
|
+
keyBits: 40
|
|
29015
|
+
};
|
|
29016
|
+
if (version === 2) return {
|
|
29017
|
+
method: "rc4",
|
|
29018
|
+
keyBits: lengthBits ?? 40
|
|
29019
|
+
};
|
|
29020
|
+
if (version === 5) return {
|
|
29021
|
+
method: "aes",
|
|
29022
|
+
keyBits: 256
|
|
29023
|
+
};
|
|
29024
|
+
if (version === 4) {
|
|
29025
|
+
const cfm = resolveCryptFilterMethod(dict);
|
|
29026
|
+
if (cfm === "/AESV3") return {
|
|
29027
|
+
method: "aes",
|
|
29028
|
+
keyBits: 256
|
|
29029
|
+
};
|
|
29030
|
+
if (cfm === "/AESV2") return {
|
|
29031
|
+
method: "aes",
|
|
29032
|
+
keyBits: lengthBits ?? 128
|
|
29033
|
+
};
|
|
29034
|
+
if (cfm === "/V2") return {
|
|
29035
|
+
method: "rc4",
|
|
29036
|
+
keyBits: lengthBits ?? 128
|
|
29037
|
+
};
|
|
29038
|
+
return {
|
|
29039
|
+
method: void 0,
|
|
29040
|
+
keyBits: lengthBits ?? void 0
|
|
29041
|
+
};
|
|
29042
|
+
}
|
|
29043
|
+
return {
|
|
29044
|
+
method: void 0,
|
|
29045
|
+
keyBits: lengthBits ?? void 0
|
|
29046
|
+
};
|
|
29047
|
+
}
|
|
29048
|
+
/**
|
|
29049
|
+
* For V=4 documents, resolve the `/CFM` (crypt filter method) name of the
|
|
29050
|
+
* stream crypt filter. Looks up `/StmF` in `/CF`, then falls back to the
|
|
29051
|
+
* conventional `/StdCF` entry.
|
|
29052
|
+
*/
|
|
29053
|
+
function resolveCryptFilterMethod(dict) {
|
|
29054
|
+
const cf = dict.get("/CF");
|
|
29055
|
+
if (cf === void 0 || cf.kind !== "dict") return void 0;
|
|
29056
|
+
const cfDict = cf;
|
|
29057
|
+
let filterName = getName(dict, "/StmF");
|
|
29058
|
+
if (filterName === "/Identity") return "/Identity";
|
|
29059
|
+
let stdCf = filterName !== void 0 ? cfDict.get(filterName) : void 0;
|
|
29060
|
+
if (stdCf === void 0 || stdCf.kind !== "dict") {
|
|
29061
|
+
stdCf = cfDict.get("/StdCF");
|
|
29062
|
+
filterName = "/StdCF";
|
|
29063
|
+
}
|
|
29064
|
+
if (stdCf === void 0 || stdCf.kind !== "dict") return void 0;
|
|
29065
|
+
return getName(stdCf, "/CFM");
|
|
29066
|
+
}
|
|
29067
|
+
/**
|
|
29068
|
+
* Decode the 32-bit `/P` integer into individual permission flags.
|
|
29069
|
+
*
|
|
29070
|
+
* The `/P` value is a *signed* 32-bit integer; bitwise AND in JavaScript
|
|
29071
|
+
* operates on the 32-bit two's-complement representation, so masks apply
|
|
29072
|
+
* correctly even when `/P` is negative (the usual case, since reserved
|
|
29073
|
+
* high bits are set to 1).
|
|
29074
|
+
*/
|
|
29075
|
+
function decodePermissionBits(p) {
|
|
29076
|
+
const has = (mask) => (p & mask) !== 0;
|
|
29077
|
+
const copy = has(BIT_COPY);
|
|
29078
|
+
return {
|
|
29079
|
+
print: has(BIT_PRINT),
|
|
29080
|
+
modify: has(BIT_MODIFY),
|
|
29081
|
+
copy,
|
|
29082
|
+
annotate: has(BIT_ANNOTATE),
|
|
29083
|
+
fillForms: has(BIT_FILL_FORMS),
|
|
29084
|
+
extract: copy,
|
|
29085
|
+
assemble: has(BIT_ASSEMBLE),
|
|
29086
|
+
printHighRes: has(BIT_PRINT_HIGH_RES)
|
|
29087
|
+
};
|
|
29088
|
+
}
|
|
29089
|
+
/**
|
|
29090
|
+
* Test whether the empty user password (`""`) authenticates against the
|
|
29091
|
+
* standard security handler. Reuses {@link PdfEncryptionHandler.fromEncryptDict},
|
|
29092
|
+
* which throws when the password is incorrect; a successful return means
|
|
29093
|
+
* the empty password derived a valid file key.
|
|
29094
|
+
*
|
|
29095
|
+
* Returns `undefined` when the test cannot be performed (e.g. the handler
|
|
29096
|
+
* implementation does not support the document's /V/R).
|
|
29097
|
+
*/
|
|
29098
|
+
async function testEmptyUserPassword(dict, fileId) {
|
|
29099
|
+
try {
|
|
29100
|
+
await PdfEncryptionHandler.fromEncryptDict(dict, fileId, "");
|
|
29101
|
+
return true;
|
|
29102
|
+
} catch (err) {
|
|
29103
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
29104
|
+
if (/password/i.test(message)) return false;
|
|
29105
|
+
return;
|
|
29106
|
+
}
|
|
29107
|
+
}
|
|
29108
|
+
/** Read a `/Name` value (including the leading `/`) from a dict. */
|
|
29109
|
+
function getName(dict, key) {
|
|
29110
|
+
const obj = dict.get(key);
|
|
29111
|
+
if (obj !== void 0 && obj.kind === "name") return obj.value;
|
|
29112
|
+
}
|
|
29113
|
+
/** Read a numeric value from a dict. */
|
|
29114
|
+
function getNumber(dict, key) {
|
|
29115
|
+
const obj = dict.get(key);
|
|
29116
|
+
if (obj !== void 0 && obj.kind === "number") return obj.value;
|
|
29117
|
+
}
|
|
29118
|
+
//#endregion
|
|
29119
|
+
//#region src/text/bidi.ts
|
|
29120
|
+
/** UAX #9 §3.3.2: the maximum explicit embedding depth. */
|
|
29121
|
+
const MAX_DEPTH = 125;
|
|
29122
|
+
/**
|
|
29123
|
+
* Classify a Unicode scalar value into its {@link BC} Bidi_Class.
|
|
29124
|
+
*
|
|
29125
|
+
* Range-based subset of DerivedBidiClass.txt (Unicode 16.0.0). Covers Latin,
|
|
29126
|
+
* Hebrew, Arabic, the digit groups, neutrals/weaks and all explicit-format and
|
|
29127
|
+
* isolate characters. Anything outside the listed ranges falls back to the
|
|
29128
|
+
* Unicode default class for that area (`R`/`AL` in the default-RTL blocks,
|
|
29129
|
+
* else `L`).
|
|
29130
|
+
*
|
|
29131
|
+
* @param cp Unicode scalar value (code point).
|
|
29132
|
+
* @returns The character's Bidi_Class.
|
|
29133
|
+
*/
|
|
29134
|
+
function bidiClass(cp) {
|
|
29135
|
+
switch (cp) {
|
|
29136
|
+
case 8234: return "LRE";
|
|
29137
|
+
case 8235: return "RLE";
|
|
29138
|
+
case 8237: return "LRO";
|
|
29139
|
+
case 8238: return "RLO";
|
|
29140
|
+
case 8236: return "PDF";
|
|
29141
|
+
case 8294: return "LRI";
|
|
29142
|
+
case 8295: return "RLI";
|
|
29143
|
+
case 8296: return "FSI";
|
|
29144
|
+
case 8297: return "PDI";
|
|
29145
|
+
default: break;
|
|
29146
|
+
}
|
|
29147
|
+
if (cp < 128) {
|
|
29148
|
+
if (cp === 10 || cp === 13 || cp === 28 || cp === 29 || cp === 30 || cp === 133) return "B";
|
|
29149
|
+
if (cp === 9 || cp === 11 || cp === 31) return "S";
|
|
29150
|
+
if (cp === 12 || cp === 32) return "WS";
|
|
29151
|
+
if (cp >= 48 && cp <= 57) return "EN";
|
|
29152
|
+
if (cp === 43 || cp === 45) return "ES";
|
|
29153
|
+
if (cp === 35 || cp === 36 || cp === 37) return "ET";
|
|
29154
|
+
if (cp === 44 || cp === 46 || cp === 47 || cp === 58) return "CS";
|
|
29155
|
+
if (cp >= 65 && cp <= 90 || cp >= 97 && cp <= 122) return "L";
|
|
29156
|
+
return "ON";
|
|
29157
|
+
}
|
|
29158
|
+
if (cp === 133) return "B";
|
|
29159
|
+
if (cp === 160) return "CS";
|
|
29160
|
+
if (cp === 163 || cp === 164 || cp === 165 || cp === 176 || cp === 177) return "ET";
|
|
29161
|
+
if (cp === 171 || cp === 187) return "ON";
|
|
29162
|
+
if (cp >= 1425 && cp <= 1469) return "NSM";
|
|
29163
|
+
if (cp === 1471 || cp === 1473 || cp === 1474 || cp === 1476 || cp === 1477 || cp === 1479) return "NSM";
|
|
29164
|
+
if (cp >= 1424 && cp <= 1535) return "R";
|
|
29165
|
+
if (cp >= 1536 && cp <= 1541) return "AN";
|
|
29166
|
+
if (cp >= 1632 && cp <= 1641) return "AN";
|
|
29167
|
+
if (cp === 1643 || cp === 1644) return "AN";
|
|
29168
|
+
if (cp === 1642) return "ET";
|
|
29169
|
+
if (cp >= 1776 && cp <= 1785) return "EN";
|
|
29170
|
+
if (cp >= 1552 && cp <= 1562) return "NSM";
|
|
29171
|
+
if (cp >= 1611 && cp <= 1631) return "NSM";
|
|
29172
|
+
if (cp === 1648) return "NSM";
|
|
29173
|
+
if (cp >= 1750 && cp <= 1756) return "NSM";
|
|
29174
|
+
if (cp >= 1759 && cp <= 1764) return "NSM";
|
|
29175
|
+
if (cp === 1767 || cp === 1768) return "NSM";
|
|
29176
|
+
if (cp >= 1770 && cp <= 1773) return "NSM";
|
|
29177
|
+
if (cp >= 1536 && cp <= 1791) return "AL";
|
|
29178
|
+
if (cp >= 1792 && cp <= 1871) return "AL";
|
|
29179
|
+
if (cp >= 1872 && cp <= 1919) return "AL";
|
|
29180
|
+
if (cp >= 1920 && cp <= 1983) return "AL";
|
|
29181
|
+
if (cp === 8232) return "WS";
|
|
29182
|
+
if (cp === 8233) return "B";
|
|
29183
|
+
if (cp >= 8192 && cp <= 8202) return "WS";
|
|
29184
|
+
if (cp === 8203) return "BN";
|
|
29185
|
+
if (cp === 8206) return "L";
|
|
29186
|
+
if (cp === 8207) return "R";
|
|
29187
|
+
if (cp === 1564) return "AL";
|
|
29188
|
+
if (cp === 8239) return "CS";
|
|
29189
|
+
if (cp === 8287) return "WS";
|
|
29190
|
+
if (cp === 12288) return "WS";
|
|
29191
|
+
if (cp === 65279) return "BN";
|
|
29192
|
+
if (cp >= 768 && cp <= 879) return "NSM";
|
|
29193
|
+
if (cp >= 8400 && cp <= 8447) return "NSM";
|
|
29194
|
+
return "L";
|
|
29195
|
+
}
|
|
29196
|
+
/**
|
|
29197
|
+
* Map of opening bracket → closing bracket and vice versa for UAX #9 N0
|
|
29198
|
+
* (paired brackets). Subset of BidiBrackets.txt (Unicode 16.0.0) covering the
|
|
29199
|
+
* ASCII and common CJK/typographic pairs. Canonical-equivalent singletons
|
|
29200
|
+
* (U+2329/U+232A) are folded onto their canonical forms per BD16.
|
|
29201
|
+
*/
|
|
29202
|
+
const BRACKET_PAIRS = /* @__PURE__ */ new Map([
|
|
29203
|
+
[40, {
|
|
29204
|
+
other: 41,
|
|
29205
|
+
open: true
|
|
29206
|
+
}],
|
|
29207
|
+
[41, {
|
|
29208
|
+
other: 40,
|
|
29209
|
+
open: false
|
|
29210
|
+
}],
|
|
29211
|
+
[91, {
|
|
29212
|
+
other: 93,
|
|
29213
|
+
open: true
|
|
29214
|
+
}],
|
|
29215
|
+
[93, {
|
|
29216
|
+
other: 91,
|
|
29217
|
+
open: false
|
|
29218
|
+
}],
|
|
29219
|
+
[123, {
|
|
29220
|
+
other: 125,
|
|
29221
|
+
open: true
|
|
29222
|
+
}],
|
|
29223
|
+
[125, {
|
|
29224
|
+
other: 123,
|
|
29225
|
+
open: false
|
|
29226
|
+
}],
|
|
29227
|
+
[3898, {
|
|
29228
|
+
other: 3899,
|
|
29229
|
+
open: true
|
|
29230
|
+
}],
|
|
29231
|
+
[3899, {
|
|
29232
|
+
other: 3898,
|
|
29233
|
+
open: false
|
|
29234
|
+
}],
|
|
29235
|
+
[3900, {
|
|
29236
|
+
other: 3901,
|
|
29237
|
+
open: true
|
|
29238
|
+
}],
|
|
29239
|
+
[3901, {
|
|
29240
|
+
other: 3900,
|
|
29241
|
+
open: false
|
|
29242
|
+
}],
|
|
29243
|
+
[5787, {
|
|
29244
|
+
other: 5788,
|
|
29245
|
+
open: true
|
|
29246
|
+
}],
|
|
29247
|
+
[5788, {
|
|
29248
|
+
other: 5787,
|
|
29249
|
+
open: false
|
|
29250
|
+
}],
|
|
29251
|
+
[8261, {
|
|
29252
|
+
other: 8262,
|
|
29253
|
+
open: true
|
|
29254
|
+
}],
|
|
29255
|
+
[8262, {
|
|
29256
|
+
other: 8261,
|
|
29257
|
+
open: false
|
|
29258
|
+
}],
|
|
29259
|
+
[8317, {
|
|
29260
|
+
other: 8318,
|
|
29261
|
+
open: true
|
|
29262
|
+
}],
|
|
29263
|
+
[8318, {
|
|
29264
|
+
other: 8317,
|
|
29265
|
+
open: false
|
|
29266
|
+
}],
|
|
29267
|
+
[8333, {
|
|
29268
|
+
other: 8334,
|
|
29269
|
+
open: true
|
|
29270
|
+
}],
|
|
29271
|
+
[8334, {
|
|
29272
|
+
other: 8333,
|
|
29273
|
+
open: false
|
|
29274
|
+
}],
|
|
29275
|
+
[9001, {
|
|
29276
|
+
other: 9002,
|
|
29277
|
+
open: true
|
|
29278
|
+
}],
|
|
29279
|
+
[9002, {
|
|
29280
|
+
other: 9001,
|
|
29281
|
+
open: false
|
|
29282
|
+
}],
|
|
29283
|
+
[10088, {
|
|
29284
|
+
other: 10089,
|
|
29285
|
+
open: true
|
|
29286
|
+
}],
|
|
29287
|
+
[10089, {
|
|
29288
|
+
other: 10088,
|
|
29289
|
+
open: false
|
|
29290
|
+
}],
|
|
29291
|
+
[10090, {
|
|
29292
|
+
other: 10091,
|
|
29293
|
+
open: true
|
|
29294
|
+
}],
|
|
29295
|
+
[10091, {
|
|
29296
|
+
other: 10090,
|
|
29297
|
+
open: false
|
|
29298
|
+
}],
|
|
29299
|
+
[10092, {
|
|
29300
|
+
other: 10093,
|
|
29301
|
+
open: true
|
|
29302
|
+
}],
|
|
29303
|
+
[10093, {
|
|
29304
|
+
other: 10092,
|
|
29305
|
+
open: false
|
|
29306
|
+
}],
|
|
29307
|
+
[10094, {
|
|
29308
|
+
other: 10095,
|
|
29309
|
+
open: true
|
|
29310
|
+
}],
|
|
29311
|
+
[10095, {
|
|
29312
|
+
other: 10094,
|
|
29313
|
+
open: false
|
|
29314
|
+
}],
|
|
29315
|
+
[10096, {
|
|
29316
|
+
other: 10097,
|
|
29317
|
+
open: true
|
|
29318
|
+
}],
|
|
29319
|
+
[10097, {
|
|
29320
|
+
other: 10096,
|
|
29321
|
+
open: false
|
|
29322
|
+
}],
|
|
29323
|
+
[10098, {
|
|
29324
|
+
other: 10099,
|
|
29325
|
+
open: true
|
|
29326
|
+
}],
|
|
29327
|
+
[10099, {
|
|
29328
|
+
other: 10098,
|
|
29329
|
+
open: false
|
|
29330
|
+
}],
|
|
29331
|
+
[10100, {
|
|
29332
|
+
other: 10101,
|
|
29333
|
+
open: true
|
|
29334
|
+
}],
|
|
29335
|
+
[10101, {
|
|
29336
|
+
other: 10100,
|
|
29337
|
+
open: false
|
|
29338
|
+
}],
|
|
29339
|
+
[10214, {
|
|
29340
|
+
other: 10215,
|
|
29341
|
+
open: true
|
|
29342
|
+
}],
|
|
29343
|
+
[10215, {
|
|
29344
|
+
other: 10214,
|
|
29345
|
+
open: false
|
|
29346
|
+
}],
|
|
29347
|
+
[10216, {
|
|
29348
|
+
other: 10217,
|
|
29349
|
+
open: true
|
|
29350
|
+
}],
|
|
29351
|
+
[10217, {
|
|
29352
|
+
other: 10216,
|
|
29353
|
+
open: false
|
|
29354
|
+
}],
|
|
29355
|
+
[10218, {
|
|
29356
|
+
other: 10219,
|
|
29357
|
+
open: true
|
|
29358
|
+
}],
|
|
29359
|
+
[10219, {
|
|
29360
|
+
other: 10218,
|
|
29361
|
+
open: false
|
|
29362
|
+
}],
|
|
29363
|
+
[10220, {
|
|
29364
|
+
other: 10221,
|
|
29365
|
+
open: true
|
|
29366
|
+
}],
|
|
29367
|
+
[10221, {
|
|
29368
|
+
other: 10220,
|
|
29369
|
+
open: false
|
|
29370
|
+
}],
|
|
29371
|
+
[10222, {
|
|
29372
|
+
other: 10223,
|
|
29373
|
+
open: true
|
|
29374
|
+
}],
|
|
29375
|
+
[10223, {
|
|
29376
|
+
other: 10222,
|
|
29377
|
+
open: false
|
|
29378
|
+
}],
|
|
29379
|
+
[10627, {
|
|
29380
|
+
other: 10628,
|
|
29381
|
+
open: true
|
|
29382
|
+
}],
|
|
29383
|
+
[10628, {
|
|
29384
|
+
other: 10627,
|
|
29385
|
+
open: false
|
|
29386
|
+
}],
|
|
29387
|
+
[12296, {
|
|
29388
|
+
other: 12297,
|
|
29389
|
+
open: true
|
|
29390
|
+
}],
|
|
29391
|
+
[12297, {
|
|
29392
|
+
other: 12296,
|
|
29393
|
+
open: false
|
|
29394
|
+
}],
|
|
29395
|
+
[12298, {
|
|
29396
|
+
other: 12299,
|
|
29397
|
+
open: true
|
|
29398
|
+
}],
|
|
29399
|
+
[12299, {
|
|
29400
|
+
other: 12298,
|
|
29401
|
+
open: false
|
|
29402
|
+
}],
|
|
29403
|
+
[12300, {
|
|
29404
|
+
other: 12301,
|
|
29405
|
+
open: true
|
|
29406
|
+
}],
|
|
29407
|
+
[12301, {
|
|
29408
|
+
other: 12300,
|
|
29409
|
+
open: false
|
|
29410
|
+
}],
|
|
29411
|
+
[12302, {
|
|
29412
|
+
other: 12303,
|
|
29413
|
+
open: true
|
|
29414
|
+
}],
|
|
29415
|
+
[12303, {
|
|
29416
|
+
other: 12302,
|
|
29417
|
+
open: false
|
|
29418
|
+
}],
|
|
29419
|
+
[12304, {
|
|
29420
|
+
other: 12305,
|
|
29421
|
+
open: true
|
|
29422
|
+
}],
|
|
29423
|
+
[12305, {
|
|
29424
|
+
other: 12304,
|
|
29425
|
+
open: false
|
|
29426
|
+
}],
|
|
29427
|
+
[12308, {
|
|
29428
|
+
other: 12309,
|
|
29429
|
+
open: true
|
|
29430
|
+
}],
|
|
29431
|
+
[12309, {
|
|
29432
|
+
other: 12308,
|
|
29433
|
+
open: false
|
|
29434
|
+
}],
|
|
29435
|
+
[12310, {
|
|
29436
|
+
other: 12311,
|
|
29437
|
+
open: true
|
|
29438
|
+
}],
|
|
29439
|
+
[12311, {
|
|
29440
|
+
other: 12310,
|
|
29441
|
+
open: false
|
|
29442
|
+
}],
|
|
29443
|
+
[12312, {
|
|
29444
|
+
other: 12313,
|
|
29445
|
+
open: true
|
|
29446
|
+
}],
|
|
29447
|
+
[12313, {
|
|
29448
|
+
other: 12312,
|
|
29449
|
+
open: false
|
|
29450
|
+
}],
|
|
29451
|
+
[12314, {
|
|
29452
|
+
other: 12315,
|
|
29453
|
+
open: true
|
|
29454
|
+
}],
|
|
29455
|
+
[12315, {
|
|
29456
|
+
other: 12314,
|
|
29457
|
+
open: false
|
|
29458
|
+
}]
|
|
29459
|
+
]);
|
|
29460
|
+
/** BD16 canonical-equivalence folding for bracket matching. */
|
|
29461
|
+
function canonicalBracket(cp) {
|
|
29462
|
+
if (cp === 12296) return 9001;
|
|
29463
|
+
if (cp === 12297) return 9002;
|
|
29464
|
+
return cp;
|
|
29465
|
+
}
|
|
29466
|
+
/** True if `bc` is a removed-by-X9 explicit/BN type. */
|
|
29467
|
+
function isRemovedByX9(bc) {
|
|
29468
|
+
return bc === "RLE" || bc === "LRE" || bc === "RLO" || bc === "LRO" || bc === "PDF" || bc === "BN";
|
|
29469
|
+
}
|
|
29470
|
+
/** Strong direction (0 = L, 1 = R) implied by an embedding level's parity. */
|
|
29471
|
+
function dirFromLevel(level) {
|
|
29472
|
+
return level % 2 === 0 ? "L" : "R";
|
|
29473
|
+
}
|
|
29474
|
+
/**
|
|
29475
|
+
* Compute the first-strong base level over `types`, skipping the contents of
|
|
29476
|
+
* isolate initiators (LRI/RLI/FSI ... matching PDI) per UAX #9 P2/P3, X5c.
|
|
29477
|
+
* Returns 0 (LTR) when no strong character is found.
|
|
29478
|
+
*/
|
|
29479
|
+
function computeBaseLevel(types) {
|
|
29480
|
+
let isolateDepth = 0;
|
|
29481
|
+
for (const t of types) if (t === "LRI" || t === "RLI" || t === "FSI") isolateDepth++;
|
|
29482
|
+
else if (t === "PDI") {
|
|
29483
|
+
if (isolateDepth > 0) isolateDepth--;
|
|
29484
|
+
} else if (isolateDepth === 0) {
|
|
29485
|
+
if (t === "L") return 0;
|
|
29486
|
+
if (t === "R" || t === "AL") return 1;
|
|
29487
|
+
}
|
|
29488
|
+
return 0;
|
|
29489
|
+
}
|
|
29490
|
+
/**
|
|
29491
|
+
* Resolve explicit embedding levels and per-character override directions
|
|
29492
|
+
* (UAX #9 X1–X8 plus the isolate rules X5a/X5b/X5c/X6a). Mutates `levels` and
|
|
29493
|
+
* returns an array of resolved working types (with overrides applied and X9
|
|
29494
|
+
* removals tagged as BN).
|
|
29495
|
+
*/
|
|
29496
|
+
function resolveExplicit(types, baseLevel, levels) {
|
|
29497
|
+
const result = types.slice();
|
|
29498
|
+
const stack = [{
|
|
29499
|
+
level: baseLevel,
|
|
29500
|
+
override: "neutral",
|
|
29501
|
+
isolate: false
|
|
29502
|
+
}];
|
|
29503
|
+
let overflowIsolate = 0;
|
|
29504
|
+
let overflowEmbedding = 0;
|
|
29505
|
+
let validIsolate = 0;
|
|
29506
|
+
const n = types.length;
|
|
29507
|
+
const matchingPDI = computeMatchingPDI(types);
|
|
29508
|
+
for (let i = 0; i < n; i++) {
|
|
29509
|
+
const t = types[i];
|
|
29510
|
+
const top = stack[stack.length - 1];
|
|
29511
|
+
switch (t) {
|
|
29512
|
+
case "RLE":
|
|
29513
|
+
case "LRE":
|
|
29514
|
+
case "RLO":
|
|
29515
|
+
case "LRO": {
|
|
29516
|
+
levels[i] = top.level;
|
|
29517
|
+
result[i] = "BN";
|
|
29518
|
+
const newLevel = t === "RLE" || t === "RLO" ? nextOddLevel(top.level) : nextEvenLevel(top.level);
|
|
29519
|
+
if (newLevel <= MAX_DEPTH && overflowIsolate === 0 && overflowEmbedding === 0) stack.push({
|
|
29520
|
+
level: newLevel,
|
|
29521
|
+
override: t === "RLO" ? "R" : t === "LRO" ? "L" : "neutral",
|
|
29522
|
+
isolate: false
|
|
29523
|
+
});
|
|
29524
|
+
else if (overflowIsolate === 0) overflowEmbedding++;
|
|
29525
|
+
break;
|
|
29526
|
+
}
|
|
29527
|
+
case "RLI":
|
|
29528
|
+
case "LRI":
|
|
29529
|
+
case "FSI": {
|
|
29530
|
+
levels[i] = top.level;
|
|
29531
|
+
if (top.override !== "neutral") result[i] = top.override;
|
|
29532
|
+
let isRTL = t === "RLI";
|
|
29533
|
+
if (t === "FSI") {
|
|
29534
|
+
const pdi = matchingPDI[i];
|
|
29535
|
+
isRTL = computeBaseLevel(types.slice(i + 1, pdi)) === 1;
|
|
29536
|
+
}
|
|
29537
|
+
const newLevel = isRTL ? nextOddLevel(top.level) : nextEvenLevel(top.level);
|
|
29538
|
+
if (newLevel <= MAX_DEPTH && overflowIsolate === 0 && overflowEmbedding === 0) {
|
|
29539
|
+
validIsolate++;
|
|
29540
|
+
stack.push({
|
|
29541
|
+
level: newLevel,
|
|
29542
|
+
override: "neutral",
|
|
29543
|
+
isolate: true
|
|
29544
|
+
});
|
|
29545
|
+
} else overflowIsolate++;
|
|
29546
|
+
break;
|
|
29547
|
+
}
|
|
29548
|
+
case "PDI": {
|
|
29549
|
+
if (overflowIsolate > 0) overflowIsolate--;
|
|
29550
|
+
else if (validIsolate > 0) {
|
|
29551
|
+
overflowEmbedding = 0;
|
|
29552
|
+
while (!stack[stack.length - 1].isolate) stack.pop();
|
|
29553
|
+
stack.pop();
|
|
29554
|
+
validIsolate--;
|
|
29555
|
+
}
|
|
29556
|
+
const after = stack[stack.length - 1];
|
|
29557
|
+
levels[i] = after.level;
|
|
29558
|
+
if (after.override !== "neutral") result[i] = after.override;
|
|
29559
|
+
break;
|
|
29560
|
+
}
|
|
29561
|
+
case "PDF":
|
|
29562
|
+
levels[i] = top.level;
|
|
29563
|
+
result[i] = "BN";
|
|
29564
|
+
if (overflowIsolate > 0) {} else if (overflowEmbedding > 0) overflowEmbedding--;
|
|
29565
|
+
else if (!top.isolate && stack.length >= 2) stack.pop();
|
|
29566
|
+
break;
|
|
29567
|
+
case "B":
|
|
29568
|
+
stack.length = 1;
|
|
29569
|
+
stack[0] = {
|
|
29570
|
+
level: baseLevel,
|
|
29571
|
+
override: "neutral",
|
|
29572
|
+
isolate: false
|
|
29573
|
+
};
|
|
29574
|
+
overflowIsolate = 0;
|
|
29575
|
+
overflowEmbedding = 0;
|
|
29576
|
+
validIsolate = 0;
|
|
29577
|
+
levels[i] = baseLevel;
|
|
29578
|
+
break;
|
|
29579
|
+
case "BN":
|
|
29580
|
+
levels[i] = top.level;
|
|
29581
|
+
break;
|
|
29582
|
+
default:
|
|
29583
|
+
levels[i] = top.level;
|
|
29584
|
+
if (top.override !== "neutral") result[i] = top.override;
|
|
29585
|
+
break;
|
|
29586
|
+
}
|
|
29587
|
+
}
|
|
29588
|
+
return result;
|
|
29589
|
+
}
|
|
29590
|
+
/** Next odd level strictly greater than `level` (for RTL embeddings). */
|
|
29591
|
+
function nextOddLevel(level) {
|
|
29592
|
+
return level % 2 === 0 ? level + 1 : level + 2;
|
|
29593
|
+
}
|
|
29594
|
+
/** Next even level strictly greater than `level` (for LTR embeddings). */
|
|
29595
|
+
function nextEvenLevel(level) {
|
|
29596
|
+
return level % 2 === 0 ? level + 2 : level + 1;
|
|
29597
|
+
}
|
|
29598
|
+
/**
|
|
29599
|
+
* BD9: for each isolate initiator, find the index of its matching PDI (or `n`
|
|
29600
|
+
* if unmatched). Non-initiator positions map to `-1`.
|
|
29601
|
+
*/
|
|
29602
|
+
function computeMatchingPDI(types) {
|
|
29603
|
+
const n = types.length;
|
|
29604
|
+
const out = new Array(n).fill(-1);
|
|
29605
|
+
for (let i = 0; i < n; i++) {
|
|
29606
|
+
const t = types[i];
|
|
29607
|
+
if (t === "LRI" || t === "RLI" || t === "FSI") {
|
|
29608
|
+
let depth = 1;
|
|
29609
|
+
let j = i + 1;
|
|
29610
|
+
for (; j < n; j++) {
|
|
29611
|
+
const tj = types[j];
|
|
29612
|
+
if (tj === "LRI" || tj === "RLI" || tj === "FSI") depth++;
|
|
29613
|
+
else if (tj === "PDI") {
|
|
29614
|
+
depth--;
|
|
29615
|
+
if (depth === 0) break;
|
|
29616
|
+
}
|
|
29617
|
+
}
|
|
29618
|
+
out[i] = j;
|
|
29619
|
+
}
|
|
29620
|
+
}
|
|
29621
|
+
return out;
|
|
29622
|
+
}
|
|
29623
|
+
/**
|
|
29624
|
+
* Build the isolating run sequences (UAX #9 X10 / BD13) from the resolved
|
|
29625
|
+
* levels, skipping characters removed by X9. Computes each sequence's sos/eos
|
|
29626
|
+
* boundary directions from the higher of the adjacent levels (rule X10).
|
|
29627
|
+
*/
|
|
29628
|
+
function buildIsolatingRunSequences(types, levels, baseLevel) {
|
|
29629
|
+
const n = types.length;
|
|
29630
|
+
const retained = [];
|
|
29631
|
+
for (let i = 0; i < n; i++) if (!isRemovedByX9(types[i])) retained.push(i);
|
|
29632
|
+
const levelRuns = [];
|
|
29633
|
+
let cur = [];
|
|
29634
|
+
let curLevel = -1;
|
|
29635
|
+
for (const i of retained) {
|
|
29636
|
+
if (levels[i] !== curLevel) {
|
|
29637
|
+
if (cur.length > 0) levelRuns.push(cur);
|
|
29638
|
+
cur = [];
|
|
29639
|
+
curLevel = levels[i];
|
|
29640
|
+
}
|
|
29641
|
+
cur.push(i);
|
|
29642
|
+
}
|
|
29643
|
+
if (cur.length > 0) levelRuns.push(cur);
|
|
29644
|
+
const runByStart = /* @__PURE__ */ new Map();
|
|
29645
|
+
for (let r = 0; r < levelRuns.length; r++) runByStart.set(levelRuns[r][0], r);
|
|
29646
|
+
const matchingPDI = computeMatchingPDI(types);
|
|
29647
|
+
const matchingInitiator = new Array(n).fill(-1);
|
|
29648
|
+
for (let i = 0; i < n; i++) {
|
|
29649
|
+
const t = types[i];
|
|
29650
|
+
if ((t === "LRI" || t === "RLI" || t === "FSI") && matchingPDI[i] < n) matchingInitiator[matchingPDI[i]] = i;
|
|
29651
|
+
}
|
|
29652
|
+
const used = new Array(levelRuns.length).fill(false);
|
|
29653
|
+
const sequences = [];
|
|
29654
|
+
for (let r = 0; r < levelRuns.length; r++) {
|
|
29655
|
+
if (used[r]) continue;
|
|
29656
|
+
const first = levelRuns[r][0];
|
|
29657
|
+
if (types[first] === "PDI" && matchingInitiator[first] >= 0) continue;
|
|
29658
|
+
const seqIndices = [];
|
|
29659
|
+
let runIdx = r;
|
|
29660
|
+
for (;;) {
|
|
29661
|
+
used[runIdx] = true;
|
|
29662
|
+
const run = levelRuns[runIdx];
|
|
29663
|
+
for (const idx of run) seqIndices.push(idx);
|
|
29664
|
+
const last = run[run.length - 1];
|
|
29665
|
+
const t = types[last];
|
|
29666
|
+
if ((t === "LRI" || t === "RLI" || t === "FSI") && matchingPDI[last] < n) {
|
|
29667
|
+
const next = runByStart.get(matchingPDI[last]);
|
|
29668
|
+
if (next === void 0 || used[next]) break;
|
|
29669
|
+
runIdx = next;
|
|
29670
|
+
} else break;
|
|
29671
|
+
}
|
|
29672
|
+
const seqLevel = levels[seqIndices[0]];
|
|
29673
|
+
const startPos = retained.indexOf(seqIndices[0]);
|
|
29674
|
+
const endPos = retained.indexOf(seqIndices[seqIndices.length - 1]);
|
|
29675
|
+
const prevLevel = startPos > 0 ? levels[retained[startPos - 1]] : baseLevel;
|
|
29676
|
+
const lastType = types[seqIndices[seqIndices.length - 1]];
|
|
29677
|
+
const nextLevel = (lastType === "LRI" || lastType === "RLI" || lastType === "FSI") && matchingPDI[seqIndices[seqIndices.length - 1]] >= n || endPos === retained.length - 1 ? baseLevel : levels[retained[endPos + 1]];
|
|
29678
|
+
sequences.push({
|
|
29679
|
+
indices: seqIndices,
|
|
29680
|
+
sos: dirFromLevel(Math.max(seqLevel, prevLevel)),
|
|
29681
|
+
eos: dirFromLevel(Math.max(seqLevel, nextLevel))
|
|
29682
|
+
});
|
|
29683
|
+
}
|
|
29684
|
+
return sequences;
|
|
29685
|
+
}
|
|
29686
|
+
/** Apply the weak-type rules W1–W7 to one isolating run sequence in place. */
|
|
29687
|
+
function resolveWeak(seq, types) {
|
|
29688
|
+
const idx = seq.indices;
|
|
29689
|
+
const m = idx.length;
|
|
29690
|
+
for (let k = 0; k < m; k++) {
|
|
29691
|
+
const i = idx[k];
|
|
29692
|
+
if (types[i] === "NSM") if (k === 0) types[i] = seq.sos;
|
|
29693
|
+
else {
|
|
29694
|
+
const prev = types[idx[k - 1]];
|
|
29695
|
+
types[i] = prev === "LRI" || prev === "RLI" || prev === "FSI" || prev === "PDI" ? "ON" : prev;
|
|
29696
|
+
}
|
|
29697
|
+
}
|
|
29698
|
+
for (let k = 0; k < m; k++) {
|
|
29699
|
+
const i = idx[k];
|
|
29700
|
+
if (types[i] === "EN") {
|
|
29701
|
+
let strong = seq.sos;
|
|
29702
|
+
for (let j = k - 1; j >= 0; j--) {
|
|
29703
|
+
const tj = types[idx[j]];
|
|
29704
|
+
if (tj === "R" || tj === "L" || tj === "AL") {
|
|
29705
|
+
strong = tj;
|
|
29706
|
+
break;
|
|
29707
|
+
}
|
|
29708
|
+
}
|
|
29709
|
+
if (strong === "AL") types[i] = "AN";
|
|
29710
|
+
}
|
|
29711
|
+
}
|
|
29712
|
+
for (let k = 0; k < m; k++) {
|
|
29713
|
+
const i = idx[k];
|
|
29714
|
+
if (types[i] === "AL") types[i] = "R";
|
|
29715
|
+
}
|
|
29716
|
+
for (let k = 1; k < m - 1; k++) {
|
|
29717
|
+
const i = idx[k];
|
|
29718
|
+
const prev = types[idx[k - 1]];
|
|
29719
|
+
const next = types[idx[k + 1]];
|
|
29720
|
+
if (types[i] === "ES" && prev === "EN" && next === "EN") types[i] = "EN";
|
|
29721
|
+
else if (types[i] === "CS" && prev === next && (prev === "EN" || prev === "AN")) types[i] = prev;
|
|
29722
|
+
}
|
|
29723
|
+
for (let k = 0; k < m; k++) {
|
|
29724
|
+
if (types[idx[k]] !== "ET") continue;
|
|
29725
|
+
let end = k;
|
|
29726
|
+
while (end < m && types[idx[end]] === "ET") end++;
|
|
29727
|
+
const before = k > 0 ? types[idx[k - 1]] : seq.sos;
|
|
29728
|
+
const after = end < m ? types[idx[end]] : seq.eos;
|
|
29729
|
+
if (before === "EN" || after === "EN") for (let j = k; j < end; j++) types[idx[j]] = "EN";
|
|
29730
|
+
k = end - 1;
|
|
29731
|
+
}
|
|
29732
|
+
for (let k = 0; k < m; k++) {
|
|
29733
|
+
const i = idx[k];
|
|
29734
|
+
if (types[i] === "ES" || types[i] === "ET" || types[i] === "CS") types[i] = "ON";
|
|
29735
|
+
}
|
|
29736
|
+
for (let k = 0; k < m; k++) {
|
|
29737
|
+
const i = idx[k];
|
|
29738
|
+
if (types[i] === "EN") {
|
|
29739
|
+
let strong = seq.sos;
|
|
29740
|
+
for (let j = k - 1; j >= 0; j--) {
|
|
29741
|
+
const tj = types[idx[j]];
|
|
29742
|
+
if (tj === "R" || tj === "L") {
|
|
29743
|
+
strong = tj;
|
|
29744
|
+
break;
|
|
29745
|
+
}
|
|
29746
|
+
}
|
|
29747
|
+
if (strong === "L") types[i] = "L";
|
|
29748
|
+
}
|
|
29749
|
+
}
|
|
29750
|
+
}
|
|
29751
|
+
/** True if a working type counts as a "neutral or isolate formatting" char (NI). */
|
|
29752
|
+
function isNI(t) {
|
|
29753
|
+
return t === "B" || t === "S" || t === "WS" || t === "ON" || t === "FSI" || t === "LRI" || t === "RLI" || t === "PDI";
|
|
29754
|
+
}
|
|
29755
|
+
/** Apply N0 (paired brackets) then N1/N2 to one isolating run sequence. */
|
|
29756
|
+
function resolveNeutral(seq, types, levels, codepoints) {
|
|
29757
|
+
const idx = seq.indices;
|
|
29758
|
+
const m = idx.length;
|
|
29759
|
+
const e = dirFromLevel(levels[idx[0]]);
|
|
29760
|
+
resolveBrackets(seq, types, codepoints, e);
|
|
29761
|
+
let k = 0;
|
|
29762
|
+
while (k < m) {
|
|
29763
|
+
if (!isNI(types[idx[k]])) {
|
|
29764
|
+
k++;
|
|
29765
|
+
continue;
|
|
29766
|
+
}
|
|
29767
|
+
let end = k;
|
|
29768
|
+
while (end < m && isNI(types[idx[end]])) end++;
|
|
29769
|
+
const beforeRaw = k > 0 ? types[idx[k - 1]] : seq.sos;
|
|
29770
|
+
const afterRaw = end < m ? types[idx[end]] : seq.eos;
|
|
29771
|
+
const before = beforeRaw === "EN" || beforeRaw === "AN" ? "R" : beforeRaw;
|
|
29772
|
+
const resolved = before === (afterRaw === "EN" || afterRaw === "AN" ? "R" : afterRaw) && (before === "L" || before === "R") ? before : e;
|
|
29773
|
+
for (let j = k; j < end; j++) types[idx[j]] = resolved;
|
|
29774
|
+
k = end;
|
|
29775
|
+
}
|
|
29776
|
+
}
|
|
29777
|
+
/** N0 paired-bracket resolution (UAX #9 N0 with BD16 pairing). */
|
|
29778
|
+
function resolveBrackets(seq, types, codepoints, e) {
|
|
29779
|
+
const idx = seq.indices;
|
|
29780
|
+
const m = idx.length;
|
|
29781
|
+
const stack = [];
|
|
29782
|
+
const pairs = [];
|
|
29783
|
+
for (let k = 0; k < m; k++) {
|
|
29784
|
+
const i = idx[k];
|
|
29785
|
+
if (types[i] !== "ON") continue;
|
|
29786
|
+
const cp = canonicalBracket(codepoints[i]);
|
|
29787
|
+
const info = BRACKET_PAIRS.get(codepoints[i]);
|
|
29788
|
+
if (!info) continue;
|
|
29789
|
+
if (info.open) {
|
|
29790
|
+
if (stack.length >= 63) break;
|
|
29791
|
+
stack.push({
|
|
29792
|
+
bracket: canonicalBracket(info.other),
|
|
29793
|
+
seqPos: k
|
|
29794
|
+
});
|
|
29795
|
+
} else for (let s = stack.length - 1; s >= 0; s--) if (stack[s].bracket === cp) {
|
|
29796
|
+
pairs.push({
|
|
29797
|
+
open: stack[s].seqPos,
|
|
29798
|
+
close: k
|
|
29799
|
+
});
|
|
29800
|
+
stack.length = s;
|
|
29801
|
+
break;
|
|
29802
|
+
}
|
|
29803
|
+
}
|
|
29804
|
+
pairs.sort((a, b) => a.open - b.open);
|
|
29805
|
+
for (const pair of pairs) {
|
|
29806
|
+
let foundE = false;
|
|
29807
|
+
let foundOpposite = false;
|
|
29808
|
+
const opposite = e === "L" ? "R" : "L";
|
|
29809
|
+
for (let k = pair.open + 1; k < pair.close; k++) {
|
|
29810
|
+
const t = strongClass(types[idx[k]]);
|
|
29811
|
+
if (t === e) {
|
|
29812
|
+
foundE = true;
|
|
29813
|
+
break;
|
|
29814
|
+
}
|
|
29815
|
+
if (t === opposite) foundOpposite = true;
|
|
29816
|
+
}
|
|
29817
|
+
let setDir = null;
|
|
29818
|
+
if (foundE) setDir = e;
|
|
29819
|
+
else if (foundOpposite) {
|
|
29820
|
+
let context = seq.sos;
|
|
29821
|
+
for (let k = pair.open - 1; k >= 0; k--) {
|
|
29822
|
+
const t = strongClass(types[idx[k]]);
|
|
29823
|
+
if (t === "L" || t === "R") {
|
|
29824
|
+
context = t;
|
|
29825
|
+
break;
|
|
29826
|
+
}
|
|
29827
|
+
}
|
|
29828
|
+
setDir = context === opposite ? opposite : e;
|
|
29829
|
+
}
|
|
29830
|
+
if (setDir) {
|
|
29831
|
+
types[idx[pair.open]] = setDir;
|
|
29832
|
+
types[idx[pair.close]] = setDir;
|
|
29833
|
+
for (let k = pair.open + 1; k < m; k++) if (originalIsNSM(idx[k], codepoints)) types[idx[k]] = setDir;
|
|
29834
|
+
else break;
|
|
29835
|
+
for (let k = pair.close + 1; k < m; k++) if (originalIsNSM(idx[k], codepoints)) types[idx[k]] = setDir;
|
|
29836
|
+
else break;
|
|
29837
|
+
}
|
|
29838
|
+
}
|
|
29839
|
+
}
|
|
29840
|
+
/** Reduce a working type to the strong direction it counts as for N0 (EN/AN→R). */
|
|
29841
|
+
function strongClass(t) {
|
|
29842
|
+
if (t === "L") return "L";
|
|
29843
|
+
if (t === "R" || t === "EN" || t === "AN") return "R";
|
|
29844
|
+
return null;
|
|
29845
|
+
}
|
|
29846
|
+
/** True if the original code point at logical index `i` has Bidi_Class NSM. */
|
|
29847
|
+
function originalIsNSM(i, codepoints) {
|
|
29848
|
+
return bidiClass(codepoints[i]) === "NSM";
|
|
29849
|
+
}
|
|
29850
|
+
/** Resolve implicit embedding levels (UAX #9 I1/I2) for one sequence. */
|
|
29851
|
+
function resolveImplicit(seq, types, levels) {
|
|
29852
|
+
for (const i of seq.indices) {
|
|
29853
|
+
const level = levels[i];
|
|
29854
|
+
const t = types[i];
|
|
29855
|
+
if (level % 2 === 0) {
|
|
29856
|
+
if (t === "R") levels[i] = level + 1;
|
|
29857
|
+
else if (t === "AN" || t === "EN") levels[i] = level + 2;
|
|
29858
|
+
} else if (t === "L" || t === "EN" || t === "AN") levels[i] = level + 1;
|
|
29859
|
+
}
|
|
29860
|
+
}
|
|
29861
|
+
/**
|
|
29862
|
+
* L1: reset to the paragraph level (1) segment/paragraph separators, and
|
|
29863
|
+
* (2) any sequence of whitespace / isolate formatting chars preceding a
|
|
29864
|
+
* separator or the end of the line. Operates on a single line (= the whole
|
|
29865
|
+
* paragraph here, since we do not perform line breaking).
|
|
29866
|
+
*/
|
|
29867
|
+
function resetWhitespaceLevels(originalTypes, levels, baseLevel) {
|
|
29868
|
+
const n = levels.length;
|
|
29869
|
+
let resetFrom = n;
|
|
29870
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
29871
|
+
const t = originalTypes[i];
|
|
29872
|
+
if (t === "B" || t === "S") {
|
|
29873
|
+
levels[i] = baseLevel;
|
|
29874
|
+
for (let j = i + 1; j < resetFrom; j++) levels[j] = baseLevel;
|
|
29875
|
+
resetFrom = i;
|
|
29876
|
+
} else if (t === "WS" || t === "FSI" || t === "LRI" || t === "RLI" || t === "PDI" || isRemovedByX9(t)) {} else {
|
|
29877
|
+
if (resetFrom === n) {}
|
|
29878
|
+
resetFrom = i + 1 > resetFrom ? resetFrom : i + 1;
|
|
29879
|
+
resetFrom = i + 1;
|
|
29880
|
+
}
|
|
29881
|
+
}
|
|
29882
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
29883
|
+
const t = originalTypes[i];
|
|
29884
|
+
if (t === "WS" || t === "FSI" || t === "LRI" || t === "RLI" || t === "PDI" || isRemovedByX9(t)) levels[i] = baseLevel;
|
|
29885
|
+
else break;
|
|
29886
|
+
}
|
|
29887
|
+
}
|
|
29888
|
+
/** L2: reorder code-unit indices into visual order from the resolved levels. */
|
|
29889
|
+
function reorderLevels(levels) {
|
|
29890
|
+
const n = levels.length;
|
|
29891
|
+
const order = Array.from({ length: n }, (_, i) => i);
|
|
29892
|
+
if (n === 0) return order;
|
|
29893
|
+
let highest = 0;
|
|
29894
|
+
let lowestOdd = 127;
|
|
29895
|
+
for (const l of levels) {
|
|
29896
|
+
if (l > highest) highest = l;
|
|
29897
|
+
if (l % 2 === 1 && l < lowestOdd) lowestOdd = l;
|
|
29898
|
+
}
|
|
29899
|
+
for (let level = highest; level >= lowestOdd; level--) {
|
|
29900
|
+
let i = 0;
|
|
29901
|
+
while (i < n) if (levels[order[i]] >= level) {
|
|
29902
|
+
let j = i;
|
|
29903
|
+
while (j < n && levels[order[j]] >= level) j++;
|
|
29904
|
+
for (let a = i, b = j - 1; a < b; a++, b--) {
|
|
29905
|
+
const tmp = order[a];
|
|
29906
|
+
order[a] = order[b];
|
|
29907
|
+
order[b] = tmp;
|
|
29908
|
+
}
|
|
29909
|
+
i = j;
|
|
29910
|
+
} else i++;
|
|
29911
|
+
}
|
|
29912
|
+
return order;
|
|
29913
|
+
}
|
|
29914
|
+
/**
|
|
29915
|
+
* Run the Unicode Bidirectional Algorithm (UAX #9) over `text`.
|
|
29916
|
+
*
|
|
29917
|
+
* Indices in the result refer to UTF-16 code units of the input string (the
|
|
29918
|
+
* same units JavaScript's `string[i]` and `.length` use). Astral characters
|
|
29919
|
+
* (surrogate pairs) are classified by their scalar value but occupy two code
|
|
29920
|
+
* units, both assigned the same level.
|
|
29921
|
+
*
|
|
29922
|
+
* @param text The logical-order input string.
|
|
29923
|
+
* @param base Paragraph direction: `'ltr'` / `'rtl'` force the base level;
|
|
29924
|
+
* `'auto'` (the default) derives it from the first strong character (P2/P3).
|
|
29925
|
+
* @returns The resolved levels, same-level runs, visual order and base level.
|
|
29926
|
+
*/
|
|
29927
|
+
function resolveBidi(text, base = "auto") {
|
|
29928
|
+
const n = text.length;
|
|
29929
|
+
const codepoints = new Array(n);
|
|
29930
|
+
for (let i = 0; i < n; i++) {
|
|
29931
|
+
const c = text.charCodeAt(i);
|
|
29932
|
+
if (c >= 55296 && c <= 56319 && i + 1 < n) {
|
|
29933
|
+
const c2 = text.charCodeAt(i + 1);
|
|
29934
|
+
if (c2 >= 56320 && c2 <= 57343) {
|
|
29935
|
+
const scalar = (c - 55296) * 1024 + (c2 - 56320) + 65536;
|
|
29936
|
+
codepoints[i] = scalar;
|
|
29937
|
+
codepoints[i + 1] = scalar;
|
|
29938
|
+
i++;
|
|
29939
|
+
continue;
|
|
29940
|
+
}
|
|
29941
|
+
}
|
|
29942
|
+
codepoints[i] = c;
|
|
29943
|
+
}
|
|
29944
|
+
const originalTypes = codepoints.map(bidiClass);
|
|
29945
|
+
let baseLevel;
|
|
29946
|
+
if (base === "ltr") baseLevel = 0;
|
|
29947
|
+
else if (base === "rtl") baseLevel = 1;
|
|
29948
|
+
else baseLevel = computeBaseLevel(originalTypes);
|
|
29949
|
+
const levels = new Array(n).fill(baseLevel);
|
|
29950
|
+
if (n === 0) return {
|
|
29951
|
+
runs: [],
|
|
29952
|
+
levels: [],
|
|
29953
|
+
visualOrder: [],
|
|
29954
|
+
baseLevel
|
|
29955
|
+
};
|
|
29956
|
+
const workingTypes = resolveExplicit(originalTypes, baseLevel, levels);
|
|
29957
|
+
const sequences = buildIsolatingRunSequences(workingTypes, levels, baseLevel);
|
|
29958
|
+
for (const seq of sequences) {
|
|
29959
|
+
resolveWeak(seq, workingTypes);
|
|
29960
|
+
resolveNeutral(seq, workingTypes, levels, codepoints);
|
|
29961
|
+
resolveImplicit(seq, workingTypes, levels);
|
|
29962
|
+
}
|
|
29963
|
+
resetWhitespaceLevels(originalTypes, levels, baseLevel);
|
|
29964
|
+
const visualOrder = reorderLevels(levels);
|
|
29965
|
+
const runs = [];
|
|
29966
|
+
let start = 0;
|
|
29967
|
+
while (start < n) {
|
|
29968
|
+
const level = levels[start];
|
|
29969
|
+
let end = start + 1;
|
|
29970
|
+
while (end < n && levels[end] === level) end++;
|
|
29971
|
+
runs.push({
|
|
29972
|
+
text: text.slice(start, end),
|
|
29973
|
+
level,
|
|
29974
|
+
direction: dirFromLevel(level) === "L" ? "ltr" : "rtl",
|
|
29975
|
+
start,
|
|
29976
|
+
length: end - start
|
|
29977
|
+
});
|
|
29978
|
+
start = end;
|
|
29979
|
+
}
|
|
29980
|
+
return {
|
|
29981
|
+
runs,
|
|
29982
|
+
levels,
|
|
29983
|
+
visualOrder,
|
|
29984
|
+
baseLevel
|
|
29985
|
+
};
|
|
29986
|
+
}
|
|
29987
|
+
/**
|
|
29988
|
+
* Convenience wrapper that returns `text` reordered into visual (left-to-right)
|
|
29989
|
+
* order via {@link resolveBidi}'s L2 result.
|
|
29990
|
+
*
|
|
29991
|
+
* Note: this performs pure reordering of code units. It does not apply Arabic
|
|
29992
|
+
* cursive shaping or mirror neutral glyphs; callers that need shaped glyphs
|
|
29993
|
+
* should pass the resolved runs to a shaping engine.
|
|
29994
|
+
*
|
|
29995
|
+
* @param text The logical-order input string.
|
|
29996
|
+
* @param base Paragraph direction (see {@link resolveBidi}).
|
|
29997
|
+
* @returns The visually reordered string.
|
|
29998
|
+
*/
|
|
29999
|
+
function reorderVisual(text, base = "auto") {
|
|
30000
|
+
const { visualOrder } = resolveBidi(text, base);
|
|
30001
|
+
let out = "";
|
|
30002
|
+
for (const i of visualOrder) out += text[i];
|
|
30003
|
+
return out;
|
|
30004
|
+
}
|
|
30005
|
+
//#endregion
|
|
30006
|
+
//#region src/assets/font/variableFont.ts
|
|
30007
|
+
function readTableDirectory$1(data) {
|
|
30008
|
+
if (data.length < 12) return null;
|
|
30009
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
30010
|
+
const numTables = view.getUint16(4, false);
|
|
30011
|
+
const dirEnd = 12 + numTables * 16;
|
|
30012
|
+
if (numTables === 0 || dirEnd > data.length) return null;
|
|
30013
|
+
const tables = /* @__PURE__ */ new Map();
|
|
30014
|
+
for (let i = 0; i < numTables; i++) {
|
|
30015
|
+
const recOff = 12 + i * 16;
|
|
30016
|
+
const tag = String.fromCharCode(data[recOff], data[recOff + 1], data[recOff + 2], data[recOff + 3]);
|
|
30017
|
+
const offset = view.getUint32(recOff + 8, false);
|
|
30018
|
+
const length = view.getUint32(recOff + 12, false);
|
|
30019
|
+
tables.set(tag, {
|
|
30020
|
+
offset,
|
|
30021
|
+
length
|
|
30022
|
+
});
|
|
30023
|
+
}
|
|
30024
|
+
return tables;
|
|
30025
|
+
}
|
|
30026
|
+
/**
|
|
30027
|
+
* Read a Fixed (16.16 signed) value: a big-endian int32 divided by 65536.
|
|
30028
|
+
* Used by 'fvar' for axis min/default/max and instance coordinates.
|
|
30029
|
+
*/
|
|
30030
|
+
function readFixed(view, off) {
|
|
30031
|
+
return view.getInt32(off, false) / 65536;
|
|
30032
|
+
}
|
|
30033
|
+
/**
|
|
30034
|
+
* Read an F2DOT14 (2.14 signed) value: a big-endian int16 divided by 16384.
|
|
30035
|
+
* Used by 'avar' for AxisValueMap from/to coordinates.
|
|
30036
|
+
*/
|
|
30037
|
+
function readF2Dot14(view, off) {
|
|
30038
|
+
return view.getInt16(off, false) / 16384;
|
|
30039
|
+
}
|
|
30040
|
+
function parseAvar(data, rec, expectedAxisCount) {
|
|
30041
|
+
if (rec.offset + 8 > data.length) return void 0;
|
|
30042
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
30043
|
+
if (view.getUint16(rec.offset, false) !== 1) return void 0;
|
|
30044
|
+
const axisCount = view.getUint16(rec.offset + 6, false);
|
|
30045
|
+
if (axisCount !== expectedAxisCount) return void 0;
|
|
30046
|
+
const segmentMaps = [];
|
|
30047
|
+
let cursor = rec.offset + 8;
|
|
30048
|
+
const tableEnd = rec.offset + rec.length;
|
|
30049
|
+
for (let a = 0; a < axisCount; a++) {
|
|
30050
|
+
if (cursor + 2 > data.length || cursor + 2 > tableEnd) return void 0;
|
|
30051
|
+
const positionMapCount = view.getUint16(cursor, false);
|
|
30052
|
+
cursor += 2;
|
|
30053
|
+
const recordsBytes = positionMapCount * 4;
|
|
30054
|
+
if (cursor + recordsBytes > data.length || cursor + recordsBytes > tableEnd) return;
|
|
30055
|
+
const maps = [];
|
|
30056
|
+
for (let i = 0; i < positionMapCount; i++) {
|
|
30057
|
+
const from = readF2Dot14(view, cursor);
|
|
30058
|
+
const to = readF2Dot14(view, cursor + 2);
|
|
30059
|
+
maps.push({
|
|
30060
|
+
fromCoordinate: from,
|
|
30061
|
+
toCoordinate: to
|
|
30062
|
+
});
|
|
30063
|
+
cursor += 4;
|
|
30064
|
+
}
|
|
30065
|
+
segmentMaps.push(maps);
|
|
30066
|
+
}
|
|
30067
|
+
return segmentMaps;
|
|
30068
|
+
}
|
|
30069
|
+
/**
|
|
30070
|
+
* Parse the variable-font model from raw OpenType/TrueType font bytes.
|
|
30071
|
+
*
|
|
30072
|
+
* If the font has no 'fvar' table (or it is malformed / has zero axes), the
|
|
30073
|
+
* font is treated as non-variable and
|
|
30074
|
+
* `{ isVariable: false, axes: [], namedInstances: [] }` is returned.
|
|
30075
|
+
*
|
|
30076
|
+
* @param fontData Raw font file bytes (sfnt: TrueType 0x00010000 or 'OTTO' CFF).
|
|
30077
|
+
* @returns The parsed {@link VariableFontInfo}.
|
|
30078
|
+
*/
|
|
30079
|
+
function parseVariableFont(fontData) {
|
|
30080
|
+
const notVariable = {
|
|
30081
|
+
isVariable: false,
|
|
30082
|
+
axes: [],
|
|
30083
|
+
namedInstances: []
|
|
30084
|
+
};
|
|
30085
|
+
const tables = readTableDirectory$1(fontData);
|
|
30086
|
+
if (!tables) return notVariable;
|
|
30087
|
+
const fvar = tables.get("fvar");
|
|
30088
|
+
if (!fvar) return notVariable;
|
|
30089
|
+
if (fvar.offset + 16 > fontData.length) return notVariable;
|
|
30090
|
+
const view = new DataView(fontData.buffer, fontData.byteOffset, fontData.byteLength);
|
|
30091
|
+
if (view.getUint16(fvar.offset, false) !== 1) return notVariable;
|
|
30092
|
+
const axesArrayOffset = view.getUint16(fvar.offset + 4, false);
|
|
30093
|
+
const axisCount = view.getUint16(fvar.offset + 8, false);
|
|
30094
|
+
const axisSize = view.getUint16(fvar.offset + 10, false);
|
|
30095
|
+
const instanceCount = view.getUint16(fvar.offset + 12, false);
|
|
30096
|
+
const instanceSize = view.getUint16(fvar.offset + 14, false);
|
|
30097
|
+
if (axisCount === 0) return notVariable;
|
|
30098
|
+
if (axisSize < 20) return notVariable;
|
|
30099
|
+
const axes = [];
|
|
30100
|
+
const axesStart = fvar.offset + axesArrayOffset;
|
|
30101
|
+
const axesBytes = axisCount * axisSize;
|
|
30102
|
+
if (axesStart + axesBytes > fontData.length) return notVariable;
|
|
30103
|
+
for (let i = 0; i < axisCount; i++) {
|
|
30104
|
+
const recOff = axesStart + i * axisSize;
|
|
30105
|
+
const tag = String.fromCharCode(view.getUint8(recOff), view.getUint8(recOff + 1), view.getUint8(recOff + 2), view.getUint8(recOff + 3));
|
|
30106
|
+
const minValue = readFixed(view, recOff + 4);
|
|
30107
|
+
const defaultValue = readFixed(view, recOff + 8);
|
|
30108
|
+
const maxValue = readFixed(view, recOff + 12);
|
|
30109
|
+
const flags = view.getUint16(recOff + 16, false);
|
|
30110
|
+
axes.push({
|
|
30111
|
+
tag,
|
|
30112
|
+
minValue,
|
|
30113
|
+
defaultValue,
|
|
30114
|
+
maxValue,
|
|
30115
|
+
flags,
|
|
30116
|
+
name: void 0
|
|
30117
|
+
});
|
|
30118
|
+
}
|
|
30119
|
+
const coordsBytes = axisCount * 4;
|
|
30120
|
+
const baseInstanceSize = coordsBytes + 4;
|
|
30121
|
+
const hasPostScriptName = instanceSize >= baseInstanceSize + 2;
|
|
30122
|
+
const namedInstances = [];
|
|
30123
|
+
const instancesStart = axesStart + axesBytes;
|
|
30124
|
+
if (instanceSize >= baseInstanceSize) {
|
|
30125
|
+
if (instancesStart + instanceCount * instanceSize <= fontData.length) for (let i = 0; i < instanceCount; i++) {
|
|
30126
|
+
const recOff = instancesStart + i * instanceSize;
|
|
30127
|
+
const subfamilyNameID = view.getUint16(recOff, false);
|
|
30128
|
+
const coordinates = {};
|
|
30129
|
+
for (let a = 0; a < axisCount; a++) {
|
|
30130
|
+
const value = readFixed(view, recOff + 4 + a * 4);
|
|
30131
|
+
const axis = axes[a];
|
|
30132
|
+
coordinates[axis.tag] = value;
|
|
30133
|
+
}
|
|
30134
|
+
const instance = {
|
|
30135
|
+
nameId: subfamilyNameID,
|
|
30136
|
+
coordinates,
|
|
30137
|
+
name: void 0
|
|
30138
|
+
};
|
|
30139
|
+
if (hasPostScriptName) {
|
|
30140
|
+
const psNameId = view.getUint16(recOff + 4 + coordsBytes, false);
|
|
30141
|
+
instance.postScriptNameId = psNameId === 65535 ? void 0 : psNameId;
|
|
30142
|
+
}
|
|
30143
|
+
namedInstances.push(instance);
|
|
30144
|
+
}
|
|
30145
|
+
}
|
|
30146
|
+
let avar;
|
|
30147
|
+
const avarRec = tables.get("avar");
|
|
30148
|
+
if (avarRec) avar = parseAvar(fontData, avarRec, axisCount);
|
|
30149
|
+
const info = {
|
|
30150
|
+
isVariable: true,
|
|
30151
|
+
axes,
|
|
30152
|
+
namedInstances
|
|
30153
|
+
};
|
|
30154
|
+
if (avar) info.avar = avar;
|
|
30155
|
+
return info;
|
|
30156
|
+
}
|
|
30157
|
+
/**
|
|
30158
|
+
* Apply an 'avar' segment map to a default-normalized coordinate.
|
|
30159
|
+
*
|
|
30160
|
+
* The segment map is an ordered list of (from, to) pairs in normalized space.
|
|
30161
|
+
* The input is located between two consecutive `fromCoordinate` nodes and the
|
|
30162
|
+
* output is linearly interpolated between the corresponding `toCoordinate`
|
|
30163
|
+
* values. Per spec, a valid map includes the mandatory nodes (-1,-1), (0,0),
|
|
30164
|
+
* (1,1); if it does not span the input, the input is clamped to the map's
|
|
30165
|
+
* endpoints.
|
|
30166
|
+
*/
|
|
30167
|
+
function applyAvarSegmentMap(value, segmentMap) {
|
|
30168
|
+
const n = segmentMap.length;
|
|
30169
|
+
if (n === 0) return value;
|
|
30170
|
+
const first = segmentMap[0];
|
|
30171
|
+
if (value <= first.fromCoordinate) return first.toCoordinate;
|
|
30172
|
+
const last = segmentMap[n - 1];
|
|
30173
|
+
if (value >= last.fromCoordinate) return last.toCoordinate;
|
|
30174
|
+
for (let i = 1; i < n; i++) {
|
|
30175
|
+
const lo = segmentMap[i - 1];
|
|
30176
|
+
const hi = segmentMap[i];
|
|
30177
|
+
if (value >= lo.fromCoordinate && value <= hi.fromCoordinate) {
|
|
30178
|
+
const span = hi.fromCoordinate - lo.fromCoordinate;
|
|
30179
|
+
if (span <= 0) return lo.toCoordinate;
|
|
30180
|
+
const t = (value - lo.fromCoordinate) / span;
|
|
30181
|
+
return lo.toCoordinate + t * (hi.toCoordinate - lo.toCoordinate);
|
|
30182
|
+
}
|
|
30183
|
+
}
|
|
30184
|
+
return value;
|
|
30185
|
+
}
|
|
30186
|
+
/**
|
|
30187
|
+
* Normalize a user-scale coordinate for an axis to the normalized [-1, 0, +1]
|
|
30188
|
+
* scale, per the OpenType default-normalization algorithm.
|
|
30189
|
+
*
|
|
30190
|
+
* Steps:
|
|
30191
|
+
* 1. Clamp `userValue` to the axis's [minValue, maxValue].
|
|
30192
|
+
* 2. Map to normalized space: minValue→-1, defaultValue→0, maxValue→+1, with
|
|
30193
|
+
* linear interpolation on each side of the default (note: the slopes on the
|
|
30194
|
+
* two sides differ unless the default is exactly centered).
|
|
30195
|
+
* 3. If an 'avar' `segmentMap` is supplied, apply it to the result; otherwise
|
|
30196
|
+
* no avar adjustment is made (the caller can pass `info.avar[axisIndex]`).
|
|
30197
|
+
*
|
|
30198
|
+
* @param axis The axis whose user-scale bounds define the normalization.
|
|
30199
|
+
* @param userValue The user-scale coordinate (e.g. 250 for a 'wght' axis).
|
|
30200
|
+
* @param avar Optional 'avar' segment map for this axis.
|
|
30201
|
+
* @returns The normalized coordinate in [-1, 1].
|
|
30202
|
+
*/
|
|
30203
|
+
function normalizeAxisCoordinate(axis, userValue, avar) {
|
|
30204
|
+
const { minValue, defaultValue, maxValue } = axis;
|
|
30205
|
+
let v = userValue;
|
|
30206
|
+
if (v < minValue) v = minValue;
|
|
30207
|
+
else if (v > maxValue) v = maxValue;
|
|
30208
|
+
let normalized;
|
|
30209
|
+
if (v < defaultValue) {
|
|
30210
|
+
const denom = defaultValue - minValue;
|
|
30211
|
+
normalized = denom === 0 ? 0 : -(defaultValue - v) / denom;
|
|
30212
|
+
} else if (v > defaultValue) {
|
|
30213
|
+
const denom = maxValue - defaultValue;
|
|
30214
|
+
normalized = denom === 0 ? 0 : (v - defaultValue) / denom;
|
|
30215
|
+
} else normalized = 0;
|
|
30216
|
+
if (normalized < -1) normalized = -1;
|
|
30217
|
+
else if (normalized > 1) normalized = 1;
|
|
30218
|
+
if (avar && avar.length > 0) normalized = applyAvarSegmentMap(normalized, avar);
|
|
30219
|
+
return normalized;
|
|
30220
|
+
}
|
|
30221
|
+
/**
|
|
30222
|
+
* Resolve a named instance's coordinates against the font's axes.
|
|
30223
|
+
*
|
|
30224
|
+
* Produces a complete axis-tag → user-scale-coordinate map covering every axis
|
|
30225
|
+
* in the font: any axis the instance does not specify is filled with that
|
|
30226
|
+
* axis's `defaultValue`, and any specified coordinate is clamped to the axis's
|
|
30227
|
+
* [minValue, maxValue] range (per the fvar "Variation Instance Selection"
|
|
30228
|
+
* rules). Coordinates for tags not present on any axis are ignored.
|
|
30229
|
+
*
|
|
30230
|
+
* @param info The parsed variable-font model.
|
|
30231
|
+
* @param instance The named instance to resolve.
|
|
30232
|
+
* @returns A validated axis-tag → user-scale coordinate map.
|
|
30233
|
+
*/
|
|
30234
|
+
function resolveInstanceCoordinates(info, instance) {
|
|
30235
|
+
const resolved = {};
|
|
30236
|
+
for (const axis of info.axes) {
|
|
30237
|
+
let value = instance.coordinates[axis.tag] ?? axis.defaultValue;
|
|
30238
|
+
if (value < axis.minValue) value = axis.minValue;
|
|
30239
|
+
else if (value > axis.maxValue) value = axis.maxValue;
|
|
30240
|
+
resolved[axis.tag] = value;
|
|
30241
|
+
}
|
|
30242
|
+
return resolved;
|
|
30243
|
+
}
|
|
30244
|
+
//#endregion
|
|
30245
|
+
//#region src/assets/font/colorFont.ts
|
|
30246
|
+
/**
|
|
30247
|
+
* @module assets/font/colorFont
|
|
30248
|
+
*
|
|
30249
|
+
* COLR v0 + CPAL color-font parsing (layered color glyphs).
|
|
30250
|
+
*
|
|
30251
|
+
* This module reads the two OpenType tables that together describe simple,
|
|
30252
|
+
* layered color glyphs:
|
|
30253
|
+
*
|
|
30254
|
+
* - **CPAL** (Color Palette Table): one or more palettes, each a list of
|
|
30255
|
+
* sRGB colors. Color records are stored on disk as **BGRA** bytes and are
|
|
30256
|
+
* exposed here as **RGBA** (0..255 per channel).
|
|
30257
|
+
* - **COLR** v0 (Color Table, version 0): maps a *base* glyph to an ordered
|
|
30258
|
+
* list of *layers*. Each layer is another glyph (a monochrome outline) plus
|
|
30259
|
+
* a CPAL palette-entry index giving its color.
|
|
30260
|
+
*
|
|
30261
|
+
* A renderer paints a color glyph by drawing each layer's outline glyph filled
|
|
30262
|
+
* with that layer's resolved palette color, bottom layer first.
|
|
30263
|
+
*
|
|
30264
|
+
* ## Verified against the OpenType 1.9.1 specification
|
|
30265
|
+
*
|
|
30266
|
+
* - CPAL header v0 / v1 and the BGRA ColorRecord byte order:
|
|
30267
|
+
* https://learn.microsoft.com/en-us/typography/opentype/spec/cpal
|
|
30268
|
+
* - CPAL v0 header (big-endian): version(u16)\@0, numPaletteEntries(u16)\@2,
|
|
30269
|
+
* numPalettes(u16)\@4, numColorRecords(u16)\@6,
|
|
30270
|
+
* colorRecordsArrayOffset(Offset32)\@8, colorRecordIndices[numPalettes]
|
|
30271
|
+
* (u16 each)\@12.
|
|
30272
|
+
* - "Each color record specifies a color ... using 8-bit BGRA (blue, green,
|
|
30273
|
+
* red, alpha) representation." ColorRecord = blue(u8), green(u8), red(u8),
|
|
30274
|
+
* alpha(u8).
|
|
30275
|
+
* - "colorRecordIndex = colorRecordIndices[paletteIndex] + paletteEntryIndex".
|
|
30276
|
+
* - COLR v0 header and records:
|
|
30277
|
+
* https://learn.microsoft.com/en-us/typography/opentype/spec/colr
|
|
30278
|
+
* - COLR v0 header (big-endian): version(u16)\@0, numBaseGlyphRecords(u16)\@2,
|
|
30279
|
+
* baseGlyphRecordsOffset(Offset32)\@4, layerRecordsOffset(Offset32)\@8,
|
|
30280
|
+
* numLayerRecords(u16)\@12.
|
|
30281
|
+
* - BaseGlyph record (6 bytes): glyphID(u16), firstLayerIndex(u16),
|
|
30282
|
+
* numLayers(u16). "The BaseGlyph records must be sorted in increasing
|
|
30283
|
+
* glyphID order ... a binary search can be used" — we binary-search them.
|
|
30284
|
+
* - Layer record (4 bytes): glyphID(u16), paletteIndex(u16).
|
|
30285
|
+
*
|
|
30286
|
+
* ## Scope
|
|
30287
|
+
*
|
|
30288
|
+
* - Only **COLR version 0** (flat layered glyphs) is parsed. **COLR version 1**
|
|
30289
|
+
* (PaintColrLayers, gradients, affine transforms, compositing) is OUT OF
|
|
30290
|
+
* SCOPE and intentionally not parsed here.
|
|
30291
|
+
* - The CPAL `paletteIndex` special value `0xFFFF` ("use the current text
|
|
30292
|
+
* foreground color") is preserved on each layer via `paletteIndex` but cannot
|
|
30293
|
+
* be resolved to an RGBA value here (there is no palette entry for it); such a
|
|
30294
|
+
* layer is reported with `rgba = [0, 0, 0, 255]` as a neutral fallback. A
|
|
30295
|
+
* renderer should substitute the active foreground color when it sees
|
|
30296
|
+
* `paletteIndex === 0xFFFF`.
|
|
30297
|
+
* - This module exposes the *layer + palette model only*. Actually RENDERING or
|
|
30298
|
+
* EMBEDDING color glyphs into a PDF (e.g. as a Type3 font) is OUT OF SCOPE and
|
|
30299
|
+
* left to a consuming renderer.
|
|
30300
|
+
*
|
|
30301
|
+
* No external dependencies. No Buffer — uses Uint8Array and DataView.
|
|
30302
|
+
*/
|
|
30303
|
+
/** The CPAL special palette index meaning "use the text foreground color". */
|
|
30304
|
+
const FOREGROUND_PALETTE_INDEX = 65535;
|
|
30305
|
+
/**
|
|
30306
|
+
* Read the sfnt table directory. The directory is at offset 12 as a sequence
|
|
30307
|
+
* of 16-byte records: { tag[4], checksum[4], offset[4], length[4] }, all
|
|
30308
|
+
* big-endian. Supports sfnt version 0x00010000 (TrueType) and 'OTTO' (CFF);
|
|
30309
|
+
* the actual sfnt version value is not needed beyond reaching the directory.
|
|
30310
|
+
*
|
|
30311
|
+
* @returns A map from 4-char table tag to its offset/length, or `undefined`
|
|
30312
|
+
* if the data is too small to contain a valid directory.
|
|
30313
|
+
*/
|
|
30314
|
+
function readTableDirectory(data) {
|
|
30315
|
+
if (data.length < 12) return void 0;
|
|
30316
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
30317
|
+
const numTables = view.getUint16(4, false);
|
|
30318
|
+
const requiredLen = 12 + numTables * 16;
|
|
30319
|
+
if (data.length < requiredLen) return void 0;
|
|
30320
|
+
const tables = /* @__PURE__ */ new Map();
|
|
30321
|
+
for (let i = 0; i < numTables; i++) {
|
|
30322
|
+
const off = 12 + i * 16;
|
|
30323
|
+
const tag = String.fromCharCode(data[off], data[off + 1], data[off + 2], data[off + 3]);
|
|
30324
|
+
const offset = view.getUint32(off + 8, false);
|
|
30325
|
+
const length = view.getUint32(off + 12, false);
|
|
30326
|
+
tables.set(tag, {
|
|
30327
|
+
offset,
|
|
30328
|
+
length
|
|
30329
|
+
});
|
|
30330
|
+
}
|
|
30331
|
+
return tables;
|
|
30332
|
+
}
|
|
30333
|
+
/**
|
|
30334
|
+
* Return a `DataView` bounded to a single table, or `undefined` if the table
|
|
30335
|
+
* is absent or its declared extent falls outside the font data.
|
|
30336
|
+
*/
|
|
30337
|
+
function tableView(data, tables, tag) {
|
|
30338
|
+
const rec = tables.get(tag);
|
|
30339
|
+
if (rec === void 0) return void 0;
|
|
30340
|
+
if (rec.offset + rec.length > data.length) return void 0;
|
|
30341
|
+
return new DataView(data.buffer, data.byteOffset + rec.offset, rec.length);
|
|
30342
|
+
}
|
|
30343
|
+
/**
|
|
30344
|
+
* Parse the CPAL table into palettes of RGBA colors.
|
|
30345
|
+
*
|
|
30346
|
+
* Handles CPAL versions 0 and 1 (the v1 trailing offset arrays are ignored —
|
|
30347
|
+
* they only carry optional UI labels and palette-type flags). Color records
|
|
30348
|
+
* are read as BGRA bytes and converted to RGBA.
|
|
30349
|
+
*
|
|
30350
|
+
* @returns The parsed palettes, or `undefined` if the table is malformed.
|
|
30351
|
+
*/
|
|
30352
|
+
function parseCpal(view) {
|
|
30353
|
+
if (view.byteLength < 12) return void 0;
|
|
30354
|
+
const numPaletteEntries = view.getUint16(2, false);
|
|
30355
|
+
const numPalettes = view.getUint16(4, false);
|
|
30356
|
+
const numColorRecords = view.getUint16(6, false);
|
|
30357
|
+
const colorRecordsArrayOffset = view.getUint32(8, false);
|
|
30358
|
+
const indicesStart = 12;
|
|
30359
|
+
if (indicesStart + numPalettes * 2 > view.byteLength) return void 0;
|
|
30360
|
+
if (colorRecordsArrayOffset + numColorRecords * 4 > view.byteLength) return;
|
|
30361
|
+
const colorRecordIndices = [];
|
|
30362
|
+
for (let i = 0; i < numPalettes; i++) colorRecordIndices.push(view.getUint16(indicesStart + i * 2, false));
|
|
30363
|
+
const palettes = [];
|
|
30364
|
+
for (let p = 0; p < numPalettes; p++) {
|
|
30365
|
+
const firstRecord = colorRecordIndices[p];
|
|
30366
|
+
const colors = [];
|
|
30367
|
+
for (let e = 0; e < numPaletteEntries; e++) {
|
|
30368
|
+
const recIndex = firstRecord + e;
|
|
30369
|
+
if (recIndex >= numColorRecords) break;
|
|
30370
|
+
const recOff = colorRecordsArrayOffset + recIndex * 4;
|
|
30371
|
+
const blue = view.getUint8(recOff + 0);
|
|
30372
|
+
const green = view.getUint8(recOff + 1);
|
|
30373
|
+
const red = view.getUint8(recOff + 2);
|
|
30374
|
+
const alpha = view.getUint8(recOff + 3);
|
|
30375
|
+
colors.push([
|
|
30376
|
+
red,
|
|
30377
|
+
green,
|
|
30378
|
+
blue,
|
|
30379
|
+
alpha
|
|
30380
|
+
]);
|
|
30381
|
+
}
|
|
30382
|
+
palettes.push({ colors });
|
|
30383
|
+
}
|
|
30384
|
+
return palettes;
|
|
30385
|
+
}
|
|
30386
|
+
/**
|
|
30387
|
+
* Read the COLR v0 header. Returns `undefined` if the table is too small or
|
|
30388
|
+
* its declared offsets/counts fall outside the table.
|
|
30389
|
+
*/
|
|
30390
|
+
function readColrV0(view) {
|
|
30391
|
+
if (view.byteLength < 14) return void 0;
|
|
30392
|
+
const numBaseGlyphRecords = view.getUint16(2, false);
|
|
30393
|
+
const baseGlyphRecordsOffset = view.getUint32(4, false);
|
|
30394
|
+
const layerRecordsOffset = view.getUint32(8, false);
|
|
30395
|
+
const numLayerRecords = view.getUint16(12, false);
|
|
30396
|
+
if (baseGlyphRecordsOffset + numBaseGlyphRecords * 6 > view.byteLength || layerRecordsOffset + numLayerRecords * 4 > view.byteLength) return;
|
|
30397
|
+
return {
|
|
30398
|
+
baseGlyphRecordsOffset,
|
|
30399
|
+
numBaseGlyphRecords,
|
|
30400
|
+
layerRecordsOffset,
|
|
30401
|
+
numLayerRecords,
|
|
30402
|
+
view
|
|
30403
|
+
};
|
|
30404
|
+
}
|
|
30405
|
+
/**
|
|
30406
|
+
* Binary-search the base-glyph records (sorted by glyphID, per spec) for the
|
|
30407
|
+
* record matching `glyphId`.
|
|
30408
|
+
*
|
|
30409
|
+
* @returns `{ firstLayerIndex, numLayers }` or `undefined` if not a base glyph.
|
|
30410
|
+
*/
|
|
30411
|
+
function findBaseGlyph(colr, glyphId) {
|
|
30412
|
+
const { view, baseGlyphRecordsOffset } = colr;
|
|
30413
|
+
let lo = 0;
|
|
30414
|
+
let hi = colr.numBaseGlyphRecords - 1;
|
|
30415
|
+
while (lo <= hi) {
|
|
30416
|
+
const mid = lo + hi >> 1;
|
|
30417
|
+
const recOff = baseGlyphRecordsOffset + mid * 6;
|
|
30418
|
+
const gid = view.getUint16(recOff, false);
|
|
30419
|
+
if (gid === glyphId) return {
|
|
30420
|
+
firstLayerIndex: view.getUint16(recOff + 2, false),
|
|
30421
|
+
numLayers: view.getUint16(recOff + 4, false)
|
|
30422
|
+
};
|
|
30423
|
+
if (gid < glyphId) lo = mid + 1;
|
|
30424
|
+
else hi = mid - 1;
|
|
30425
|
+
}
|
|
30426
|
+
}
|
|
30427
|
+
/**
|
|
30428
|
+
* Parse a font's color capability and CPAL palettes.
|
|
30429
|
+
*
|
|
30430
|
+
* `hasColor` is true iff the font contains a 'COLR' table. The palettes come
|
|
30431
|
+
* from the 'CPAL' table (which is required whenever 'COLR' is present, per the
|
|
30432
|
+
* OpenType spec). Color records are returned as RGBA (converted from the on-disk
|
|
30433
|
+
* BGRA layout).
|
|
30434
|
+
*
|
|
30435
|
+
* @param fontData - Raw sfnt font bytes (TrueType 0x00010000 or 'OTTO').
|
|
30436
|
+
* @returns Color info; `{ hasColor: false, numPalettes: 0, palettes: [] }` if
|
|
30437
|
+
* the font has no COLR/CPAL tables or cannot be parsed.
|
|
30438
|
+
*/
|
|
30439
|
+
function parseColorFont(fontData) {
|
|
30440
|
+
const empty = {
|
|
30441
|
+
hasColor: false,
|
|
30442
|
+
numPalettes: 0,
|
|
30443
|
+
palettes: []
|
|
30444
|
+
};
|
|
30445
|
+
const tables = readTableDirectory(fontData);
|
|
30446
|
+
if (tables === void 0) return empty;
|
|
30447
|
+
const hasColor = tables.has("COLR");
|
|
30448
|
+
const cpalView = tableView(fontData, tables, "CPAL");
|
|
30449
|
+
if (cpalView === void 0) return {
|
|
30450
|
+
hasColor,
|
|
30451
|
+
numPalettes: 0,
|
|
30452
|
+
palettes: []
|
|
30453
|
+
};
|
|
30454
|
+
const palettes = parseCpal(cpalView);
|
|
30455
|
+
if (palettes === void 0) return {
|
|
30456
|
+
hasColor,
|
|
30457
|
+
numPalettes: 0,
|
|
30458
|
+
palettes: []
|
|
30459
|
+
};
|
|
30460
|
+
return {
|
|
30461
|
+
hasColor,
|
|
30462
|
+
numPalettes: palettes.length,
|
|
30463
|
+
palettes
|
|
30464
|
+
};
|
|
30465
|
+
}
|
|
30466
|
+
/**
|
|
30467
|
+
* Resolve the COLR v0 layers of a base glyph into colored layers.
|
|
30468
|
+
*
|
|
30469
|
+
* For each layer of the requested base glyph, the layer's outline glyph id is
|
|
30470
|
+
* returned together with its CPAL palette-entry index and the RGBA color that
|
|
30471
|
+
* index resolves to in the selected palette.
|
|
30472
|
+
*
|
|
30473
|
+
* A glyph that is **not** a COLR base glyph (or a font with no COLR/CPAL table)
|
|
30474
|
+
* returns `[]` — such a glyph is drawn as a normal monochrome glyph.
|
|
30475
|
+
*
|
|
30476
|
+
* The special palette index `0xFFFF` ("foreground color") is preserved on the
|
|
30477
|
+
* layer's `paletteIndex` but, having no palette entry, resolves to the neutral
|
|
30478
|
+
* fallback `[0, 0, 0, 255]`; a renderer should substitute the active text color.
|
|
30479
|
+
*
|
|
30480
|
+
* @param fontData - Raw sfnt font bytes.
|
|
30481
|
+
* @param glyphId - The base glyph id to expand.
|
|
30482
|
+
* @param paletteIndex - Which CPAL palette to resolve colors from. Defaults to
|
|
30483
|
+
* palette 0 (the default palette). Out-of-range values fall back to palette 0.
|
|
30484
|
+
* @returns The ordered (bottom-first) list of colored layers, or `[]`.
|
|
30485
|
+
*/
|
|
30486
|
+
function getColorGlyphLayers(fontData, glyphId, paletteIndex = 0) {
|
|
30487
|
+
const tables = readTableDirectory(fontData);
|
|
30488
|
+
if (tables === void 0) return [];
|
|
30489
|
+
const colrView = tableView(fontData, tables, "COLR");
|
|
30490
|
+
if (colrView === void 0) return [];
|
|
30491
|
+
const colr = readColrV0(colrView);
|
|
30492
|
+
if (colr === void 0) return [];
|
|
30493
|
+
const base = findBaseGlyph(colr, glyphId);
|
|
30494
|
+
if (base === void 0) return [];
|
|
30495
|
+
const cpalView = tableView(fontData, tables, "CPAL");
|
|
30496
|
+
const palettes = cpalView !== void 0 ? parseCpal(cpalView) : void 0;
|
|
30497
|
+
const selectedPalette = palettes !== void 0 && palettes.length > 0 ? palettes[paletteIndex] ?? palettes[0] : void 0;
|
|
30498
|
+
const layers = [];
|
|
30499
|
+
const { view, layerRecordsOffset, numLayerRecords } = colr;
|
|
30500
|
+
for (let i = 0; i < base.numLayers; i++) {
|
|
30501
|
+
const layerIndex = base.firstLayerIndex + i;
|
|
30502
|
+
if (layerIndex >= numLayerRecords) break;
|
|
30503
|
+
const recOff = layerRecordsOffset + layerIndex * 4;
|
|
30504
|
+
const layerGid = view.getUint16(recOff, false);
|
|
30505
|
+
const layerPaletteIndex = view.getUint16(recOff + 2, false);
|
|
30506
|
+
let rgba = [
|
|
30507
|
+
0,
|
|
30508
|
+
0,
|
|
30509
|
+
0,
|
|
30510
|
+
255
|
|
30511
|
+
];
|
|
30512
|
+
if (layerPaletteIndex !== FOREGROUND_PALETTE_INDEX && selectedPalette !== void 0 && layerPaletteIndex < selectedPalette.colors.length) {
|
|
30513
|
+
const c = selectedPalette.colors[layerPaletteIndex];
|
|
30514
|
+
rgba = [
|
|
30515
|
+
c[0],
|
|
30516
|
+
c[1],
|
|
30517
|
+
c[2],
|
|
30518
|
+
c[3]
|
|
30519
|
+
];
|
|
30520
|
+
}
|
|
30521
|
+
layers.push({
|
|
30522
|
+
glyphId: layerGid,
|
|
30523
|
+
paletteIndex: layerPaletteIndex,
|
|
30524
|
+
rgba
|
|
30525
|
+
});
|
|
30526
|
+
}
|
|
30527
|
+
return layers;
|
|
30528
|
+
}
|
|
30529
|
+
//#endregion
|
|
28084
30530
|
//#region src/render/matrix.ts
|
|
28085
30531
|
/** The identity transform. */
|
|
28086
30532
|
function identity() {
|
|
@@ -31889,15 +34335,15 @@ async function initWasm(options) {
|
|
|
31889
34335
|
if (wasmInitialized) return;
|
|
31890
34336
|
const inits = [];
|
|
31891
34337
|
if (options.deflate || options.deflateWasm) inits.push((async () => {
|
|
31892
|
-
const { initDeflateWasm } = await import("./pdfDocument-
|
|
34338
|
+
const { initDeflateWasm } = await import("./pdfDocument-Q0wrwKEZ.mjs").then((n) => n.Wt);
|
|
31893
34339
|
await initDeflateWasm(options.deflateWasm);
|
|
31894
34340
|
})());
|
|
31895
34341
|
if (options.png || options.pngWasm) inits.push((async () => {
|
|
31896
|
-
const { initPngWasm } = await import("./pdfDocument-
|
|
34342
|
+
const { initPngWasm } = await import("./pdfDocument-Q0wrwKEZ.mjs").then((n) => n.Ft);
|
|
31897
34343
|
await initPngWasm(options.pngWasm);
|
|
31898
34344
|
})());
|
|
31899
34345
|
if (options.fonts || options.fontWasm) inits.push((async () => {
|
|
31900
|
-
const { initSubsetWasm } = await import("./pdfDocument-
|
|
34346
|
+
const { initSubsetWasm } = await import("./pdfDocument-Q0wrwKEZ.mjs").then((n) => n.zt);
|
|
31901
34347
|
await initSubsetWasm(options.fontWasm);
|
|
31902
34348
|
})());
|
|
31903
34349
|
if (options.jpeg || options.jpegWasm) inits.push((async () => {
|
|
@@ -31908,6 +34354,6 @@ async function initWasm(options) {
|
|
|
31908
34354
|
wasmInitialized = true;
|
|
31909
34355
|
}
|
|
31910
34356
|
//#endregion
|
|
31911
|
-
export {
|
|
34357
|
+
export { getColorGlyphLayers as $, requestTimestamp as $a, applyRedaction as $i, PDF2_NAMESPACE as $n, shrinkFontSize as $r, parseAcrobatDate as $t, buildSoftMaskNone as A, optimizeIncrementalSave as Aa, computeCode39CheckDigit as Ai, STANDARD_SPOT_FUNCTIONS as An, parseXmpMetadata$1 as Ao, FieldAlreadyExistsError as Ar, summarizeBitDepth as At, redactRegions as B, detectModifications as Ba, recompressWebP as Bi, reconstructLines as Bn, RemovePageFromEmptyDocumentError as Br, isCertificateRevoked as Bt, tagTableHeaderCell as C, validatePdfA as Ca, ean13ToOperators as Ci, levenshtein as Cn, generateSquigglyAppearance as Co, flattenField as Cr, buildPdfXOutputIntent as Ct, buildImageSoftMask as D, linearizePdf as Da, encodeItf as Di, buildGtsPdfxVersion as Dn, PdfTextAnnotation as Do, CombedTextLayoutError as Dr, getComponentDepths as Dt, buildColorKeyMask as E, isLinearized as Ea, encodeEan8 as Ei, buildBoxDict as En, PdfLinkAnnotation as Eo, BatchProcessingError as Er, downscale16To8 as Et, attachAssociatedFiles as F, getCounterSignatures as Fa, valuesToModules as Fi, identityTransferFunction as Fn, saveDocumentIncremental as Fo, InvalidFieldNamePartError as Fr, parseTileInfo as Ft, extractImages as G, validateSignatureChain as Ga, isCmykTiff as Gi, buildCalRGB as Gn, applyHeaderFooterToPage as Gr, resolveFieldReference as Gt, compareImages as H, buildDocMdpReference as Ha, webpToPng as Hi, DEFAULT_DOC_TIMESTAMP_CONTENTS_SIZE as Hn, StreamingParseError as Hr, extractOcspUrl as Ht, registerEmbeddedFile as I, diffSignedContent as Ia, PdfWorker as Ii, nameHalftone as In, saveIncremental as Io, InvalidPageSizeError as Ir, verifySignatureDetailed as It, renderPageToCanvas as J, parseExistingTrailer as Ja, analyzeJpegMarkers as Ji, DEFAULT_SARIF_TOOL_NAME as Jn, toAlpha as Jr, setFieldVisibility as Jt, generateThumbnail as K, appendIncrementalUpdate as Ka, extractJpegMetadata as Ki, buildLab as Kn, formatDate$1 as Kr, setFieldValue as Kt, RenderCache as L, addFieldLock as La, canDirectEmbed as Li, evaluateFunction as Ln, MissingOnValueCheckError as Lr, TrustStore as Lt, buildUnencryptedWrapper as M, embedLtvData as Ma, code128ToOperators as Mi, buildThresholdHalftone as Mn, buildDeviceNColorSpace as Mo, FontNotEmbeddedError as Mr, assembleTiles as Mt, attachOutputIntents as N, hasLtvData as Na, encodeCode128 as Ni, buildType1Halftone as Nn, buildSeparationColorSpace as No, ForeignPageError as Nr, decodeTile as Nt, buildStencilMask as O, computeObjectHash as Oa, itfToOperators as Oi, buildPdfX6OutputIntent as On, buildXmpMetadata as Oo, EncryptedPdfError as Or, normalizeComponentDepth as Ot, buildPageOutputIntent as P, addCounterSignature as Pa, encodeCode128Values as Pi, buildType5Halftone as Pn, ChangeTracker as Po, InvalidColorError as Pr, decodeTileRegion as Pt, interpretPage as Q, parseTimestampResponse as Qa, recompressImage as Qi, MATHML_NAMESPACE as Qn, estimateTextWidth as Qr, formatDate as Qt, computeTileGrid as R, buildFieldLockDict as Ra, embedTiffDirect as Ri, markdownToPdf as Rn, NoSuchFieldError as Rr, downloadCrl as Rt, tagTableDataCell as S, enforcePdfA as Sa, calculateEanCheckDigit as Si, didYouMean as Sn, generateSquareAppearance as So, removeBookmark as Sr, layoutTextFlow as St, buildBlackPointCompensationExtGState as T, getLinearizationInfo as Ta, encodeEan13 as Ti, generateCiiXml as Tn, generateUnderlineAppearance as To, flattenForm as Tr, validatePdfX as Tt, comparePages as U, getCertificationLevel as Ua, convertTiffCmykToRgb as Ui, buildDocTimeStampDict as Un, UnexpectedFieldTypeError as Ur, createSandbox as Ut, applyOcr as V, MdpPermission as Va, webpToJpeg as Vi, reconstructParagraphs as Vn, RichTextFieldReadError as Vr, checkCertificateStatus as Vt, extractFonts as W, setCertificationLevel as Wa, embedTiffCmyk as Wi, buildCalGray as Wn, applyHeaderFooter as Wr, getFieldValue as Wt, renderPageToImage as X, validateByteRangeIntegrity as Xa, estimateJpegQuality as Xi, toJsonReport as Xn, applyOverflow as Xr, AFSpecial_Format as Xt, rasterize as Y, saveIncrementalWithSignaturePreservation as Ya, downscaleImage as Yi, SARIF_SCHEMA_URI as Yn, toRoman as Yr, validateFieldValue as Yt, interpretContentStream as Z, buildTimestampRequest as Za, optimizeImage as Zi, toSarif as Zn, ellipsisText as Zr, AFDate_FormatEx as Zt, tagLink as _, buildOutputIntent as _a, dataMatrixToOperators as _i, generateXRechnungCii as _n, generateCircleAppearance as _o, batchMerge as _r, buildCertificateChain as _t, assembleFacturX as a, buildAfArray as aa, minimalPreset as ai, readWoffHeader as an, PdfInkAnnotation as ao, buildDPartRoot as ar, resolveBidi as at, tagParagraph as b, detectTransparency as ba, encodeUpcA as bi, buildFunctionShading as bn, generateInkAppearance as bo, getBookmarks as br, layoutColumns as bt, buildWtpdfIdentificationXmp as c, parseXmpMetadata as ca, readBarcode as ci, buildPdfVtDParts as cn, PdfLineAnnotation as co, tableToJson as cr, sanitizePdf as ct, autoTagPage as d, getSupportedLevels as da, readEan13 as di, h as dn, PdfSquareAnnotation as do, timestampPlugin as dr, extractEmbeddedRevocationData as dt, enforcePdfAFull as ea, truncateText as ei, AFNumber_Format as en, searchTextItems as eo, buildNamespace as er, parseColorFont as et, buildPdfUa2Xmp as f, isValidLevel as fa, readEan8 as fi, renderToPdf as fn, PdfHighlightAnnotation as fo, StreamingPdfParser as fr, verifyOfflineRevocation as ft, tagHeading as g, getToUnicodeCmap as ga, pdf417ToOperators as gi, generateOrderX as gn, PdfFreeTextAnnotation as go, batchFlatten as gr, validateKeyUsage as gt, tagFigure as h, generateZapfDingbatsToUnicodeCmap as ha, encodePdf417 as hi, splitByScript as hn, PdfUnderlineAnnotation as ho, validatePdfUa as hr, validateExtendedKeyUsage as ht, validateEn16931 as i, stripProhibitedFeatures as ia, borderedPreset as ii, isWoff2 as in, PdfRedactAnnotation as io, buildRequirements as ir, reorderVisual as it, buildEncryptedPayload as j, buildDssDictionary as ja, encodeCode39 as ji, buildSampledTransferFunction as jn, PDFOperator as jo, FieldExistsAsNonTerminalError as jr, upscale8To16 as jt, buildSoftMaskGroupExtGState as k, findChangedObjects as ka, code39ToOperators as ki, validateBoxGeometry as kn, createXmpStream as ko, ExceededMaxLengthError as kr, offsetSignedToUnsigned as kt, convertPdfAConformanceXmp as l, validateXmpMetadata as la, readCode128 as li, buildVtDpm as ln, PdfPolyLineAnnotation as lo, accessibilityPlugin as lr, scanPdfThreats as lt, LIST_NUMBERING_KEY as m, generateWinAnsiToUnicodeCmap as ma, renderStyledBarcode as mi, resolveFallback as mn, PdfStrikeOutAnnotation as mo, enforcePdfUa as mr, validateCertificatePolicy as mt, detectFacturXProfile as n, generatePdfAXmpBytes as na, applyPreset as ni, decodeWoff as nn, PdfCaretAnnotation as no, buildPieceInfo as nr, parseVariableFont as nt, buildFacturXXmp as o, createAssociatedFile as oa, professionalPreset as oi, signDeferred as on, PdfStampAnnotation as oo, extractTables as or, inspectEncryption as ot, validatePdfUa2 as p, generateSymbolToUnicodeCmap as pa, calculateBarcodeDimensions as pi, createRangeFetcher as pn, PdfSquigglyAnnotation as po, PdfDocumentBuilder as pr, EKU_OIDS as pt, renderDisplayListToCanvas as q, findExistingSignatures as qa, injectJpegMetadata as qi, labToRgb as qn, replaceTemplateVariables as qr, addVisibilityAction as qt, parseCiiXml as r, countOccurrences as ra, applyTablePreset as ri, isWoff as rn, PdfPopupAnnotation as ro, buildRequirement as rr, resolveInstanceCoordinates as rt, buildPdfRIdentificationXmp as s, extractXmpMetadata as sa, stripedPreset as si, createWorkerPool as sn, PdfCircleAnnotation as so, tableToCsv as sr, verifyRedactions as st, initWasm as t, generatePdfAXmp as ta, wrapText$2 as ti, formatNumber$1 as tn, PdfFileAttachmentAnnotation as to, buildNamespacesArray as tr, normalizeAxisCoordinate as tt, preflightPdfA as u, getProfile as ua, readCode39 as ui, gtsPdfVtVersion as un, PdfPolygonAnnotation as uo, metadataPlugin as ur, buildCertPath as ut, tagList as v, SRGB_ICC_PROFILE as va, encodeDataMatrix as vi, buildPdfA4Xmp as vn, generateFreeTextAppearance as vo, processBatch as vr, validateCertificateChain as vt, tagTableRow as w, delinearizePdf as wa, ean8ToOperators as wi, renderCodeFrame as wn, generateStrikeOutAppearance as wo, flattenFields as wr, enforcePdfX as wt, tagTable as x, flattenTransparency as xa, upcAToOperators as xi, sampleShadingColor as xn, generateLineAppearance as xo, removeAllBookmarks as xr, layoutParagraph as xt, tagListItem as y, generateSrgbIccProfile as ya, calculateUpcCheckDigit as yi, pdfA4Rules as yn, generateHighlightAppearance as yo, addBookmark as yr, findHyphenationPoints as yt, renderPageTile as z, getFieldLocks as za, encodePngFromPixels as zi, buildCollection as zn, PluginError as zr, extractCrlUrls as zt };
|
|
31912
34358
|
|
|
31913
|
-
//# sourceMappingURL=src-
|
|
34359
|
+
//# sourceMappingURL=src-4wfgzZvz.mjs.map
|