@file-viewer/doc 2.3.0 → 2.3.1

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/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { parseMsDoc } from './msdoc/parser.js';
2
- export { renderMsDoc, defaultMsDocCss } from './render/html.js';
3
- export { createMsDocViewer, mountMsDoc, parseMsDocToHtml } from './viewer.js';
2
+ export { renderMsDoc, defaultMsDocCss, sanitizeMsDocLinkHref } from './render/html.js';
3
+ export { createMsDocViewer, mountMsDoc, parseMsDocToHtml, sanitizeMsDocHtml } from './viewer.js';
4
4
  export { MsDocWorkerClient } from './worker-client.js';
5
5
  export type * from './types.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { parseMsDoc } from './msdoc/parser.js';
2
- export { renderMsDoc, defaultMsDocCss } from './render/html.js';
3
- export { createMsDocViewer, mountMsDoc, parseMsDocToHtml } from './viewer.js';
2
+ export { renderMsDoc, defaultMsDocCss, sanitizeMsDocLinkHref } from './render/html.js';
3
+ export { createMsDocViewer, mountMsDoc, parseMsDocToHtml, sanitizeMsDocHtml } from './viewer.js';
4
4
  export { MsDocWorkerClient } from './worker-client.js';
@@ -1,4 +1,11 @@
1
1
  import type { MsDocParseResult, MsDocRenderOptions, MsDocRenderResult } from '../types.js';
2
+ type ExternalLinkPolicy = NonNullable<MsDocRenderOptions['externalLinkPolicy']>;
3
+ /**
4
+ * Normalizes a document-provided hyperlink before it reaches generated HTML.
5
+ * Internal bookmarks always remain available. External navigation is opt-in
6
+ * and is still restricted to browser-safe schemes and relative paths.
7
+ */
8
+ export declare function sanitizeMsDocLinkHref(href: string | undefined | null, policy?: ExternalLinkPolicy): string | null;
2
9
  export declare function defaultMsDocCss(): string;
3
10
  /**
4
11
  * Converts the parsed AST into HTML and a companion CSS string.
@@ -6,3 +13,4 @@ export declare function defaultMsDocCss(): string;
6
13
  * to customize styles or consume the AST directly.
7
14
  */
8
15
  export declare function renderMsDoc(parsed: MsDocParseResult, options?: MsDocRenderOptions): MsDocRenderResult;
16
+ export {};
@@ -22,9 +22,51 @@ const COLOR_INDEX_MAP = {
22
22
  function styleObjectToCss(style) {
23
23
  return Object.entries(style)
24
24
  .filter(([, value]) => value != null && value !== '')
25
- .map(([key, value]) => `${key}:${value}`)
25
+ .map(([key, value]) => `${key}:${escapeHtml(value)}`)
26
26
  .join(';');
27
27
  }
28
+ function quoteCssString(value) {
29
+ const escaped = String(value ?? '')
30
+ .replace(/\\/g, '\\\\')
31
+ .replace(/'/g, "\\'")
32
+ .replace(/[\n\r\f]/g, ' ')
33
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
34
+ return `'${escaped}'`;
35
+ }
36
+ /**
37
+ * Normalizes a document-provided hyperlink before it reaches generated HTML.
38
+ * Internal bookmarks always remain available. External navigation is opt-in
39
+ * and is still restricted to browser-safe schemes and relative paths.
40
+ */
41
+ export function sanitizeMsDocLinkHref(href, policy = 'block') {
42
+ const value = String(href ?? '')
43
+ .replace(/[\u0000-\u0020\u007f-\u009f]/g, '')
44
+ .trim();
45
+ if (!value)
46
+ return null;
47
+ if (value.startsWith('#'))
48
+ return value;
49
+ if (policy !== 'allow')
50
+ return null;
51
+ if (/^(?:https?:|mailto:|tel:)/i.test(value))
52
+ return value;
53
+ if (value.startsWith('//') || value.startsWith('\\'))
54
+ return null;
55
+ if (/^(?:\/|\.\/|\.\.\/)/.test(value) && !value.startsWith('//') && !value.startsWith('\\\\')) {
56
+ return value;
57
+ }
58
+ if (!/^[a-z][a-z\d+.-]*:/i.test(value) && !value.startsWith('\\'))
59
+ return value;
60
+ return null;
61
+ }
62
+ function renderLink(content, href) {
63
+ if (!href)
64
+ return content;
65
+ const escapedHref = escapeHtml(href);
66
+ if (href.startsWith('#'))
67
+ return `<a class="msdoc-link" href="${escapedHref}">${content}</a>`;
68
+ return `<a class="msdoc-link" href="${escapedHref}" target="_blank" rel="noreferrer noopener">${content}</a>`;
69
+ }
28
70
  function borderToCss(border) {
29
71
  if (!border || border.nil)
30
72
  return null;
@@ -167,7 +209,7 @@ function inlineStyleToCss(styleState) {
167
209
  if (styleState.fontSizeHalfPoints)
168
210
  style['font-size'] = `${styleState.fontSizeHalfPoints / 2}pt`;
169
211
  if (styleState.fontFamily)
170
- style['font-family'] = `'${String(styleState.fontFamily).replace(/'/g, "\\'")}', sans-serif`;
212
+ style['font-family'] = `${quoteCssString(styleState.fontFamily)},sans-serif`;
171
213
  if (styleState.colorIndex && COLOR_INDEX_MAP[styleState.colorIndex])
172
214
  style.color = COLOR_INDEX_MAP[styleState.colorIndex];
173
215
  const highlightIndex = typeof styleState.highlight === 'number' ? styleState.highlight : styleState.highlight?.index;
@@ -196,16 +238,13 @@ function inlineStyleToCss(styleState) {
196
238
  style.direction = 'rtl';
197
239
  return style;
198
240
  }
199
- function renderTextNode(node) {
241
+ function renderTextNode(node, context) {
200
242
  const content = escapeHtml(node.text);
201
243
  const inlineStyle = inlineStyleToCss(node.style);
202
244
  inlineStyle['white-space'] = 'break-spaces';
203
245
  const style = styleObjectToCss(inlineStyle);
204
246
  const inner = `<span${style ? ` style="${style}"` : ''}>${content}</span>`;
205
- if (node.href) {
206
- return `<a class="msdoc-link" href="${escapeHtml(node.href)}" target="_blank" rel="noreferrer noopener">${inner}</a>`;
207
- }
208
- return inner;
247
+ return renderLink(inner, sanitizeMsDocLinkHref(node.href, context.externalLinkPolicy));
209
248
  }
210
249
  function inlineImageDisplaySizePx(asset) {
211
250
  const widthPx = twipsToPx(asset.meta?.displayWidthTwips);
@@ -234,15 +273,31 @@ function sanitizeImageSource(src) {
234
273
  return value;
235
274
  return null;
236
275
  }
237
- function sanitizeAssetHref(href) {
238
- const value = String(href || '').trim();
276
+ function sanitizeDownloadHref(href) {
277
+ const value = String(href ?? '').trim();
239
278
  if (!value)
240
279
  return null;
241
- if (/^(?:data:|blob:|https?:)/i.test(value))
280
+ const dataUrl = /^data:([^;,]+);base64,([a-z\d+/=\s]+)$/i.exec(value);
281
+ if (!dataUrl)
282
+ return null;
283
+ const mimeType = dataUrl[1].trim().toLowerCase();
284
+ if (/^(?:text\/(?:html|javascript)|application\/(?:javascript|xhtml\+xml|xml)|image\/svg\+xml)$/.test(mimeType)) {
285
+ return null;
286
+ }
287
+ if (mimeType.endsWith('+xml'))
288
+ return null;
289
+ return value;
290
+ }
291
+ function sanitizeAssetHref(href, context) {
292
+ const downloadHref = sanitizeDownloadHref(href);
293
+ if (downloadHref)
294
+ return downloadHref;
295
+ const value = sanitizeMsDocLinkHref(href, context.externalLinkPolicy);
296
+ if (value && !value.startsWith('#'))
242
297
  return value;
243
298
  return null;
244
299
  }
245
- function renderImageNode(node) {
300
+ function renderImageNode(node, context) {
246
301
  const src = sanitizeImageSource(node.asset.sourceUrl) || sanitizeImageSource(node.asset.dataUrl);
247
302
  const baseStyle = inlineStyleToCss(node.style);
248
303
  const displaySize = inlineImageDisplaySizePx(node.asset);
@@ -257,38 +312,33 @@ function renderImageNode(node) {
257
312
  baseStyle.height = 'auto';
258
313
  }
259
314
  if (!src || node.asset.displayable === false) {
260
- const fallbackHref = sanitizeAssetHref(node.asset.dataUrl) || sanitizeAssetHref(node.asset.sourceUrl);
315
+ const fallbackHref = sanitizeAssetHref(node.asset.dataUrl, context) || sanitizeAssetHref(node.asset.sourceUrl, context);
261
316
  const label = escapeHtml(String(node.asset.meta?.linkedPath || node.asset.mime || 'image'));
262
317
  const inner = fallbackHref
263
318
  ? `<a class="msdoc-attachment msdoc-image-fallback" href="${escapeHtml(fallbackHref)}" target="_blank" rel="noreferrer noopener">🖼 ${label}</a>`
264
319
  : `<span class="msdoc-image-fallback">🖼 ${label}</span>`;
265
- if (node.href) {
266
- return `<a class="msdoc-link" href="${escapeHtml(node.href)}" target="_blank" rel="noreferrer noopener">${inner}</a>`;
267
- }
268
- return inner;
320
+ return renderLink(inner, sanitizeMsDocLinkHref(node.href, context.externalLinkPolicy));
269
321
  }
270
322
  const img = `<img class="msdoc-image" src="${escapeHtml(src)}" alt="" style="${styleObjectToCss(baseStyle)}">`;
271
- if (node.href) {
272
- return `<a class="msdoc-link" href="${escapeHtml(node.href)}" target="_blank" rel="noreferrer noopener">${img}</a>`;
273
- }
274
- return img;
323
+ return renderLink(img, sanitizeMsDocLinkHref(node.href, context.externalLinkPolicy));
275
324
  }
276
- function renderAttachmentNode(node) {
325
+ function renderAttachmentNode(node, context) {
277
326
  const label = escapeHtml(node.asset.name || 'embedded-file');
278
- const inner = `<a class="msdoc-attachment" href="${escapeHtml(node.asset.dataUrl)}" download="${label}">📎 ${label}</a>`;
279
- if (node.href) {
280
- return `<a class="msdoc-link" href="${escapeHtml(node.href)}" target="_blank" rel="noreferrer noopener">${inner}</a>`;
281
- }
282
- return inner;
327
+ const downloadHref = sanitizeDownloadHref(node.asset.dataUrl);
328
+ const inner = downloadHref
329
+ ? `<a class="msdoc-attachment" href="${escapeHtml(downloadHref)}" download="${label}">📎 ${label}</a>`
330
+ : `<span class="msdoc-attachment">📎 ${label}</span>`;
331
+ const href = sanitizeMsDocLinkHref(node.href, context.externalLinkPolicy);
332
+ return href ? `<span class="msdoc-inline-group">${inner}${renderLink('↗', href)}</span>` : inner;
283
333
  }
284
- function renderInlineNodes(nodes) {
334
+ function renderInlineNodes(nodes, context) {
285
335
  return nodes.map((node) => {
286
336
  if (node.type === 'text')
287
- return renderTextNode(node);
337
+ return renderTextNode(node, context);
288
338
  if (node.type === 'image')
289
- return renderImageNode(node);
339
+ return renderImageNode(node, context);
290
340
  if (node.type === 'attachment')
291
- return renderAttachmentNode(node);
341
+ return renderAttachmentNode(node, context);
292
342
  if (node.type === 'lineBreak')
293
343
  return '<br>';
294
344
  if (node.type === 'pageBreak')
@@ -296,10 +346,10 @@ function renderInlineNodes(nodes) {
296
346
  return '';
297
347
  }).join('');
298
348
  }
299
- function renderParagraphBlock(block, options = {}) {
349
+ function renderParagraphBlock(block, context, options = {}) {
300
350
  const tag = options.inline ? 'div' : 'p';
301
351
  const style = styleObjectToCss(paragraphStyleToCss(block.paraState));
302
- const body = renderInlineNodes(block.inlines || []);
352
+ const body = renderInlineNodes(block.inlines || [], context);
303
353
  const empty = body || '<br>';
304
354
  const classNames = ['msdoc-paragraph'];
305
355
  if (block.styleName)
@@ -380,7 +430,7 @@ function tableStyle(block) {
380
430
  style['table-layout'] = 'fixed';
381
431
  return style;
382
432
  }
383
- function renderTableBlock(block) {
433
+ function renderTableBlock(block, context) {
384
434
  const rows = block.rows.map((row) => {
385
435
  const rowHeight = row.state?.rowHeight ? twipsToPx(Math.abs(row.state.rowHeight)) : null;
386
436
  const rowStyle = rowHeight ? ` style="height:${rowHeight}px"` : '';
@@ -393,7 +443,7 @@ function renderTableBlock(block) {
393
443
  if ((cell.rowspan ?? 1) > 1)
394
444
  attrs.push(` rowspan="${cell.rowspan}"`);
395
445
  const style = styleObjectToCss(cellStyle(cell));
396
- const body = cell.paragraphs.map((paragraph) => renderParagraphBlock(paragraph, { inline: true })).join('');
446
+ const body = cell.paragraphs.map((paragraph) => renderParagraphBlock(paragraph, context, { inline: true })).join('');
397
447
  return `<td class="msdoc-cell"${attrs.join('')}${style ? ` style="${style}"` : ''}>${body || '<div class="msdoc-paragraph"><br></div>'}</td>`;
398
448
  })
399
449
  .join('');
@@ -402,7 +452,14 @@ function renderTableBlock(block) {
402
452
  return `<table class="msdoc-table msdoc-table-depth-${block.depth}" style="${styleObjectToCss(tableStyle(block))}"><tbody>${rows}</tbody></table>`;
403
453
  }
404
454
  function renderAttachmentsBlock(block) {
405
- const items = block.items.map((item) => `<li><a class="msdoc-attachment" href="${escapeHtml(item.dataUrl)}" download="${escapeHtml(item.name || 'embedded-file')}">📎 ${escapeHtml(item.name || 'embedded-file')}</a></li>`).join('');
455
+ const items = block.items.map((item) => {
456
+ const label = escapeHtml(item.name || 'embedded-file');
457
+ const downloadHref = sanitizeDownloadHref(item.dataUrl);
458
+ const content = downloadHref
459
+ ? `<a class="msdoc-attachment" href="${escapeHtml(downloadHref)}" download="${label}">📎 ${label}</a>`
460
+ : `<span class="msdoc-attachment">📎 ${label}</span>`;
461
+ return `<li>${content}</li>`;
462
+ }).join('');
406
463
  return `<section class="msdoc-attachments"><div class="msdoc-attachments-title">Embedded attachments</div><ul>${items}</ul></section>`;
407
464
  }
408
465
  export function defaultMsDocCss() {
@@ -430,11 +487,14 @@ export function defaultMsDocCss() {
430
487
  */
431
488
  export function renderMsDoc(parsed, options = {}) {
432
489
  const css = options.css ?? defaultMsDocCss();
490
+ const context = {
491
+ externalLinkPolicy: options.externalLinkPolicy ?? 'block',
492
+ };
433
493
  const html = parsed.blocks.map((block) => {
434
494
  if (block.type === 'paragraph')
435
- return renderParagraphBlock(block);
495
+ return renderParagraphBlock(block, context);
436
496
  if (block.type === 'table')
437
- return renderTableBlock(block);
497
+ return renderTableBlock(block, context);
438
498
  if (block.type === 'attachments')
439
499
  return renderAttachmentsBlock(block);
440
500
  return '';
package/dist/types.d.ts CHANGED
@@ -617,6 +617,8 @@ export interface MsDocParseOptions {
617
617
  }
618
618
  export interface MsDocRenderOptions {
619
619
  css?: string;
620
+ /** External links are blocked by default; internal document bookmarks remain active. */
621
+ externalLinkPolicy?: 'allow' | 'block';
620
622
  }
621
623
  export interface MsDocMeta {
622
624
  fib: {
package/dist/viewer.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { MsDocParseToHtmlOptions, MsDocRenderResult, MsDocViewer, MsDocViewerConfig, ViewerInput } from './types.js';
2
+ export declare function sanitizeMsDocHtml(html: string, targetWindow: Window): DocumentFragment;
2
3
  export declare function mountMsDoc(container: HTMLElement, rendered: MsDocRenderResult): HTMLElement;
3
4
  export declare function parseMsDocToHtml(input: ViewerInput, options?: MsDocParseToHtmlOptions): Promise<MsDocRenderResult>;
4
5
  /**
package/dist/viewer.js CHANGED
@@ -1,3 +1,4 @@
1
+ import createDOMPurify from 'dompurify';
1
2
  import { parseMsDoc } from './msdoc/parser.js';
2
3
  import { renderMsDoc } from './render/html.js';
3
4
  async function normalizeInput(input) {
@@ -19,10 +20,28 @@ async function normalizeInput(input) {
19
20
  }
20
21
  throw new TypeError('Unsupported input type');
21
22
  }
23
+ export function sanitizeMsDocHtml(html, targetWindow) {
24
+ const purifier = createDOMPurify(targetWindow);
25
+ return purifier.sanitize(html, {
26
+ RETURN_DOM_FRAGMENT: true,
27
+ USE_PROFILES: { html: true },
28
+ FORBID_TAGS: ['base', 'embed', 'form', 'iframe', 'object', 'script', 'style', 'template'],
29
+ FORBID_ATTR: ['action', 'formaction', 'srcdoc'],
30
+ });
31
+ }
22
32
  export function mountMsDoc(container, rendered) {
23
33
  if (!container)
24
34
  throw new Error('A container element is required');
25
- container.innerHTML = `<style data-msdoc>${rendered.css}</style><div class="msdoc-root">${rendered.html}</div>`;
35
+ const targetWindow = container.ownerDocument.defaultView;
36
+ if (!targetWindow)
37
+ throw new Error('The container must belong to a browser document');
38
+ const style = container.ownerDocument.createElement('style');
39
+ style.dataset.msdoc = '';
40
+ style.textContent = rendered.css;
41
+ const root = container.ownerDocument.createElement('div');
42
+ root.className = 'msdoc-root';
43
+ root.append(sanitizeMsDocHtml(rendered.html, targetWindow));
44
+ container.replaceChildren(style, root);
26
45
  return container;
27
46
  }
28
47
  export async function parseMsDocToHtml(input, options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@file-viewer/doc",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser-native legacy MS-DOC (.doc) parser and HTML renderer extracted from the public msdoc-viewer 0.2.0 line.",
@@ -61,6 +61,9 @@
61
61
  "README.en.md",
62
62
  "LICENSE"
63
63
  ],
64
+ "dependencies": {
65
+ "dompurify": "^3.4.13"
66
+ },
64
67
  "devDependencies": {
65
68
  "typescript": "^6.0.3"
66
69
  },