@file-viewer/renderer-typst 2.4.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.
package/README.en.md CHANGED
@@ -44,6 +44,10 @@ The default asset paths are:
44
44
 
45
45
  For private deployments, override them with `options.typst.compilerWasmUrl`, `options.typst.rendererWasmUrl`, and `options.typst.fontAssetsUrl`. The default text fonts ship with this package and are copied by `file-viewer-copy-assets` / `@file-viewer/vite-plugin`, so the runtime does not depend on public CDNs.
46
46
 
47
+ ## CSP and Trusted Types
48
+
49
+ Typst SVG is rendered through controlled XML parsing, structural sanitization, and DOM node import without `innerHTML`. If the site enables `require-trusted-types-for 'script'`, allow `file-viewer-typst-svg` in the `trusted-types` directive (the DOMPurify export/print path also needs `dompurify`).
50
+
47
51
  ## Migration Note
48
52
 
49
53
  Typst rendering has moved out of `@file-viewer/core` into this package. Core only keeps the `renderFileViewerTypst()` compatibility export with a clear installation error, and no longer installs `@myriaddreamin/*` by default. Install this renderer explicitly, or use `@file-viewer/preset-all`, when real Typst preview is required.
package/README.md CHANGED
@@ -44,6 +44,10 @@ const options = {
44
44
 
45
45
  私有化部署时可以通过 `options.typst.compilerWasmUrl`、`options.typst.rendererWasmUrl` 和 `options.typst.fontAssetsUrl` 覆盖。默认字体资产随本包发布并由 `file-viewer-copy-assets` / `@file-viewer/vite-plugin` 复制到本地静态目录,预览运行时不会访问公共 CDN。
46
46
 
47
+ ## CSP 与 Trusted Types
48
+
49
+ Typst SVG 通过受控的 XML 解析、结构化净化和 DOM 节点导入渲染,不使用 `innerHTML`。如果站点启用了 `require-trusted-types-for 'script'`,请在 `trusted-types` 指令中允许 `file-viewer-typst-svg`(DOMPurify 的导出/打印路径还需要 `dompurify`)。
50
+
47
51
  ## 迁移说明
48
52
 
49
53
  Typst 渲染已经从 `@file-viewer/core` 迁移到本包。core 只保留 `renderFileViewerTypst()` 兼容导出并给出明确安装提示,不再默认安装 `@myriaddreamin/*`。需要 Typst 真实预览时,请显式安装本包,或直接使用 `@file-viewer/preset-all`。
@@ -0,0 +1 @@
1
+ export declare const sanitizeTypstSvgDocument: (root: Document | Element) => void;
@@ -0,0 +1,104 @@
1
+ import { sanitizeFileViewerSvgResources } from '@file-viewer/core';
2
+ const URL_ATTRIBUTES = new Set([
3
+ 'href',
4
+ 'xlink:href',
5
+ 'src',
6
+ 'poster',
7
+ 'action',
8
+ 'formaction',
9
+ ]);
10
+ const SAFE_RELATIVE_URL_ASCII = new Set(`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~!$&'()*+,;=@%/?#-`);
11
+ const stripUrlControlCharacters = (value) => {
12
+ let normalized = '';
13
+ for (const character of value) {
14
+ const code = character.charCodeAt(0);
15
+ if (code > 0x20 && (code < 0x7f || code > 0x9f)) {
16
+ normalized += character;
17
+ }
18
+ }
19
+ return normalized;
20
+ };
21
+ const normalizeTypstResourceUrl = (value) => {
22
+ const normalized = stripUrlControlCharacters(value).trim();
23
+ if (!normalized) {
24
+ return null;
25
+ }
26
+ if (/^#[A-Za-z0-9_.:-]+$/.test(normalized)) {
27
+ return normalized;
28
+ }
29
+ if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=]+$/i.test(normalized)) {
30
+ return normalized;
31
+ }
32
+ if (/^blob:[A-Za-z0-9.+-]+:\/\/[^\s]+$/i.test(normalized)) {
33
+ return normalized;
34
+ }
35
+ if (!normalized.includes(':') &&
36
+ !normalized.includes('\\') &&
37
+ !normalized.startsWith('//') &&
38
+ Array.from(normalized).every(character => (character.charCodeAt(0) > 0x7f || SAFE_RELATIVE_URL_ASCII.has(character)))) {
39
+ return normalized;
40
+ }
41
+ return null;
42
+ };
43
+ const hasOnlySafeCssUrls = (value) => {
44
+ const normalized = stripUrlControlCharacters(value);
45
+ if (normalized.includes('\\') ||
46
+ normalized.includes('/*') ||
47
+ /(?:expression|image(?:-set)?|paint|var)\s*\(|@import|(?:^|[^-])behavior\s*:|-moz-binding/i.test(normalized)) {
48
+ return false;
49
+ }
50
+ let cursor = 0;
51
+ while (cursor < normalized.length) {
52
+ const match = /url\s*\(/ig;
53
+ match.lastIndex = cursor;
54
+ const next = match.exec(normalized);
55
+ if (!next) {
56
+ return true;
57
+ }
58
+ const close = normalized.indexOf(')', match.lastIndex);
59
+ if (close < 0) {
60
+ return false;
61
+ }
62
+ const target = normalized
63
+ .slice(match.lastIndex, close)
64
+ .trim()
65
+ .replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, '$1$2');
66
+ if (!normalizeTypstResourceUrl(target)) {
67
+ return false;
68
+ }
69
+ cursor = close + 1;
70
+ }
71
+ return true;
72
+ };
73
+ export const sanitizeTypstSvgDocument = (root) => {
74
+ root.querySelectorAll('script,iframe,object,embed,form').forEach(node => node.remove());
75
+ root.querySelectorAll('*').forEach(element => {
76
+ for (const attribute of Array.from(element.attributes)) {
77
+ const name = attribute.name.toLowerCase();
78
+ if (name.startsWith('on') || name === 'srcdoc') {
79
+ element.removeAttribute(attribute.name);
80
+ continue;
81
+ }
82
+ if (URL_ATTRIBUTES.has(name)) {
83
+ const normalizedUrl = normalizeTypstResourceUrl(attribute.value);
84
+ if (!normalizedUrl) {
85
+ element.removeAttribute(attribute.name);
86
+ continue;
87
+ }
88
+ attribute.value = normalizedUrl;
89
+ }
90
+ if ((name === 'style' || /url\s*\(/i.test(attribute.value)) && !hasOnlySafeCssUrls(attribute.value)) {
91
+ element.removeAttribute(attribute.name);
92
+ }
93
+ }
94
+ });
95
+ root.querySelectorAll('style').forEach(style => {
96
+ if (!hasOnlySafeCssUrls(style.textContent || '')) {
97
+ style.remove();
98
+ }
99
+ });
100
+ // Typst output may contain navigation links, but embedded resources must be
101
+ // self-contained. The shared SVG gate removes relative/remote image loads
102
+ // while retaining safe anchors, fragments, blobs, and raster data URLs.
103
+ sanitizeFileViewerSvgResources(root);
104
+ };
package/dist/typst.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type FileRenderContext, type FileViewerRenderedInstance } from '@file-viewer/core';
1
+ import { type FileRenderContext, type FileViewerRenderedInstance, type PrintPageSize } from '@file-viewer/core';
2
2
  declare global {
3
3
  interface Window {
4
4
  __FLYFISH_TYPST_COMPILER_WASM_URL__?: string;
@@ -6,4 +6,11 @@ declare global {
6
6
  __FLYFISH_TYPST_RENDERER_WASM_URL__?: string;
7
7
  }
8
8
  }
9
+ export interface TypstRenderedPage extends PrintPageSize {
10
+ index: number;
11
+ svg: string;
12
+ svgNode: Element;
13
+ }
14
+ export declare const parseTypstSvgPages: (svgText: string, svgParseFailedMessage: string, documentRef?: Document) => TypstRenderedPage[];
15
+ export declare const importTypstSvgPageNode: (documentRef: Document, page: Pick<TypstRenderedPage, "svgNode">) => Element;
9
16
  export default function renderTypst(buffer: ArrayBuffer, target: HTMLDivElement, _type?: string, context?: FileRenderContext): Promise<FileViewerRenderedInstance>;
package/dist/typst.js CHANGED
@@ -2,6 +2,40 @@ import { $typst, MemoryAccessModel } from '@myriaddreamin/typst.ts';
2
2
  import { TypstSnippet } from '@myriaddreamin/typst.ts/contrib/snippet';
3
3
  import { resolveFileViewerTypstCompilerWasmUrl, resolveFileViewerTypstFontAssetsUrl, resolveFileViewerTypstRendererWasmUrl, resolveFileViewerRuntimeAssetBaseUrl, } from '@file-viewer/core/assets';
4
4
  import { createFileViewerTranslator, createFileViewerZoomChangeEmitter, formatCssPixels, registerFileViewerZoomProvider, readFileViewerText, unregisterFileViewerZoomProvider, } from '@file-viewer/core';
5
+ import { sanitizeTypstSvgDocument } from './sanitize.js';
6
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
7
+ const XMLNS_NAMESPACE = 'http://www.w3.org/2000/xmlns/';
8
+ const TYPST_TRUSTED_TYPES_POLICY_NAME = 'file-viewer-typst-svg';
9
+ const typstTrustedTypesPolicies = new WeakMap();
10
+ const assertTypstSvgIsSafeForInertParsing = (value) => {
11
+ if (/<!\s*(?:doctype|entity)\b|<\?|<\s*\/?\s*(?:script|iframe|object|embed|form|base|link|meta)\b|\s(?:on[a-z0-9_-]+|srcdoc)\s*=/i.test(value)) {
12
+ throw new TypeError('Typst SVG contains executable markup and was rejected before parsing.');
13
+ }
14
+ return value;
15
+ };
16
+ const createTypstSvgParserInput = (value, documentRef) => {
17
+ const windowRef = (documentRef === null || documentRef === void 0 ? void 0 : documentRef.defaultView) || (typeof window !== 'undefined' ? window : null);
18
+ const trustedTypes = windowRef === null || windowRef === void 0 ? void 0 : windowRef.trustedTypes;
19
+ if (!trustedTypes) {
20
+ return value;
21
+ }
22
+ let policy = typstTrustedTypesPolicies.get(trustedTypes);
23
+ if (!policy) {
24
+ try {
25
+ policy = trustedTypes.createPolicy(TYPST_TRUSTED_TYPES_POLICY_NAME, {
26
+ createHTML: assertTypstSvgIsSafeForInertParsing,
27
+ });
28
+ typstTrustedTypesPolicies.set(trustedTypes, policy);
29
+ }
30
+ catch (error) {
31
+ const detail = error instanceof Error ? ` ${error.message}` : '';
32
+ const policyError = new Error(`Trusted Types must allow the ${TYPST_TRUSTED_TYPES_POLICY_NAME} policy for Typst SVG parsing.${detail}`);
33
+ policyError.cause = error;
34
+ throw policyError;
35
+ }
36
+ }
37
+ return policy.createHTML(value);
38
+ };
5
39
  const typstStyle = `
6
40
  .typst-viewer{min-height:100%;overflow:auto;background:var(--file-viewer-render-surface-background,#eef1f4);color:#172033}
7
41
  .typst-toolbar{position:sticky;top:0;z-index:2;display:flex;min-height:52px;align-items:center;justify-content:space-between;gap:16px;padding:10px 18px;border-bottom:1px solid rgba(120,134,155,.18);background:rgba(248,250,252,.92);backdrop-filter:blur(16px)}
@@ -169,34 +203,26 @@ const readNumberAttribute = (element, name) => {
169
203
  const value = Number.parseFloat(element.getAttribute(name) || '');
170
204
  return Number.isFinite(value) && value > 0 ? value : 0;
171
205
  };
172
- const removeUnsafeSvgContent = (root) => {
173
- root.querySelectorAll('script').forEach(script => script.remove());
174
- root.querySelectorAll('*').forEach(element => {
175
- Array.from(element.attributes).forEach(attribute => {
176
- const name = attribute.name.toLowerCase();
177
- const value = attribute.value.trim().toLowerCase();
178
- if (name.startsWith('on') || value.startsWith('javascript:')) {
179
- element.removeAttribute(attribute.name);
180
- }
181
- });
182
- });
183
- };
184
206
  const serializeNode = (node) => {
185
207
  return new XMLSerializer().serializeToString(node);
186
208
  };
187
- const parseTypstSvgPages = (svgText, svgParseFailedMessage) => {
188
- const parser = new DOMParser();
189
- const documentSvg = parser.parseFromString(svgText, 'image/svg+xml');
209
+ export const parseTypstSvgPages = (svgText, svgParseFailedMessage, documentRef) => {
210
+ var _a;
211
+ const Parser = ((_a = documentRef === null || documentRef === void 0 ? void 0 : documentRef.defaultView) === null || _a === void 0 ? void 0 : _a.DOMParser) || DOMParser;
212
+ const parser = new Parser();
213
+ const documentSvg = parser.parseFromString(createTypstSvgParserInput(svgText, documentRef), 'image/svg+xml');
190
214
  const parseError = documentSvg.querySelector('parsererror');
191
215
  if (parseError) {
192
216
  throw new Error(parseError.textContent || svgParseFailedMessage);
193
217
  }
194
- removeUnsafeSvgContent(documentSvg);
218
+ sanitizeTypstSvgDocument(documentSvg);
195
219
  const root = documentSvg.documentElement;
220
+ if (root.namespaceURI !== SVG_NAMESPACE || root.localName.toLowerCase() !== 'svg') {
221
+ throw new Error(svgParseFailedMessage);
222
+ }
196
223
  const sharedNodes = Array.from(root.children)
197
224
  .filter(child => ['style', 'defs'].includes(child.tagName.toLowerCase()))
198
- .map(serializeNode)
199
- .join('');
225
+ .map(child => child.cloneNode(true));
200
226
  const pageGroups = Array.from(root.querySelectorAll('g.typst-page'));
201
227
  const fallbackWidth = readNumberAttribute(root, 'data-width') ||
202
228
  readNumberAttribute(root, 'width') ||
@@ -209,7 +235,8 @@ const parseTypstSvgPages = (svgText, svgParseFailedMessage) => {
209
235
  index: 1,
210
236
  width: fallbackWidth,
211
237
  height: fallbackHeight,
212
- svg: svgText,
238
+ svg: serializeNode(root),
239
+ svgNode: root,
213
240
  }];
214
241
  }
215
242
  return pageGroups.map((group, index) => {
@@ -217,20 +244,30 @@ const parseTypstSvgPages = (svgText, svgParseFailedMessage) => {
217
244
  const pageHeight = readNumberAttribute(group, 'data-page-height') || fallbackHeight;
218
245
  const pageClone = group.cloneNode(true);
219
246
  pageClone.setAttribute('transform', 'translate(0, 0)');
220
- const pageSvg = [
221
- `<svg style="overflow:visible;" class="typst-doc" viewBox="0 0 ${pageWidth} ${pageHeight}" width="${pageWidth}" height="${pageHeight}" data-width="${pageWidth}" data-height="${pageHeight}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:h5="http://www.w3.org/1999/xhtml">`,
222
- sharedNodes,
223
- serializeNode(pageClone),
224
- '</svg>',
225
- ].join('');
247
+ const pageSvg = documentSvg.createElementNS(SVG_NAMESPACE, 'svg');
248
+ pageSvg.setAttribute('style', 'overflow:visible;');
249
+ pageSvg.setAttribute('class', 'typst-doc');
250
+ pageSvg.setAttribute('viewBox', `0 0 ${pageWidth} ${pageHeight}`);
251
+ pageSvg.setAttribute('width', String(pageWidth));
252
+ pageSvg.setAttribute('height', String(pageHeight));
253
+ pageSvg.setAttribute('data-width', String(pageWidth));
254
+ pageSvg.setAttribute('data-height', String(pageHeight));
255
+ pageSvg.setAttributeNS(XMLNS_NAMESPACE, 'xmlns:xlink', 'http://www.w3.org/1999/xlink');
256
+ pageSvg.setAttributeNS(XMLNS_NAMESPACE, 'xmlns:h5', 'http://www.w3.org/1999/xhtml');
257
+ pageSvg.append(...sharedNodes.map(node => node.cloneNode(true)), pageClone);
258
+ sanitizeTypstSvgDocument(pageSvg);
226
259
  return {
227
260
  index: index + 1,
228
261
  width: pageWidth,
229
262
  height: pageHeight,
230
- svg: pageSvg,
263
+ svg: serializeNode(pageSvg),
264
+ svgNode: pageSvg,
231
265
  };
232
266
  });
233
267
  };
268
+ export const importTypstSvgPageNode = (documentRef, page) => {
269
+ return documentRef.importNode(page.svgNode, true);
270
+ };
234
271
  const formatTypstError = (error) => {
235
272
  if (Array.isArray(error)) {
236
273
  return error.map(item => {
@@ -465,7 +502,7 @@ export default async function renderTypst(buffer, target, _type, context) {
465
502
  const shell = createElement(documentRef, 'section', 'typst-page-shell');
466
503
  shell.setAttribute('aria-label', `Page ${page.index}`);
467
504
  const content = createElement(documentRef, 'div', 'typst-page-content');
468
- content.innerHTML = page.svg;
505
+ content.replaceChildren(importTypstSvgPageNode(documentRef, page));
469
506
  shell.append(content);
470
507
  pageShells.set(page.index, shell);
471
508
  pagesRoot.append(shell);
@@ -539,7 +576,7 @@ export default async function renderTypst(buffer, target, _type, context) {
539
576
  if (disposed || token !== renderToken) {
540
577
  return;
541
578
  }
542
- pages = parseTypstSvgPages(svg, t('typst.error.svgParseFailed'));
579
+ pages = parseTypstSvgPages(svg, t('typst.error.svgParseFailed'), documentRef);
543
580
  state = 'ready';
544
581
  syncUi();
545
582
  registerExportAdapter();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@file-viewer/renderer-typst",
3
- "version": "2.4.0",
3
+ "version": "3.0.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone Typst renderer plugin for File Viewer powered by browser WASM.",
@@ -54,7 +54,7 @@
54
54
  "LICENSE"
55
55
  ],
56
56
  "dependencies": {
57
- "@file-viewer/core": "2.4.0",
57
+ "@file-viewer/core": "3.0.0",
58
58
  "@myriaddreamin/typst-ts-renderer": "0.7.0",
59
59
  "@myriaddreamin/typst-ts-web-compiler": "0.7.0",
60
60
  "@myriaddreamin/typst.ts": "0.7.0"