| 1 | import fs from 'fs' |
| 2 | import os from 'os' |
| 3 | import path from 'path' |
| 4 | import { describe, expect, it } from 'vitest' |
| 5 | import { |
| 6 | collectMergedPageResourceKeys, |
| 7 | collectUnsafeMergedPageResourceReferences, |
| 8 | extractMergePageFontProfile, |
| 9 | isMergePathInside, |
| 10 | resolveMergeFileInside, |
| 11 | rewriteMergedPageHtml |
| 12 | } from '../../../src/main/session/page-merge-rewriter' |
| 13 | import { validatePersistedPageHtml } from '../../../src/main/presentation/html/html-utils' |
| 14 | |
| 15 | const targetFontHtml = `<html><head> |
| 16 | <style data-ppt-fonts="google">@font-face{font-family:"Target Title";src:url("./assets/fonts/google-fonts/Target-Title/title.woff2") format("woff2")}</style> |
| 17 | <style data-ppt-fonts="google">@font-face{font-family:"Target Body";src:url("./assets/fonts/google-fonts/Target-Body/body.woff2") format("woff2")}</style> |
| 18 | <style data-ppt-fonts="1">:root{--ppt-title-font:"Target Title";--ppt-body-font:"Target Body"}</style> |
| 19 | </head><body></body></html>` |
| 20 | |
| 21 | const targetFontProfile = extractMergePageFontProfile(targetFontHtml)! |
| 22 | |
| 23 | describe('page merge resource rewriter', () => { |
| 24 | it('rewrites page identity and local resource attributes', () => { |
| 25 | const html = `<!doctype html> |
| 26 | <html> |
| 27 | <head> |
| 28 | <style>.hero{background-image:url('./images/bg.png?x=1')}</style> |
| 29 | </head> |
| 30 | <body data-page-id="page-old"> |
| 31 | <div data-page-id="page-old" style="background:url(./images/pattern.svg#mask)"> |
| 32 | <img src="./images/a.png" srcset="./images/a.png 1x, ./images/a@2x.png 2x"> |
| 33 | <img src="./images/page-old.png"> |
| 34 | <video src="./videos/demo.mp4" poster="./images/poster.jpg"></video> |
| 35 | <svg> |
| 36 | <image href="./images/vector.svg" /> |
| 37 | <image xlink:href="./images/vector-legacy.svg" /> |
| 38 | </svg> |
| 39 | </div> |
| 40 | <script>window.pageId = 'page-old'</script> |
| 41 | </body> |
| 42 | </html>` |
| 43 | const resourcePathMap = new Map([ |
| 44 | ['images/bg.png', './assets/merged-pages/batch/page-new/images/bg.png'], |
| 45 | ['images/pattern.svg', './assets/merged-pages/batch/page-new/images/pattern.svg'], |
| 46 | ['images/a.png', './assets/merged-pages/batch/page-new/images/a.png'], |
| 47 | ['images/a@2x.png', './assets/merged-pages/batch/page-new/images/a@2x.png'], |
| 48 | ['images/page-old.png', './assets/merged-pages/batch/page-new/images/page-old.png'], |
| 49 | ['videos/demo.mp4', './assets/merged-pages/batch/page-new/videos/demo.mp4'], |
| 50 | ['images/poster.jpg', './assets/merged-pages/batch/page-new/images/poster.jpg'], |
| 51 | ['images/vector.svg', './assets/merged-pages/batch/page-new/images/vector.svg'], |
| 52 | ['images/vector-legacy.svg', './assets/merged-pages/batch/page-new/images/vector-legacy.svg'] |
| 53 | ]) |
| 54 | |
| 55 | const rewritten = rewriteMergedPageHtml({ |
| 56 | html, |
| 57 | oldPageId: 'page-old', |
| 58 | nextPageId: 'page-new', |
| 59 | resourcePathMap, |
| 60 | targetFontProfile |
| 61 | }) |
| 62 | |
| 63 | expect(rewritten).toContain('data-page-id="page-new"') |
| 64 | expect(rewritten).toContain("window.pageId = 'page-new'") |
| 65 | expect(rewritten).toContain('./assets/merged-pages/batch/page-new/images/a.png') |
| 66 | expect(rewritten).toContain('./assets/merged-pages/batch/page-new/images/a@2x.png 2x') |
| 67 | expect(rewritten).toContain('./assets/merged-pages/batch/page-new/images/page-old.png') |
| 68 | expect(rewritten).toContain('./assets/merged-pages/batch/page-new/videos/demo.mp4') |
| 69 | expect(rewritten).toContain('./assets/merged-pages/batch/page-new/images/bg.png?x=1') |
| 70 | expect(rewritten).toContain('./assets/merged-pages/batch/page-new/images/pattern.svg#mask') |
| 71 | expect(rewritten).toContain('./assets/merged-pages/batch/page-new/images/vector.svg') |
| 72 | expect(rewritten).toContain('./assets/merged-pages/batch/page-new/images/vector-legacy.svg') |
| 73 | }) |
| 74 | |
| 75 | it('collects local resources and ignores external or unsafe references', () => { |
| 76 | const html = ` |
| 77 | <img src="./images/a.png"> |
| 78 | <img src="https://example.com/remote.png"> |
| 79 | <img src="data:image/png;base64,abc"> |
| 80 | <img src="../outside.png"> |
| 81 | <video src="./videos/demo.mp4#t=1"></video> |
| 82 | <script src="./assets/ppt-runtime.js"></script> |
| 83 | <div style="background:url('./images/bg.png')"></div> |
| 84 | ` |
| 85 | |
| 86 | expect(collectMergedPageResourceKeys(html)).toEqual([ |
| 87 | 'assets/ppt-runtime.js', |
| 88 | 'images/a.png', |
| 89 | 'images/bg.png', |
| 90 | 'videos/demo.mp4' |
| 91 | ]) |
| 92 | }) |
| 93 | |
| 94 | it('reports unsafe non-font resource references', () => { |
| 95 | expect( |
| 96 | collectUnsafeMergedPageResourceReferences(` |
| 97 | <img src="../outside.png"> |
| 98 | <div style="background:url('%2e%2e/encoded.png')"></div> |
| 99 | <style>@font-face{src:url('../fonts/font.woff2')}</style> |
| 100 | `) |
| 101 | ).toEqual(['%2e%2e/encoded.png', '../outside.png']) |
| 102 | }) |
| 103 | |
| 104 | it('keeps resources that are not present in the rewrite map', () => { |
| 105 | const html = ` |
| 106 | <body data-page-id="page-a"> |
| 107 | <script src="./assets/ppt-runtime.js"></script> |
| 108 | <img src="https://example.com/image.png"> |
| 109 | </body> |
| 110 | ` |
| 111 | const rewritten = rewriteMergedPageHtml({ |
| 112 | html, |
| 113 | oldPageId: 'page-a', |
| 114 | nextPageId: 'page-b', |
| 115 | resourcePathMap: new Map(), |
| 116 | targetFontProfile |
| 117 | }) |
| 118 | |
| 119 | expect(rewritten).toContain('./assets/ppt-runtime.js') |
| 120 | expect(rewritten).toContain('https://example.com/image.png') |
| 121 | }) |
| 122 | |
| 123 | it('rejects paths that escape the source project root', () => { |
| 124 | expect(isMergePathInside('/sessions/source/images/a.png', '/sessions/source')).toBe(true) |
| 125 | expect(isMergePathInside('/sessions/source', '/sessions/source')).toBe(true) |
| 126 | expect(isMergePathInside('/sessions/source-other/a.png', '/sessions/source')).toBe(false) |
| 127 | expect(isMergePathInside('/sessions/outside.png', '/sessions/source')).toBe(false) |
| 128 | }) |
| 129 | |
| 130 | it('rejects symlinks that resolve outside the source project root', async () => { |
| 131 | const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'page-merge-path-')) |
| 132 | const sourceRoot = path.join(root, 'source') |
| 133 | const outsidePath = path.join(root, 'outside.png') |
| 134 | await fs.promises.mkdir(sourceRoot, { recursive: true }) |
| 135 | await fs.promises.writeFile(outsidePath, 'outside') |
| 136 | const linkedPath = path.join(sourceRoot, 'linked.png') |
| 137 | await fs.promises.symlink(outsidePath, linkedPath) |
| 138 | |
| 139 | try { |
| 140 | await expect(resolveMergeFileInside(linkedPath, sourceRoot)).resolves.toBeNull() |
| 141 | } finally { |
| 142 | await fs.promises.rm(root, { recursive: true, force: true }) |
| 143 | } |
| 144 | }) |
| 145 | |
| 146 | it('replaces source font injection with the target template fonts', () => { |
| 147 | const html = `<html><head> |
| 148 | <style data-ppt-fonts="google">@font-face{font-family:"Source Title";src:url("./assets/fonts/google-fonts/Source-Title/title.woff2") format("woff2")}</style> |
| 149 | <style data-ppt-fonts="google">@font-face{font-family:"Source Body";src:url("./assets/fonts/google-fonts/Source-Body/body.woff2") format("woff2")}</style> |
| 150 | <style data-ppt-fonts="1">:root{--ppt-title-font:"Source Title";--ppt-body-font:"Source Body"}</style> |
| 151 | <style>@font-face{font-family:"Legacy Font";src:url("./legacy.woff2")}.title{font-family:"Source Title"}.body{font-family:Source Body}.legacy{font-family:"Legacy Font"}</style> |
| 152 | </head><body data-page-id="page-source"><p style="font-family:'Source Body'">Text</p></body></html>` |
| 153 | |
| 154 | const rewritten = rewriteMergedPageHtml({ |
| 155 | html, |
| 156 | oldPageId: 'page-source', |
| 157 | nextPageId: 'page-target', |
| 158 | resourcePathMap: new Map(), |
| 159 | targetFontProfile |
| 160 | }) |
| 161 | |
| 162 | expect(rewritten).not.toContain('Source Title') |
| 163 | expect(rewritten).not.toContain('Source Body') |
| 164 | expect(rewritten).not.toContain('Source-Title') |
| 165 | expect(rewritten).not.toContain('Legacy Font') |
| 166 | expect(rewritten).not.toContain('@font-face{font-family:"Legacy Font"') |
| 167 | expect(rewritten).toContain('--ppt-title-font:"Target Title"') |
| 168 | expect(rewritten).toContain('--ppt-body-font:"Target Body"') |
| 169 | expect(rewritten).toContain('./assets/fonts/google-fonts/Target-Title/title.woff2') |
| 170 | expect(rewritten).not.toContain('assets/merged-pages') |
| 171 | expect( |
| 172 | validatePersistedPageHtml(rewritten, 'page-target').errors.filter((error) => |
| 173 | error.includes('字体') |
| 174 | ) |
| 175 | ).toEqual([]) |
| 176 | }) |
| 177 | }) |
| 178 |