返回 oh-my-ppt
html-editor-import.ts
根目录 / src / main / html-editor / html-editor-import.ts
1 import { pathToFileURL } from 'node:url'
2 import path from 'node:path'
3 import * as cheerio from 'cheerio'
4 import type { AnyNode } from 'domhandler'
5
6 /**
7 * 独立 HTML 编辑器(/edit-html)的导入归一化纯函数。
8 *
9 * 与 session-edit 完全解耦:不读 DB、不写 git、不碰 session 项目目录。
10 * 仅做两件事:
11 * 1. 轻量包裹成 slide 脚手架(`main.ppt-page-root[data-ppt-guard-root]` + `.ppt-page-fit-scope`),
12 * 只固定设计宽度、不限定高度(document/滚动模式,支持长页)。
13 * 2. 把相对资源引用改写为指向源文件目录的 `file://` URL,使工作文件 webview 仍能加载同目录资源。
14 *
15 * 全部为纯函数(html in → html out),便于单测。
16 */
17
18 export const DEFAULT_DESIGN_WIDTH = 1280
19
20 const PROTOCOL_RE = /^(https?:|data:|blob:|file:|mailto:|tel:|javascript:|#|\/\/)/i
21 const EXTERNAL_MEDIA_PROTOCOLS = ['http:', 'https:']
22
23 /** 是否已含 slide 脚手架(`main.ppt-page-root[data-ppt-guard-root]`)。 */
24 export function hasSlideScaffold(html: string): boolean {
25 const $ = cheerio.load(html, { scriptingEnabled: false })
26 return $('main.ppt-page-root[data-ppt-guard-root]').length > 0
27 }
28
29 /**
30 * 把一个相对 URL 解析为指向 `sourceDir` 的 `file://` URL。
31 * 已是协议/片段/绝对路径/`..` 逃逸的,返回 null(保持原样)。
32 */
33 function resolveRelativeUrl(url: string, sourceDir: string): string | null {
34 const trimmed = url.trim()
35 if (!trimmed) return null
36 if (PROTOCOL_RE.test(trimmed)) return null
37 if (trimmed.startsWith('/')) return null // 绝对路径:v1 不改写
38 // 分离 fragment / query,避免 pathToFileURL 把 # 编码成 %23
39 let base = trimmed.replace(/^\.\//, '')
40 let frag = ''
41 const hashIdx = base.indexOf('#')
42 if (hashIdx >= 0) {
43 frag = base.slice(hashIdx)
44 base = base.slice(0, hashIdx)
45 }
46 let query = ''
47 const qIdx = base.indexOf('?')
48 if (qIdx >= 0) {
49 query = base.slice(qIdx)
50 base = base.slice(0, qIdx)
51 }
52 if (!base) return null
53 const resolved = path.resolve(sourceDir, base)
54 const rel = path.relative(sourceDir, resolved)
55 if (rel.startsWith('..') || path.isAbsolute(rel)) return null // 逃逸出 sourceDir:不改写
56 return pathToFileURL(resolved).href + query + frag
57 }
58
59 function rewriteCssUrls(css: string, sourceDir: string): string {
60 return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (full, _quote, url) => {
61 const r = resolveRelativeUrl(url, sourceDir)
62 return r ? `url("${r}")` : full
63 })
64 }
65
66 function injectRuntimeScripts($: cheerio.CheerioAPI, hrefs: string[]): void {
67 if (hrefs.length === 0) return
68 if ($('head').length === 0) $('<head></head>').prependTo('html')
69 const existing = new Set(
70 $('script[src]')
71 .toArray()
72 .map((el) => {
73 const src = String($(el).attr('src') || '')
74 const clean = src.split(/[?#]/, 1)[0]
75 if (/^(?:https?:|\/\/)/i.test(clean)) return ''
76 return clean.replace(/\\/g, '/').split('/').pop() || ''
77 })
78 )
79 for (const href of hrefs) {
80 const fileName = href.replace(/\\/g, '/').split(/[?#]/, 1)[0].split('/').pop() || ''
81 if (!fileName || existing.has(fileName)) continue
82 $('head').append($('<script></script>').attr('src', href))
83 existing.add(fileName)
84 }
85 }
86
87 /**
88 * 编辑器中的文档由 webview 单独加载,不继承应用壳层的 CSP。导入页若自行限制
89 * img-src/media-src,会让用户新增的外链媒体无法加载;仅放开这两类资源,不改脚本策略。
90 */
91 function allowExternalMediaInDocumentCsp($: cheerio.CheerioAPI): void {
92 $('meta[http-equiv]').each((_, el) => {
93 const node = $(el)
94 if ((node.attr('http-equiv') || '').trim().toLowerCase() !== 'content-security-policy') return
95 const content = (node.attr('content') || '').trim()
96 if (!content) return
97
98 const directives = content
99 .split(';')
100 .map((rawDirective) => rawDirective.trim())
101 .filter(Boolean)
102 .map((directive) => {
103 const [name = '', ...values] = directive.split(/\s+/)
104 const normalizedName = name.toLowerCase()
105 if (normalizedName !== 'img-src' && normalizedName !== 'media-src') return directive
106 const allowedValues = values.filter((value) => value !== "'none'")
107 for (const protocol of EXTERNAL_MEDIA_PROTOCOLS) {
108 if (!allowedValues.includes(protocol)) allowedValues.push(protocol)
109 }
110 return [name, ...allowedValues].join(' ')
111 })
112
113 const hasDirective = (name: 'img-src' | 'media-src'): boolean =>
114 directives.some((directive) => directive.split(/\s+/, 1)[0]?.toLowerCase() === name)
115 for (const name of ['img-src', 'media-src'] as const) {
116 if (!hasDirective(name)) {
117 directives.push(`${name} 'self' data: local-asset: file: http: https:`)
118 }
119 }
120
121 node.attr('content', directives.join('; '))
122 })
123 }
124
125 /**
126 * 改写 HTML 内的相对资源引用为指向 `sourceDir` 的 `file://` URL。
127 * 覆盖:`src`/`href`/`poster`/`xlink:href`/`srcset`、行内 `style` 与 `<style>` 内的 `url(...)`。
128 * 保留 `http(s)`/`data`/`blob`/`file`/片段原样;`..` 逃逸与绝对路径不改写。
129 */
130 export function rewriteRelativeAssetsToSource(input: { html: string; sourceDir: string }): string {
131 const { html, sourceDir } = input
132 const $ = cheerio.load(html, { scriptingEnabled: false })
133
134 const rewriteAttr = (node: cheerio.Cheerio<AnyNode>, attr: string): void => {
135 const v = node.attr(attr)
136 if (!v) return
137 const r = resolveRelativeUrl(v, sourceDir)
138 if (r) node.attr(attr, r)
139 }
140
141 $('img,script,video,source,audio,embed,track,link,use,image').each((_, el) => {
142 const node = $(el)
143 rewriteAttr(node, 'src')
144 rewriteAttr(node, 'href')
145 rewriteAttr(node, 'poster')
146 rewriteAttr(node, 'xlink:href')
147 const ss = node.attr('srcset')
148 if (ss) {
149 const rewritten = ss
150 .split(',')
151 .map((part) => {
152 const seg = part.trim()
153 if (!seg) return seg
154 const [url, ...desc] = seg.split(/\s+/)
155 const r = resolveRelativeUrl(url, sourceDir)
156 return r ? [r, ...desc].join(' ') : seg
157 })
158 .join(', ')
159 node.attr('srcset', rewritten)
160 }
161 })
162
163 $('*[style]').each((_, el) => {
164 const node = $(el)
165 const s = node.attr('style')
166 if (s && s.includes('url(')) node.attr('style', rewriteCssUrls(s, sourceDir))
167 })
168
169 $('style').each((_, el) => {
170 const node = $(el)
171 const css = node.html()
172 if (css && css.includes('url(')) node.html(rewriteCssUrls(css, sourceDir))
173 })
174
175 return $.html()
176 }
177
178 /**
179 * 归一化导入的 HTML:
180 * - 确保有 `main.ppt-page-root[data-ppt-guard-root]` + `.ppt-page-fit-scope`(presentation editor runtime 依赖);
181 * - 只设 `data-ppt-width`(designWidth),不设高度——document/滚动模式;
182 * - 确保 `body[data-page-id=docId]`;补齐图表运行时;保留原 `<head>`;
183 * - 相对资源改写为 `file://`。
184 */
185 export function normalizeImportedHtml(input: {
186 html: string
187 sourceDir: string
188 docId: string
189 defaultDesignWidth?: number
190 /** 注入 <head> 的运行时样式表 file:// URL。 */
191 runtimeStyleHrefs?: string[]
192 /** 注入 <head> 的运行时脚本 file:// URL(Chart.js、PPT runtime)。 */
193 runtimeScriptHrefs?: string[]
194 }): { html: string; designWidth: number; title: string } {
195 const { html, sourceDir, docId } = input
196 const defaultWidth = input.defaultDesignWidth ?? DEFAULT_DESIGN_WIDTH
197 const $ = cheerio.load(html, { scriptingEnabled: false })
198
199 // 注入运行时样式
200 const styles = input.runtimeStyleHrefs ?? []
201 if (styles.length > 0) {
202 if ($('head').length === 0) $('<head></head>').prependTo('html')
203 for (const href of styles) {
204 $('head').append(`<link rel="stylesheet" href="${href}">`)
205 }
206 }
207 injectRuntimeScripts($, input.runtimeScriptHrefs ?? [])
208
209 const title = $('title').first().text().trim() || ''
210 let designWidth = defaultWidth
211
212 const existing = $('main.ppt-page-root[data-ppt-guard-root]').first()
213 if (existing.length > 0) {
214 const w = parseInt(existing.attr('data-ppt-width') || '', 10)
215 if (Number.isFinite(w) && w > 0) designWidth = w
216 existing.removeAttr('data-ppt-height') // 不限定高度
217 $('body').attr('data-page-id', docId)
218 } else {
219 const bodyInner = $('body').html() ?? ''
220 $('body').empty()
221 $('body').attr('data-page-id', docId)
222 $('body').append(
223 `<main class="ppt-page-root" data-ppt-guard-root="1" data-ppt-width="${designWidth}"><div class="ppt-page-fit-scope">${bodyInner}</div></main>`
224 )
225 }
226
227 // 确保 fit-scope 存在
228 if ($('.ppt-page-fit-scope').length === 0) {
229 const main = $('main.ppt-page-root[data-ppt-guard-root]').first()
230 const inner = main.html()
231 main.empty().append(`<div class="ppt-page-fit-scope">${inner}</div>`)
232 }
233
234 allowExternalMediaInDocumentCsp($)
235
236 let out = $.html()
237 out = rewriteRelativeAssetsToSource({ html: out, sourceDir })
238 return { html: out, designWidth, title }
239 }
240
240 lines TYPESCRIPT