@file-viewer/core 2.3.0 → 3.0.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.
@@ -0,0 +1,177 @@
1
+ const SVG_URL_ATTRIBUTES = new Set(['href', 'xlink:href', 'src', 'poster']);
2
+ const SAFE_EMBEDDED_IMAGE_DATA_URL = /^data:image\/(?:avif|bmp|gif|jpeg|png|webp|x-icon);base64,[A-Za-z0-9+/=\s]+$/i;
3
+ const stripResourceUrlControlCharacters = (value) => {
4
+ let normalized = '';
5
+ for (const character of value) {
6
+ const codePoint = character.charCodeAt(0);
7
+ if (codePoint <= 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f))
8
+ continue;
9
+ normalized += character;
10
+ }
11
+ return normalized.trim();
12
+ };
13
+ const isSafeSvgAnchorUrl = (value) => {
14
+ const normalized = stripResourceUrlControlCharacters(value);
15
+ if (!normalized)
16
+ return false;
17
+ if (/^#[A-Za-z0-9_.:-]+$/.test(normalized))
18
+ return true;
19
+ if (/^(?:https?|mailto|tel):/i.test(normalized))
20
+ return true;
21
+ return !normalized.includes(':') &&
22
+ !normalized.startsWith('//') &&
23
+ !normalized.startsWith('\\');
24
+ };
25
+ const isSafeEmbeddedSvgUrl = (value, allowFragment) => {
26
+ const normalized = stripResourceUrlControlCharacters(value);
27
+ if (allowFragment && /^#[A-Za-z0-9_.:-]+$/.test(normalized))
28
+ return true;
29
+ if (/^blob:/i.test(normalized))
30
+ return true;
31
+ return SAFE_EMBEDDED_IMAGE_DATA_URL.test(normalized);
32
+ };
33
+ const normalizeCssUrl = (value) => {
34
+ let normalized = value.trim();
35
+ const quote = normalized[0];
36
+ if ((quote === '"' || quote === "'") && normalized[normalized.length - 1] === quote) {
37
+ normalized = normalized.slice(1, -1).trim();
38
+ }
39
+ return stripResourceUrlControlCharacters(normalized);
40
+ };
41
+ const isSafeSvgCssUrl = (value) => {
42
+ const normalized = normalizeCssUrl(value);
43
+ return /^#[A-Za-z0-9_.:-]+$/.test(normalized) ||
44
+ /^blob:/i.test(normalized) ||
45
+ SAFE_EMBEDDED_IMAGE_DATA_URL.test(normalized);
46
+ };
47
+ /**
48
+ * Keep ordinary SVG styles while preventing imports and resource requests.
49
+ * Obfuscated URL tokens fail closed instead of depending on parser quirks.
50
+ */
51
+ export const sanitizeFileViewerSvgCss = (value) => {
52
+ const normalizedCharacters = [];
53
+ for (const character of value) {
54
+ const codePoint = character.charCodeAt(0);
55
+ const disallowedControl = codePoint <= 0x08 || codePoint === 0x0b || codePoint === 0x0c ||
56
+ (codePoint >= 0x0e && codePoint <= 0x1f) || (codePoint >= 0x7f && codePoint <= 0x9f);
57
+ if (!disallowedControl)
58
+ normalizedCharacters.push(character);
59
+ }
60
+ const normalized = normalizedCharacters.join('');
61
+ if (normalized.includes('\\') || normalized.includes('/*') || /@import\b/i.test(normalized)) {
62
+ return '';
63
+ }
64
+ const chunks = [];
65
+ let segmentStart = 0;
66
+ let cursor = 0;
67
+ while (cursor < normalized.length) {
68
+ const hasUrlPrefix = (normalized.charCodeAt(cursor) | 0x20) === 0x75 &&
69
+ (normalized.charCodeAt(cursor + 1) | 0x20) === 0x72 &&
70
+ (normalized.charCodeAt(cursor + 2) | 0x20) === 0x6c;
71
+ if (!hasUrlPrefix) {
72
+ cursor += 1;
73
+ continue;
74
+ }
75
+ let open = cursor + 3;
76
+ while (open < normalized.length && normalized[open].trim() === '')
77
+ open += 1;
78
+ if (normalized[open] !== '(') {
79
+ cursor += 1;
80
+ continue;
81
+ }
82
+ let close = open + 1;
83
+ while (close < normalized.length && normalized[close] !== ')')
84
+ close += 1;
85
+ if (close >= normalized.length)
86
+ return '';
87
+ const target = normalized.slice(open + 1, close);
88
+ chunks.push(normalized.slice(segmentStart, cursor));
89
+ chunks.push(isSafeSvgCssUrl(target) ? `url(${target})` : 'none');
90
+ cursor = close + 1;
91
+ segmentStart = cursor;
92
+ }
93
+ chunks.push(normalized.slice(segmentStart));
94
+ return chunks.join('');
95
+ };
96
+ /** Remove executable attributes and every SVG resource URL that can load data remotely. */
97
+ export const sanitizeFileViewerSvgResources = (root) => {
98
+ root.querySelectorAll('*').forEach(element => {
99
+ for (const attribute of Array.from(element.attributes)) {
100
+ const name = attribute.name.toLowerCase();
101
+ if (name.startsWith('on') || name === 'srcdoc') {
102
+ element.removeAttribute(attribute.name);
103
+ continue;
104
+ }
105
+ if (name === 'style') {
106
+ const sanitizedStyle = sanitizeFileViewerSvgCss(attribute.value);
107
+ if (sanitizedStyle) {
108
+ element.setAttribute(attribute.name, sanitizedStyle);
109
+ }
110
+ else {
111
+ element.removeAttribute(attribute.name);
112
+ }
113
+ continue;
114
+ }
115
+ if (SVG_URL_ATTRIBUTES.has(name)) {
116
+ const localName = element.localName.toLowerCase();
117
+ const isAnchor = localName === 'a' && name === 'href';
118
+ const allowFragment = localName === 'use' || localName === 'mpath';
119
+ const safe = isAnchor
120
+ ? isSafeSvgAnchorUrl(attribute.value)
121
+ : isSafeEmbeddedSvgUrl(attribute.value, allowFragment);
122
+ if (safe) {
123
+ attribute.value = stripResourceUrlControlCharacters(attribute.value);
124
+ }
125
+ else {
126
+ element.removeAttribute(attribute.name);
127
+ }
128
+ continue;
129
+ }
130
+ if (/url\s*\(/i.test(attribute.value)) {
131
+ const sanitizedValue = sanitizeFileViewerSvgCss(attribute.value);
132
+ if (sanitizedValue) {
133
+ element.setAttribute(attribute.name, sanitizedValue);
134
+ }
135
+ else {
136
+ element.removeAttribute(attribute.name);
137
+ }
138
+ }
139
+ }
140
+ });
141
+ root.querySelectorAll('style').forEach(style => {
142
+ const sanitizedStyle = sanitizeFileViewerSvgCss(style.textContent || '');
143
+ if (sanitizedStyle) {
144
+ style.textContent = sanitizedStyle;
145
+ }
146
+ else {
147
+ style.remove();
148
+ }
149
+ });
150
+ };
151
+ const canonicalizeMermaidSourceForResourceScan = (source) => {
152
+ let output = '';
153
+ for (const character of source) {
154
+ const codePoint = character.charCodeAt(0);
155
+ if (codePoint === 0 || (codePoint >= 0x7f && codePoint <= 0x9f))
156
+ continue;
157
+ output += codePoint < 0x20 && character !== '\n' && character !== '\r' && character !== '\t'
158
+ ? ' '
159
+ : character;
160
+ }
161
+ return output;
162
+ };
163
+ /**
164
+ * Mermaid 11 resolves image-node URLs with `new Image()` while rendering,
165
+ * before its SVG can be sanitized. Reject image metadata and explicit remote
166
+ * URLs before calling Mermaid so an untrusted diagram cannot initiate I/O.
167
+ */
168
+ export const assertFileViewerMermaidSourceHasNoExternalResources = (source) => {
169
+ const normalized = canonicalizeMermaidSourceForResourceScan(String(source || ''));
170
+ const compact = normalized.replace(/[\t\r\n ]+/g, '');
171
+ const hasImageMetadata = /(?:^|[,{])\s*(?:img|["']img["'])\s*:/im.test(normalized);
172
+ const hasRemoteUrl = /(?:https?|ftp):\/\//i.test(compact) ||
173
+ /(?:^|[\s"'(=:,])(?:\/\/|\\\\)[A-Za-z0-9]/m.test(normalized);
174
+ if (hasImageMetadata || hasRemoteUrl) {
175
+ throw new Error('Mermaid diagrams may not load external image resources.');
176
+ }
177
+ };
@@ -141,7 +141,7 @@ export const createFileViewerTextDecoder = (encoding) => {
141
141
  }
142
142
  catch (error) {
143
143
  if (encoding === 'gb18030' && error instanceof RangeError) {
144
- throw new Error('This browser does not provide GB18030 text decoding. Use a current browser or select UTF-8 explicitly.');
144
+ throw Object.assign(new Error('This browser does not provide GB18030 text decoding. Use a current browser or select UTF-8 explicitly.'), { cause: error });
145
145
  }
146
146
  throw error;
147
147
  }
@@ -319,6 +319,47 @@ export const createViewer = (container, createOptions = {}) => {
319
319
  registerHandler,
320
320
  });
321
321
  };
322
+ const renderNestedBuffer = async (buffer, type, target, context) => {
323
+ var _a;
324
+ const inheritedOptions = (context === null || context === void 0 ? void 0 : context.options) || options;
325
+ const nestedViewer = createViewer(target, {
326
+ registry,
327
+ options: {
328
+ ...inheritedOptions,
329
+ // The parent already resolved its renderer assembly into `registry`.
330
+ // Reuse that exact registry without reinstalling presets or mutating a
331
+ // shared registry while an attachment is open.
332
+ autoRenderers: false,
333
+ rendererMode: 'extend',
334
+ renderers: undefined,
335
+ preset: undefined,
336
+ presets: undefined,
337
+ },
338
+ signal: context === null || context === void 0 ? void 0 : context.signal,
339
+ renderPurpose: (context === null || context === void 0 ? void 0 : context.renderPurpose) || createOptions.renderPurpose || 'preview',
340
+ });
341
+ try {
342
+ await nestedViewer.load({
343
+ buffer,
344
+ filename: (context === null || context === void 0 ? void 0 : context.filename) || `attachment.${type || 'bin'}`,
345
+ type,
346
+ }, {
347
+ signal: context === null || context === void 0 ? void 0 : context.signal,
348
+ });
349
+ if ((_a = context === null || context === void 0 ? void 0 : context.signal) === null || _a === void 0 ? void 0 : _a.aborted) {
350
+ await nestedViewer.destroy('replace');
351
+ return undefined;
352
+ }
353
+ return {
354
+ $el: target,
355
+ destroy: () => nestedViewer.destroy('component-unmount'),
356
+ };
357
+ }
358
+ catch (error) {
359
+ await nestedViewer.destroy('replace');
360
+ throw error;
361
+ }
362
+ };
322
363
  const buildCurrentLifecycleContext = () => {
323
364
  const source = currentSource || normalizeSource({});
324
365
  return buildFileViewerLifecycleContextFromNormalizedSource({
@@ -514,6 +555,7 @@ export const createViewer = (container, createOptions = {}) => {
514
555
  },
515
556
  renderContext: {
516
557
  renderPurpose: createOptions.renderPurpose || 'preview',
558
+ renderNestedBuffer,
517
559
  },
518
560
  });
519
561
  }
@@ -87,6 +87,7 @@ export declare const resolveFileViewerOriginalFilename: (source: FileViewerOrigi
87
87
  export declare const resolveFileViewerOperationFilename: ({ filename, source, fallback, }: ResolveFileViewerOperationFilenameInput) => string;
88
88
  export declare const resolveFileViewerOperationActionErrorMessage: ({ context, formatErrorMessage, prefixes, i18n, }: ResolveFileViewerOperationActionErrorMessageInput) => string;
89
89
  export declare const hasFileViewerOriginalSource: (source: FileViewerOriginalSourceState) => boolean;
90
+ export declare const replaceFileViewerPrintDocument: (targetDocument: Document, sanitizedRoot: HTMLHtmlElement) => void;
90
91
  export declare const executeFileViewerDownloadOperation: ({ source, filename, beforeOperation, i18n, throwOnMissingSource, }: ExecuteFileViewerDownloadOperationInput) => Promise<boolean>;
91
92
  export declare const executeFileViewerExportHtmlOperation: ({ download, filename, beforeOperation, i18n, ...input }: ExecuteFileViewerExportHtmlOperationInput) => Promise<string>;
92
93
  export declare const executeFileViewerPrintOperation: ({ autoPrint, beforeOperation, i18n, openWindow, printAvailable, printWindow, ...input }: ExecuteFileViewerPrintOperationInput) => Promise<boolean>;
@@ -1,4 +1,4 @@
1
- import { buildFileViewerRenderedHtmlDocument, triggerFileViewerBlobDownload, triggerFileViewerUrlDownload, waitForFileViewerPrintWindowReady, } from '../output/export.js';
1
+ import { triggerFileViewerBlobDownload, triggerFileViewerUrlDownload, waitForFileViewerPrintWindowReady, } from '../output/export.js';
2
2
  import { DEFAULT_FILE_VIEWER_SOURCE_FILENAME } from '../source/index.js';
3
3
  import { translateFileViewerMessage, } from '../i18n/messages.js';
4
4
  export const DEFAULT_FILE_VIEWER_PREVIEW_TITLE = 'file-viewer-preview';
@@ -65,6 +65,7 @@ const buildRenderedHtmlDocumentFromOperation = async (mode, { source, title, fil
65
65
  if (!source) {
66
66
  throw new Error(translateFileViewerMessage(i18n, 'error.noExportContent'));
67
67
  }
68
+ const { buildFileViewerRenderedHtmlDocument } = await import('../export.js');
68
69
  return buildFileViewerRenderedHtmlDocument({
69
70
  source,
70
71
  mode,
@@ -77,6 +78,69 @@ const buildRenderedHtmlDocumentFromOperation = async (mode, { source, title, fil
77
78
  mask: mode === 'print' ? mask : null,
78
79
  });
79
80
  };
81
+ const buildRenderedDomDocumentFromOperation = async ({ source, title, filename, adapter = null, watermarkInlineStyle, mask = null, i18n, }) => {
82
+ if (!source) {
83
+ throw new Error(translateFileViewerMessage(i18n, 'error.noExportContent'));
84
+ }
85
+ const { buildFileViewerRenderedDomDocument } = await import('../export.js');
86
+ return buildFileViewerRenderedDomDocument({
87
+ source,
88
+ mode: 'print',
89
+ title: resolveFileViewerOperationFilename({
90
+ filename: title || filename,
91
+ fallback: DEFAULT_FILE_VIEWER_PREVIEW_TITLE,
92
+ }),
93
+ adapter,
94
+ watermarkInlineStyle,
95
+ mask,
96
+ });
97
+ };
98
+ export const replaceFileViewerPrintDocument = (targetDocument, sanitizedRoot) => {
99
+ const importedRoot = targetDocument.importNode(sanitizedRoot, true);
100
+ const currentRoot = targetDocument.documentElement;
101
+ if (currentRoot) {
102
+ currentRoot.replaceWith(importedRoot);
103
+ }
104
+ else {
105
+ targetDocument.append(importedRoot);
106
+ }
107
+ if (!targetDocument.doctype) {
108
+ targetDocument.insertBefore(targetDocument.implementation.createDocumentType('html', '', ''), importedRoot);
109
+ }
110
+ };
111
+ const FILE_VIEWER_PRINT_STANDARDS_DOCUMENT = '<!doctype html><html lang="en"><head><meta charset="utf-8"></head><body></body></html>';
112
+ const ensureFileViewerPrintStandardsMode = async (targetWindow) => {
113
+ if (targetWindow.document.compatMode === 'CSS1Compat') {
114
+ return;
115
+ }
116
+ const targetGlobal = targetWindow;
117
+ const objectUrl = targetGlobal.URL.createObjectURL(new targetGlobal.Blob([FILE_VIEWER_PRINT_STANDARDS_DOCUMENT], {
118
+ type: 'text/html;charset=utf-8',
119
+ }));
120
+ try {
121
+ await new Promise((resolve, reject) => {
122
+ let settled = false;
123
+ const finish = () => {
124
+ if (settled)
125
+ return;
126
+ settled = true;
127
+ globalThis.clearTimeout(timeoutId);
128
+ if (targetWindow.document.compatMode === 'CSS1Compat') {
129
+ resolve();
130
+ }
131
+ else {
132
+ reject(new Error('Unable to initialize the print window in standards mode.'));
133
+ }
134
+ };
135
+ const timeoutId = globalThis.setTimeout(finish, 1500);
136
+ targetWindow.addEventListener('load', finish, { once: true });
137
+ targetWindow.location.replace(objectUrl);
138
+ });
139
+ }
140
+ finally {
141
+ targetGlobal.URL.revokeObjectURL(objectUrl);
142
+ }
143
+ };
80
144
  export const executeFileViewerDownloadOperation = async ({ source, filename, beforeOperation, i18n, throwOnMissingSource = true, }) => {
81
145
  var _a;
82
146
  if (!hasFileViewerOriginalSource(source)) {
@@ -145,16 +209,15 @@ export const executeFileViewerPrintOperation = async ({ autoPrint = true, before
145
209
  if (!await runBeforeOperation(beforeOperation, 'print')) {
146
210
  return false;
147
211
  }
148
- const html = await buildRenderedHtmlDocumentFromOperation('print', { ...input, i18n });
212
+ const printableRoot = await buildRenderedDomDocumentFromOperation({ ...input, i18n });
149
213
  const targetWindow = printWindow ||
150
214
  (openWindow === null || openWindow === void 0 ? void 0 : openWindow()) ||
151
215
  (typeof window !== 'undefined' ? window.open('', '_blank') : null);
152
216
  if (!targetWindow) {
153
217
  throw new Error(translateFileViewerMessage(i18n, 'error.printWindowBlocked'));
154
218
  }
155
- targetWindow.document.open();
156
- targetWindow.document.write(html);
157
- targetWindow.document.close();
219
+ await ensureFileViewerPrintStandardsMode(targetWindow);
220
+ replaceFileViewerPrintDocument(targetWindow.document, printableRoot);
158
221
  targetWindow.focus();
159
222
  await waitForFileViewerPrintWindowReady(targetWindow);
160
223
  if (autoPrint !== false) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@file-viewer/core",
3
- "version": "2.3.0",
3
+ "version": "3.0.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Framework-neutral modular TypeScript core for File Viewer renderer orchestration, lifecycle APIs, search, zoom, print, and export",
@@ -62,6 +62,11 @@
62
62
  "import": "./dist/browser.js",
63
63
  "default": "./dist/browser.js"
64
64
  },
65
+ "./export": {
66
+ "types": "./dist/export.d.ts",
67
+ "import": "./dist/export.js",
68
+ "default": "./dist/export.js"
69
+ },
65
70
  "./print-mask": {
66
71
  "types": "./dist/print-mask.d.ts",
67
72
  "import": "./dist/print-mask.js",
@@ -84,6 +89,9 @@
84
89
  "README.en.md",
85
90
  "LICENSE"
86
91
  ],
92
+ "dependencies": {
93
+ "dompurify": "3.4.13"
94
+ },
87
95
  "devDependencies": {
88
96
  "typescript": "^6.0.3"
89
97
  },