yonge 1 день назад
Родитель
Сommit
cb40c42167

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dist/assets/aiPptRenderedQuality-Btpjs-RL.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
dist/assets/index-6AgBhxUD.js


+ 1 - 1
dist/assets/index-nN5PyIP0.js → dist/assets/index-BZ9imM9e.js

@@ -1,4 +1,4 @@
-import{cI as pw,cJ as gw,aG as mw,cK as yw,cL as Sw,cM as bw,cN as xw,ah as Wt,cO as Cw,cP as ww,cQ as Tw,cR as Iw,aM as _w,aF as Aw,cS as Ew,aB as d1,bg as Pw,cT as Mw,cU as Rw,cV as Dw,cW as Lw,cX as kw,cY as Ow,cZ as Bw,aE as Nw,c_ as Fw,s as Te,bl as $w,al as je,ag as Fe,af as he,ak as Z,c$ as zw,d0 as Uw,d1 as Hw,d2 as Vw,bk as Gw,cz as cl,aS as wv,aq as Ye,d3 as Ww,d4 as jw,a5 as ze,d5 as Kw,d6 as Xw,d7 as Yw,d8 as Zw,d9 as Qw,da as Jw,db as qw,dc as eT,dd as tT,de as rT,df as nT,v as iT,dg as aT,dh as oT,di as sT,aW as lT,dj as uT,dk as cT,dl as fT,dm as dT,dn as hT,ay as ur,dp as vT,dq as pT,dr as gT,ds as mT,A as yT,dt as ST,du as bT,bq as xT,dv as CT,dw as wT,dx as TT,ap as IT,ac as Tv,ai as ct,dy as _T,au as xe,dz as AT,X as h1,y as ET,dA as PT,aP as MT,dB as RT,w as Er,dC as DT,dD as LT,H as kT,dE as OT,bA as hn,aD as BT,ae as ne,bL as ea,$ as Tc,dF as NT,bK as ta,dG as FT,aC as $T,aO as zT,R as fe,dH as UT,br as ih,b3 as _r,aj as Hs,b1 as lt,b2 as Ua,an as Iv,dI as HT,dJ as VT,dK as GT,dL as WT,dM as jT,dN as KT,dO as XT,Q as YT,dP as ZT,dQ as QT,dR as JT,at as Mt,dS as qT,aR as eI,aY as tI,ad as fl,aZ as rI,dT as nI,dU as iI,dV as aI,P as ce,a6 as oI,dW as sI,dX as lI,dY as uI,dZ as cI,a7 as fI,d_ as dI,bP as hI,d$ as vI,e0 as pI,bO as gI,b4 as v1,aA as _v,e1 as mI,e2 as yI,E as Jt,x as SI,e3 as bI,e4 as xI,e5 as CI,am as _n,e6 as wI,az as sr,aU as Vs,e7 as TI,as as pr,e8 as II,e9 as Av,cH as yr,by as ah,bI as oh,bJ as _I,bH as Ve,bu as sh,bv as Ev,bx as jt,bB as Ic,bS as di,ea as Tu,bE as hi,eb as AI,bM as ra,bC as EI,bw as Lt,ec as PI,ed as ui,bQ as MI,ee as RI,ef as DI,eg as LI,eh as kI,ei as Jp,ej as OI,bF as qp,cE as _c,cD as BI,ek as Vu,el as _l}from"./index-D2QfjzK0.js";/**
+import{cI as pw,cJ as gw,aG as mw,cK as yw,cL as Sw,cM as bw,cN as xw,ah as Wt,cO as Cw,cP as ww,cQ as Tw,cR as Iw,aM as _w,aF as Aw,cS as Ew,aB as d1,bg as Pw,cT as Mw,cU as Rw,cV as Dw,cW as Lw,cX as kw,cY as Ow,cZ as Bw,aE as Nw,c_ as Fw,s as Te,bl as $w,al as je,ag as Fe,af as he,ak as Z,c$ as zw,d0 as Uw,d1 as Hw,d2 as Vw,bk as Gw,cz as cl,aS as wv,aq as Ye,d3 as Ww,d4 as jw,a5 as ze,d5 as Kw,d6 as Xw,d7 as Yw,d8 as Zw,d9 as Qw,da as Jw,db as qw,dc as eT,dd as tT,de as rT,df as nT,v as iT,dg as aT,dh as oT,di as sT,aW as lT,dj as uT,dk as cT,dl as fT,dm as dT,dn as hT,ay as ur,dp as vT,dq as pT,dr as gT,ds as mT,A as yT,dt as ST,du as bT,bq as xT,dv as CT,dw as wT,dx as TT,ap as IT,ac as Tv,ai as ct,dy as _T,au as xe,dz as AT,X as h1,y as ET,dA as PT,aP as MT,dB as RT,w as Er,dC as DT,dD as LT,H as kT,dE as OT,bA as hn,aD as BT,ae as ne,bL as ea,$ as Tc,dF as NT,bK as ta,dG as FT,aC as $T,aO as zT,R as fe,dH as UT,br as ih,b3 as _r,aj as Hs,b1 as lt,b2 as Ua,an as Iv,dI as HT,dJ as VT,dK as GT,dL as WT,dM as jT,dN as KT,dO as XT,Q as YT,dP as ZT,dQ as QT,dR as JT,at as Mt,dS as qT,aR as eI,aY as tI,ad as fl,aZ as rI,dT as nI,dU as iI,dV as aI,P as ce,a6 as oI,dW as sI,dX as lI,dY as uI,dZ as cI,a7 as fI,d_ as dI,bP as hI,d$ as vI,e0 as pI,bO as gI,b4 as v1,aA as _v,e1 as mI,e2 as yI,E as Jt,x as SI,e3 as bI,e4 as xI,e5 as CI,am as _n,e6 as wI,az as sr,aU as Vs,e7 as TI,as as pr,e8 as II,e9 as Av,cH as yr,by as ah,bI as oh,bJ as _I,bH as Ve,bu as sh,bv as Ev,bx as jt,bB as Ic,bS as di,ea as Tu,bE as hi,eb as AI,bM as ra,bC as EI,bw as Lt,ec as PI,ed as ui,bQ as MI,ee as RI,ef as DI,eg as LI,eh as kI,ei as Jp,ej as OI,bF as qp,cE as _c,cD as BI,ek as Vu,el as _l}from"./index-DCGMNxmG.js";/**
 * vue v3.4.34
 * (c) 2018-present Yuxi (Evan) You and Vue contributors
 * @license MIT

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dist/assets/index-Cu2SCqTH.css


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
dist/assets/index-DCGMNxmG.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dist/assets/index-DVYxpLqz.js


+ 1 - 1
dist/assets/index-CKtkKYz2.js → dist/assets/index-DVguX3Ap.js

@@ -1 +1 @@
-import{u as c,ag as l}from"./index-nN5PyIP0.js";import{w as p,bA as d,a5 as g,cE as m,R as t,ae as r,af as u,al as f,ag as w,bH as k}from"./index-D2QfjzK0.js";import"./shapes-DG-CIAVU.js";const S=()=>{function o(a){if(window.parent===window)return;const{api:e,playState:n}=a.data||{};e==="headerTogge"?window.parent.postMessage({api:"headerTogge",playState:n},"*"):e==="changeTogge"?window.parent.postMessage({api:"changeTogge"},"*"):["clickTempo","clickViewFigner"].includes(e)&&window.parent.postMessage({api:e},"*")}p(()=>{window.addEventListener("message",o)}),d(()=>{window.removeEventListener("message",o)})},_=g({__name:"mobileScreen",setup(o){S();const a=m(),e=t(!0),n=t(!1),i=c();return a.setScreenMode("mobileScreen"),i.initPPTData().then(()=>{e.value=!1}),(v,s)=>(r(),u("div",{class:"mobileScreen",onClick:s[0]||(s[0]=b=>n.value=!1)},[e.value?w("",!0):(r(),f(l,{key:0}))]))}}),E=k(_,[["__scopeId","data-v-90b2692a"]]);export{E as default};
+import{u as c,ag as l}from"./index-BZ9imM9e.js";import{w as p,bA as d,a5 as g,cE as m,R as t,ae as r,af as u,al as f,ag as w,bH as k}from"./index-DCGMNxmG.js";import"./shapes-BUwCPLia.js";const S=()=>{function o(a){if(window.parent===window)return;const{api:e,playState:n}=a.data||{};e==="headerTogge"?window.parent.postMessage({api:"headerTogge",playState:n},"*"):e==="changeTogge"?window.parent.postMessage({api:"changeTogge"},"*"):["clickTempo","clickViewFigner"].includes(e)&&window.parent.postMessage({api:e},"*")}p(()=>{window.addEventListener("message",o)}),d(()=>{window.removeEventListener("message",o)})},_=g({__name:"mobileScreen",setup(o){S();const a=m(),e=t(!0),n=t(!1),i=c();return a.setScreenMode("mobileScreen"),i.initPPTData().then(()=>{e.value=!1}),(v,s)=>(r(),u("div",{class:"mobileScreen",onClick:s[0]||(s[0]=b=>n.value=!1)},[e.value?w("",!0):(r(),f(l,{key:0}))]))}}),E=k(_,[["__scopeId","data-v-90b2692a"]]);export{E as default};

+ 1 - 1
dist/assets/index-Dtx5Wbe5.js → dist/assets/index-DpNiBfyg.js

@@ -1 +1 @@
-import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,ep as t,ae as r,af as c,bK as _,bL as d,ak as s,bH as p}from"./index-D2QfjzK0.js";const i=e=>(_("data-v-dda25179"),e=e(),d(),e),n={class:"errorPage"},m=i(()=>s("div",{class:"error"},[s("img",{src:a,class:"img",alt:""}),s("div",{class:"tit"},"页面找不到了~")],-1)),l=[m],f=o({__name:"errorPage",setup(e){return t(),(u,h)=>(r(),c("div",n,l))}}),I=p(f,[["__scopeId","data-v-dda25179"]]);export{I as default};
+import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,ep as t,ae as r,af as c,bK as _,bL as d,ak as s,bH as p}from"./index-DCGMNxmG.js";const i=e=>(_("data-v-dda25179"),e=e(),d(),e),n={class:"errorPage"},m=i(()=>s("div",{class:"error"},[s("img",{src:a,class:"img",alt:""}),s("div",{class:"tit"},"页面找不到了~")],-1)),l=[m],f=o({__name:"errorPage",setup(e){return t(),(u,h)=>(r(),c("div",n,l))}}),I=p(f,[["__scopeId","data-v-dda25179"]]);export{I as default};

+ 1 - 1
dist/assets/index-D4RSbcyx.js → dist/assets/index-Epnr0Ly_.js

@@ -1 +1 @@
-import{u as s,ag as r}from"./index-nN5PyIP0.js";import{a5 as c,cE as n,R as p,ae as t,af as _,al as u,ag as i,bH as l}from"./index-D2QfjzK0.js";const m={class:"pptScreen"},d=c({__name:"pptScreen",setup(f){const a=n(),e=p(!0),o=s();return a.setScreenMode("pptScreen"),o.initPPTData().then(()=>{e.value=!1}),(S,k)=>(t(),_("div",m,[e.value?i("",!0):(t(),u(r,{key:0}))]))}}),g=l(d,[["__scopeId","data-v-678905e9"]]);export{g as default};
+import{u as s,ag as r}from"./index-BZ9imM9e.js";import{a5 as c,cE as n,R as p,ae as t,af as _,al as u,ag as i,bH as l}from"./index-DCGMNxmG.js";const m={class:"pptScreen"},d=c({__name:"pptScreen",setup(f){const a=n(),e=p(!0),o=s();return a.setScreenMode("pptScreen"),o.initPPTData().then(()=>{e.value=!1}),(S,k)=>(t(),_("div",m,[e.value?i("",!0):(t(),u(r,{key:0}))]))}}),g=l(d,[["__scopeId","data-v-678905e9"]]);export{g as default};

+ 1 - 1
dist/assets/index-BY0eRu6G.js → dist/assets/index-mu1reoqP.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-D18s75YP.js";import{a5 as n,ae as i,af as r,ak as o,aq as _,am as d,P as l,aS as p,bK as m,bL as f,bH as u}from"./index-D2QfjzK0.js";const s=a=>(m("data-v-340bbdaa"),a=a(),f(),a),h={class:"login"},b={class:"error"},g=s(()=>o("img",{src:t,class:"img",alt:""},null,-1)),k=s(()=>o("div",{class:"tit"},"登录已过期或服务器错误!",-1)),v=n({__name:"login",setup(a){function e(){window.close()}return(x,B)=>(i(),r("div",h,[o("div",b,[g,k,_(l(c),{class:"backBtn",type:"primary",plain:"",onClick:e},{default:d(()=>[p("关闭页面")]),_:1})])]))}}),C=u(v,[["__scopeId","data-v-340bbdaa"]]);export{C as default};
+import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-DVYxpLqz.js";import{a5 as n,ae as i,af as r,ak as o,aq as _,am as d,P as l,aS as p,bK as m,bL as f,bH as u}from"./index-DCGMNxmG.js";const s=a=>(m("data-v-340bbdaa"),a=a(),f(),a),h={class:"login"},b={class:"error"},g=s(()=>o("img",{src:t,class:"img",alt:""},null,-1)),k=s(()=>o("div",{class:"tit"},"登录已过期或服务器错误!",-1)),v=n({__name:"login",setup(a){function e(){window.close()}return(x,B)=>(i(),r("div",h,[o("div",b,[g,k,_(l(c),{class:"backBtn",type:"primary",plain:"",onClick:e},{default:d(()=>[p("关闭页面")]),_:1})])]))}}),C=u(v,[["__scopeId","data-v-340bbdaa"]]);export{C as default};

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
dist/assets/index-osF7mEPD.js


+ 1 - 1
dist/assets/shapes-DG-CIAVU.js → dist/assets/shapes-BUwCPLia.js

@@ -1,4 +1,4 @@
-import{cH as k,bB as $1}from"./index-D2QfjzK0.js";import{ah as Z,ai as H1,aj as U,ak as n0,al as c0,am as i0,an as j1,ao as K1,ap as Y1,aq as R}from"./index-nN5PyIP0.js";var z1={exports:{}};/*!
+import{cH as k,bB as $1}from"./index-DCGMNxmG.js";import{ah as Z,ai as H1,aj as U,ak as n0,al as c0,am as i0,an as j1,ao as K1,ap as Y1,aq as R}from"./index-BZ9imM9e.js";var z1={exports:{}};/*!
  * clipboard.js v2.0.11
  * https://clipboardjs.com/
  *

+ 1 - 1
dist/index.html

@@ -10,7 +10,7 @@
     <meta name="description" content="ppt编辑器" />
     <meta name="keywords" content="ppt,powerpoint,office powerpoint,在线ppt,幻灯片,演示文稿,ppt在线制作,Vue3,TypeScript" />
     <title>PPT</title>
-    <script type="module" crossorigin src="./assets/index-D2QfjzK0.js"></script>
+    <script type="module" crossorigin src="./assets/index-DCGMNxmG.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-AavUEaYy.css">
   </head>
   <body>

+ 36 - 1
scripts/aiPptDraftSelfTest.mjs

@@ -4,6 +4,7 @@ import { createRenderer, reactive, nextTick, h, ssrContextKey } from 'vue'
 import { renderToString } from '@vue/server-renderer'
 import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
 import { comparisonCapacityFixture } from './fixtures/aiPptComparisonCapacity.mjs'
+import { slideDesignFixture } from './fixtures/aiPptSlideDesign.mjs'
 
 // 测试实际对话框的续作/草稿状态流程。浏览器排版和媒体探测以明确结果模拟,
 // 此脚本不代表浏览器视觉验收,不访问生成服务或线上课件。
@@ -88,6 +89,11 @@ try {
   }
 
   let { state, dialog } = mount()
+  dialog.loadOutline(clone(state.result.outline))
+  dialog.confirmed = clone(state.result.outline)
+  await dialog.startGeneration()
+  assert(state.calls.find(call => call.kind === 'generate').payload.visualPrompt.includes('文字层级'), '新课件生成请求必须携带实际模板设计要求')
+  ;({ state, dialog } = mount())
   await stageDraft(dialog)
   assert(dialog.completionSource && dialog.showCompletionContent, '未完成任务自动展示预览')
   const before = JSON.stringify(state.slides)
@@ -175,7 +181,36 @@ try {
   assert.equal(state.slides.slides[0].id, 'old', '显式打开也不能混用课题')
   assert(dialog.generationError.includes('不符'))
 
-  console.log('PASS: 草稿自动预览、独立画布、重复缺图续作、打开编辑、问题保留、快照、排版阻断、取消替换和课题校验')
+  ;({ state, dialog } = mount())
+  state.result = { outline: slideDesignFixture(), context: {}, blockingMessage: '' }
+  dialog.loadOutline(clone(state.result.outline))
+  dialog.task = { taskId: 'model-designed', status: 'DONE' }
+  state.renderedIssues = [{ code: 'rendered_text_overflow', pageNo: 2, severity: 'error', message: '正文实际换行超过预留高度' }]
+  state.request = async function(kind, payload) {
+    this.calls.push({ kind, payload: clone(payload) })
+    return { code: 200, data: kind === 'generate' ? { taskId: 'design-repaired', status: 'PENDING' }
+      : kind === 'task' ? { taskId: 'design-repaired', status: 'DONE' } : clone(this.result) }
+  }
+  await dialog.enterEditor(clone(state.result), () => true)
+  const repairs = state.calls.filter(call => call.kind === 'generate')
+  assert.equal(repairs.length, 2, '同一渲染问题最多自动修复两轮')
+  assert(repairs.every(call => call.payload.designOnly === true && call.payload.repairOnly === true))
+  assert.deepEqual(repairs[0].payload.renderFeedback, [{ pageNo: 2, code: 'rendered_text_overflow', message: '正文实际换行超过预留高度' }])
+  assert.deepEqual(repairs[0].payload.outline.pages.map(page => page.studentContent), state.result.outline.pages.map(page => page.studentContent))
+  assert.equal(state.emitted.length, 0, '仍未通过实际渲染检查时不得应用到课件')
+  assert(dialog.completionSource && dialog.generationError, '失败时保留全部页面和错误原因')
+
+  ;({ state, dialog } = mount())
+  state.result = { outline: slideDesignFixture(), context: {}, blockingMessage: '第2页视觉设计尚未完成',
+    renderFeedback: [{ pageNo: 2, code: 'rendered_text_overflow', message: '正文实际换行超过预留高度' }] }
+  await stageDraft(dialog)
+  assert(dialog.layoutRepairSource && dialog.modelLayoutRepair, '服务端设计修复失败后继续保留设计修复入口')
+  await dialog.retry()
+  const repeated = state.calls.find(call => call.kind === 'generate')
+  assert.equal(repeated.payload.designOnly, true)
+  assert.deepEqual(repeated.payload.renderFeedback, state.result.renderFeedback, '不能丢失失败页的浏览器诊断而重复普通内容生成')
+
+  console.log('PASS: 草稿预览与续作回归;模型渲染诊断仅修对应页布局、保留正文,两轮失败后保留草稿且不应用课件')
 } finally {
   app?.unmount()
   await vite.close()

+ 52 - 1
scripts/aiPptExportSelfTest.mjs

@@ -2,8 +2,29 @@ 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 {
@@ -19,6 +40,15 @@ try {
   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)
@@ -47,7 +77,28 @@ try {
   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 })
-  writeFileSync('artifacts/ai-ppt-tests/editorial-layout.pptx', Buffer.from(await editorialPpt.write({ outputType: 'arraybuffer' })))
+  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() }

+ 38 - 5
scripts/aiPptSelfTest.mjs

@@ -13,6 +13,7 @@ import { storyVisualFixture } from './fixtures/aiPptStoryVisual.mjs'
 import { nativeVisualFixture } from './fixtures/aiPptNativeVisual.mjs'
 import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
 import { comparisonCapacityFixture } from './fixtures/aiPptComparisonCapacity.mjs'
+import { typographyFixture } from './fixtures/aiPptTypography.mjs'
 
 const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
 
@@ -42,6 +43,34 @@ try {
   for (const step of editorial.pages[3].studentContent.steps) assert(stepText.some(element => element.content.replace(/<[^>]*>/g, '').includes(step)), '步骤原文完整保留')
   assert(editorialSlides[1].elements.filter(element => element.type === 'text' && element.content.includes('<strong>')).length === 3, '明确标注的要点形成主次层级')
   assert(editorialSlides[0].elements.find(element => element.type === 'text' && element.content.includes(editorial.title)).content.includes('text-align:center'), '文字封面以课题为中心')
+  const typography = typographyFixture(), typographySlides = aiCoursewareToSlides(typography).slides
+  assert.equal(typographySlides.length, 10)
+  assert.deepEqual(inspectAiPptQuality(typography, typographySlides).errors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font'].includes(issue.code)), [])
+  assert.equal(typographySlides.filter(slide => slide.elements.some(element => element.type === 'image')).length, 1, '只有实物观察页需要真实图片')
+  assert.equal(typographySlides[2].elements.filter(element => element.name === '教学分组色块').length, 2, '配图页继续使用同一组教学色块')
+  const comparisonIndex = typography.pages.findIndex(page => page.title === '同伴互评的三个角度')
+  const comparisonLabels = typography.pages[comparisonIndex].studentContent.comparisonGroups.map(group => typographySlides[comparisonIndex].elements.find(element => element.type === 'text' && element.content.replace(/<[^>]*>/g, '') === group.label))
+  assert(comparisonLabels.every((label, i) => label && label.top === comparisonLabels[0].top && (!i || label.left > comparisonLabels[i - 1].left)), '三个比较对象完整并列,不能拆成不对称的两行')
+  const groupPanels = typographySlides[comparisonIndex].elements.filter(element => element.name === '教学分组色块')
+  assert.equal(groupPanels.length, 3)
+  assert.equal(new Set(groupPanels.map(element => element.fill)).size, 3, '三个评价角度用不同浅色色块区分')
+  assert(!typographySlides[comparisonIndex].elements.some(element => element.name === '教学顺序箭头'), '平行比较不能被画成顺序关系')
+  const sequenceIndex = typography.pages.findIndex(page => page.diagram?.kind === 'sequence')
+  assert.equal(typographySlides[sequenceIndex].elements.filter(element => element.name === '教学分组色块').length, 2)
+  assert.equal(typographySlides[sequenceIndex].elements.filter(element => element.name === '教学顺序箭头').length, 1, '明确的先后关系用原生箭头连接')
+  const simpleSequence = structuredClone(typography)
+  simpleSequence.pages[sequenceIndex].studentContent.comparisonGroups = []
+  const simpleSequenceSlide = aiCoursewareToSlides(simpleSequence).slides[sequenceIndex]
+  assert.equal(simpleSequenceSlide.elements.filter(element => element.name === '教学顺序箭头').length, 1, '只有原生 sequence 数据时也完整绘制结构色块和箭头')
+  assert(typographySlides.every(slide => slide.remark.includes('判断标准:')), '教师备注保留每页的判断标准')
+  const withoutPhoto = structuredClone(typography)
+  withoutPhoto.pages[2].image = undefined
+  assert(inspectAiPptQuality(withoutPhoto, aiCoursewareToSlides(withoutPhoto).slides).errors.some(issue => issue.code === 'missing_required_visual' && issue.pageNo === 3), '实物图缺失不能用漂亮排版掩盖')
+  const { studentTexts } = await vite.ssrLoadModule('/src/utils/aiPptContent.ts')
+  typography.pages.forEach((page, index) => {
+    const visible = typographySlides[index].elements.filter(element => element.type === 'text').map(element => element.content.replace(/<[^>]*>/g, '')).join('')
+    for (const text of studentTexts(page)) assert(visible.includes(text), `第${index + 1}页学生内容必须完整保留`)
+  })
   const gateFixture = contentGateFixture(), gateSlides = aiCoursewareToSlides(gateFixture).slides
   const layoutCodes = ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font']
   const live = liveRegressionFixture(), liveSlides = aiCoursewareToSlides(live).slides
@@ -58,7 +87,7 @@ try {
   assert.deepEqual(inspectAiPptQuality(gateFixture, gateSlides).errors.filter(issue => layoutCodes.includes(issue.code)), [], '线上结构回归应完整可读')
   const dots = slide => slide.elements.filter(element => element.type === 'shape' && element.path === 'M100,0 A100,100 0 1,1 99.99,0 Z')
   assert.equal(dots(liveSlides[10]).length, 2, '混合概念图只为强弱绘制圆形,长短画条形')
-  const bars = liveSlides[10].elements.filter(e => e.type === 'shape' && !dots(liveSlides[10]).includes(e))
+  const bars = liveSlides[10].elements.filter(e => e.type === 'shape' && e.name !== '版式分隔线' && !dots(liveSlides[10]).includes(e))
   assert.equal(bars.length, 2)
   assert(bars[0].width > bars[1].width, '长条必须比短条长')
   const { assessmentAnnotationTexts } = await vite.ssrLoadModule('/src/utils/aiPptContent.ts')
@@ -89,7 +118,7 @@ try {
   })
   assert.deepEqual(inquiryReport.errors, [], `无媒体教学样例:${JSON.stringify(inquiryReport.errors)}`)
   assert(inquiryResult.slides.some(slide => slide.background.color.toUpperCase() === '#FAF8F2'), '保留整课纸底主题色')
-  assert(new Set(inquiryResult.slides.map(slide => slide.background.color)).size >= 3, '封面、情境和原生图示使用有层次的主题底色')
+  assert.equal(new Set(inquiryResult.slides.map(slide => slide.background.color)).size, 1, '通用教学样式保持统一纸底,用教学色块区分内容')
   const story = storyVisualFixture(), storySlides = aiCoursewareToSlides(story).slides
   assert.deepEqual(inspectAiPptQuality(story, storySlides).errors.filter(issue => layoutCodes.includes(issue.code)), [], '角色配图、步骤与完整范例不得重叠或缺字')
   const roleImage = storySlides[1].elements.find(e => e.type === 'image')
@@ -100,8 +129,12 @@ try {
   assert.deepEqual(inspectAiPptQuality(native, nativeSlides).errors.filter(issue => layoutCodes.includes(issue.code)), [], '无图课件的色块、任务图标和文字应完整且不重叠')
   assert(nativeSlides.every(slide => slide.elements.every(e => e.type !== 'image')), '原生视觉不应新增图片资源')
   const taskIcons = slide => slide.elements.filter(e => e.name?.startsWith('教学图标:'))
-  assert.deepEqual(taskIcons(nativeSlides[1]).map(e => e.name), ['教学图标:聆听', '教学图标:观察', '教学图标:交流'])
-  assert.equal(taskIcons(nativeSlides[2]).length, 3)
+  assert.equal(taskIcons(nativeSlides[1]).length, 0, '无图文字版式以内容层级组织任务,不再依赖装饰图标')
+  assert.equal(taskIcons(nativeSlides[2]).length, 0)
+  for (const pageIndex of [1, 2]) {
+    const visible = nativeSlides[pageIndex].elements.filter(e => e.type === 'text').map(e => e.content.replace(/<[^>]*>/g, '')).join('')
+    for (const text of [...native.pages[pageIndex].studentContent.points, ...(native.pages[pageIndex].studentContent.steps || [])]) assert(visible.includes(text), '调整视觉表达后仍须逐字保留课堂任务')
+  }
   assert.equal(taskIcons(nativeSlides[3]).length, 0, '节奏图示不混入任务装饰图标')
   assert(taskIcons(nativeSlides[1]).every(e => e.type === 'shape' && e.fixedRatio && /^[MLCZ0-9. ,]+$/.test(e.path)), '图标应是可编辑且可导出的矢量路径')
   const { teachingActionIcon } = await vite.ssrLoadModule('/src/config/aiPptTeachingIcons.ts')
@@ -138,7 +171,7 @@ try {
   assert.deepEqual(inspectAiPptQuality(repairFixture, repaired).errors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font', 'invalid_teaching_diagram'].includes(issue.code)), [])
   const repairedCoverImage = repaired[0].elements.find(el => el.type === 'image')
   assert(repairedCoverImage.width * repairedCoverImage.height / (1920 * 1080) >= .2, '真实封面结构不得把主图压成小横幅')
-  const durationBars = repaired[2].elements.filter(el => el.type === 'shape')
+  const durationBars = repaired[2].elements.filter(el => el.type === 'shape' && el.name !== '版式分隔线')
   assert.equal(durationBars.length, 4, '每个短音独立呈现')
   assert(Math.abs(durationBars[0].width / durationBars[1].width - 2) < .01, '时值条严格使用提供的2:1比例')
   const pitchLabels = ['do', 're', 'mi'].map(label => repaired[3].elements.find(el => elementText(el) === label))

+ 39 - 0
scripts/aiPptSlideDesignSelfTest.mjs

@@ -0,0 +1,39 @@
+import { createServer } from 'vite'
+import assert from 'node:assert/strict'
+import { writeFileSync } from 'node:fs'
+import { slideDesignFixture } from './fixtures/aiPptSlideDesign.mjs'
+const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' })
+try {
+  const { aiCoursewareToSlides } = await vite.ssrLoadModule('/src/utils/aiCoursewareToSlides.ts')
+  const { modelSlideDesignIssues } = await vite.ssrLoadModule('/src/utils/aiPptModelSlideDesign.ts')
+  const { getAiPptImageAspectRatios, buildAiPptSlidePreviews } = await vite.ssrLoadModule('/src/utils/aiPptTemplateEngine.ts')
+  const fixture = slideDesignFixture(), before = structuredClone(fixture)
+  const result = aiCoursewareToSlides(fixture)
+  assert.deepEqual(fixture, before, '渲染不得改写输入内容')
+  assert.equal(result.slides[1].elements.find(e => e.name === '模型教学色块').left, 130 * 1.2)
+  assert.equal(result.slides[1].elements.find(e => e.name === '模型教学色块').fill, '#FCE4CE')
+  assert(result.slides[0].elements.find(e => e.type === 'text').content.includes('67.2px'), '模型字号按画布比例转换,不由前端重新选择')
+  assert(result.slides.every(s => s.background.color === '#FFFDF5'))
+  assert.equal(getAiPptImageAspectRatios('blank').model_design_v7, 1)
+  for (const [name, mutate, reason] of [
+    ['overflow', p => p.pageDesign.elements[3].height = 10, /实际高度|投屏安全区/],
+    ['missing', p => p.pageDesign.elements.pop(), /遗漏/],
+    ['contrast', p => p.pageDesign.elements[3].color = '#D9EDF3', /对比度/],
+    ['unknown reference', p => p.pageDesign.elements[3].textRef = 'interaction.answer', /引用/],
+    ['required image', p => p.visualPlan.visualMode = 'reference_image', /槽位/],
+    ['unknown element', p => p.pageDesign.elements[0].kind = 'script', /无效/]
+  ]) {
+    const broken = structuredClone(fixture); mutate(broken.pages[0])
+    assert.throws(() => aiCoursewareToSlides(broken), reason, name)
+    const previews = buildAiPptSlidePreviews(broken)
+    assert(previews[0].error && previews[1].slide, `${name}不能清空其他页`)
+  }
+  assert.deepEqual(modelSlideDesignIssues(fixture.pages[0]), [])
+  const textOnly = structuredClone(fixture)
+  textOnly.pages.forEach(page => { page.pageDesign.elements = page.pageDesign.elements.filter(e => e.kind === 'text') })
+  const textOnlySlides = aiCoursewareToSlides(textOnly).slides
+  assert(textOnlySlides.every(slide => slide.elements.every(element => element.type === 'text')), '纯文字设计可通过,不得自动添加色块或分隔线')
+  writeFileSync('artifacts/ai-ppt-tests/model-slide-design-fixture.json', JSON.stringify(fixture, null, 2))
+  writeFileSync('artifacts/ai-ppt-tests/model-slide-design-render.json', JSON.stringify(result, null, 2))
+  console.log('PASS: 模型坐标/颜色/字号忠实执行;内容不变;溢出、遗漏、低对比、未知引用、缺图片槽位和未知元素均阻断;单页错误保留其他预览。此测试使用协议夹具。')
+} finally { await vite.close() }

+ 24 - 5
scripts/aiPptVisualTest.html

@@ -30,6 +30,8 @@
       import { modelDesignFixture } from "./fixtures/aiPptModelDesign.mjs"
       import { inquiryFixture } from './fixtures/aiPptInquiry.mjs'
       import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
+      import { typographyFixture } from './fixtures/aiPptTypography.mjs'
+      import { slideDesignFixture } from './fixtures/aiPptSlideDesign.mjs'
       import { inspectAiPptRenderedText } from '/src/utils/aiPptRenderedQuality.ts'
 
       const id = new URLSearchParams(location.search).get("template") || "ethnic-flower"
@@ -51,18 +53,35 @@
       }
       const scenario = new URLSearchParams(location.search).get("scenario")
       const fixtureImage = '/ai-courseware-templates/extracted/bird-garden/background-01.jpg'
-      const fixture = scenario === 'editorial' ? editorialFixture() : scenario === 'live' ? liveRegressionFixture() : scenario === 'content-gate' ? contentGateFixture() : scenario === 'repair' ? lessonRepairFixture(fixtureImage) : scenario === 'inquiry' ? inquiryFixture() : scenario === 'hero-images' ? heroImageRegressionFixture(fixtureImage) : scenario === 'density' ? densityRegressionFixture(fixtureImage) : scenario === 'semantic' ? semanticDesignFixture(fixtureImage) : scenario === 'model-design' ? modelDesignFixture(fixtureImage)
+      const teachingImage = scenario === 'typography' ? await new Promise(async (resolve, reject) => {
+        try {
+          const response = await fetch('/scripts/fixtures/assets/pianica-yamaha-p32d.jpg')
+          if (!response.ok) throw new Error('实物图读取失败')
+          const reader = new FileReader()
+          reader.onload = () => resolve(reader.result)
+          reader.onerror = reject
+          reader.readAsDataURL(await response.blob())
+        } catch (error) { reject(error) }
+      }) : undefined
+      const fixture = scenario === 'slide-design' ? slideDesignFixture() : scenario === 'typography' ? typographyFixture(teachingImage) : scenario === 'editorial' ? editorialFixture() : scenario === 'live' ? liveRegressionFixture() : scenario === 'content-gate' ? contentGateFixture() : scenario === 'repair' ? lessonRepairFixture(fixtureImage) : scenario === 'inquiry' ? inquiryFixture() : scenario === 'hero-images' ? heroImageRegressionFixture(fixtureImage) : scenario === 'density' ? densityRegressionFixture(fixtureImage) : scenario === 'semantic' ? semanticDesignFixture(fixtureImage) : scenario === 'model-design' ? modelDesignFixture(fixtureImage)
         : scenario === 'classroom' ? classroomFixture(fixtureImage) : outline
       const result = aiCoursewareToSlides(fixture, { templateId: id })
       const group = ref(0)
       const geometryStatus = ref("正在检查实际文字边界")
-      const previewWidth = Math.floor(Math.min(720, (window.innerHeight - 96) * 8 / 9))
+      const previewColumns = window.innerWidth >= 1100 ? 2 : 1
+      const previewWidth = Math.floor(Math.min(720, (window.innerHeight - 180) * 8 / 9, (window.innerWidth - 48 - (previewColumns - 1) * 24) / previewColumns))
+      const presentationPreview = new URLSearchParams(location.search).get('present') === '1'
+      if (presentationPreview) {
+        const style = document.createElement('style')
+        style.textContent = 'body{padding:24px!important;background:#e9edf1!important}main>h1{margin:0 0 14px!important;font-size:22px!important;color:#26334a}main>button{padding:8px 16px;margin:0 8px 18px 0;border:1px solid #c4cbd4;background:white;cursor:pointer;border-radius:4px}#geometry-status,#rendered-quality-status,#export-status{display:none}.grid{margin-bottom:24px}'
+        document.head.appendChild(style)
+      }
       const app = createApp({
         render: () => h("main", [
-          h("h1", `${getAiCoursewareTemplate(id).name} · 通用版式视觉回归`),
+          h("h1", presentationPreview ? `教学色块与配图预览 · ${result.slides.length}页设计样例` : `${getAiCoursewareTemplate(id).name} · 通用版式视觉回归`),
           h("div", { id: "geometry-status" }, geometryStatus.value),
           ...Array.from({ length: Math.ceil(result.slides.length / 4) }, (_, index) => h('button', { onClick: () => { group.value = index } }, `查看第${index * 4 + 1}—${Math.min(index * 4 + 4, result.slides.length)}页`)),
-          h("section", { class: "grid", style: { gridTemplateColumns: `repeat(2, ${previewWidth}px)` } }, result.slides.slice(group.value * 4, group.value * 4 + 4).map(slide => h("div", { class: "slide" }, [h(ThumbnailSlide, { slide, size: previewWidth })])))
+          h("section", { class: "grid", style: { gridTemplateColumns: `repeat(${previewColumns}, ${previewWidth}px)` } }, result.slides.slice(group.value * 4, group.value * 4 + 4).map(slide => h("div", { class: "slide" }, [h(ThumbnailSlide, { slide, size: previewWidth })])))
         ])
       })
       const pinia = createPinia()
@@ -92,7 +111,7 @@
       watch(group, checkRenderedGeometry)
       checkRenderedGeometry()
 
-      if (scenario === 'editorial') {
+      if (['editorial', 'typography', 'slide-design'].includes(scenario)) {
         const status = document.createElement('p')
         status.id = 'rendered-quality-status'
         status.textContent = '正在运行实际渲染回归'

+ 17 - 0
scripts/fixtures/aiPptSlideDesign.mjs

@@ -0,0 +1,17 @@
+// 协议联调夹具;不是大模型实测结果。
+export function slideDesignFixture() {
+  const page = (pageNo, title, points, x = 80, fill = '#D9EDF3') => ({
+    pageNo, type: pageNo === 1 ? 'cover' : 'knowledge', title, bullets: [], studentContent: { points },
+    visualPlan: { composition: 'text_focus', visualMode: 'text_only', textPresentation: 'plain', imageNecessity: 'none', imagePurpose: 'none' },
+    pageDesign: { version: 1, background: '#FFFDF5', rationale: '突出学习任务,以浅色色块区分内容层次', elements: [
+      { id: 'panel', kind: 'panel', x, y: 280, width: 1350, height: 410, fill, radius: 18 },
+      { id: 'title', kind: 'text', textRef: 'title', x: 80, y: 80, width: 1400, height: 120, fontSize: 56, color: '#2C3E6B', align: 'left', bold: true },
+      { id: 'accent', kind: 'line', x: 90, y: 220, width: 160, height: 5, fill: '#E67E22' },
+      ...points.map((_, i) => ({ id: `point-${i}`, kind: 'text', textRef: `studentContent.points.${i}`, x: x + 30, y: 315 + i * 115,
+        width: 1290, height: 100, fontSize: 36, color: '#2C3E6B', align: 'left' }))
+    ] }
+  })
+  return { title: '口风琴教学设计协议测试', chapterName: '口风琴教学设计协议测试', templateId: 'blank', style: 'fresh',
+    pages: [page(1, '口风琴吹奏与表现', ['用稳定的气息,让声音连贯']), page(2, '聆听与表达', ['听一听声音的变化', '说一说你听到的特点'], 130, '#FCE4CE')]
+  }
+}

+ 69 - 0
scripts/fixtures/aiPptTypography.mjs

@@ -0,0 +1,69 @@
+import { editorialFixture } from './aiPptEditorial.mjs'
+
+// 教学版式样例,不作为特定版本教材内容或模型实测结果。
+// 实物照片来自 Yamaha 官方产品页,不能替代具体曲目的谱例或指法示范。
+export const pianicaImageSource = 'https://usa.yamaha.com/products/musical_instruments/winds/pianica/p-32d/index.html'
+export function typographyFixture(image = 'https://usa.yamaha.com/files/p-32d_o_0001_f_735x735_4954d94cc3b6f1e8c6ca1863add42358.jpg') {
+  const outline = editorialFixture()
+  const make = (title, content, composition = 'concept_grid', type = 'knowledge') => ({
+    type, title, studentContent: content, imageKeywords: [], requiredVisual: false,
+    visualPlan: { composition, visualMode: 'text_only', imagePurpose: 'none', imageNecessity: 'none', textPresentation: 'plain' },
+    speakerNotes: '结合教师现场示范进行练习,按学生实际表现反馈。'
+  })
+  outline.pages.splice(4, 0,
+    make('把一句旋律吹连贯', { headline: '每次练习,留意一个细节', points: [
+      '气息:让声音平稳延续,听清长音的结束。',
+      '换气:在约定的位置停顿,下一句及时进入。',
+      '表现:比较自己的两次演奏,说出声音的变化。'
+    ] }),
+    make('同伴互评的三个角度', { comparisonGroups: [
+      { label: '连贯', points: ['长音保持到位了吗?', '乐句衔接自然吗?'] },
+      { label: '稳定', points: ['速度保持一致了吗?', '声音是否平稳?'] },
+      { label: '表达', points: ['能听出强弱变化吗?', '变化是否符合乐句?'] }
+    ] }, 'comparison', 'practice_check'))
+  outline.pages.splice(2, 0, {
+    ...make('观察键盘,找到黑键组', { headline: '看图,再在自己的口风琴上找一找', points: [
+      '黑键组:找出相邻的两个黑键和三个黑键。', '实物核对:指一指键盘和吹嘴,和同伴核对。'
+    ] }, 'image_left', 'instrument_observation'),
+    requiredVisual: true,
+    image: { url: image, keyword: '口风琴实物', provider: 'Yamaha 官方产品页', width: 735, height: 735, reviewStatus: 'verified' },
+    visualPlan: { composition: 'image_left', visualMode: 'reference_image', imageNecessity: 'essential', imagePurpose: 'observation', imageReason: '学生需对照真实键盘找出两黑键与三黑键组,文字不能替代观察对象。', imageFit: 'contain', textPresentation: 'cards', visualShare: .55 },
+    speakerNotes: `先让学生指认照片中的黑键组,再迁移到手中的实物;本图只支持部件和键盘观察,不用于验证吹奏姿势或某曲指法。图片来源:${pianicaImageSource}`
+  })
+  outline.pages.splice(4, 0, {
+    ...make('先分句,再连接', { comparisonGroups: [
+      { label: '分句练习', points: ['按示范的速度吹一个乐句。', '把长音保持到约定的位置。'] },
+      { label: '连接乐句', points: ['把相邻的两个乐句连起来。', '换气后及时进入下一句。'] }
+    ], points: ['练习顺序:先完成一个乐句,再尝试连接两个乐句。'] }, 'comparison', 'performance_activity'),
+    diagram: { kind: 'sequence', caption: '练习的先后关系', basis: '本课分句练习安排', items: [{ label: '分句练习' }, { label: '连接乐句' }] },
+    visualPlan: { composition: 'comparison', visualMode: 'native_diagram', imageNecessity: 'none', imagePurpose: 'none', imageReason: '色块与顺序箭头已表达练习关系,无需外部图片。', textPresentation: 'plain' }
+  })
+  const actions = [
+    ['准备口风琴和本课已选定的短旋律,口头说明今天练习连贯吹奏。', '准备好乐器,听清本课任务。', '能够说出今天练习连贯和换气。'],
+    ['读学习任务,请学生选出自己最想改进的一项。', '说出自己要重点练习的一项。', '能把学习任务和一次具体练习联系起来。'],
+    ['先展示实物照片,再让学生在自己的口风琴上指认;型号差异以实际乐器为准。', '找到两个黑键和三个黑键的相邻组,和同伴核对。', '能在照片和实物上分别指出一组两黑键与一组三黑键。'],
+    ['用同一旋律和速度示范两遍:第一遍在乐句中途停顿,第二遍在乐句结束处换气。不要提前揭示结论。', '比较两次示范,说出一处听到的差别。', '能指出哪一遍的乐句衔接更自然,并说出停顿位置的依据。'],
+    ['示范一个乐句的结束,再示范两个乐句连接;约定换气位置后让学生尝试。', '先吹一个乐句,再连接两个乐句。', '长音保持到位,换气后能及时进入。'],
+    ['按本页四个步骤组织练习,每次只纠正一个问题;使用课前选定且学生已学过的短旋律。', '依次完成聆听、分句、连接和同伴反馈。', '两次练习之间有一处明确改善,同伴能指出具体位置。'],
+    ['分别示范声音不稳定、换气过迟与平稳连接,让学生每次只关注一个细节。', '选一个细节,比较并改进自己的吹奏。', '能说出关注的细节,并在第二次尝试中作出调整。'],
+    ['两人交换演奏与观察角色,从三个角度中选一项反馈,避免只说“很好”。', '给同伴一条有具体乐句依据的反馈。', '反馈包含具体位置、听到的现象和一个改进建议。'],
+    ['同一短旋律示范两遍:一次在乐句中途换气,一次在乐句结束处换气。允许学生说明不同听感。', '判断哪一次换气更自然,并说出听到的依据。', '能把判断与乐句停顿位置联系起来。'],
+    ['请学生说出一处进步和一个下次目标,围绕同一个乐句再练一次。', '回顾进步,选择一个问题再练习。', '能说明进步的具体表现,并给出可执行的练习目标。']
+  ]
+  outline.pages.forEach((page, i) => {
+    const [teacherInstruction, studentTask, successCriteria] = actions[i]
+    page.teachingPurpose = i === 0 ? '明确学习任务' : `通过“${page.title}”改进口风琴观察、聆听或实践表现。`
+    page.learningEvidence = successCriteria
+    page.classroomAction = { teacherInstruction, studentTask, successCriteria }
+    page.durationMinutes = [1, 2, 3, 4, 3, 8, 5, 5, 3, 2][i]
+    page.speakerNotes = `${teacherInstruction}\n判断标准:${successCriteria}\n${page.speakerNotes}`
+    page.visualPlan.imageReason ||= '文字、教学色块或教师现场示范能够完成本页任务,无需外部图片。'
+  })
+  // 开放判断题的答案只供教师参考,不出现在学生画面。
+  const question = outline.pages.find(page => page.title === '听完再判断')
+  question.interaction = { type: 'question', question: question.studentContent.headline, answer: '在乐句结束处换气通常更自然。', explanation: '依据示范中停顿的位置及下一句的进入是否及时作出判断。' }
+  outline.pages.find(page => page.title === '同伴互评的三个角度').type = 'peer_review'
+  outline.pages.find(page => page.title === '哪一次演奏更连贯?').studentContent.comparisonGroups[1].points = ['长音的结尾是否完整?', '乐句衔接有什么变化?']
+  outline.pages.forEach((page, i) => { page.pageNo = i + 1 })
+  return outline
+}

BIN
scripts/fixtures/assets/pianica-yamaha-p32d.jpg


+ 17 - 0
src/api/aiCourseware.ts

@@ -19,6 +19,8 @@ export interface AiCoursewareGenerateQuery {
   preferences?: AiCoursewarePreferences
   regeneratePageNo?: number
   repairOnly?: boolean
+  designOnly?: boolean
+  renderFeedback?: Array<{ pageNo: number; code: string; message: string }>
   outlineConfirmed?: boolean
   outlineMode?: 'teaching_plan'
 }
@@ -94,6 +96,7 @@ export interface AiCoursewareTextbookResearch {
 }
 
 export interface AiCoursewarePage {
+  pageDesign?: AiCoursewareSlideDesign
   imageReview?: string
   imageStatus?: 'not_required' | 'ready' | 'budget_exhausted' | 'generation_failed' | 'review_rejected' | 'reference_missing' | 'unavailable'
   /** 教师确认的教学安排,指导扩写,不直接作为学生可见正文。 */
@@ -202,6 +205,19 @@ export interface AiCoursewareResourceBinding {
   verified?: boolean
 }
 
+export interface AiCoursewareSlideDesign {
+  version: 1
+  background: string
+  rationale: string
+  elements: Array<{
+    id: string; kind: 'text' | 'panel' | 'line' | 'arrow' | 'image' | 'diagram' | 'audio' | 'score' | 'video'
+    textRef?: string
+    x: number; y: number; width: number; height: number
+    fontSize?: number; color?: string; fill?: string; radius?: number
+    bold?: boolean; align?: 'left' | 'center' | 'right'
+  }>
+}
+
 export interface AiCoursewareTaskInfo {
   taskId: string
   status: "PENDING" | "RUNNING" | "DONE" | "DRAFT" | "FAILED"
@@ -210,6 +226,7 @@ export interface AiCoursewareTaskInfo {
 }
 
 export interface AiCoursewareGenerateResult {
+  renderFeedback?: Array<{ pageNo: number; code: string; message: string }>
   reviewMessage?: string
   blockingMessage?: string
   context: Record<string, any>

+ 3 - 3
src/config/aiCoursewareTemplates.ts

@@ -147,7 +147,7 @@ export const AI_COURSEWARE_TEMPLATES: AiCoursewareTemplate[] = [
     name: "AI 主题设计",
     sourceName: "空白模板",
     scene: "通用课堂",
-    description: "根据本课主题统一配色、主视觉和教学图示,按内容设计页面",
+    description: "按内容组织视觉层级、留白与图文,兼顾美观和教学用途",
     templateFile: "",
     templateSlides: 10,
     backgroundFiles: [],
@@ -158,9 +158,9 @@ export const AI_COURSEWARE_TEMPLATES: AiCoursewareTemplate[] = [
     illustrationStyle: "配图依据教学内容、学生年级和教师要求生成",
     layoutCapabilities: ["cover_scene", "hero_focus", "objectives", "media_focus", "split_content", "text_focus", "diagram_focus", "compare", "activity", "summary"],
     pageLayouts: extractedPageLayouts,
-    visualPrompt: "根据已确认教学主线设计整套课件,采用visualBible.palette中的浅色背景、深色文字与强调色,保持投屏对比度。封面用大标题与主题主视觉,探究页用图示或比较,实践页用清晰操作路径;不要每页都套白底列表。配图画风与本课内容、学生年龄一致,装饰不能抢占教学区域。",
+    visualPrompt: "根据已确认教学主线设计整套课件,以内容清晰、视觉美观和教学用途为目标。先判断本页重点、信息关系和文字密度,再安排文字层级、留白、对齐、阅读顺序与整课视觉节奏;依据visualBible保持配色和字体风格一致。色块、短线、图标、关系图与图片都是可选手段,只有帮助理解、区分实际分组或突出重点时才使用,不设数量要求;不强制使用浅蓝、浅橙、浅绿,也不为了装饰给每段文字加底框。纯文字与留白也可以形成完整而美观的页面。封面突出课题,目标可用要点加说明,对比可用comparisonGroups并列对齐,短概念可用concept_grid,长操作可用steps,提问可突出headline;均按实际内容选择,不让所有页面套同一种卡片。明确的先后关系可用diagram.kind=sequence、visualMode=native_diagram,平行比较不要添加箭头。逐页填写imageNecessity及imageReason:目标、情绪描述、结构关系、步骤说明、提问与总结通常无需外部图片,使用text_only或native_diagram;实物部件、指法、演奏姿势、动作细节和具体谱例观察需要匹配教学任务的真实参考图片或已核验资源,用reference_image并完整呈现主体,禁止用装饰图代替。教材事实、谱例、指法不可凭空编造,未核验的材料注明待补充。每页给出可执行的学生任务与可观察的完成标准;教师操作、示范方法、时间、参考答案与素材准备写入classroomAction及speakerNotes,不放到学生画面。若听辨可由教师现场示范完成,在备注明确两次示范的差别,否则绑定对应音频。每页只有一个主要任务,保留完整内容。",
     matching: { keywords: [], matchedReason: "适用于各类课程", unmatchedReason: "根据本课主题设计,适用于各类课程" },
-    palettes: extractedPalettes("#334155", "#64748B", "#475569", "#94A3B8", "#FFFFFF")
+    palettes: extractedPalettes("#2C3E6B", "#E67E22", "#407C91", "#BFA77B", "#FFFDF5")
   },
   extractedTemplate("ethnic-flower", "民族花园", "《彝家娃娃真幸福》", "民族歌曲与律动", "民族人物、花草边饰与低年级互动", "culture", ["#DF7A2B", "#4C9A71", "#E45667", "#F2C84B", "#FFFDF6"], {
     keywords: ["一年级", "二年级", "低年级", "故事", "童话", "儿歌", "律动", "节奏", "活动", "民族", "民歌"],

+ 70 - 0
src/utils/aiPptModelSlideDesign.ts

@@ -0,0 +1,70 @@
+import tinycolor from 'tinycolor2'
+import type { AiCoursewarePage, AiCoursewareSlideDesign } from '@/api/aiCourseware'
+import { isTeacherFacingText } from './aiPptContent'
+
+/** 与服务端协议一致:只允许引用已复核正文,设计阶段不能增加教材事实。 */
+export function modelSlideTextReferences(page: AiCoursewarePage): Record<string, string> {
+  const refs: Record<string, string> = {}
+  const put = (key: string, value?: string) => { if (value?.trim()) refs[key] = value.trim() }
+  const list = (key: string, values?: string[]) => values?.forEach((value, i) => put(`${key}.${i}`, value))
+  put('title', page.title)
+  const c = page.studentContent, question = page.interaction?.question?.trim()
+  if (c) {
+    if (!question) put('studentContent.headline', c.headline)
+    if (!question && !c.headline?.trim()) put('subtitle', page.subtitle)
+    list('studentContent.points', c.points); list('studentContent.steps', c.steps)
+    if (c.example?.trim()) put('studentContent.example', `例子:${c.example}`)
+    c.comparisonGroups?.forEach((g, i) => { put(`studentContent.comparisonGroups.${i}.label`, g.label); list(`studentContent.comparisonGroups.${i}.points`, g.points) })
+  }
+  if (!c || !c.points?.length && !c.steps?.length && !c.comparisonGroups?.length && !c.example?.trim()) list('bullets', page.bullets)
+  put('classroomAction.studentTask', page.classroomAction?.studentTask)
+  put('interaction.question', page.interaction?.question); list('interaction.options', page.interaction?.options)
+  return refs
+}
+
+type Node = AiCoursewareSlideDesign['elements'][number]
+const hex = (value?: string) => typeof value === 'string' && /^#[\da-f]{6}$/i.test(value)
+const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
+const overlaps = (a: Node, b: Node) => Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 1
+  && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 1
+const contains = (a: Node, b: Node) => a.x <= b.x && a.y <= b.y && a.x + a.width >= b.x + b.width && a.y + a.height >= b.y + b.height
+
+export function modelSlideDesignIssues(page: AiCoursewarePage): string[] {
+  const d = page.pageDesign, problems: string[] = []
+  if (!d || d.version !== 1 || !hex(d.background) || !d.rationale?.trim() || !Array.isArray(d.elements) || !d.elements.length || d.elements.length > 60)
+    return ['模型页面设计缺失或格式不完整']
+  const refs = modelSlideTextReferences(page), drawn = new Set<string>(), ids = new Set<string>(), texts: Node[] = []
+  const slots: Record<string, number> = {}
+  for (const el of d.elements) {
+    if (!el || !el.id || ids.has(el.id) || !['text','panel','line','arrow','image','diagram','audio','score','video'].includes(el.kind)
+      || ![el.x, el.y, el.width, el.height].every(finite) || el.x < 64 || el.y < 48 || el.width <= 0 || el.height <= 0 || el.x + el.width > 1536 || el.y + el.height > 852) {
+      problems.push(`模型设计元素${el?.id || ''}无效或超出投屏安全区`); continue
+    }
+    ids.add(el.id)
+    if (el.kind === 'text') {
+      const value = refs[el.textRef || '']
+      if (!value || isTeacherFacingText(value) || !finite(el.fontSize) || el.fontSize < 28 || el.fontSize > 100 || !hex(el.color) || !['left','center','right'].includes(el.align || '')) {
+        problems.push(`文字${el.id}的内容引用、字号或样式无效`); continue
+      }
+      if (drawn.has(value)) problems.push(`文字重复绘制:${el.textRef}`)
+      drawn.add(value); texts.push(el)
+      const background = d.elements.filter(p => p?.kind === 'panel' && contains(p, el) && hex(p.fill)).reverse()[0]?.fill || d.background
+      if (tinycolor.readability(el.color!, background) < 4.5) problems.push(`文字${el.id}与底色对比度不足`)
+    } else if (['panel','line','arrow'].includes(el.kind)) {
+      if (!hex(el.fill) || el.radius !== undefined && (!finite(el.radius) || el.radius < 0 || el.radius > 32)) problems.push(`色块${el.id}的填色或圆角无效`)
+      if (el.kind === 'arrow' && page.diagram?.kind !== 'sequence' && !page.studentContent?.steps?.length) problems.push('平行内容不能添加流程箭头')
+    } else slots[el.kind] = (slots[el.kind] || 0) + 1
+  }
+  Object.entries(refs).forEach(([key, value]) => { if (!drawn.has(value)) problems.push(`设计遗漏学生文字:${key}`) })
+  texts.forEach((a, i) => texts.slice(i + 1).forEach(b => { if (overlaps(a, b)) problems.push(`文字框重叠:${a.id}与${b.id}`) }))
+  const resources = page.resourceBindings?.filter(b => b.verified === true) || []
+  const score = resources.some(b => b.resourceType === 'MUSIC' && b.resourceId)
+  const video = resources.some(b => b.resourceType === 'VIDEO' && b.contentUrl)
+  const image = !score && !video && ['generated_image','reference_image'].includes(page.visualPlan?.visualMode || '')
+  const expected = { image, diagram: !!page.diagram, audio: resources.some(b => b.resourceType === 'SONG' && b.contentUrl), score, video }
+  Object.entries(expected).forEach(([kind, needed]) => { if ((slots[kind] || 0) !== Number(needed)) problems.push(`${kind}槽位与已确认教学素材不符`) })
+  const visuals = d.elements.filter(e => ['image','diagram','audio','score','video'].includes(e.kind))
+  texts.forEach(a => visuals.forEach(b => { if (overlaps(a, b)) problems.push(`文字${a.id}与教学素材${b.id}重叠`) }))
+  if (image && d.elements.some(e => e.kind === 'image' && e.width * e.height < 1600 * 900 * .18)) problems.push('教学图片槽位小于整页18%')
+  return [...new Set(problems)]
+}

+ 6 - 2
src/utils/aiPptQuality.ts

@@ -7,6 +7,7 @@ import { resolveTemplatePageLayout } from "@/config/aiCoursewareTemplateMasters"
 import { aiPptTextGeometry } from "@/utils/aiPptTextMetrics"
 import { hasModelPageDesign, externalVisual } from "@/utils/aiPptPageDesign"
 import { imagePreparationMessage } from '@/utils/aiPptFeedback'
+import { modelSlideDesignIssues } from './aiPptModelSlideDesign'
 
 type BoundedPPTElement = Exclude<PPTElement, { type: "line" }>
 
@@ -114,6 +115,7 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
 
   pages.forEach((page, index) => {
     const pageNo = page.pageNo || index + 1
+    if (page.pageDesign) modelSlideDesignIssues(page).forEach(message => push(issues, 'invalid_model_slide_design', 'error', pageNo, message))
     if (page.diagram && (!validTeachingDiagram(page.diagram) || page.visualPlan?.visualMode !== 'native_diagram'))
       push(issues, 'invalid_teaching_diagram', 'error', pageNo, '教学图示数据不完整,请重新生成本页')
     const resolved = resolveAiPptLayout(page, index)
@@ -121,10 +123,12 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
     if (!resolveTemplatePageLayout(layout)) push(issues, "invalid_layout", "error", pageNo, "本页排版需要检查,请确认文字、图片和活动内容显示完整")
     if (index === 0 && layout !== "cover_scene") push(issues, "invalid_cover_layout", "error", pageNo, "第1页必须使用封面场景布局")
     const designed = hasModelPageDesign(page)
-    const designKey = designed ? `${page.visualPlan!.composition}:${page.visualPlan!.textPresentation || 'plain'}` : layout
+    const designKey = page.pageDesign ? JSON.stringify(page.pageDesign.elements.filter(e => e.kind === 'panel' || e.kind === 'text').map(e =>
+      [e.kind, Math.round(e.x / 160), Math.round(e.y / 90), Math.round(e.width / 160), Math.round(e.height / 90)]))
+      : designed ? `${page.visualPlan!.composition}:${page.visualPlan!.textPresentation || 'plain'}` : layout
     layoutCounts.set(designKey, (layoutCounts.get(designKey) || 0) + 1)
     cardPages += designed ? ['cards', 'grid', 'steps'].includes(page.visualPlan!.textPresentation || '') ? 1 : 0 : CARD_LAYOUTS.has(layout) ? 1 : 0
-    if (!designed && page.visualPlan?.composition) push(issues, 'invalid_page_design', 'warning', pageNo, '模型页面设计字段不完整,已使用兼容版式,请核对页面构图')
+    if (!page.pageDesign && !designed && page.visualPlan?.composition) push(issues, 'invalid_page_design', 'warning', pageNo, '模型页面设计字段不完整,已使用兼容版式,请核对页面构图')
 
     if (designKey === previousLayout) consecutive += 1
     else consecutive = 1

+ 126 - 6
src/utils/aiPptTemplateEngine.ts

@@ -21,6 +21,8 @@ import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
 import { audioBindings, scoreBinding, isScorePage, usableResourceUrl, isTeacherFacingText, teacherFacingTexts, modelPageContent, rhythmRows } from "@/utils/aiPptContent"
 import { compilePageDesign, hasModelPageDesign, externalVisual } from "@/utils/aiPptPageDesign"
 import { teachingActionIcon } from '@/config/aiPptTeachingIcons'
+import { composeAiPptTypography, type TypographyDesign } from '@/utils/aiPptTypography'
+import { modelSlideDesignIssues, modelSlideTextReferences } from './aiPptModelSlideDesign'
 import {
   AI_COURSEWARE_TEMPLATES,
   getAiCoursewarePalette,
@@ -133,12 +135,13 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
   const role = pageRole(page, index)
   const intent = pageRenderIntent(page, role)
   const originalMaster = getAiCoursewareTemplateMaster(options.templateId!)
+  if (page.pageDesign) return buildModelDesignedSlide(page, originalMaster, palette, options)
   const designedTheme = hasModelPageDesign(page)
   // 保持整课同一组颜色,以内容角色区分纸底、情境底和章节重点底。
   // 使用真实背景填色,不把装饰图当作缺失的教学图片。
   if (designedTheme) {
     const emphasis = role === 'cover' || role === 'summary'
-    const tint = emphasis ? 20 : externalVisual(page) ? 10 : page.visualPlan?.visualMode === 'native_diagram' ? 5 : 0
+    const tint = options.templateId === 'blank' || page.visualPlan?.visualMode === 'text_only' ? 0 : emphasis ? 20 : externalVisual(page) ? 10 : page.visualPlan?.visualMode === 'native_diagram' ? 5 : 0
     const candidate = tinycolor.mix(palette.background, palette.secondary, tint).toHexString()
     const background = tinycolor.readability(candidate, palette.text) >= 4.5 ? candidate : palette.background
     palette = { ...palette, background }
@@ -158,9 +161,15 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
   let masterPage = designed
     ? compilePageDesign(page, index, master, intent.showMedia, (externalVisual(page) && hasImage(page)) || Boolean(videoBinding(page)), Boolean(headline))
     : fitVisualSlots(adapted, hasImage(page) || Boolean(videoBinding(page)), nativeVisual)
-  let contentElements = designed
+  const groupedSequence = page.diagram?.kind === 'sequence' && modelPageContent(page).groups.length >= 2
+  const typography = designed && !hasImage(page) && !videoBinding(page) && (!page.diagram || groupedSequence)
+    && (page.visualPlan?.visualMode === 'text_only' || externalVisual(page) || groupedSequence)
+    ? composeAiPptTypography(page, outline, index, intent.showMedia) : undefined
+  let contentElements = typography ? renderTypography(typography, master, palette)
+    : designed
     ? fillDesignedPage(master, masterPage, page, outline, palette, index, headline, intent)
     : fillTemplatePage(master, masterPage, page, outline, palette, role, intent)
+  if (typography) addInlineMedia(contentElements, slotOf(masterPage, 'media'), page, master, palette, intent.showMedia)
   const visualArea = (items: PPTElement[]) => Math.max(0, ...items.map(item => item.type === 'image' ? item.width * item.height : 0))
   const smallMainImage = hasImage(page) && visualArea(contentElements) < SLIDE_WIDTH * SLIDE_HEIGHT * .18
   if (designed && (aiPptTextGeometry(scaleElementsToViewport(contentElements)).length || smallMainImage)) {
@@ -179,8 +188,11 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
   assertContentContract(master, masterPage, contentElements)
   const elements = [
     ...renderTemplateMasterElements(masterPage.base),
-    ...(designed && role === 'cover' && !hasImage(page) && !intent.showMedia
+    ...(typography ? [] : designed && role === 'cover' && !hasImage(page) && !intent.showMedia
       ? [lineElement(80, 70, 1520, 70, palette.primary, 6), lineElement(80, 842, 1520, 842, palette.primary, 2)]
+      : designed && options.templateId === 'blank'
+        ? [{ ...shapeElement({ left: slotOf(masterPage, 'title')!.left + 8, top: slotOf(masterPage, 'title')!.top + slotOf(masterPage, 'title')!.height + 4,
+          width: 110, height: 4, fill: palette.secondary }), name: '版式分隔线' }]
       : designed ? [lineElement(52, slotOf(masterPage, 'title')!.top + 8, 52,
       slotOf(masterPage, 'title')!.top + Math.min(slotOf(masterPage, 'title')!.height - 8, 64), palette.secondary, 6)] : []),
     ...contentElements,
@@ -214,9 +226,39 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
   ratios.model_design_v4 = 1 // 整课一致性复核与内容驱动的图片尺寸。
   ratios.model_design_v5 = 1 // 按拍分组、学生范例和内容复核完成状态。
   ratios.model_design_v6 = 1 // 学生文字预算、去重及混合声音属性检查。
+  ratios.model_design_v7 = 1 // 模型输出逐页原生元素布局,支持渲染诊断反馈修复。
   return ratios
 }
 
+function buildModelDesignedSlide(page: AiCoursewarePage, master: TemplateMaster, palette: Palette, options: AiPptOptions): Slide {
+  const problems = modelSlideDesignIssues(page)
+  if (problems.length) throw new Error(`模型页面设计未通过:${problems.join(';')}`)
+  const design = page.pageDesign!, refs = modelSlideTextReferences(page), elements: PPTElement[] = []
+  // 色块作底,其他元素严格执行模型给定的几何;不重新选择模板、不缩小字号。
+  const nodes = [...design.elements.filter(e => e.kind === 'panel'), ...design.elements.filter(e => e.kind !== 'panel')]
+  for (const node of nodes) {
+    const rect = { left: node.x, top: node.y, width: node.width, height: node.height }
+    const slot = { ...rect, id: node.id, kind: 'image' } as TemplateSlot
+    const before = elements.length
+    if (node.kind === 'text') elements.push(textElement({ ...rect, content: refs[node.textRef!],
+      fontSize: node.fontSize!, minimumFontSize: node.fontSize!, color: node.color!, bold: node.bold, align: node.align }))
+    else if (node.kind === 'panel' || node.kind === 'line') elements.push({ ...shapeElement({ ...rect, fill: node.fill!, radius: node.radius || 0 }), name: '模型教学色块' })
+    else if (node.kind === 'arrow') elements.push({ ...sequenceArrow(node.x, node.y + node.height / 2, node.width, node.fill!), ...rect })
+    else if (node.kind === 'image' && hasImage(page)) elements.push(imageElement(page, rect))
+    else if (node.kind === 'diagram') addTeachingDiagram(elements, slot, page.diagram!, master, palette)
+    else if (node.kind === 'audio') addInlineMedia(elements, slot, page, master, palette, true)
+    else if (node.kind === 'score' || node.kind === 'video') addVisualOrResource(elements, slot, {
+      ...page, resourceBindings: page.resourceBindings?.filter(b => b.resourceType === (node.kind === 'score' ? 'MUSIC' : 'VIDEO')).map(b => ({ ...b, dedicatedPage: true }))
+    }, master, palette)
+    elements.slice(before).forEach(element => { element.id = `${node.id}-${element.id}` })
+  }
+  const scaled = scaleElementsToViewport(elements), errors = aiPptTextGeometry(scaled)
+  if (errors.length) throw new Error(`模型页面设计文字超出投屏安全区:${errors.join(';')}`)
+  return { id: nanoid(10), elements: scaled, background: { type: 'solid', color: design.background },
+    remark: `${slideRemark(page)}\n设计依据:${design.rationale}`, animations: options.enableAnimations ? entranceAnimations(elements) : undefined,
+    turningMode: options.enableAnimations ? 'fade' : 'no' }
+}
+
 function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number, headline: string, intent: PageRenderIntent): PPTElement[] {
   const elements: PPTElement[] = []
   const content = modelPageContent(page)
@@ -241,6 +283,8 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
       values.forEach((value, i) => addSlotText(elements, { ...coverSlot, top: coverSlot.top + i * (height + gap), height }, value, master.textColor, false, 'center'))
       return
     }
+    if (hasImage(page) && ['cards', 'grid'].includes(format)
+      && addIllustratedPanels(elements, slot, values, master, palette)) return
     if (addEditorialRows(elements, slot, values, format, master, palette)) return
     if (!hasImage(page) && !page.diagram && !content.groups.length
       && addDesignedPanels(elements, slot, values, format, master, palette)) return
@@ -353,6 +397,66 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
   return elements
 }
 
+function renderTypography(design: TypographyDesign, master: TemplateMaster, palette: Palette): PPTElement[] {
+  const accent = tinycolor(palette.secondary)
+  while (tinycolor.readability(accent, palette.background) < 3 && accent.getLuminance() > .05) accent.darken(5)
+  const body = tinycolor.mix(master.textColor, '#404040', 65).toHexString()
+  const colors = { title: master.titleColor, body: tinycolor.readability(body, palette.background) >= 4.5 ? body : master.textColor,
+    muted: master.mutedColor, accent: tinycolor.readability(accent, palette.background) >= 3 ? accent.toHexString() : master.titleColor }
+  return [
+    ...design.panels.map(panel => ({ ...shapeElement({ ...panel, fill: semanticPanelFill(panel.tone, palette), radius: 12 }), name: '教学分组色块' })),
+    ...design.arrows.map(arrow => sequenceArrow(arrow.left, arrow.top, arrow.width, master.mutedColor)),
+    // 用有面积的原生细矩形作分隔线,避免零高度路径在 PPTX 阅读器中消失。
+    ...design.rules.map(rule => ({ ...shapeElement({ left: rule.left, top: rule.top,
+      width: Math.max(rule.right - rule.left, rule.accent ? 4 : 1), height: Math.max(rule.bottom - rule.top, rule.accent ? 4 : 1),
+      fill: rule.accent ? palette.secondary : tinycolor.mix(palette.background, master.titleColor, 20).toHexString() }), name: '版式分隔线' })),
+    ...design.texts.map(block => {
+      const element = textElement({ ...block, content: block.text, fontSize: block.font, minimumFontSize: block.font,
+        color: colors[block.tone], align: block.center ? 'center' : 'left' })
+      if (block.lead) {
+        const rest = block.text.slice(block.lead.length)
+        element.content = element.content.replace(escapeHtml(block.text),
+          `<p style="margin:0;font-size:${block.font + 4}px;color:${master.titleColor}"><strong>${escapeHtml(block.lead)}</strong></p><p style="margin:8px 0 0">${escapeHtml(rest)}</p>`)
+      }
+      return element
+    })
+  ]
+}
+
+function semanticPanelFill(index: number, palette: Palette) {
+  // 浅色表示分组,标题和正文继续使用深色,避免用颜色单独传达含义。
+  return tinycolor.mix(palette.background, ['#75BBD9', palette.secondary, '#8DBA99'][index % 3], index % 3 === 1 ? 20 : 28).toHexString()
+}
+
+function addIllustratedPanels(elements: PPTElement[], slot: TemplateSlot, values: string[], master: TemplateMaster, palette: Palette) {
+  if (values.length < 2 || values.length > 3 || slot.width < 400) return false
+  const labels = values.map(value => value.match(/^(.{2,14}[::])(.+)$/))
+  if (!labels.every(Boolean)) return false
+  const padding = 24, width = slot.width - padding * 2
+  const heightOf = (value: string, font: number) => {
+    const label = value.match(/^(.{2,14}[::])(.+)$/)!
+    return measureAiPptText(label[1], width, font + 4, 1.2, true) + measureAiPptText(label[2], width, font) - 16 + 8
+  }
+  const font = [38, 36, 34, 32, 30, 28].find(font => values.reduce((sum, value) => sum + heightOf(value, font) + padding * 2, 0) + 24 * (values.length - 1) <= slot.height)
+  if (!font) return false
+  const design: TypographyDesign = { panels: [], texts: [], rules: [], arrows: [] }
+  let top = slot.top
+  values.forEach((text, i) => {
+    const height = heightOf(text, font)
+    design.panels.push({ left: slot.left, top, width: slot.width, height: height + padding * 2, tone: i })
+    design.texts.push({ text, left: slot.left + padding, top: top + padding, width, height, font, tone: 'body', lead: labels[i]![1] })
+    top += height + padding * 2 + 24
+  })
+  elements.push(...renderTypography(design, master, palette))
+  return true
+}
+
+function sequenceArrow(left: number, center: number, width: number, color: string): PPTShapeElement {
+  // 箭头是可编辑原生路径,有非零面积,浏览器和 PPTX 使用相同几何。
+  return { ...shapeElement({ left, top: center - 12, width, height: 24, fill: color }),
+    name: '教学顺序箭头', viewBox: [100, 24], path: 'M0,10 L84,10 L84,0 L100,12 L84,24 L84,14 L0,14 Z' }
+}
+
 /** 长步骤采用纵向阅读;明确的“要点:说明”保留原文并突出要点,不自动发明小标题。 */
 function addEditorialRows(elements: PPTElement[], slot: TemplateSlot, values: string[], format: string, master: TemplateMaster, palette: Palette) {
   if (slot.width < 900 || values.length < 2 || values.length > 5 || values.some(value => value.startsWith('例子:'))) return false
@@ -455,6 +559,22 @@ export function validTeachingDiagram(diagram: NonNullable<AiCoursewarePage['diag
 }
 
 function addTeachingDiagram(elements: PPTElement[], slot: TemplateSlot, diagram: NonNullable<AiCoursewarePage['diagram']>, master: TemplateMaster, palette: Palette) {
+  if (diagram.kind === 'sequence') {
+    const captionHeight = diagram.caption ? measureAiPptText(diagram.caption, slot.width, 32) + 24 : 0
+    const gap = 56, width = (slot.width - gap * (diagram.items.length - 1)) / diagram.items.length
+    const height = Math.min(180, slot.height - captionHeight)
+    if (height >= 90) {
+      const top = slot.top + captionHeight + Math.min(36, (slot.height - captionHeight - height) / 2)
+      if (diagram.caption) addSlotText(elements, { ...slot, height: captionHeight - 24, preferredFontSize: 32 }, diagram.caption, master.titleColor, true)
+      diagram.items.forEach((item, i) => {
+        const left = slot.left + i * (width + gap)
+        elements.push({ ...shapeElement({ left, top, width, height, fill: semanticPanelFill(i, palette), radius: 12 }), name: '教学分组色块' })
+        addSlotText(elements, { ...slot, left: left + 20, top: top + 20, width: width - 40, height: height - 40, preferredFontSize: 36 }, item.label, master.titleColor, true, 'center')
+        if (i < diagram.items.length - 1) elements.push(sequenceArrow(left + width + 10, top + height / 2, gap - 20, master.mutedColor))
+      })
+      return
+    }
+  }
   const captionHeight = diagram.caption ? Math.max(54, measureAiPptText(diagram.caption, slot.width, 32)) : 0
   const area = { ...slot, height: slot.height - captionHeight - 16 }
   const gap = 32, count = diagram.items.length
@@ -1054,7 +1174,7 @@ function addVisualOrResource(elements: PPTElement[], slot: TemplateSlot | undefi
     return
   }
   if (hasImage(page)) {
-    elements.push(imageElement(page, { left: slot.left, top: slot.top, width: slot.width, height: slot.height, radius: 18 }))
+    elements.push(imageElement(page, { left: slot.left, top: slot.top, width: slot.width, height: slot.height, radius: hasModelPageDesign(page) ? 0 : 18 }))
     return
   }
   // 无教学图片时留给文字排版;通用装饰不能充当观察对象。
@@ -1273,7 +1393,7 @@ function textElement(params: {
   minimumFontSize?: number
   color: string
   bold?: boolean
-  align?: 'left' | 'center'
+  align?: 'left' | 'center' | 'right'
   lineHeight?: number
 }): PPTTextElement {
   const lineHeight = params.lineHeight || 1.25
@@ -1288,7 +1408,7 @@ function textElement(params: {
     height: params.height,
     rotate: 0,
     // 永远保留完整文本。若最小字号仍放不下,由质量门禁阻止应用,而不是静默截断。
-    content: `<div style="box-sizing:border-box;width:calc(100% - 20px);text-align:${params.align || 'left'};font-family:${FONT_NAME};font-size:${resolvedFontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(cleanText(params.content))}</div>`,
+    content: `<div style="box-sizing:border-box;width:calc(100% - 20px);text-align:${params.align || 'left'};font-family:${FONT_NAME};font-size:${resolvedFontSize}px;${params.bold ? "font-weight:bold;" : ""}">${escapeHtml(cleanText(params.content))}</div>`,
     defaultFontName: FONT_NAME,
     defaultColor: params.color,
     lineHeight,

+ 150 - 0
src/utils/aiPptTypography.ts

@@ -0,0 +1,150 @@
+import type { AiCoursewareOutline, AiCoursewarePage } from '@/api/aiCourseware'
+import { modelPageContent } from '@/utils/aiPptContent'
+import { measureAiPptText } from '@/utils/aiPptTextMetrics'
+
+type Tone = 'title' | 'body' | 'accent' | 'muted'
+export interface TypographyText {
+  text: string; left: number; top: number; width: number; height: number; font: number
+  tone: Tone; bold?: boolean; center?: boolean; lead?: string
+}
+export interface TypographyDesign {
+  texts: TypographyText[]
+  rules: { left: number; top: number; right: number; bottom: number; accent?: boolean }[]
+  panels: { left: number; top: number; width: number; height: number; tone: number }[]
+  arrows: { left: number; top: number; width: number }[]
+}
+
+/** 无图页也有完整构图。只分组已有内容,不截断正文、不创造教学事实。 */
+export function composeAiPptTypography(page: AiCoursewarePage, outline: AiCoursewareOutline, index: number, showMedia: boolean): TypographyDesign | undefined {
+  const content = modelPageContent(page)
+  const design: TypographyDesign = { texts: [], rules: [], panels: [], arrows: [] }
+  const left = 88, width = 1424, bottom = showMedia ? 722 : 818
+  const heightOf = (text: string, width: number, font: number, bold = false) => measureAiPptText(text, width, font, 1.2, bold)
+  const add = (text: string, x: number, y: number, w: number, font: number, tone: Tone = 'body', bold = false, center = false) => {
+    if (!text) return 0
+    const height = heightOf(text, w, font, bold)
+    design.texts.push({ text, left: x, top: y, width: w, height, font, tone, bold, center })
+    return height
+  }
+  const rule = (x: number, y: number, x2: number, y2 = y, accent = false) => design.rules.push({ left: x, top: y, right: x2, bottom: y2, accent })
+  const split = (value: string) => value.match(/^(.{2,14}[::])(.+)$/)
+  const richHeight = (value: string, w: number, font: number) => {
+    const parts = split(value)
+    return parts ? heightOf(parts[1], w, font + 4, true) + heightOf(parts[2], w, font) - 16 + 8 : heightOf(value, w, font)
+  }
+  const rich = (value: string, x: number, y: number, w: number, font: number) => {
+    const height = richHeight(value, w, font)
+    design.texts.push({ text: value, left: x, top: y, width: w, height, font, tone: 'body', lead: split(value)?.[1] })
+    return height
+  }
+  const fonts = [44, 42, 40, 38, 36, 34, 32, 30, 28]
+  const flow = (values: string[], x: number, y: number, w: number, available: number, preferred = 38) => {
+    const font = fonts.filter(font => font <= preferred).find(font => values.reduce((sum, value) => sum + richHeight(value, w, font), 0) + Math.max(0, values.length - 1) * 24 <= available)
+    if (!font && values.length) return false
+    for (const value of values) y += rich(value, x, y, w, font || preferred) + 24
+    return true
+  }
+  const finish = () => design.texts.every(item => item.top >= 48 && item.top + item.height <= bottom && item.left >= 64 && item.left + item.width <= 1536) ? design : undefined
+
+  if (index === 0) {
+    if (content.groups.length || content.steps.length || content.points.length > 2 || showMedia) return undefined
+    const meta = [...new Set([outline.gradeName, outline.textbookName, outline.unitName].filter(Boolean))].join(' · ')
+    const lines = [content.heading, content.task, ...content.points].filter(Boolean)
+    const font = [82, 76, 70, 64].find(font => heightOf(page.title, 1320, font, true) <= 210)
+    if (!font) return undefined
+    const titleHeight = heightOf(page.title, 1320, font, true)
+    const total = titleHeight + (meta ? heightOf(meta, 1320, 28) + 24 : 0) + lines.reduce((sum, value) => sum + heightOf(value, 1240, 36) + 26, 0) + 42
+    if (total > 600) return undefined
+    let y = 110 + (620 - total) / 2
+    y += add(page.title, 140, y, 1320, font, 'title', true, true) + 24
+    if (meta) y += add(meta, 140, y, 1320, 28, 'muted', false, true) + 42
+    for (const value of lines) y += add(value, 180, y, 1240, 36, 'title', false, true) + 26
+    rule(88, 72, 1512); rule(88, 834, 1512)
+    return finish()
+  }
+
+  let top = 64 + add(page.title, left, 64, width, 54, 'title', true)
+  rule(left + 8, top + 8, left + 118, top + 8, true)
+  top += 48
+  const isFocus = !content.groups.length && !content.steps.length && content.heading && content.heading.length <= 40
+    && (page.visualPlan?.composition === 'focus_question' || content.points.length <= 1)
+  if (isFocus) {
+    const questionHeight = heightOf(content.heading, 1272, 64, true)
+    const supporting = [content.task, ...content.points].filter(Boolean)
+    const supportHeight = supporting.reduce((sum, text) => sum + heightOf(text, 1200, 36) + 22, 0)
+    const start = top + Math.max(20, (bottom - top - questionHeight - supportHeight - 70) / 2)
+    add(content.heading, 164, start, 1272, 64, 'title', true, true)
+    let y = start + questionHeight + 50
+    for (const text of supporting) y += add(text, 200, y, 1200, 36, 'body', false, true) + 22
+    return finish()
+  }
+  if (content.heading) top += add(content.heading, left, top, width, 38, 'title', true) + 20
+  if (content.task) top += add(content.task, left, top, width, 30, 'muted') + 20
+
+  if (content.groups.length) {
+    // 箭头只绘制模型明确提供的顺序,平行比较对象之间不暗示先后。
+    const sequence = page.diagram?.kind === 'sequence' && page.diagram.items.length === content.groups.length
+      && page.diagram.items.every((item, i) => item.label === content.groups[i].label)
+    if (page.diagram && !sequence) return undefined
+    if (sequence && page.diagram?.caption) top += add(page.diagram.caption, left, top, width, 34, 'title', true) + 24
+    const columns = content.groups.length === 3 ? 3 : 2
+    if (sequence && content.groups.length > columns) return undefined
+    const rows = Math.ceil(content.groups.length / columns), gap = sequence ? 120 : 64, w = (width - gap * (columns - 1)) / columns
+    const sharedHeight = content.points.reduce((sum, text) => sum + richHeight(text, width, 32) + 24, 0)
+    const h = (bottom - top - (sharedHeight ? sharedHeight + 22 : 0) - 40 * (rows - 1)) / rows
+    if (h < 100) return undefined
+    const padding = 32, inner = w - padding * 2
+    const groupHeight = (group: typeof content.groups[number], font: number) => (group.label ? heightOf(group.label, inner, 40, true) + 24 : 0)
+      + group.points.reduce((sum, point) => sum + richHeight(point, inner, font), 0) + Math.max(0, group.points.length - 1) * 24 + padding * 2
+    const font = fonts.filter(font => font <= 40).find(font => content.groups.every(group => groupHeight(group, font) <= h))
+    if (!font) return undefined
+    const panelHeight = Math.max(...content.groups.map(group => groupHeight(group, font)))
+    const inset = Math.min(40, Math.max(0, (h - panelHeight) / 3))
+    for (let i = 0; i < content.groups.length; i++) {
+      const group = content.groups[i], x = left + i % columns * (w + gap), y = top + inset + Math.floor(i / columns) * (h + 40)
+      design.panels.push({ left: x, top: y, width: w, height: panelHeight, tone: i })
+      const labelHeight = group.label ? add(group.label, x + padding, y + padding, inner, 40, 'title', true) + 24 : 0
+      if (!flow(group.points, x + padding, y + padding + labelHeight, inner, panelHeight - padding * 2 - labelHeight, font)) return undefined
+      if (sequence && i < content.groups.length - 1) design.arrows.push({ left: x + w + 16, top: y + panelHeight / 2, width: gap - 32 })
+    }
+    if (sharedHeight && !flow(content.points, left, bottom - sharedHeight, width, sharedHeight, 32)) return undefined
+    return finish()
+  }
+
+  const values = [...content.steps, ...content.points]
+  if (!values.length) return finish()
+  const numbered = content.steps.length > 0 || page.type === 'objectives'
+  const summary = /summary/.test(page.type)
+  // 短概念并列;长说明与步骤纵读。不会因为缺图把全部页面排成同一种列表。
+  const columns = !numbered && !showMedia && values.length >= 2 && values.length <= 4
+    && values.every(value => value.length <= 80) && (summary || page.visualPlan?.composition === 'concept_grid')
+  if (columns) {
+    const count = values.length === 3 ? 3 : 2, rows = Math.ceil(values.length / count), gap = 64
+    const w = (width - gap * (count - 1)) / count
+    const h = (bottom - top - 44 * (rows - 1)) / rows
+    const padding = 32, inner = w - padding * 2
+    const font = fonts.filter(font => font <= 40).find(font => values.every(value => richHeight(value, inner, font) + padding * 2 <= h))
+    if (!font) return undefined
+    const panelHeight = Math.max(...values.map(value => richHeight(value, inner, font))) + padding * 2
+    const yStart = top + Math.min(40, (h - panelHeight) / 3)
+    values.forEach((value, i) => {
+      const x = left + i % count * (w + gap), y = yStart + Math.floor(i / count) * (h + 44)
+      design.panels.push({ left: x, top: y, width: w, height: panelHeight, tone: i })
+      rich(value, x + padding, y + padding, inner, font)
+    })
+    return finish()
+  }
+  const gutter = numbered ? 94 : 0, w = width - gutter
+  const font = fonts.find(font => values.reduce((sum, value) => sum + Math.max(70, richHeight(value, w, font)), 0) + (values.length - 1) * 24 <= bottom - top)
+  if (!font) return undefined
+  const heights = values.map(value => Math.max(70, richHeight(value, w, font)))
+  const gap = values.length > 1 ? Math.min(48, (bottom - top - heights.reduce((a, b) => a + b, 0)) / (values.length - 1)) : 0
+  let y = top + Math.min(30, Math.max(0, (bottom - top - heights.reduce((a, b) => a + b, 0) - gap * (values.length - 1)) / 2))
+  values.forEach((value, i) => {
+    if (numbered && (!content.steps.length || i < content.steps.length)) add(String(i + 1), left, y, 70, 42, 'accent', true)
+    rich(value, left + gutter, y, w, font)
+    if (i < values.length - 1) rule(left + gutter + 8, y + heights[i] + gap / 2, left + width - 8)
+    y += heights[i] + gap
+  })
+  return finish()
+}

+ 42 - 5
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -213,7 +213,7 @@
           <Button v-if="!transferring" @click="backToSettings">修改需求</Button>
           <Button v-if="completionSource" :disabled="transferring" :aria-expanded="showCompletionContent" @click="showCompletionContent = !showCompletionContent">{{ showCompletionContent ? "收起预览" : "查看已生成内容" }}</Button>
           <Button v-if="!task && !completionSource" @click="step = 2">返回修改大纲</Button>
-          <Button :type="completionSource ? 'default' : 'primary'" :disabled="transferring" @click="retry">{{ layoutRepairSource && layoutRepairFeedback ? "精简并重新排版" : completionSource ? "继续自动完善" : task ? "继续查看课件" : "重试生成" }}</Button>
+          <Button :type="completionSource ? 'default' : 'primary'" :disabled="transferring" @click="retry">{{ layoutRepairSource && layoutRepairFeedback ? (modelLayoutRepair ? "重新设计页面" : "精简并重新排版") : completionSource ? "继续自动完善" : task ? "继续查看课件" : "重试生成" }}</Button>
           <Button v-if="completionSource" type="primary" :disabled="transferring" @click="openCompletionDraft">{{ transferring ? "正在打开…" : "打开草稿编辑" }}</Button>
         </div>
       </footer>
@@ -304,6 +304,9 @@ const task = ref<AiCoursewareTaskInfo>(),
   confirmed = ref<AiCoursewareOutline>()
 const layoutRepairSource = ref<AiCoursewareOutline>()
 const layoutRepairFeedback = ref('')
+const modelLayoutRepair = computed(() => layoutRepairSource.value?.pages.some(page => !!page.pageDesign))
+const renderDiagnostics = ref<Array<{ pageNo: number; code: string; message: string }>>([])
+let automaticDesignRepairs = 0
 const completionSource = ref<AiCoursewareOutline>()
 const completionReviewMessage = ref('')
 const mainContent = ref<HTMLElement>()
@@ -632,6 +635,8 @@ async function confirmAndGenerate() {
 async function startGeneration() {
   if (!confirmed.value || !pptWork.id || transferring.value || task.value) return
   layoutRepairSource.value = undefined
+  automaticDesignRepairs = 0
+  renderDiagnostics.value = []
   completionSource.value = undefined
   const requestVersion = ++version
   step.value = 3
@@ -648,6 +653,7 @@ async function startGeneration() {
       fromType: source(),
       outline: plan,
       extraPrompt: extraPrompt.value.trim(),
+      visualPrompt: getAiCoursewareTemplate(templateId.value).visualPrompt,
       imageAspectRatios: getAiPptImageAspectRatios(templateId.value),
       templateId: templateId.value,
       outlineConfirmed: true,
@@ -690,11 +696,17 @@ async function repairBlockedLayout() {
     await assertAiPptClientCurrent()
     if (requestVersion !== version) return
     const plan = clone(layoutRepairSource.value)
+    const modelDesign = plan.pages.some(page => page.pageDesign)
+    const feedback = renderDiagnostics.value.length ? clone(renderDiagnostics.value) : plan.pages
+      .filter(page => reason.includes(`第${page.pageNo}页`)).map(page => ({ pageNo: page.pageNo, code: 'rendered_layout', message: reason.slice(0, 2000) }))
     const response = await httpAjax(aiCoursewareGenerateApi, {
       id: pptWork.id, fromType: source(), outline: plan, templateId: templateId.value, repairOnly: true,
+      ...(modelDesign && feedback.length ? { designOnly: true, renderFeedback: feedback } : {}),
       imageAspectRatios: getAiPptImageAspectRatios(templateId.value),
       preferences: { ...clone(preferences), minPages: plan.pages.length, maxPages: plan.pages.length },
-      visualPrompt: `保持页数与教学目标,精简重复的学生文字,教师操作放备注,不截断歌词或完整范例。解决排版问题:${reason}`
+      visualPrompt: modelDesign
+        ? getAiCoursewareTemplate(templateId.value).visualPrompt
+        : `${getAiCoursewareTemplate(templateId.value).visualPrompt}\n保持页数与教学目标,精简重复的学生文字,教师操作放备注,不截断歌词或完整范例。解决排版问题:${reason}`
     })
     if (requestVersion !== version) return
     if (response.code !== 200 || !response.data?.taskId) throw new Error(response.message || '未能开始重新排版')
@@ -797,6 +809,18 @@ function recordCompletionLayoutErrors(messages: string[]) {
   if (!completionSource.value || !messages.length) return
   layoutRepairSource.value = clone(completionSource.value)
   layoutRepairFeedback.value = messages.join(';')
+  renderDiagnostics.value = messages.flatMap(message => {
+    const pageNo = Number(message.match(/第(\d+)页/)?.[1])
+    return pageNo ? [{ pageNo, code: 'rendered_layout', message: message.slice(0, 2000) }] : []
+  })
+}
+async function automaticallyRepairDesign(result: AiCoursewareGenerateResult, active: () => boolean, allowIncompleteDraft: boolean) {
+  if (!active() || allowIncompleteDraft || automaticDesignRepairs >= 2 || !renderDiagnostics.value.length || !result.outline.pages.some(page => page.pageDesign)) return false
+  automaticDesignRepairs++
+  completionSource.value = clone(result.outline)
+  completionReviewMessage.value = result.reviewMessage || ''
+  await repairBlockedLayout()
+  return true
 }
 async function enterEditor(result: AiCoursewareGenerateResult, active: () => boolean, allowIncompleteDraft = false) {
   if (!result?.outline?.pages?.length) throw new Error("尚未获得课件内容,请继续查看")
@@ -811,19 +835,30 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
       completionSource.value = clone(result.outline)
       completionReviewMessage.value = result.reviewMessage || ''
       generationError.value = coursewareCompletionMessage(result, task.value.message)
+      if (result.renderFeedback?.length) {
+        renderDiagnostics.value = clone(result.renderFeedback)
+        layoutRepairSource.value = clone(result.outline)
+        layoutRepairFeedback.value = result.renderFeedback.map(item => `第${item.pageNo}页:${item.message}`).join(';')
+      }
       remember()
       return
     }
   }
   layoutRepairSource.value = clone(result.outline)
   layoutRepairFeedback.value = ''
+  renderDiagnostics.value = []
   let converted: ReturnType<typeof aiCoursewareToSlides>
   try {
     converted = aiCoursewareToSlides(result.outline, { templateId: templateId.value, enableAnimations: preferences.enableAnimations })
   } catch (error) {
-    if (error instanceof Error && /投屏安全区|保留区/.test(error.message)) {
+    if (error instanceof Error && /投屏安全区|保留区|模型页面设计/.test(error.message)) {
       layoutRepairFeedback.value = error.message
-      throw new Error(`${error.message.match(/^第\d+页/)?.[0] || '生成内容'}超出页面容量,已保留当前课件。请精简并重新排版。`)
+      const pageNo = Number(error.message.match(/^第(\d+)页/)?.[1])
+      renderDiagnostics.value = pageNo ? [{ pageNo, code: 'model_design_geometry', message: error.message.slice(0, 2000) }] : []
+      if (await automaticallyRepairDesign(result, active, allowIncompleteDraft)) return
+      completionSource.value = clone(result.outline)
+      remember()
+      throw new Error(`${error.message.match(/^第\d+页/)?.[0] || '生成内容'}页面设计未通过,已保留当前课件。${modelLayoutRepair.value ? '请重新设计页面。' : '请精简并重新排版。'}`)
     }
     throw error
   }
@@ -837,7 +872,9 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
   if (!active()) return
   const layoutIssues = [...blockingAiPptLayoutIssues(report), ...renderedIssues]
   layoutRepairFeedback.value = layoutIssues.map(issue => `第${issue.pageNo}页:${issue.message}`).join(';')
-  if (layoutIssues.length) throw new Error(`第${[...new Set(layoutIssues.map(issue => issue.pageNo))].join('、')}页文字过密,已保留当前课件。请精简并重新排版。`)
+  renderDiagnostics.value = layoutIssues.filter(issue => !!issue.pageNo).map(issue => ({ pageNo: issue.pageNo!, code: issue.code, message: issue.message.slice(0, 2000) }))
+  if (layoutIssues.length && await automaticallyRepairDesign(result, active, allowIncompleteDraft)) return
+  if (layoutIssues.length) throw new Error(`第${[...new Set(layoutIssues.map(issue => issue.pageNo))].join('、')}页文字过密,已保留当前课件。${modelLayoutRepair.value ? '自动调整后仍未通过,请重新设计页面或查看草稿。' : '请精简并重新排版。'}`)
   layoutRepairSource.value = undefined
   const issues = [
     ...report.errors,

Некоторые файлы не были показаны из-за большого количества измененных файлов