| 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 |