返回 oh-my-ppt
page-merge-rewriter.ts
根目录 / src / main / session / page-merge-rewriter.ts
1 import fs from 'fs'
2 import path from 'path'
3 import * as cheerio from 'cheerio'
4 import { ensureMasterStyleLink } from '../presentation/html/master-link'
5
6 export const isMergePathInside = (candidate: string, root: string): boolean => {
7 const relative = path.relative(path.resolve(root), path.resolve(candidate))
8 return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
9 }
10
11 export const resolveMergeFileInside = async (
12 candidate: string,
13 root: string
14 ): Promise<string | null> => {
15 try {
16 const [resolvedCandidate, resolvedRoot] = await Promise.all([
17 fs.promises.realpath(candidate),
18 fs.promises.realpath(root)
19 ])
20 return isMergePathInside(resolvedCandidate, resolvedRoot) ? resolvedCandidate : null
21 } catch {
22 return null
23 }
24 }
25
26 const splitResourceSuffix = (value: string): { pathname: string; suffix: string } => {
27 const match = value.match(/^([^?#]*)([?#].*)?$/)
28 return { pathname: match?.[1] || value, suffix: match?.[2] || '' }
29 }
30
31 const isIgnoredMergeResourceValue = (value: string): boolean =>
32 !value ||
33 value.startsWith('#') ||
34 /^(?:data|blob|https?|javascript|mailto|tel|local-asset):/i.test(value)
35
36 const normalizeResourceKey = (value: string): string | null => {
37 const raw = value.trim().replace(/^['"]|['"]$/g, '')
38 if (isIgnoredMergeResourceValue(raw)) return null
39 const { pathname } = splitResourceSuffix(raw)
40 if (!pathname || path.isAbsolute(pathname) || pathname.startsWith('/')) return null
41 let decodedPathname = pathname
42 try {
43 decodedPathname = decodeURIComponent(pathname)
44 } catch {
45 return null
46 }
47 const normalized = path.posix.normalize(decodedPathname.replace(/\\/g, '/').replace(/^\.\//, ''))
48 if (!normalized || normalized === '.' || normalized.startsWith('../')) return null
49 return normalized
50 }
51
52 const unsafeLocalResourceValue = (value: string): string | null => {
53 const raw = value.trim().replace(/^['"]|['"]$/g, '')
54 if (isIgnoredMergeResourceValue(raw)) return null
55 const { pathname } = splitResourceSuffix(raw)
56 let decodedPathname = pathname
57 try {
58 decodedPathname = decodeURIComponent(pathname)
59 } catch {
60 return raw
61 }
62 if (/\.(?:woff2?|ttf|otf|eot)$/i.test(decodedPathname)) return null
63 const normalized = path.posix.normalize(decodedPathname.replace(/\\/g, '/'))
64 return path.isAbsolute(decodedPathname) ||
65 decodedPathname.startsWith('/') ||
66 normalized.startsWith('../')
67 ? raw
68 : null
69 }
70
71 const rewriteResourceValue = (value: string, resourcePathMap: Map<string, string>): string => {
72 const { pathname, suffix } = splitResourceSuffix(value.trim())
73 const key = normalizeResourceKey(pathname)
74 if (!key) return value
75 const replacement = resourcePathMap.get(key)
76 return replacement ? `${replacement}${suffix}` : value
77 }
78
79 const rewriteSrcset = (value: string, resourcePathMap: Map<string, string>): string =>
80 value
81 .split(',')
82 .map((candidate) => {
83 const trimmed = candidate.trim()
84 if (!trimmed) return trimmed
85 const [url, ...descriptor] = trimmed.split(/\s+/)
86 return [rewriteResourceValue(url, resourcePathMap), ...descriptor].join(' ')
87 })
88 .join(', ')
89
90 const rewriteCssUrls = (value: string, resourcePathMap: Map<string, string>): string =>
91 value.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (full, quote: string, url: string) => {
92 const rewritten = rewriteResourceValue(url, resourcePathMap)
93 return rewritten === url ? full : `url(${quote}${rewritten}${quote})`
94 })
95
96 const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
97
98 export interface MergePageFontProfile {
99 titleFont: string
100 bodyFont: string
101 declaredFamilies: string[]
102 headTags: string[]
103 }
104
105 const readCssVariable = (css: string, name: string): string => {
106 const match = css.match(new RegExp(`${escapeRegExp(name)}\\s*:\\s*(["']?)([^;"'}]+)\\1`, 'i'))
107 return match?.[2]?.trim() || ''
108 }
109
110 export function extractMergePageFontProfile(html: string): MergePageFontProfile | null {
111 const $ = cheerio.load(html, { scriptingEnabled: false })
112 const families = new Set<string>()
113 const headTags: string[] = []
114 let variableCss = ''
115 $('style[data-ppt-fonts]').each((_, node) => {
116 const marker = ($(node).attr('data-ppt-fonts') || '').trim()
117 const css = $(node).text()
118 headTags.push($.html(node))
119 if (marker === '1') variableCss += `\n${css}`
120 })
121 $('style[data-ppt-fonts="google"], style[data-ppt-fonts="user"]').each((_, node) => {
122 const css = $(node).text()
123 for (const match of css.matchAll(/font-family\s*:\s*(["']?)([^;"'}]+)\1\s*;/gi)) {
124 const family = match[2]?.trim()
125 if (family) families.add(family)
126 }
127 })
128 const titleFont = readCssVariable(variableCss, '--ppt-title-font')
129 const bodyFont = readCssVariable(variableCss, '--ppt-body-font')
130 if (!titleFont || !bodyFont || headTags.length === 0) return null
131 return {
132 titleFont,
133 bodyFont,
134 declaredFamilies: Array.from(families),
135 headTags
136 }
137 }
138
139 const buildFontFamilyMap = (
140 sourceProfile: MergePageFontProfile | null,
141 targetProfile: MergePageFontProfile,
142 extraSourceFamilies: string[] = []
143 ): Map<string, string> => {
144 const replacements = new Map<string, string>()
145 if (sourceProfile) {
146 if (sourceProfile.titleFont) replacements.set(sourceProfile.titleFont, targetProfile.titleFont)
147 if (sourceProfile.bodyFont && !replacements.has(sourceProfile.bodyFont)) {
148 replacements.set(sourceProfile.bodyFont, targetProfile.bodyFont)
149 }
150 for (const family of sourceProfile.declaredFamilies) {
151 if (!replacements.has(family)) replacements.set(family, targetProfile.bodyFont)
152 }
153 }
154 for (const family of extraSourceFamilies) {
155 if (!replacements.has(family)) replacements.set(family, targetProfile.bodyFont)
156 }
157 return replacements
158 }
159
160 const stripFontFaceBlocks = (css: string, families: Set<string>): string =>
161 css.replace(/@font-face\s*\{([^{}]*)\}/gi, (_full, body: string) => {
162 const family = body.match(/font-family\s*:\s*(["']?)([^;"'}]+)\1\s*;/i)?.[2]?.trim()
163 if (family) families.add(family)
164 return ''
165 })
166
167 const replaceInjectedFontFamilies = (value: string, replacements: Map<string, string>): string => {
168 let next = value
169 for (const [sourceFamily, targetFamily] of replacements) {
170 const escaped = escapeRegExp(sourceFamily)
171 next = next.replace(
172 new RegExp(`(["'])${escaped}\\1`, 'gi'),
173 (_match, quote: string) => `${quote}${targetFamily}${quote}`
174 )
175 next = next.replace(
176 new RegExp(`((?:font-family\\s*:|,)\\s*)${escaped}(?=\\s*(?:[,;}]))`, 'gi'),
177 `$1${targetFamily}`
178 )
179 next = next.replace(
180 new RegExp(`(--[A-Za-z0-9_-]*font[A-Za-z0-9_-]*\\s*:\\s*)${escaped}(?=\\s*[;}])`, 'gi'),
181 `$1${targetFamily}`
182 )
183 }
184 return next
185 }
186
187 const replacePageIdentityBoundary = (
188 html: string,
189 oldPageId: string,
190 nextPageId: string
191 ): string => {
192 const oldId = oldPageId.trim()
193 if (!oldId || oldId === nextPageId) return html
194 const escapedOldId = oldId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
195 const pattern = new RegExp(`(^|[^A-Za-z0-9_-])${escapedOldId}(?=$|[^A-Za-z0-9_-])`, 'g')
196 return html.replace(pattern, `$1${nextPageId}`)
197 }
198
199 export function rewriteMergedPageHtml(args: {
200 html: string
201 oldPageId: string
202 nextPageId: string
203 resourcePathMap: Map<string, string>
204 targetFontProfile: MergePageFontProfile
205 }): string {
206 const $ = cheerio.load(args.html, { scriptingEnabled: false })
207 const sourceFontProfile = extractMergePageFontProfile(args.html)
208 const unmarkedFontFamilies = new Set<string>()
209 $('style:not([data-ppt-fonts])').each((_, node) => {
210 const element = $(node)
211 element.text(stripFontFaceBlocks(element.html() || '', unmarkedFontFamilies))
212 })
213 const fontFamilyMap = buildFontFamilyMap(
214 sourceFontProfile,
215 args.targetFontProfile,
216 Array.from(unmarkedFontFamilies)
217 )
218 $('style[data-ppt-fonts]').remove()
219 $('link[href]').each((_, node) => {
220 const element = $(node)
221 const href = element.attr('href') || ''
222 if (
223 (element.attr('as') || '').toLowerCase() === 'font' ||
224 /\.(?:woff2?|ttf|otf|eot)(?:[?#].*)?$/i.test(href)
225 ) {
226 element.remove()
227 }
228 })
229 $('body').attr('data-page-id', args.nextPageId)
230 $('[data-page-id]').each((_, node) => {
231 const element = $(node)
232 if ((element.attr('data-page-id') || '').trim() === args.oldPageId) {
233 element.attr('data-page-id', args.nextPageId)
234 }
235 })
236 const resourceAttributes = new Set(['src', 'poster', 'href', 'xlink:href', 'srcset', 'style'])
237 $('*').each((_, node) => {
238 const element = $(node)
239 const attributes = element.attr() || {}
240 for (const [attribute, value] of Object.entries(attributes)) {
241 if (resourceAttributes.has(attribute) || typeof value !== 'string') continue
242 element.attr(attribute, replacePageIdentityBoundary(value, args.oldPageId, args.nextPageId))
243 }
244 })
245 $('[src], [poster], [href], [xlink\\:href], [srcset]').each((_, node) => {
246 const element = $(node)
247 for (const attribute of ['src', 'poster', 'href', 'xlink:href']) {
248 const value = element.attr(attribute)
249 if (value) element.attr(attribute, rewriteResourceValue(value, args.resourcePathMap))
250 }
251 const srcset = element.attr('srcset')
252 if (srcset) element.attr('srcset', rewriteSrcset(srcset, args.resourcePathMap))
253 })
254 $('[style]').each((_, node) => {
255 const element = $(node)
256 const style = element.attr('style')
257 if (style) {
258 element.attr(
259 'style',
260 replaceInjectedFontFamilies(rewriteCssUrls(style, args.resourcePathMap), fontFamilyMap)
261 )
262 }
263 })
264 $('style').each((_, node) => {
265 const element = $(node)
266 element.text(
267 replaceInjectedFontFamilies(
268 rewriteCssUrls(element.html() || '', args.resourcePathMap),
269 fontFamilyMap
270 )
271 )
272 })
273 $('script:not([src])').each((_, node) => {
274 const element = $(node)
275 element.text(replacePageIdentityBoundary(element.html() || '', args.oldPageId, args.nextPageId))
276 })
277 $('head').append(`\n${args.targetFontProfile.headTags.join('\n')}\n`)
278 return ensureMasterStyleLink($.html())
279 }
280
281 export function collectMergedPageResourceKeys(html: string): string[] {
282 const keys = new Set<string>()
283 const collect = (value?: string | null): void => {
284 if (!value) return
285 const key = normalizeResourceKey(value)
286 if (key) keys.add(key)
287 }
288 const collectCss = (value?: string | null): void => {
289 if (!value) return
290 value.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (full, _quote, url: string) => {
291 collect(url)
292 return full
293 })
294 }
295 const $ = cheerio.load(html, { scriptingEnabled: false })
296 $('[src], [poster], [href], [xlink\\:href], [srcset]').each((_, node) => {
297 const element = $(node)
298 collect(element.attr('src'))
299 collect(element.attr('poster'))
300 collect(element.attr('href'))
301 collect(element.attr('xlink:href'))
302 const srcset = element.attr('srcset')
303 if (srcset) {
304 srcset.split(',').forEach((candidate) => collect(candidate.trim().split(/\s+/)[0]))
305 }
306 })
307 $('[style]').each((_, node) => collectCss($(node).attr('style')))
308 $('style').each((_, node) => collectCss($(node).html()))
309 return Array.from(keys).sort()
310 }
311
312 export function collectUnsafeMergedPageResourceReferences(html: string): string[] {
313 const unsafe = new Set<string>()
314 const collect = (value?: string | null): void => {
315 if (!value) return
316 const invalid = unsafeLocalResourceValue(value)
317 if (invalid) unsafe.add(invalid)
318 }
319 const collectCss = (value?: string | null): void => {
320 if (!value) return
321 value.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (full, _quote, url: string) => {
322 collect(url)
323 return full
324 })
325 }
326 const $ = cheerio.load(html, { scriptingEnabled: false })
327 $('[src], [poster], [href], [xlink\\:href], [srcset]').each((_, node) => {
328 const element = $(node)
329 collect(element.attr('src'))
330 collect(element.attr('poster'))
331 collect(element.attr('href'))
332 collect(element.attr('xlink:href'))
333 const srcset = element.attr('srcset')
334 if (srcset) {
335 srcset.split(',').forEach((candidate) => collect(candidate.trim().split(/\s+/)[0]))
336 }
337 })
338 $('[style]').each((_, node) => collectCss($(node).attr('style')))
339 $('style').each((_, node) => collectCss($(node).html()))
340 return Array.from(unsafe).sort()
341 }
342
342 lines TYPESCRIPT