omni-viewer-core 0.20.0 → 0.21.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.md +7 -1
- package/dist/i18n/catalog.en.js +6 -0
- package/dist/i18n/catalog.ja.js +3 -0
- package/dist/i18n/catalog.ko.js +3 -0
- package/dist/i18n/catalog.zh-cn.js +3 -0
- package/dist/registry/index.d.ts +2 -1
- package/dist/registry/index.js +6 -1
- package/dist/registry/sniff.d.ts +3 -1
- package/dist/registry/sniff.js +8 -0
- package/dist/styles/html.css +15 -0
- package/dist/viewers/html/index.d.ts +19 -0
- package/dist/viewers/html/index.js +254 -0
- package/dist/viewers/html/styles.d.ts +1 -0
- package/dist/viewers/html/styles.js +16 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ models, viewers mount into a DOM element, and everything host-specific
|
|
|
13
13
|
## Supported formats
|
|
14
14
|
|
|
15
15
|
- **Documents** — PDF, Word (DOCX and legacy DOC), HWP, PowerPoint (PPTX and
|
|
16
|
-
legacy PPT), Markdown, Jupyter Notebook (.ipynb), EPUB e-books (2 and 3),
|
|
16
|
+
legacy PPT), HTML, Markdown, Jupyter Notebook (.ipynb), EPUB e-books (2 and 3),
|
|
17
17
|
LaTeX (structure and math preview, not typesetting)
|
|
18
18
|
- **Data & spreadsheets** — Excel, CSV/TSV, JSON, JSONL/NDJSON, YAML, TOML, XML,
|
|
19
19
|
Parquet, Avro, HDF5, MATLAB MAT, NumPy (NPY/NPZ), Safetensors, GGUF, ONNX, TFLite/LiteRT,
|
|
@@ -28,6 +28,12 @@ models, viewers mount into a DOM element, and everything host-specific
|
|
|
28
28
|
- **Diagrams & GIS** — Mermaid, PlantUML, ESRI Shapefile
|
|
29
29
|
- **Archives** — ZIP-family listing and safe entry preview
|
|
30
30
|
|
|
31
|
+
The HTML viewer handles `.html` and `.htm` files with preview and source tabs.
|
|
32
|
+
The preview runs in a sandboxed iframe with scripts, external resources, and
|
|
33
|
+
navigation removed or blocked; inline styles and embedded data images remain.
|
|
34
|
+
Files over 8 MiB show a bounded source preview. Very dense markup opens in
|
|
35
|
+
source view to keep parsing responsive.
|
|
36
|
+
|
|
31
37
|
## Install
|
|
32
38
|
|
|
33
39
|
```sh
|
package/dist/i18n/catalog.en.js
CHANGED
|
@@ -1221,6 +1221,12 @@ export const CATALOG_EN = {
|
|
|
1221
1221
|
'image.spriteDetection.failed': 'Sprite detection failed.',
|
|
1222
1222
|
'image.saveAsPng': 'Save as PNG',
|
|
1223
1223
|
'image.saveAsPng.note': 'The edited image is saved as PNG (.png).',
|
|
1224
|
+
// HTML viewer
|
|
1225
|
+
'html.title': 'HTML',
|
|
1226
|
+
'html.preview': 'Preview',
|
|
1227
|
+
'html.source': 'Source',
|
|
1228
|
+
'html.tooLarge': 'Preview is unavailable above {limit} bytes. Showing the first part of the source.',
|
|
1229
|
+
'html.tooComplex': 'This document is too complex to preview safely. Showing the source.',
|
|
1224
1230
|
// Fallback viewer
|
|
1225
1231
|
'fallback.title.text': 'Text preview',
|
|
1226
1232
|
'fallback.title.hex': 'Hex preview',
|
package/dist/i18n/catalog.ja.js
CHANGED
|
@@ -1076,6 +1076,9 @@ export const CATALOG_JA = {
|
|
|
1076
1076
|
'image.tool.detectSprites.unavailable': 'この環境ではスプライト検出を利用できません',
|
|
1077
1077
|
'image.spriteDetection.working': 'スプライトを検出中…', 'image.spriteDetection.failed': 'スプライトの検出に失敗しました。',
|
|
1078
1078
|
'image.saveAsPng': 'PNGとして保存', 'image.saveAsPng.note': '編集した画像はPNG(.png)として保存されます。',
|
|
1079
|
+
'html.title': 'HTML', 'html.preview': 'プレビュー', 'html.source': 'ソース',
|
|
1080
|
+
'html.tooLarge': '{limit}バイトを超えるためプレビューできません。ソースの先頭を表示します。',
|
|
1081
|
+
'html.tooComplex': '文書が複雑すぎるため安全にプレビューできません。ソースを表示します。',
|
|
1079
1082
|
'fallback.title.text': 'テキストプレビュー', 'fallback.title.hex': '16進数プレビュー',
|
|
1080
1083
|
'fallback.truncated': 'プレビューは先頭{bytes}バイトまでに切り詰められました。',
|
|
1081
1084
|
'fallback.reason.missing-dependency': 'この環境では完全なビューアーを読み込めませんでした。',
|
package/dist/i18n/catalog.ko.js
CHANGED
|
@@ -1202,6 +1202,9 @@ export const CATALOG_KO = {
|
|
|
1202
1202
|
'image.saveAsPng': 'PNG로 저장',
|
|
1203
1203
|
'image.saveAsPng.note': '편집된 이미지는 PNG(.png)로 저장됩니다.',
|
|
1204
1204
|
// 폴백 뷰어
|
|
1205
|
+
'html.title': 'HTML', 'html.preview': '미리보기', 'html.source': '소스',
|
|
1206
|
+
'html.tooLarge': '{limit}바이트를 초과해 미리보기를 표시할 수 없습니다. 소스의 앞부분을 표시합니다.',
|
|
1207
|
+
'html.tooComplex': '문서가 너무 복잡해 미리보기를 표시할 수 없습니다. 소스를 표시합니다.',
|
|
1205
1208
|
'fallback.title.text': '텍스트 미리보기',
|
|
1206
1209
|
'fallback.title.hex': '16진수 미리보기',
|
|
1207
1210
|
'fallback.truncated': '미리보기가 처음 {bytes}바이트로 잘렸습니다.',
|
|
@@ -1042,6 +1042,9 @@ export const CATALOG_ZH_CN = {
|
|
|
1042
1042
|
'image.tool.detectSprites': '检测精灵图', 'image.tool.detectSprites.unavailable': '此平台无法检测精灵图',
|
|
1043
1043
|
'image.spriteDetection.working': '正在检测精灵图…', 'image.spriteDetection.failed': '精灵图检测失败。',
|
|
1044
1044
|
'image.saveAsPng': '另存为 PNG', 'image.saveAsPng.note': '编辑后的图像将保存为 PNG(.png)。',
|
|
1045
|
+
'html.title': 'HTML', 'html.preview': '预览', 'html.source': '源代码',
|
|
1046
|
+
'html.tooLarge': '文件超过 {limit} 字节,无法预览。正在显示源代码的开头部分。',
|
|
1047
|
+
'html.tooComplex': '文档过于复杂,无法安全预览。正在显示源代码。',
|
|
1045
1048
|
'fallback.title.text': '文本预览', 'fallback.title.hex': '十六进制预览',
|
|
1046
1049
|
'fallback.truncated': '预览已截断,仅显示前 {bytes} 字节。',
|
|
1047
1050
|
'fallback.reason.missing-dependency': '此平台无法加载完整查看器。',
|
package/dist/registry/index.d.ts
CHANGED
|
@@ -124,11 +124,12 @@ export declare const HWP_VIEWER_DESCRIPTOR: ViewerDescriptor;
|
|
|
124
124
|
*/
|
|
125
125
|
export declare const IMAGE_VIEWER_DESCRIPTOR: ViewerDescriptor;
|
|
126
126
|
export declare const MARKDOWN_VIEWER_DESCRIPTOR: ViewerDescriptor;
|
|
127
|
+
export declare const HTML_VIEWER_DESCRIPTOR: ViewerDescriptor;
|
|
127
128
|
export declare const ARCHIVE_MAGIC_SIGNATURES: readonly MagicSignature[];
|
|
128
129
|
/** Archive is intentionally lower priority than Office container viewers. */
|
|
129
130
|
export declare const ARCHIVE_VIEWER_DESCRIPTOR: ViewerDescriptor;
|
|
130
131
|
export declare const CORE_VIEWER_DESCRIPTORS: readonly ViewerDescriptor[];
|
|
131
|
-
export { looksLikeHar, looksLikeNotebook, looksLikeJsonDocument, looksLikeJsonl, looksLikeLatex, looksLikeOpenVinoIr, looksLikeProto, looksLikeXml, sniffTextViewer } from './sniff.js';
|
|
132
|
+
export { looksLikeHar, looksLikeNotebook, looksLikeJsonDocument, looksLikeJsonl, looksLikeHtml, looksLikeLatex, looksLikeOpenVinoIr, looksLikeProto, looksLikeXml, sniffTextViewer } from './sniff.js';
|
|
132
133
|
export { CONTAINER_SNIFF_BYTES, OFFICE_CONTAINER_BY_EXT, OLE_MAGIC, ZIP_MAGIC, sniffContainer, type ContainerKind } from './container.js';
|
|
133
134
|
export { probeContainer } from './probe.js';
|
|
134
135
|
export interface DetectionResult {
|
package/dist/registry/index.js
CHANGED
|
@@ -240,6 +240,10 @@ export const MARKDOWN_VIEWER_DESCRIPTOR = {
|
|
|
240
240
|
extensions: ['md', 'markdown', 'mdown', 'mkdn', 'mkd'], priority: 10,
|
|
241
241
|
requiredServices: [], optionalServices: ['clipboard', 'navigation', 'documentAssets', 'writeback', 'save']
|
|
242
242
|
};
|
|
243
|
+
export const HTML_VIEWER_DESCRIPTOR = {
|
|
244
|
+
id: 'html', displayNameKey: 'html.title', extensions: ['html', 'htm'], priority: 10,
|
|
245
|
+
requiredServices: [], optionalServices: []
|
|
246
|
+
};
|
|
243
247
|
export const ARCHIVE_MAGIC_SIGNATURES = [
|
|
244
248
|
[{ offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] }], [{ offset: 0, bytes: [0x50, 0x4b, 0x05, 0x06] }],
|
|
245
249
|
[{ offset: 257, bytes: [0x75, 0x73, 0x74, 0x61, 0x72] }], [{ offset: 0, bytes: [0x1f, 0x8b] }],
|
|
@@ -294,6 +298,7 @@ export const CORE_VIEWER_DESCRIPTORS = [
|
|
|
294
298
|
EXCEL_VIEWER_DESCRIPTOR,
|
|
295
299
|
IMAGE_VIEWER_DESCRIPTOR,
|
|
296
300
|
MARKDOWN_VIEWER_DESCRIPTOR,
|
|
301
|
+
HTML_VIEWER_DESCRIPTOR,
|
|
297
302
|
NOTEBOOK_VIEWER_DESCRIPTOR,
|
|
298
303
|
ARCHIVE_VIEWER_DESCRIPTOR,
|
|
299
304
|
CSV_VIEWER_DESCRIPTOR,
|
|
@@ -306,7 +311,7 @@ export const CORE_VIEWER_DESCRIPTORS = [
|
|
|
306
311
|
LATEX_VIEWER_DESCRIPTOR,
|
|
307
312
|
FALLBACK_VIEWER_DESCRIPTOR
|
|
308
313
|
];
|
|
309
|
-
export { looksLikeHar, looksLikeNotebook, looksLikeJsonDocument, looksLikeJsonl, looksLikeLatex, looksLikeOpenVinoIr, looksLikeProto, looksLikeXml, sniffTextViewer } from './sniff.js';
|
|
314
|
+
export { looksLikeHar, looksLikeNotebook, looksLikeJsonDocument, looksLikeJsonl, looksLikeHtml, looksLikeLatex, looksLikeOpenVinoIr, looksLikeProto, looksLikeXml, sniffTextViewer } from './sniff.js';
|
|
310
315
|
export { CONTAINER_SNIFF_BYTES, OFFICE_CONTAINER_BY_EXT, OLE_MAGIC, ZIP_MAGIC, sniffContainer } from './container.js';
|
|
311
316
|
export { probeContainer } from './probe.js';
|
|
312
317
|
/**
|
package/dist/registry/sniff.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export declare function looksLikeProto(text: string): boolean;
|
|
|
28
28
|
* plain TeX files the viewer cannot read structurally (docs/viewers/latex.md §1).
|
|
29
29
|
*/
|
|
30
30
|
export declare function looksLikeLatex(text: string): boolean;
|
|
31
|
+
/** Claim only recognizable HTML documents; fragments need a file extension. */
|
|
32
|
+
export declare function looksLikeHtml(text: string): boolean;
|
|
31
33
|
/**
|
|
32
34
|
* True if `text` starts like an XML document: after a BOM, whitespace, the
|
|
33
35
|
* declaration, comments, processing instructions and a DOCTYPE comes a start
|
|
@@ -43,4 +45,4 @@ export declare function looksLikeXml(text: string): boolean;
|
|
|
43
45
|
* multi-line object streams so a `{…}\n{…}` file is never mis-claimed as JSON;
|
|
44
46
|
* if no jsonl viewer is registered the caller falls through to fallback.
|
|
45
47
|
*/
|
|
46
|
-
export declare function sniffTextViewer(text: string): 'jsonl' | 'har' | 'notebook' | 'json' | 'openvino' | 'xml' | 'proto' | 'latex' | null;
|
|
48
|
+
export declare function sniffTextViewer(text: string): 'jsonl' | 'har' | 'notebook' | 'json' | 'html' | 'openvino' | 'xml' | 'proto' | 'latex' | null;
|
package/dist/registry/sniff.js
CHANGED
|
@@ -86,6 +86,12 @@ export function looksLikeLatex(text) {
|
|
|
86
86
|
const sample = text.slice(0, 64 * 1024).replace(/(^|[^\\])%.*$/gm, '$1');
|
|
87
87
|
return /(^|[^\\])\\documentclass\s*(\[[^\]]*\])?\s*\{[^}]+\}/.test(sample);
|
|
88
88
|
}
|
|
89
|
+
/** Claim only recognizable HTML documents; fragments need a file extension. */
|
|
90
|
+
export function looksLikeHtml(text) {
|
|
91
|
+
const sample = text.slice(0, 64 * 1024).replace(/^\uFEFF?\s*/, '');
|
|
92
|
+
const start = sample.replace(/^(?:<!--[\s\S]*?-->\s*)*/, '');
|
|
93
|
+
return /^<!doctype\s+html(?:\s|>|\[)/i.test(start) || /^<html(?:\s|>)/i.test(start);
|
|
94
|
+
}
|
|
89
95
|
/**
|
|
90
96
|
* True if `text` starts like an XML document: after a BOM, whitespace, the
|
|
91
97
|
* declaration, comments, processing instructions and a DOCTYPE comes a start
|
|
@@ -158,6 +164,8 @@ export function sniffTextViewer(text) {
|
|
|
158
164
|
return 'notebook';
|
|
159
165
|
if (looksLikeJsonDocument(text))
|
|
160
166
|
return 'json';
|
|
167
|
+
if (looksLikeHtml(text))
|
|
168
|
+
return 'html';
|
|
161
169
|
// An OpenVINO IR is `.xml`, an extension no viewer claims, so its `<net
|
|
162
170
|
// version><layers>` root is the only route to the viewer.
|
|
163
171
|
if (looksLikeOpenVinoIr(text))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
|
|
2
|
+
.omni-viewer--html{all:initial}
|
|
3
|
+
:host,.omni-viewer--html{display:block;height:100%;box-sizing:border-box;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:var(--omni-font-size,13px)/1.4 var(--omni-font,system-ui,sans-serif)}
|
|
4
|
+
.omni-html,.omni-html *{box-sizing:border-box}
|
|
5
|
+
.omni-html{display:flex;flex-direction:column;height:100%;min-height:0}
|
|
6
|
+
.omni-html__header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-bottom:1px solid var(--omni-border,#3c3c3c)}
|
|
7
|
+
.omni-html__title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
8
|
+
.omni-html__tabs{display:flex;gap:4px;flex:none}
|
|
9
|
+
.omni-html__tabs button{font:inherit;color:inherit;background:transparent;border:1px solid var(--omni-border,#3c3c3c);border-radius:4px;padding:4px 10px;cursor:pointer}
|
|
10
|
+
.omni-html__tabs button[aria-pressed=true]{background:var(--omni-accent,#0e639c);color:var(--omni-accent-fg,#fff)}
|
|
11
|
+
.omni-html__tabs button:disabled{opacity:.5;cursor:default}
|
|
12
|
+
.omni-html__preview{display:block;flex:1;min-height:0;width:100%;border:0;background:#fff}
|
|
13
|
+
.omni-html__source{flex:1;min-height:0;overflow:auto;margin:0;padding:12px;font:13px/1.5 var(--omni-font-mono,monospace);white-space:pre;tab-size:4}
|
|
14
|
+
.omni-html__note{margin:0;padding:8px 12px;color:var(--omni-warning-fg,#cca700);border-bottom:1px solid var(--omni-border,#3c3c3c)}
|
|
15
|
+
.omni-html [hidden]{display:none!important}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HostContext } from '../../host/index.js';
|
|
2
|
+
import { type MountOptions, type ViewerHandle, type ViewerInput } from '../types.js';
|
|
3
|
+
export { htmlViewerCss } from './styles.js';
|
|
4
|
+
export declare const HTML_VIEWER_META: {
|
|
5
|
+
id: string;
|
|
6
|
+
displayNameKey: string;
|
|
7
|
+
extensions: string[];
|
|
8
|
+
priority: number;
|
|
9
|
+
requiredServices: readonly [];
|
|
10
|
+
optionalServices: readonly [];
|
|
11
|
+
inputOwnership: "borrows";
|
|
12
|
+
};
|
|
13
|
+
/** Avoid decoding and embedding an unbounded document in both the source pane and iframe. */
|
|
14
|
+
export declare const HTML_PREVIEW_MAX_BYTES: number;
|
|
15
|
+
export declare const HTML_PREVIEW_MAX_DELIMITERS = 20000;
|
|
16
|
+
/** Honor a BOM or an early HTML charset declaration before decoding bytes. */
|
|
17
|
+
export declare function decodeHtmlText(data: Uint8Array): string;
|
|
18
|
+
export declare function htmlPreviewDocument(source: string): string;
|
|
19
|
+
export declare function mountHtmlViewer(input: ViewerInput, container: HTMLElement, ctx: HostContext, options?: MountOptions): Promise<ViewerHandle>;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { MountAbortedError, VIEWER_ROOT_CLASS } from '../types.js';
|
|
2
|
+
import { htmlViewerCss } from './styles.js';
|
|
3
|
+
export { htmlViewerCss } from './styles.js';
|
|
4
|
+
export const HTML_VIEWER_META = {
|
|
5
|
+
id: 'html', displayNameKey: 'html.title', extensions: ['html', 'htm'], priority: 10,
|
|
6
|
+
requiredServices: [], optionalServices: [],
|
|
7
|
+
inputOwnership: 'borrows'
|
|
8
|
+
};
|
|
9
|
+
/** Avoid decoding and embedding an unbounded document in both the source pane and iframe. */
|
|
10
|
+
export const HTML_PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
export const HTML_PREVIEW_MAX_DELIMITERS = 20_000;
|
|
12
|
+
const SOURCE_HEAD_BYTES = 256 * 1024;
|
|
13
|
+
function hasExcessiveMarkup(source) {
|
|
14
|
+
// Every parsed element needs source markup. Count delimiters before any
|
|
15
|
+
// DOM parsing so dense, small files cannot create an unbounded DOM tree.
|
|
16
|
+
let count = 0;
|
|
17
|
+
for (let index = source.indexOf('<'); index !== -1; index = source.indexOf('<', index + 1)) {
|
|
18
|
+
if (++count > HTML_PREVIEW_MAX_DELIMITERS)
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
// The iframe has an opaque origin and no sandbox permissions. This policy is
|
|
24
|
+
// deliberately inside srcdoc as well: host CSPs cannot be relied on to protect
|
|
25
|
+
// the frame in every adapter. Inline CSS and embedded images remain usable.
|
|
26
|
+
const PREVIEW_CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'; navigate-to 'none'";
|
|
27
|
+
function tagEnd(source, from) {
|
|
28
|
+
let quote = '';
|
|
29
|
+
for (let index = from; index < source.length; index++) {
|
|
30
|
+
const char = source[index];
|
|
31
|
+
if (quote) {
|
|
32
|
+
if (char === quote)
|
|
33
|
+
quote = '';
|
|
34
|
+
}
|
|
35
|
+
else if (char === '"' || char === "'")
|
|
36
|
+
quote = char;
|
|
37
|
+
else if (char === '>')
|
|
38
|
+
return index;
|
|
39
|
+
}
|
|
40
|
+
return -1;
|
|
41
|
+
}
|
|
42
|
+
/** Scan opening tags without mistaking comments, attributes, or raw text for markup. */
|
|
43
|
+
function* openingTags(source) {
|
|
44
|
+
let index = 0;
|
|
45
|
+
let templateDepth = 0;
|
|
46
|
+
while (index < source.length) {
|
|
47
|
+
const start = source.indexOf('<', index);
|
|
48
|
+
if (start < 0)
|
|
49
|
+
return;
|
|
50
|
+
if (source.startsWith('<!--', start)) {
|
|
51
|
+
const end = source.indexOf('-->', start + 4);
|
|
52
|
+
index = end < 0 ? source.length : end + 3;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const templateEnd = /^<\/template(?=[\s/>])/i.exec(source.slice(start, start + 32));
|
|
56
|
+
if (templateEnd && templateDepth > 0) {
|
|
57
|
+
const end = tagEnd(source, start + templateEnd[0].length);
|
|
58
|
+
if (end < 0)
|
|
59
|
+
return;
|
|
60
|
+
templateDepth--;
|
|
61
|
+
index = end + 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const found = /^<([a-z][\w:-]*)\b/i.exec(source.slice(start, start + 64));
|
|
65
|
+
if (!found) {
|
|
66
|
+
index = start + 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const name = found[1].toLowerCase();
|
|
70
|
+
const end = tagEnd(source, start + found[0].length);
|
|
71
|
+
if (end < 0)
|
|
72
|
+
return;
|
|
73
|
+
index = end + 1;
|
|
74
|
+
if (name === 'template') {
|
|
75
|
+
templateDepth++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (templateDepth === 0)
|
|
79
|
+
yield { name, attributes: source.slice(start + found[0].length, end) };
|
|
80
|
+
if (['script', 'style', 'textarea', 'title', 'xmp', 'noscript', 'noembed', 'noframes'].includes(name)) {
|
|
81
|
+
const closing = new RegExp(`</${name}(?=[\\s/>])`, 'ig');
|
|
82
|
+
closing.lastIndex = index;
|
|
83
|
+
const match = closing.exec(source);
|
|
84
|
+
const closeEnd = match ? tagEnd(source, closing.lastIndex) : -1;
|
|
85
|
+
index = closeEnd < 0 ? source.length : closeEnd + 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function safeRootAttributes(source, tag) {
|
|
90
|
+
// Parsing a complete document in a template drops its root elements. Read
|
|
91
|
+
// their opening tags separately so selectors like body.page still work.
|
|
92
|
+
let match;
|
|
93
|
+
for (const candidate of openingTags(source)) {
|
|
94
|
+
if (candidate.name === tag) {
|
|
95
|
+
match = candidate;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!match)
|
|
100
|
+
return '';
|
|
101
|
+
const holder = document.createElement('template');
|
|
102
|
+
holder.innerHTML = `<div${match.attributes}></div>`;
|
|
103
|
+
const element = holder.content.firstElementChild;
|
|
104
|
+
if (!element)
|
|
105
|
+
return '';
|
|
106
|
+
return [...element.attributes]
|
|
107
|
+
.filter(({ name }) => ['class', 'id', 'lang', 'dir', 'style'].includes(name) || name.startsWith('data-'))
|
|
108
|
+
.map(({ name, value }) => ` ${name}="${value.replace(/&/g, '&').replace(/"/g, '"')}\"`)
|
|
109
|
+
.join('');
|
|
110
|
+
}
|
|
111
|
+
/** Honor a BOM or an early HTML charset declaration before decoding bytes. */
|
|
112
|
+
export function decodeHtmlText(data) {
|
|
113
|
+
let encoding = 'utf-8';
|
|
114
|
+
if (data[0] === 0xff && data[1] === 0xfe)
|
|
115
|
+
encoding = 'utf-16le';
|
|
116
|
+
else if (data[0] === 0xfe && data[1] === 0xff)
|
|
117
|
+
encoding = 'utf-16be';
|
|
118
|
+
else if (!(data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf)) {
|
|
119
|
+
const head = Array.from(data.subarray(0, 1024), byte => String.fromCharCode(byte)).join('');
|
|
120
|
+
for (const tag of openingTags(head)) {
|
|
121
|
+
if (tag.name !== 'meta')
|
|
122
|
+
continue;
|
|
123
|
+
const holder = document.createElement('template');
|
|
124
|
+
holder.innerHTML = `<meta${tag.attributes}>`;
|
|
125
|
+
const meta = holder.content.firstElementChild;
|
|
126
|
+
const contentCharset = /charset\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s;]+))/i.exec(meta?.getAttribute('content') ?? '');
|
|
127
|
+
const declared = meta?.getAttribute('charset') ??
|
|
128
|
+
(meta?.getAttribute('http-equiv')?.toLowerCase() === 'content-type'
|
|
129
|
+
? contentCharset?.[1] ?? contentCharset?.[2] ?? contentCharset?.[3]
|
|
130
|
+
: undefined);
|
|
131
|
+
if (declared) {
|
|
132
|
+
encoding = declared;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return new TextDecoder(encoding).decode(data);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return new TextDecoder('utf-8').decode(data);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function htmlPreviewDocument(source) {
|
|
145
|
+
// Template contents are inert and do not load resources while being parsed.
|
|
146
|
+
// Strip navigation and resource attributes too: frame self-navigation is
|
|
147
|
+
// possible in some engines even with an empty sandbox and navigate-to CSP.
|
|
148
|
+
const template = document.createElement('template');
|
|
149
|
+
template.innerHTML = source;
|
|
150
|
+
// <noscript> changes parsing mode when the sandbox disables scripts: its
|
|
151
|
+
// text can become active markup in the iframe, so remove the whole node.
|
|
152
|
+
// Nested templates can become declarative shadow roots when reparsed in
|
|
153
|
+
// srcdoc, and their children are invisible to querySelectorAll here.
|
|
154
|
+
template.content.querySelectorAll('script,noscript,template,base,link,meta,iframe,frame,frameset,object,embed,form,portal').forEach(node => node.remove());
|
|
155
|
+
template.content.querySelectorAll('*').forEach(node => {
|
|
156
|
+
for (const attribute of [...node.attributes]) {
|
|
157
|
+
const name = attribute.name.toLowerCase();
|
|
158
|
+
const value = attribute.value.trim();
|
|
159
|
+
if (name.startsWith('on') || ['srcdoc', 'srcset', 'ping', 'action', 'formaction', 'poster', 'background'].includes(name)) {
|
|
160
|
+
node.removeAttributeNode(attribute);
|
|
161
|
+
}
|
|
162
|
+
else if (name === 'href' || name === 'xlink:href') {
|
|
163
|
+
if (!value.startsWith('#'))
|
|
164
|
+
node.removeAttributeNode(attribute);
|
|
165
|
+
}
|
|
166
|
+
else if (name === 'src') {
|
|
167
|
+
if (node.localName !== 'img' || !/^data:image\/(?:png|jpeg|gif|webp|svg\+xml)(?:;[^,]*)?,/i.test(value))
|
|
168
|
+
node.removeAttributeNode(attribute);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
// The policy is before all untrusted content, including malformed heads.
|
|
173
|
+
return `<!doctype html><html${safeRootAttributes(source, 'html')}><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}"></head><body${safeRootAttributes(source, 'body')}>${template.innerHTML}</body></html>`;
|
|
174
|
+
}
|
|
175
|
+
export async function mountHtmlViewer(input, container, ctx, options = {}) {
|
|
176
|
+
if (options.signal?.aborted)
|
|
177
|
+
throw new MountAbortedError();
|
|
178
|
+
const isolation = options.styleIsolation ?? 'shadow';
|
|
179
|
+
let root;
|
|
180
|
+
if (isolation === 'shadow' && typeof container.attachShadow === 'function') {
|
|
181
|
+
root = container.shadowRoot ?? container.attachShadow({ mode: 'open' });
|
|
182
|
+
const style = document.createElement('style');
|
|
183
|
+
style.textContent = htmlViewerCss;
|
|
184
|
+
root.append(style);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
container.classList.add(VIEWER_ROOT_CLASS, 'omni-viewer--html');
|
|
188
|
+
root = container;
|
|
189
|
+
}
|
|
190
|
+
const t = ctx.i18n.t.bind(ctx.i18n);
|
|
191
|
+
const frame = document.createElement('section');
|
|
192
|
+
frame.className = 'omni-html';
|
|
193
|
+
const header = document.createElement('header');
|
|
194
|
+
header.className = 'omni-html__header';
|
|
195
|
+
const title = document.createElement('strong');
|
|
196
|
+
title.className = 'omni-html__title';
|
|
197
|
+
title.textContent = input.fileName;
|
|
198
|
+
header.append(title);
|
|
199
|
+
const tabs = document.createElement('div');
|
|
200
|
+
tabs.className = 'omni-html__tabs';
|
|
201
|
+
const previewButton = document.createElement('button');
|
|
202
|
+
previewButton.type = 'button';
|
|
203
|
+
previewButton.textContent = t('html.preview');
|
|
204
|
+
const sourceButton = document.createElement('button');
|
|
205
|
+
sourceButton.type = 'button';
|
|
206
|
+
sourceButton.textContent = t('html.source');
|
|
207
|
+
tabs.append(previewButton, sourceButton);
|
|
208
|
+
header.append(tabs);
|
|
209
|
+
frame.append(header);
|
|
210
|
+
const tooLarge = input.data.byteLength > HTML_PREVIEW_MAX_BYTES;
|
|
211
|
+
const bytes = tooLarge ? input.data.subarray(0, SOURCE_HEAD_BYTES) : input.data;
|
|
212
|
+
const source = decodeHtmlText(bytes);
|
|
213
|
+
const tooComplex = !tooLarge && hasExcessiveMarkup(source);
|
|
214
|
+
const sourceOnly = tooLarge || tooComplex;
|
|
215
|
+
const preview = document.createElement('iframe');
|
|
216
|
+
preview.className = 'omni-html__preview';
|
|
217
|
+
preview.title = t('html.preview');
|
|
218
|
+
preview.setAttribute('sandbox', '');
|
|
219
|
+
preview.referrerPolicy = 'no-referrer';
|
|
220
|
+
// An oversized document is source-only, avoiding partial active markup.
|
|
221
|
+
if (!sourceOnly)
|
|
222
|
+
preview.srcdoc = htmlPreviewDocument(source);
|
|
223
|
+
const sourcePane = document.createElement('pre');
|
|
224
|
+
sourcePane.className = 'omni-html__source';
|
|
225
|
+
sourcePane.textContent = source;
|
|
226
|
+
if (sourceOnly) {
|
|
227
|
+
const note = document.createElement('p');
|
|
228
|
+
note.className = 'omni-html__note';
|
|
229
|
+
note.textContent = tooLarge
|
|
230
|
+
? t('html.tooLarge', { limit: HTML_PREVIEW_MAX_BYTES })
|
|
231
|
+
: t('html.tooComplex');
|
|
232
|
+
frame.append(note);
|
|
233
|
+
}
|
|
234
|
+
frame.append(preview, sourcePane);
|
|
235
|
+
root.append(frame);
|
|
236
|
+
const setView = (view) => {
|
|
237
|
+
preview.hidden = view !== 'preview';
|
|
238
|
+
sourcePane.hidden = view !== 'source';
|
|
239
|
+
previewButton.setAttribute('aria-pressed', String(view === 'preview'));
|
|
240
|
+
sourceButton.setAttribute('aria-pressed', String(view === 'source'));
|
|
241
|
+
};
|
|
242
|
+
previewButton.disabled = sourceOnly;
|
|
243
|
+
previewButton.addEventListener('click', () => setView('preview'));
|
|
244
|
+
sourceButton.addEventListener('click', () => setView('source'));
|
|
245
|
+
setView(sourceOnly ? 'source' : 'preview');
|
|
246
|
+
return {
|
|
247
|
+
dispose() {
|
|
248
|
+
preview.removeAttribute('srcdoc');
|
|
249
|
+
root.replaceChildren();
|
|
250
|
+
if (root === container)
|
|
251
|
+
container.classList.remove(VIEWER_ROOT_CLASS, 'omni-viewer--html');
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const htmlViewerCss = "\n.omni-viewer--html{all:initial}\n:host,.omni-viewer--html{display:block;height:100%;box-sizing:border-box;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:var(--omni-font-size,13px)/1.4 var(--omni-font,system-ui,sans-serif)}\n.omni-html,.omni-html *{box-sizing:border-box}\n.omni-html{display:flex;flex-direction:column;height:100%;min-height:0}\n.omni-html__header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-bottom:1px solid var(--omni-border,#3c3c3c)}\n.omni-html__title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.omni-html__tabs{display:flex;gap:4px;flex:none}\n.omni-html__tabs button{font:inherit;color:inherit;background:transparent;border:1px solid var(--omni-border,#3c3c3c);border-radius:4px;padding:4px 10px;cursor:pointer}\n.omni-html__tabs button[aria-pressed=true]{background:var(--omni-accent,#0e639c);color:var(--omni-accent-fg,#fff)}\n.omni-html__tabs button:disabled{opacity:.5;cursor:default}\n.omni-html__preview{display:block;flex:1;min-height:0;width:100%;border:0;background:#fff}\n.omni-html__source{flex:1;min-height:0;overflow:auto;margin:0;padding:12px;font:13px/1.5 var(--omni-font-mono,monospace);white-space:pre;tab-size:4}\n.omni-html__note{margin:0;padding:8px 12px;color:var(--omni-warning-fg,#cca700);border-bottom:1px solid var(--omni-border,#3c3c3c)}\n.omni-html [hidden]{display:none!important}\n";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const htmlViewerCss = `
|
|
2
|
+
.omni-viewer--html{all:initial}
|
|
3
|
+
:host,.omni-viewer--html{display:block;height:100%;box-sizing:border-box;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:var(--omni-font-size,13px)/1.4 var(--omni-font,system-ui,sans-serif)}
|
|
4
|
+
.omni-html,.omni-html *{box-sizing:border-box}
|
|
5
|
+
.omni-html{display:flex;flex-direction:column;height:100%;min-height:0}
|
|
6
|
+
.omni-html__header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-bottom:1px solid var(--omni-border,#3c3c3c)}
|
|
7
|
+
.omni-html__title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
8
|
+
.omni-html__tabs{display:flex;gap:4px;flex:none}
|
|
9
|
+
.omni-html__tabs button{font:inherit;color:inherit;background:transparent;border:1px solid var(--omni-border,#3c3c3c);border-radius:4px;padding:4px 10px;cursor:pointer}
|
|
10
|
+
.omni-html__tabs button[aria-pressed=true]{background:var(--omni-accent,#0e639c);color:var(--omni-accent-fg,#fff)}
|
|
11
|
+
.omni-html__tabs button:disabled{opacity:.5;cursor:default}
|
|
12
|
+
.omni-html__preview{display:block;flex:1;min-height:0;width:100%;border:0;background:#fff}
|
|
13
|
+
.omni-html__source{flex:1;min-height:0;overflow:auto;margin:0;padding:12px;font:13px/1.5 var(--omni-font-mono,monospace);white-space:pre;tab-size:4}
|
|
14
|
+
.omni-html__note{margin:0;padding:8px 12px;color:var(--omni-warning-fg,#cca700);border-bottom:1px solid var(--omni-border,#3c3c3c)}
|
|
15
|
+
.omni-html [hidden]{display:none!important}
|
|
16
|
+
`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-viewer-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Shared parsing + rendering core for Omni Viewer (vscode / chrome / obsidian / web)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -399,6 +399,10 @@
|
|
|
399
399
|
"types": "./dist/viewers/fallback/index.d.ts",
|
|
400
400
|
"default": "./dist/viewers/fallback/index.js"
|
|
401
401
|
},
|
|
402
|
+
"./viewers/html": {
|
|
403
|
+
"types": "./dist/viewers/html/index.d.ts",
|
|
404
|
+
"default": "./dist/viewers/html/index.js"
|
|
405
|
+
},
|
|
402
406
|
"./viewers/pdf": {
|
|
403
407
|
"types": "./dist/viewers/pdf/index.d.ts",
|
|
404
408
|
"default": "./dist/viewers/pdf/index.js"
|