aiPptExportSelfTest.mjs 8.1 KB

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