返回 oh-my-ppt
extract-script.test.ts
根目录 / tests / unit / html-pptx / extract-script.test.ts
1 /** @vitest-environment happy-dom */
2 import { describe, expect, it } from 'vitest'
3 import fs from 'fs'
4 import path from 'path'
5 import {
6 buildHtmlToPptxExtractScript,
7 normalizeExtractedHtmlToPptxSlide
8 } from '@arcsin1/html2pptx'
9
10 describe('buildHtmlToPptxExtractScript', () => {
11 const buildScript = () =>
12 buildHtmlToPptxExtractScript({
13 pageWidthPx: 1600,
14 pageHeightPx: 900
15 })
16
17 const rect = (left: number, top: number, width: number, height: number) => ({
18 x: left,
19 y: top,
20 left,
21 top,
22 width,
23 height,
24 right: left + width,
25 bottom: top + height
26 })
27
28 const assignRect = (selector: string, left: number, top: number, width: number, height: number) => {
29 const element = document.querySelector(selector)
30 if (!element) throw new Error(`Missing test node: ${selector}`)
31 Object.defineProperty(element, 'getBoundingClientRect', {
32 configurable: true,
33 value: () => rect(left, top, width, height)
34 })
35 }
36
37 const extractInDom = async ({
38 maxTextBoxes = 360,
39 unsupportedTransformStrategy
40 }: {
41 maxTextBoxes?: number
42 unsupportedTransformStrategy?: 'native' | 'raster-fallback'
43 } = {}) =>
44 new Function(
45 `return ${
46 buildHtmlToPptxExtractScript({
47 pageWidthPx: 1600,
48 pageHeightPx: 900,
49 maxTextBoxes,
50 unsupportedTransformStrategy
51 }).trim()
52 }`
53 )() as Promise<Record<string, unknown>>
54
55 it('exports Tailwind rings with a visual hint map and computed spread fallback', () => {
56 const script = buildScript()
57
58 expect(script).toContain("['ring-1', 1]")
59 expect(script).toContain("['ring-2', 2]")
60 expect(script).toContain("['ring-4', 4]")
61 expect(script).toContain('const parseTailwindRingWidth = (utility) => {')
62 expect(script).toContain('const arbitrary = utility.match(/^ring-')
63 expect(script).toContain('Number.parseFloat(arbitrary[1])')
64 expect(script).toContain('const [offsetX, offsetY, blur, spread] = lengths;')
65 expect(script).toContain('best = { w: spread, c: color, colorSource };')
66 })
67
68 it('supports common Tailwind color scales for visual hints', () => {
69 const script = buildScript()
70
71 expect(script).toContain('const resolveTailwindColorToken = (name) => {')
72 expect(script).toContain("red: ['#FEF2F2'")
73 expect(script).toContain("'#EF4444'")
74 expect(script).toContain('const palette = tailwindColorPaletteMap.get(match[1]);')
75 })
76
77 it('generates parseable browser extraction JavaScript', () => {
78 expect(() => new Function(buildScript())).not.toThrow()
79 })
80
81 it('keeps an unexported text block in the raster fallback when the text limit is reached', async () => {
82 document.body.innerHTML = `
83 <div class="ppt-page-root" style="display:block">
84 <p id="first" style="display:block;font-size:20px">First text</p>
85 <p id="second" style="display:block;font-size:20px">Second text</p>
86 </div>
87 `
88 assignRect('.ppt-page-root', 0, 0, 1600, 900)
89 assignRect('#first', 100, 100, 320, 36)
90 assignRect('#second', 100, 160, 320, 36)
91
92 const extracted = await extractInDom({ maxTextBoxes: 1 })
93
94 expect(extracted.texts).toHaveLength(1)
95 expect(extracted.extractionReport).toMatchObject({ textLimitReached: true })
96 expect(document.querySelector('#first')?.getAttribute('data-pptx-extracted-text')).toBe('1')
97 expect(document.querySelector('#second')?.hasAttribute('data-pptx-extracted-text')).toBe(false)
98 })
99
100 it('marks extracted inline KPI text so it is removed from the raster background', async () => {
101 document.body.innerHTML = `
102 <div class="ppt-page-root" style="display:block">
103 <div id="metrics" style="display:flex">
104 <span id="market" style="font-size:48px">528</span>
105 <span id="share" style="font-size:48px">63<span id="percent" style="font-size:30px">%</span></span>
106 <span id="cost" style="font-size:48px">$2,950</span>
107 </div>
108 <p id="fallback" data-pptx-formula-block style="display:block;font-size:20px">Fallback text</p>
109 </div>
110 `
111 assignRect('.ppt-page-root', 0, 0, 1600, 900)
112 assignRect('#metrics', 100, 180, 640, 80)
113 assignRect('#market', 100, 180, 120, 60)
114 assignRect('#share', 240, 180, 120, 60)
115 assignRect('#percent', 320, 205, 32, 36)
116 assignRect('#cost', 400, 180, 180, 60)
117 assignRect('#fallback', 100, 300, 220, 36)
118
119 const originalCreateRange = document.createRange.bind(document)
120 let selectedNode: Node | null = null
121 const rangeRect = () => {
122 const id = selectedNode?.parentElement?.id
123 if (id === 'market') return rect(100, 180, 120, 60)
124 if (id === 'share') return rect(240, 180, 80, 60)
125 if (id === 'percent') return rect(320, 205, 32, 36)
126 if (id === 'cost') return rect(400, 180, 180, 60)
127 return rect(100, 300, 220, 36)
128 }
129 document.createRange = (() => ({
130 selectNode: (node: Node) => {
131 selectedNode = node
132 },
133 selectNodeContents: (node: Node) => {
134 selectedNode = node
135 },
136 setStart: () => {},
137 setEnd: () => {},
138 getBoundingClientRect: () => rangeRect(),
139 getClientRects: () => [rangeRect()],
140 detach: () => {}
141 })) as typeof document.createRange
142
143 try {
144 await extractInDom({ unsupportedTransformStrategy: 'raster-fallback' })
145
146 expect(document.querySelector('#market')?.getAttribute('data-pptx-extracted-text')).toBe('1')
147 expect(document.querySelector('#share')?.getAttribute('data-pptx-extracted-text')).toBe('1')
148 expect(document.querySelector('#percent')?.getAttribute('data-pptx-extracted-text')).toBe('1')
149 expect(document.querySelector('#cost')?.getAttribute('data-pptx-extracted-text')).toBe('1')
150 expect(document.querySelector('#fallback')?.hasAttribute('data-pptx-extracted-text')).toBe(false)
151 } finally {
152 document.createRange = originalCreateRange
153 }
154 })
155
156 it('keeps rotated shapes in the raster fallback instead of exporting a distorted rectangle', async () => {
157 document.body.innerHTML = `
158 <div class="ppt-page-root" style="display:block">
159 <div id="rotated" style="display:block;background-color:rgb(22, 96, 171);border:1px solid rgb(22, 96, 171);transform:rotate(25deg)"></div>
160 </div>
161 `
162 assignRect('.ppt-page-root', 0, 0, 1600, 900)
163 assignRect('#rotated', 120, 90, 360, 180)
164
165 const extracted = await extractInDom({ unsupportedTransformStrategy: 'raster-fallback' })
166
167 expect(extracted.shapes).toHaveLength(0)
168 expect(extracted.extractionReport).toMatchObject({ unsupportedTransformCount: 1 })
169 expect(document.querySelector('#rotated')?.hasAttribute('data-pptx-extracted-shape')).toBe(false)
170 })
171
172 it('keeps transformed elements in the public API output unless raster fallback is requested', async () => {
173 document.body.innerHTML = `
174 <div class="ppt-page-root" style="display:block">
175 <div id="rotated" style="display:block;background-color:rgb(22, 96, 171);border:1px solid rgb(22, 96, 171);transform:rotate(25deg)"></div>
176 </div>
177 `
178 assignRect('.ppt-page-root', 0, 0, 1600, 900)
179 assignRect('#rotated', 120, 90, 360, 180)
180
181 const extracted = await extractInDom()
182
183 expect(extracted.shapes).toHaveLength(1)
184 expect(extracted.extractionReport).toMatchObject({ unsupportedTransformCount: 0 })
185 })
186
187 it('uses Tailwind font weight hints when no inline font weight overrides them', () => {
188 const script = buildHtmlToPptxExtractScript({
189 pageWidthPx: 1600,
190 pageHeightPx: 900
191 })
192
193 expect(script).toContain('const resolveTailwindFontWeight = (element) => {')
194 expect(script).toContain('const resolveInlineFontWeight = (element) => {')
195 expect(script).toContain('const inlineFontWeight = resolveInlineFontWeight(parentElement);')
196 expect(script).toContain('const tailwindFontWeight = resolveTailwindFontWeight(parentElement);')
197 expect(script).toContain(
198 'const fontWeight = inlineFontWeight || tailwindFontWeight || computedFontWeight || 400;'
199 )
200 expect(script).not.toContain('tailwindTextHints.fontWeight || computedFontWeight')
201 })
202
203 it('extracts inline text runs for styled spans inside block text', () => {
204 const script = buildScript()
205
206 expect(script).toContain('const collectInlineTextRuns = (element, baseStyle) => {')
207 expect(script).toContain('const collectInlineTextLineRuns = (element, baseStyle) => {')
208 expect(script).toContain('const appendTextRun = (runs, run) => {')
209 expect(script).toContain('const textRunFor = (text, style, element) => {')
210 expect(script).toContain('const hasStyledRun = runs.some((run) => !sameTextRunStyle(run, baseRun));')
211 expect(script).toContain('const inlineRuns = collectInlineTextRuns(element, style);')
212 expect(script).toContain('const inlineLineRuns = inlineRuns?.length ? collectInlineTextLineRuns(element, style) : [];')
213 expect(script).toContain('inlineLineRuns.every((line) =>')
214 expect(script).toContain('const rollbackTextExtraction = (startIndex, seenBefore) =>')
215 expect(script).toContain('runs: inlineRuns')
216 expect(script).toContain('...(richTextRuns?.length ? { runs: richTextRuns } : {})')
217 })
218
219 it('preserves normalized rich text runs with color and bold weight', () => {
220 const slide = normalizeExtractedHtmlToPptxSlide({
221 backgroundColor: 'FFFFFF',
222 texts: [
223 {
224 text: '本页文字梳理:原始内容',
225 x: 1,
226 y: 1,
227 w: 3,
228 h: 0.4,
229 fontSize: 18,
230 color: '3E3A39',
231 runs: [
232 {
233 text: '本页文字梳理:',
234 fontSize: 18,
235 fontFace: 'Noto Sans SC',
236 color: 'C00000',
237 bold: true
238 },
239 {
240 text: '原始内容',
241 fontSize: 18,
242 fontFace: 'Noto Sans SC',
243 color: '3E3A39'
244 }
245 ]
246 }
247 ],
248 shapes: [],
249 images: [],
250 tables: []
251 })
252
253 expect(slide.texts[0]?.runs?.[0]).toMatchObject({
254 text: '本页文字梳理:',
255 color: 'C00000',
256 bold: true
257 })
258 expect(slide.texts[0]?.runs?.[1]).toMatchObject({
259 text: '原始内容',
260 color: '3E3A39',
261 bold: false
262 })
263 })
264
265 it('uses CSS stacking order as paint-order fallback for z-indexed edits', () => {
266 const script = buildScript()
267
268 expect(script).toContain('const parseCssZIndex = (style) => {')
269 expect(script).toContain(
270 "parseCssZIndex(style) !== undefined && (style.position !== 'static' || isFlexOrGridItem(element))"
271 )
272 expect(script).toContain('const stackingKeyFor = (element) => {')
273 expect(script).toContain('const compareStackingOrder = (left, right) => {')
274 expect(script).toContain('const fallback = new Map(entries.map(([id, element]) => [id, stackingKeyFor(element)]));')
275 expect(script).toContain('if (!document.elementsFromPoint) return buildFallbackResult();')
276 expect(script).not.toContain('if (entries.length === 0 || !document.elementsFromPoint) return new Map();')
277 })
278
279 it('exports a single visible border side as a line instead of a full rectangle border', () => {
280 const script = buildScript()
281
282 expect(script).toContain('const collectBorderSides = () => {')
283 expect(script).toContain('const parseTailwindBorderSideWidth = (utility) => {')
284 expect(script).toContain('const parseTailwindBorderSideColor = (utility) => {')
285 expect(script).toContain("sideKey === 'x' ? ['left', 'right']")
286 expect(script).toContain('borderSideWidthPx: {')
287 expect(script).toContain('borderSideColorSource: {')
288 expect(script).toContain('const hintedWidth = tailwindVisualHints.borderSideWidthPx?.[sideName];')
289 expect(script).toContain('const removeSide = (sideName) => {')
290 expect(script).toContain('if (hintedWidth !== undefined && hintedWidth <= 0) {')
291 expect(script).toContain('const hasUniformFourSideBorder =')
292 expect(script).toContain('const shouldSplitBorderSides =')
293 expect(script).toContain('const mainBorderInfo = shouldSplitBorderSides ? null : borderInfo;')
294 expect(script).toContain('const buildBorderLineShape = (borderSide) => {')
295 expect(script).toContain("shapeType: 'line'")
296 expect(script).toContain("if (borderSide.side === 'bottom')")
297 expect(script).toContain('border: hasMainBorder')
298 expect(script).toContain('splitBorderLineShapes.forEach((shape) => shapes.push(shape));')
299 expect(script).toContain("dash: borderSide.dash || 'solid'")
300 })
301
302 it('keeps small grid color cells as shapes for Tailwind visual panels', () => {
303 const script = buildScript()
304
305 expect(script).toContain('const isGridPaintCell =')
306 expect(script).toContain('/grid/i.test(parentDisplay)')
307 expect(script).toContain('!normalize(element.innerText || element.textContent)')
308 expect(script).toContain('!hasBorder && !isSmallBadge && !isGridPaintCell')
309 expect(script).toContain('if (shapes.length >= maxShapes) {')
310 expect(script).toContain('extractionReport.shapeLimitReached = true;')
311 expect(script).not.toContain('if (shapes.length >= maxShapes) break;')
312 })
313
314 it('keeps thin filled strips such as Tailwind header bars', () => {
315 const script = buildScript()
316
317 expect(script).toContain('const isThinPaintStrip =')
318 expect(script).toContain('(rect.width >= 24 && rect.height >= 2 && rect.height < 12)')
319 expect(script).toContain('(rect.height >= 24 && rect.width >= 2 && rect.width < 12)')
320 expect(script).toContain("if ((rect.width < 12 || rect.height < 12) && !isThinPaintStrip) continue;")
321 })
322
323 it('exports thin border connectors and CSS chevron arrows as PPT lines', () => {
324 const script = buildScript()
325
326 expect(script).toContain('const hasVisibleBorder =')
327 expect(script).toContain("['Top', 'Right', 'Bottom', 'Left'].some")
328 expect(script).toContain('const isCssChevronBorder =')
329 expect(script).toContain("hasCornerBorderSides('top', 'right')")
330 expect(script).toContain('const buildCssChevronLineShapes = () => {')
331 expect(script).toContain('flipV: true')
332 expect(script).toContain('cssChevronLineShapes.forEach((shape) => shapes.push(shape));')
333 expect(script).toContain('const shouldExportOnlyBorderLines =')
334 })
335
336 it('uses enough text, shape and image budget for dense PPTX export', () => {
337 const rendererSource = fs.readFileSync(
338 path.join(process.cwd(), 'src/main/io/html-pptx/renderer.ts'),
339 'utf-8'
340 )
341
342 expect(rendererSource).toContain('maxTextBoxes: 360')
343 expect(rendererSource).toContain('maxShapes: 400')
344 expect(rendererSource).toContain('maxImages: 80')
345 expect(rendererSource).not.toContain('maxShapes: 80')
346 })
347 })
348
348 lines TYPESCRIPT