@file-viewer/renderer-typst 2.4.0 → 3.0.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/README.en.md +4 -0
- package/README.md +4 -0
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +104 -0
- package/dist/typst.d.ts +8 -1
- package/dist/typst.js +65 -28
- package/package.json +2 -2
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;
|
package/dist/sanitize.js
ADDED
|
@@ -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
|
-
|
|
189
|
-
const
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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.
|
|
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": "
|
|
3
|
+
"version": "3.0.1",
|
|
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": "
|
|
57
|
+
"@file-viewer/core": "3.0.1",
|
|
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"
|