| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104 |
- import assert from "node:assert/strict"
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
- import vm from "node:vm"
- import { webcrypto } from "node:crypto"
- import { inflateRawSync } from 'node:zlib'
- import { createServer } from "vite"
- import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
- import { typographyFixture } from './fixtures/aiPptTypography.mjs'
- import { slideDesignFixture } from './fixtures/aiPptSlideDesign.mjs'
- function readPptxXml(bytes, name) {
- const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
- assert(end >= 0, 'PPTX 必须有 ZIP 中央目录')
- let offset = bytes.readUInt32LE(end + 16)
- while (bytes.readUInt32LE(offset) === 0x02014b50) {
- const size = bytes.readUInt32LE(offset + 20), nameSize = bytes.readUInt16LE(offset + 28)
- const path = bytes.subarray(offset + 46, offset + 46 + nameSize).toString()
- if (path === name) {
- const local = bytes.readUInt32LE(offset + 42)
- const start = local + 30 + bytes.readUInt16LE(local + 26) + bytes.readUInt16LE(local + 28)
- const data = bytes.subarray(start, start + size)
- return (bytes.readUInt16LE(offset + 10) === 8 ? inflateRawSync(data) : data).toString()
- }
- offset += 46 + nameSize + bytes.readUInt16LE(offset + 30) + bytes.readUInt16LE(offset + 32)
- }
- throw new Error(`PPTX 缺少 ${name}`)
- }
- const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
- try {
- const { prepareMediaForExport } = await vite.ssrLoadModule("/src/utils/aiPptExport.ts")
- // 加载生产导出器,在测试副本暴露其转换函数;不改写或替换生产转换逻辑。
- const source = readFileSync("public/pptworker/pptJson.js", "utf8")
- .replace('r((r.s = "9a7e"))', 'globalThis.bundleRequire = r')
- .replace('Sr = function (e, t) {', 'Sr = globalThis.renderPptForTest = function (e, t) {')
- const context = vm.createContext({ console, setTimeout, clearTimeout, setInterval, clearInterval,
- TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, Buffer, Blob, crypto: webcrypto,
- fetch: () => { throw new Error("离线导出不得请求网络") } })
- context.self = context
- vm.runInContext(source, context)
- context.bundleRequire("f5f5")
- assert.equal(typeof context.renderPptForTest, "function")
- const { aiCoursewareToSlides: renderDesign } = await vite.ssrLoadModule('/src/utils/aiCoursewareToSlides.ts')
- const designed = renderDesign(slideDesignFixture())
- const designedPpt = await context.renderPptForTest({ width: 1920, height: 1080, slides: designed.slides, theme: designed.theme, name: '模型页面协议测试' }, true)
- const designedBytes = Buffer.from(await designedPpt.write({ outputType: 'arraybuffer' }))
- mkdirSync('artifacts/ai-ppt-tests', { recursive: true })
- writeFileSync('artifacts/ai-ppt-tests/model-slide-design.pptx', designedBytes)
- const designedXml = readPptxXml(designedBytes, 'ppt/slides/slide2.xml')
- assert(designedXml.includes('FCE4CE') && designedXml.includes('聆听与表达'), '模型色块与文字必须作为原生可编辑元素导出')
- assert(designedXml.includes('a:xfrm'), '模型布局坐标必须进入PPTX')
- const png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
- const wav = Buffer.alloc(44 + 8000 * 2)
- wav.write("RIFF"); wav.writeUInt32LE(wav.length - 8, 4); wav.write("WAVEfmt ", 8)
- wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22)
- wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34)
- wav.write("data", 36); wav.writeUInt32LE(16000, 40)
- for (let i = 0; i < 8000; i++) wav.writeInt16LE(Math.round(Math.sin(i * Math.PI * 2 * 440 / 8000) * 5000), 44 + i * 2)
- const audio = `data:audio/wav;base64,${wav.toString("base64")}`
- const base = { left: 80, top: 180, width: 60, height: 60, rotate: 0 }
- const slides = [{ id: "offline-test", remark: "参考答案:轻声哼唱\n解析:比较声音的强弱。", elements: [
- { ...base, id: "title", type: "text", width: 1000, height: 90, content: '<div style="font-size:48px">离线媒体测试</div>', defaultFontName: "Microsoft Yahei", defaultColor: "#222222" },
- { ...base, top: 320, id: "audio-a", type: "elf", subtype: "elf-audio", src: audio, color: "#333333", autoplay: false, loop: false, fixedRatio: true },
- { ...base, left: 300, top: 320, id: "legacy-audio", type: "elf", subtype: "elf-enjoy", src: audio, enjoyList: [{ src: audio }] },
- { ...base, top: 500, id: "image", type: "image", src: png, fixedRatio: true }
- ] }]
- const prepared = await prepareMediaForExport(slides)
- assert.equal(prepared[0].elements.filter(element => element.subtype === "elf-audio").length, 2)
- const ppt = await context.renderPptForTest({ width: 1920, height: 1080, slides: prepared,
- theme: { fontName: "Microsoft Yahei", fontColor: "#222222", viewportRatio: 0.5625 } }, { viewportRatio: 0.5625 })
- const bytes = Buffer.from(await ppt.write({ outputType: "arraybuffer" }))
- assert.equal(bytes.subarray(0, 2).toString(), "PK")
- mkdirSync("artifacts/ai-ppt-tests", { recursive: true })
- writeFileSync("artifacts/ai-ppt-tests/offline-media.pptx", bytes)
- const { aiCoursewareToSlides } = await vite.ssrLoadModule('/src/utils/aiCoursewareToSlides.ts')
- const editorial = aiCoursewareToSlides(editorialFixture())
- const editorialPpt = await context.renderPptForTest({ width: 1920, height: 1080, slides: editorial.slides,
- theme: { ...editorial.theme, viewportRatio: 0.5625 } }, { viewportRatio: 0.5625 })
- const editorialBytes = Buffer.from(await editorialPpt.write({ outputType: 'arraybuffer' }))
- writeFileSync('artifacts/ai-ppt-tests/editorial-layout.pptx', editorialBytes)
- const coverXml = readPptxXml(editorialBytes, 'ppt/slides/slide1.xml')
- const titleRun = coverXml.match(/<a:r>.*?<\/a:r>/gs).find(run => run.includes(`<a:t>${editorial.title}</a:t>`))
- assert(titleRun && /<a:rPr\b[^>]*\bb="1"/.test(titleRun), '导出后的标题仍须是粗体,不能丢失视觉层级')
- const pageXml = readPptxXml(editorialBytes, 'ppt/slides/slide2.xml')
- assert(/<a:srgbClr val="E67E22"/.test(pageXml), '强调色分隔线必须进入 PPTX')
- const separators = pageXml.match(/<p:sp>.*?<\/p:sp>/gs).filter(shape => !shape.includes('<a:t>'))
- assert(separators.length && separators.every(shape => !/<a:ext cx="0"|<a:ext cx="\d+" cy="0"/.test(shape)), '分隔线不能导出为零面积路径')
- const photo = `data:image/jpeg;base64,${readFileSync('scripts/fixtures/assets/pianica-yamaha-p32d.jpg').toString('base64')}`
- const teaching = aiCoursewareToSlides(typographyFixture(photo))
- const teachingPpt = await context.renderPptForTest({ width: 1920, height: 1080, slides: teaching.slides,
- theme: { ...teaching.theme, viewportRatio: 0.5625 } }, { viewportRatio: 0.5625 })
- const teachingBytes = Buffer.from(await teachingPpt.write({ outputType: 'arraybuffer' }))
- writeFileSync('artifacts/ai-ppt-tests/teaching-design.pptx', teachingBytes)
- const structureXml = readPptxXml(teachingBytes, 'ppt/slides/slide5.xml')
- const panels = teaching.slides[4].elements.filter(element => element.name === '教学分组色块')
- for (const panel of panels) assert(structureXml.includes(`val="${panel.fill.slice(1).toUpperCase()}"`), '教学色块的填色必须进入 PPTX')
- assert(structureXml.includes('分句练习') && structureXml.includes('连接乐句'), '结构页必须保留可编辑文本')
- assert(structureXml.includes('<a:custGeom>'), '顺序箭头必须导出为原生形状路径')
- assert(readPptxXml(teachingBytes, 'ppt/slides/slide3.xml').includes('<p:pic>'), '观察页必须包含真实图片')
- assert(readPptxXml(teachingBytes, 'ppt/notesSlides/notesSlide5.xml').includes('判断标准'), '课堂判断标准必须进入教师备注')
- await assert.rejects(() => prepareMediaForExport([{ id: "score", elements: [{ ...base, id: "score", type: "elf", subtype: "elf-sing-play", sid: "1" }] }]), /静态|谱图/)
- console.log(JSON.stringify({ passed: true, bytes: bytes.length, output: "artifacts/ai-ppt-tests/offline-media.pptx", checks: ["native-audio", "legacy-audio", "offline-embedded-assets", "reject-online-only-score"] }))
- } finally { await vite.close() }
|