@privacyscrubber/sdk 2.2.2 → 2.2.3
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/LICENSE.md +21 -0
- package/README.md +17 -6
- package/browser.js +654 -0
- package/index.d.ts +37 -0
- package/index.js +153 -2
- package/package.json +21 -4
- package/ps-license-manager.js +1 -1
- package/ps-pii-engine.cjs +1 -1
- package/scrubber-core.cjs +1 -11
- package/vision.d.ts +56 -0
- package/vision.js +362 -0
package/vision.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript Definitions for @privacyscrubber/sdk/vision
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface ExifStripResult {
|
|
6
|
+
/** The sanitized binary buffer without EXIF/XMP/IPTC/GPS metadata. */
|
|
7
|
+
cleanBuffer: Uint8Array | Buffer;
|
|
8
|
+
/** Whether any metadata segment was found and stripped. */
|
|
9
|
+
metadataStripped: boolean;
|
|
10
|
+
/** Image format detected: 'jpeg' | 'png' | 'webp' | 'unknown'. */
|
|
11
|
+
format: string;
|
|
12
|
+
/** Array of removed segment identifiers. */
|
|
13
|
+
removedSegments: string[];
|
|
14
|
+
/** Number of bytes saved by removing metadata. */
|
|
15
|
+
bytesSaved: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface VisionBoundingBox {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
label?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface VisionRedactOptions {
|
|
27
|
+
/** Fill color for blackout box (Default: '#000000'). */
|
|
28
|
+
fillColor?: string;
|
|
29
|
+
/** Border color (Default: 'rgba(0, 240, 255, 0.4)'). */
|
|
30
|
+
borderColor?: string;
|
|
31
|
+
/** Whether to render token label badges (Default: true). */
|
|
32
|
+
showLabels?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Strips EXIF, XMP, GPS, and sensitive metadata chunks from image buffers (JPEG, PNG, WebP).
|
|
37
|
+
*/
|
|
38
|
+
export function stripExifMetadata(inputBuffer: Buffer | Uint8Array): ExifStripResult;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Redacts bounding boxes on an HTML Canvas or 2D Canvas context.
|
|
42
|
+
*/
|
|
43
|
+
export function redactImageBoundingBoxes(
|
|
44
|
+
canvasOrCtx: any,
|
|
45
|
+
boxes?: VisionBoundingBox[],
|
|
46
|
+
options?: VisionRedactOptions
|
|
47
|
+
): number;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Generates an air-gapped SVG redaction mask overlay for headless environments.
|
|
51
|
+
*/
|
|
52
|
+
export function generateSvgRedactionOverlay(
|
|
53
|
+
dimensions?: { width: number; height: number },
|
|
54
|
+
boxes?: VisionBoundingBox[],
|
|
55
|
+
options?: VisionRedactOptions
|
|
56
|
+
): string;
|
package/vision.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PrivacyScrubber Developer SDK — Multimodal Vision Submodule (@privacyscrubber/sdk/vision)
|
|
3
|
+
*
|
|
4
|
+
* Sub-millisecond in-memory binary metadata sanitization & bounding box redaction.
|
|
5
|
+
* Zero external dependencies. Zero network calls. 100% local RAM execution.
|
|
6
|
+
*
|
|
7
|
+
* https://privacyscrubber.com/features/developer-sdk/
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Strips EXIF, XMP, GPS, and sensitive metadata chunks from image buffers (JPEG, PNG, WebP).
|
|
12
|
+
* Operates purely in-memory on typed arrays with sub-millisecond execution.
|
|
13
|
+
*
|
|
14
|
+
* @param {Buffer|Uint8Array} inputBuffer - Raw image buffer
|
|
15
|
+
* @returns {{ cleanBuffer: Uint8Array|Buffer, metadataStripped: boolean, format: string, removedSegments: string[], bytesSaved: number }}
|
|
16
|
+
*/
|
|
17
|
+
export function stripExifMetadata(inputBuffer) {
|
|
18
|
+
if (!inputBuffer || inputBuffer.length < 8) {
|
|
19
|
+
return {
|
|
20
|
+
cleanBuffer: inputBuffer,
|
|
21
|
+
metadataStripped: false,
|
|
22
|
+
format: 'unknown',
|
|
23
|
+
removedSegments: [],
|
|
24
|
+
bytesSaved: 0
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const u8 = inputBuffer instanceof Uint8Array ? inputBuffer : new Uint8Array(inputBuffer);
|
|
29
|
+
const originalSize = u8.length;
|
|
30
|
+
const removedSegments = [];
|
|
31
|
+
|
|
32
|
+
// 1. JPEG Detection & Stripping (SOI: 0xFF, 0xD8)
|
|
33
|
+
if (u8[0] === 0xFF && u8[1] === 0xD8) {
|
|
34
|
+
const chunks = [];
|
|
35
|
+
chunks.push(new Uint8Array([0xFF, 0xD8])); // Keep SOI
|
|
36
|
+
let offset = 2;
|
|
37
|
+
let stripped = false;
|
|
38
|
+
|
|
39
|
+
while (offset < u8.length - 1) {
|
|
40
|
+
if (u8[offset] !== 0xFF) {
|
|
41
|
+
// Stray byte or corrupted segment, push rest and break
|
|
42
|
+
chunks.push(u8.subarray(offset));
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const marker = u8[offset + 1];
|
|
47
|
+
|
|
48
|
+
// Standalone markers without length: RST0-RST7 (0xD0-0xD7), SOI (0xD8), EOI (0xD9)
|
|
49
|
+
if (marker === 0xD9) { // EOI
|
|
50
|
+
chunks.push(new Uint8Array([0xFF, 0xD9]));
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
if (marker === 0x00 || (marker >= 0xD0 && marker <= 0xD7)) {
|
|
54
|
+
chunks.push(new Uint8Array([0xFF, marker]));
|
|
55
|
+
offset += 2;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check boundary for 16-bit big-endian length
|
|
60
|
+
if (offset + 3 >= u8.length) {
|
|
61
|
+
chunks.push(u8.subarray(offset));
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const length = (u8[offset + 2] << 8) | u8[offset + 3];
|
|
66
|
+
const segmentEnd = offset + 2 + length;
|
|
67
|
+
if (segmentEnd > u8.length) {
|
|
68
|
+
chunks.push(u8.subarray(offset));
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Markers to strip:
|
|
73
|
+
// 0xE1 = APP1 (EXIF, XMP)
|
|
74
|
+
// 0xED = APP13 (Photoshop IRB, IPTC)
|
|
75
|
+
// 0xFE = COM (Comment)
|
|
76
|
+
const isExif = marker === 0xE1;
|
|
77
|
+
const isIptc = marker === 0xED;
|
|
78
|
+
const isComment = marker === 0xFE;
|
|
79
|
+
|
|
80
|
+
if (isExif || isIptc || isComment) {
|
|
81
|
+
stripped = true;
|
|
82
|
+
const segmentName = isExif ? 'APP1 (Exif/XMP)' : isIptc ? 'APP13 (IPTC)' : 'COM (Comment)';
|
|
83
|
+
removedSegments.push(segmentName);
|
|
84
|
+
offset = segmentEnd;
|
|
85
|
+
} else if (marker === 0xDA) { // SOS (Start of Scan) - compressed bitstream follows until EOI
|
|
86
|
+
chunks.push(u8.subarray(offset));
|
|
87
|
+
break;
|
|
88
|
+
} else {
|
|
89
|
+
// Keep segment (e.g. SOF, DHT, DQT, APP0 JFIF, etc.)
|
|
90
|
+
chunks.push(u8.subarray(offset, segmentEnd));
|
|
91
|
+
offset = segmentEnd;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (stripped) {
|
|
96
|
+
let totalLen = 0;
|
|
97
|
+
for (const c of chunks) totalLen += c.length;
|
|
98
|
+
const clean = new Uint8Array(totalLen);
|
|
99
|
+
let cur = 0;
|
|
100
|
+
for (const c of chunks) {
|
|
101
|
+
clean.set(c, cur);
|
|
102
|
+
cur += c.length;
|
|
103
|
+
}
|
|
104
|
+
const isBuffer = typeof Buffer !== 'undefined' && Buffer.isBuffer(inputBuffer);
|
|
105
|
+
return {
|
|
106
|
+
cleanBuffer: isBuffer ? Buffer.from(clean.buffer, clean.byteOffset, clean.byteLength) : clean,
|
|
107
|
+
metadataStripped: true,
|
|
108
|
+
format: 'jpeg',
|
|
109
|
+
removedSegments,
|
|
110
|
+
bytesSaved: originalSize - totalLen
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
cleanBuffer: inputBuffer,
|
|
116
|
+
metadataStripped: false,
|
|
117
|
+
format: 'jpeg',
|
|
118
|
+
removedSegments: [],
|
|
119
|
+
bytesSaved: 0
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 2. PNG Detection & Stripping (\x89PNG\r\n\x1a\n)
|
|
124
|
+
if (u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4E && u8[3] === 0x47 &&
|
|
125
|
+
u8[4] === 0x0D && u8[5] === 0x0A && u8[6] === 0x1A && u8[7] === 0x0A) {
|
|
126
|
+
const chunks = [];
|
|
127
|
+
chunks.push(u8.subarray(0, 8)); // Keep 8-byte PNG header
|
|
128
|
+
let offset = 8;
|
|
129
|
+
let stripped = false;
|
|
130
|
+
|
|
131
|
+
// Ancillary chunk types to strip: eXIf, tEXt, zTXt, iTXt, tIME, pHYs
|
|
132
|
+
const ANCILLARY_TYPES = new Set(['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME', 'pHYs']);
|
|
133
|
+
|
|
134
|
+
while (offset + 8 <= u8.length) {
|
|
135
|
+
const length = (u8[offset] << 24) | (u8[offset + 1] << 16) | (u8[offset + 2] << 8) | u8[offset + 3];
|
|
136
|
+
const type = String.fromCharCode(u8[offset + 4], u8[offset + 5], u8[offset + 6], u8[offset + 7]);
|
|
137
|
+
const chunkTotalLen = 12 + length; // 4 len + 4 type + data + 4 crc
|
|
138
|
+
|
|
139
|
+
if (offset + chunkTotalLen > u8.length) {
|
|
140
|
+
chunks.push(u8.subarray(offset));
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (ANCILLARY_TYPES.has(type)) {
|
|
145
|
+
stripped = true;
|
|
146
|
+
removedSegments.push(`PNG_${type}`);
|
|
147
|
+
offset += chunkTotalLen;
|
|
148
|
+
} else {
|
|
149
|
+
chunks.push(u8.subarray(offset, offset + chunkTotalLen));
|
|
150
|
+
offset += chunkTotalLen;
|
|
151
|
+
if (type === 'IEND') break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (stripped) {
|
|
156
|
+
let totalLen = 0;
|
|
157
|
+
for (const c of chunks) totalLen += c.length;
|
|
158
|
+
const clean = new Uint8Array(totalLen);
|
|
159
|
+
let cur = 0;
|
|
160
|
+
for (const c of chunks) {
|
|
161
|
+
clean.set(c, cur);
|
|
162
|
+
cur += c.length;
|
|
163
|
+
}
|
|
164
|
+
const isBuffer = typeof Buffer !== 'undefined' && Buffer.isBuffer(inputBuffer);
|
|
165
|
+
return {
|
|
166
|
+
cleanBuffer: isBuffer ? Buffer.from(clean.buffer, clean.byteOffset, clean.byteLength) : clean,
|
|
167
|
+
metadataStripped: true,
|
|
168
|
+
format: 'png',
|
|
169
|
+
removedSegments,
|
|
170
|
+
bytesSaved: originalSize - totalLen
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
cleanBuffer: inputBuffer,
|
|
176
|
+
metadataStripped: false,
|
|
177
|
+
format: 'png',
|
|
178
|
+
removedSegments: [],
|
|
179
|
+
bytesSaved: 0
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 3. WebP Detection & Stripping (RIFF....WEBP)
|
|
184
|
+
if (u8[0] === 0x52 && u8[1] === 0x49 && u8[2] === 0x46 && u8[3] === 0x46 &&
|
|
185
|
+
u8[8] === 0x57 && u8[9] === 0x45 && u8[10] === 0x42 && u8[11] === 0x50) {
|
|
186
|
+
const chunks = [];
|
|
187
|
+
chunks.push(u8.subarray(0, 12)); // Keep RIFF header (length will be patched)
|
|
188
|
+
let offset = 12;
|
|
189
|
+
let stripped = false;
|
|
190
|
+
|
|
191
|
+
while (offset + 8 <= u8.length) {
|
|
192
|
+
const fourCC = String.fromCharCode(u8[offset], u8[offset + 1], u8[offset + 2], u8[offset + 3]);
|
|
193
|
+
const chunkSize = u8[offset + 4] | (u8[offset + 5] << 8) | (u8[offset + 6] << 16) | (u8[offset + 7] << 24);
|
|
194
|
+
const paddedSize = chunkSize + (chunkSize % 2); // WebP chunks are 2-byte aligned
|
|
195
|
+
const totalChunkSize = 8 + paddedSize;
|
|
196
|
+
|
|
197
|
+
if (offset + totalChunkSize > u8.length) {
|
|
198
|
+
chunks.push(u8.subarray(offset));
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (fourCC === 'EXIF' || fourCC === 'XMP ') {
|
|
203
|
+
stripped = true;
|
|
204
|
+
removedSegments.push(`WEBP_${fourCC.trim()}`);
|
|
205
|
+
offset += totalChunkSize;
|
|
206
|
+
} else {
|
|
207
|
+
chunks.push(u8.subarray(offset, offset + totalChunkSize));
|
|
208
|
+
offset += totalChunkSize;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (stripped) {
|
|
213
|
+
let totalLen = 0;
|
|
214
|
+
for (const c of chunks) totalLen += c.length;
|
|
215
|
+
const clean = new Uint8Array(totalLen);
|
|
216
|
+
let cur = 0;
|
|
217
|
+
for (const c of chunks) {
|
|
218
|
+
clean.set(c, cur);
|
|
219
|
+
cur += c.length;
|
|
220
|
+
}
|
|
221
|
+
// Patch RIFF file size (totalLen - 8) in little-endian at bytes 4..7
|
|
222
|
+
const newRiffSize = totalLen - 8;
|
|
223
|
+
clean[4] = newRiffSize & 0xFF;
|
|
224
|
+
clean[5] = (newRiffSize >> 8) & 0xFF;
|
|
225
|
+
clean[6] = (newRiffSize >> 16) & 0xFF;
|
|
226
|
+
clean[7] = (newRiffSize >> 24) & 0xFF;
|
|
227
|
+
|
|
228
|
+
const isBuffer = typeof Buffer !== 'undefined' && Buffer.isBuffer(inputBuffer);
|
|
229
|
+
return {
|
|
230
|
+
cleanBuffer: isBuffer ? Buffer.from(clean.buffer, clean.byteOffset, clean.byteLength) : clean,
|
|
231
|
+
metadataStripped: true,
|
|
232
|
+
format: 'webp',
|
|
233
|
+
removedSegments,
|
|
234
|
+
bytesSaved: originalSize - totalLen
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
cleanBuffer: inputBuffer,
|
|
240
|
+
metadataStripped: false,
|
|
241
|
+
format: 'webp',
|
|
242
|
+
removedSegments: [],
|
|
243
|
+
bytesSaved: 0
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Unsupported or raw format
|
|
248
|
+
return {
|
|
249
|
+
cleanBuffer: inputBuffer,
|
|
250
|
+
metadataStripped: false,
|
|
251
|
+
format: 'unknown',
|
|
252
|
+
removedSegments: [],
|
|
253
|
+
bytesSaved: 0
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Redacts bounding boxes on an HTML Canvas or 2D Canvas context.
|
|
259
|
+
* Solid blackouts or placeholder privacy badges are drawn directly in memory.
|
|
260
|
+
*
|
|
261
|
+
* @param {HTMLCanvasElement|CanvasRenderingContext2D} canvasOrCtx
|
|
262
|
+
* @param {Array<{x: number, y: number, width: number, height: number, label?: string}>} boxes
|
|
263
|
+
* @param {Object} [options]
|
|
264
|
+
* @param {string} [options.fillColor='#000000'] - Fill color for the redaction box
|
|
265
|
+
* @param {string} [options.borderColor='#00f0ff'] - Border highlight color
|
|
266
|
+
* @param {boolean} [options.showLabels=true] - Whether to render synthetic token label badges
|
|
267
|
+
* @returns {number} Count of redacted boxes
|
|
268
|
+
*/
|
|
269
|
+
export function redactImageBoundingBoxes(canvasOrCtx, boxes = [], options = {}) {
|
|
270
|
+
if (!canvasOrCtx || !Array.isArray(boxes) || boxes.length === 0) return 0;
|
|
271
|
+
|
|
272
|
+
const ctx = typeof canvasOrCtx.getContext === 'function'
|
|
273
|
+
? canvasOrCtx.getContext('2d')
|
|
274
|
+
: canvasOrCtx;
|
|
275
|
+
|
|
276
|
+
if (!ctx || typeof ctx.fillRect !== 'function') return 0;
|
|
277
|
+
|
|
278
|
+
const fillColor = options.fillColor || '#000000';
|
|
279
|
+
const borderColor = options.borderColor || 'rgba(0, 240, 255, 0.4)';
|
|
280
|
+
const showLabels = options.showLabels !== false;
|
|
281
|
+
|
|
282
|
+
ctx.save();
|
|
283
|
+
|
|
284
|
+
let count = 0;
|
|
285
|
+
for (const box of boxes) {
|
|
286
|
+
if (typeof box.x !== 'number' || typeof box.y !== 'number' ||
|
|
287
|
+
typeof box.width !== 'number' || typeof box.height !== 'number') {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 1. Draw solid blackout box
|
|
292
|
+
ctx.fillStyle = fillColor;
|
|
293
|
+
ctx.fillRect(box.x, box.y, box.width, box.height);
|
|
294
|
+
|
|
295
|
+
// 2. Draw subtle border
|
|
296
|
+
ctx.strokeStyle = borderColor;
|
|
297
|
+
ctx.lineWidth = 1.5;
|
|
298
|
+
ctx.strokeRect(box.x, box.y, box.width, box.height);
|
|
299
|
+
|
|
300
|
+
// 3. Render token label badge if provided
|
|
301
|
+
if (showLabels && box.label) {
|
|
302
|
+
const labelText = String(box.label);
|
|
303
|
+
ctx.font = 'bold 11px monospace';
|
|
304
|
+
const textMetrics = ctx.measureText(labelText);
|
|
305
|
+
const badgeWidth = textMetrics.width + 10;
|
|
306
|
+
const badgeHeight = 16;
|
|
307
|
+
const badgeX = box.x + 2;
|
|
308
|
+
const badgeY = box.y + 2;
|
|
309
|
+
|
|
310
|
+
ctx.fillStyle = 'rgba(10, 15, 29, 0.85)';
|
|
311
|
+
ctx.fillRect(badgeX, badgeY, badgeWidth, badgeHeight);
|
|
312
|
+
|
|
313
|
+
ctx.fillStyle = '#00f0ff';
|
|
314
|
+
ctx.fillText(labelText, badgeX + 5, badgeY + 12);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
count++;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
ctx.restore();
|
|
321
|
+
return count;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Generates an air-gapped SVG redaction mask overlay for headless environments.
|
|
326
|
+
*
|
|
327
|
+
* @param {{ width: number, height: number }} dimensions
|
|
328
|
+
* @param {Array<{x: number, y: number, width: number, height: number, label?: string}>} boxes
|
|
329
|
+
* @param {Object} [options]
|
|
330
|
+
* @returns {string} Standalone SVG markup
|
|
331
|
+
*/
|
|
332
|
+
export function generateSvgRedactionOverlay(dimensions = { width: 800, height: 600 }, boxes = [], options = {}) {
|
|
333
|
+
const width = dimensions.width || 800;
|
|
334
|
+
const height = dimensions.height || 600;
|
|
335
|
+
const fillColor = options.fillColor || '#000000';
|
|
336
|
+
const strokeColor = options.borderColor || '#00f0ff';
|
|
337
|
+
|
|
338
|
+
const rects = (boxes || []).map((b, idx) => {
|
|
339
|
+
const label = b.label ? `<text x="${b.x + 5}" y="${b.y + 14}" font-family="monospace" font-size="11" font-weight="bold" fill="#00f0ff">${escapeXml(b.label)}</text>` : '';
|
|
340
|
+
return `
|
|
341
|
+
<g class="ps-vision-mask" id="mask-${idx}">
|
|
342
|
+
<rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" fill="${fillColor}" stroke="${strokeColor}" stroke-width="1.5" />
|
|
343
|
+
${label}
|
|
344
|
+
</g>`;
|
|
345
|
+
}).join('');
|
|
346
|
+
|
|
347
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
|
348
|
+
${rects}
|
|
349
|
+
</svg>`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function escapeXml(unsafe) {
|
|
353
|
+
return String(unsafe).replace(/[<>&'"]/g, c => {
|
|
354
|
+
switch (c) {
|
|
355
|
+
case '<': return '<';
|
|
356
|
+
case '>': return '>';
|
|
357
|
+
case '&': return '&';
|
|
358
|
+
case '\'': return ''';
|
|
359
|
+
case '"': return '"';
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
}
|