yonge 2 дней назад
Родитель
Сommit
ad1e1c70cb

+ 1 - 1
dist/assets/index-Dr2qUrJD.js → dist/assets/index-0d8QvEtu.js

@@ -1 +1 @@
-import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,eo as t,ae as r,af as c,bO as _,bP as d,ak as s,bH as p}from"./index-C1h8K2FI.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,eo as t,ae as r,af as c,bO as _,bP as d,ak as s,bH as p}from"./index-DsJhEHFn.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-DSH0alkb.js → dist/assets/index-BVB_xaq6.js

@@ -1 +1 @@
-import{u as s,ag as r}from"./index-B6naDp-b.js";import{a5 as c,cD as n,R as p,ae as t,af as _,al as u,ag as i,bH as l}from"./index-C1h8K2FI.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-CDpGg4JB.js";import{a5 as c,cD as n,R as p,ae as t,af as _,al as u,ag as i,bH as l}from"./index-DsJhEHFn.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-B6naDp-b.js → dist/assets/index-CDpGg4JB.js

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

+ 1 - 1
dist/assets/index-X6PrfhQo.js → dist/assets/index-CigFQ5bh.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-B-QH0-xB.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,bO as m,bP as f,bH as u}from"./index-C1h8K2FI.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-DOqnoaec.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,bO as m,bP as f,bH as u}from"./index-DsJhEHFn.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 - 1
dist/assets/index-DDNAKKCx.js → dist/assets/index-CvtVGL--.js

@@ -1 +1 @@
-import{u as c,ag as l}from"./index-B6naDp-b.js";import{w as p,bA as d,a5 as g,cD as m,R as t,ae as r,af as u,al as f,ag as w,bH as k}from"./index-C1h8K2FI.js";import"./shapes-BUW3xmTK.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}))]))}}),P=k(_,[["__scopeId","data-v-90b2692a"]]);export{P as default};
+import{u as c,ag as l}from"./index-CDpGg4JB.js";import{w as p,bA as d,a5 as g,cD as m,R as t,ae as r,af as u,al as f,ag as w,bH as k}from"./index-DsJhEHFn.js";import"./shapes-DnCVbkCB.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}))]))}}),P=k(_,[["__scopeId","data-v-90b2692a"]]);export{P as default};

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


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


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


+ 1 - 1
dist/assets/shapes-BUW3xmTK.js → dist/assets/shapes-DnCVbkCB.js

@@ -1,4 +1,4 @@
-import{cG as k,bB as $1}from"./index-C1h8K2FI.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-B6naDp-b.js";var z1={exports:{}};/*!
+import{cG as k,bB as $1}from"./index-DsJhEHFn.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-CDpGg4JB.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-C1h8K2FI.js"></script>
+    <script type="module" crossorigin src="./assets/index-DsJhEHFn.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-AavUEaYy.css">
   </head>
   <body>

+ 31 - 1
scripts/aiPptSelfTest.mjs

@@ -3,6 +3,7 @@ import { existsSync } from "node:fs"
 import { resolve } from "node:path"
 import assert from "node:assert/strict"
 import { classroomFixture } from "./fixtures/aiPptClassroom.mjs"
+import { semanticDesignFixture } from "./fixtures/aiPptSemanticDesign.mjs"
 import { modelDesignFixture } from "./fixtures/aiPptModelDesign.mjs"
 
 const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
@@ -363,7 +364,36 @@ try {
   repeatedSlides[2] = structuredClone(modelSlides[1])
   repeatedSlides[3] = structuredClone(modelSlides[1])
   assert(inspectAiPptQuality(modelFixture, repeatedSlides).issues.some(issue => issue.code === 'rendered_layout_repeat'), '版式名不同但坐标相同也应被检测到')
-  console.log(JSON.stringify({ passed: failures.length === 0, scenarioCount: results.length, contractChecks: 13, imageTemplateChecks: AI_COURSEWARE_TEMPLATES.length, modelDesignChecks,
+  const semantic = semanticDesignFixture(scoreImage)
+  const semanticSlides = render(semantic)
+  const semanticErrors = inspectAiPptQuality(semantic, semanticSlides).errors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'missing_content', 'small_font'].includes(issue.code))
+  assert.deepEqual(semanticErrors, [], JSON.stringify(semanticErrors))
+  for (const template of AI_COURSEWARE_TEMPLATES) {
+    const check = structuredClone(semantic)
+    check.templateId = template.id
+    const errors = inspectAiPptQuality(check, render(check)).errors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'missing_content', 'small_font'].includes(issue.code))
+    assert.deepEqual(errors, [], `${template.id}: ${JSON.stringify(errors)}`)
+  }
+  const illustratedGroups = structuredClone(semantic)
+  Object.assign(illustratedGroups.pages[4], { image: { url: scoreImage, width: 960, height: 720 } })
+  illustratedGroups.pages[4].visualPlan.visualMode = 'generated_image'
+  illustratedGroups.pages[4].studentContent.comparisonGroups[0].points.push('用手势表现变化')
+  illustratedGroups.pages[4].studentContent.comparisonGroups[1].points.push('试着模仿它的声音')
+  const illustratedErrors = inspectAiPptQuality(illustratedGroups, render(illustratedGroups)).errors.filter(issue => issue.pageNo === 5 && ['out_of_bounds', 'text_overlap', 'text_overflow', 'missing_content', 'small_font'].includes(issue.code))
+  assert.deepEqual(illustratedErrors, [], '图文对比必须为组名、正文和公共提示预留高度')
+  const textElement = (i, text) => semanticSlides[i].elements.find(el => elementText(el).includes(text))
+  assert(textElement(3, '风的声音').left < textElement(3, '老虎的声音').left, '两种对比对象必须各在一侧')
+  assert.equal(semanticSlides[5].elements.filter(el => /^[1-9]$/.test(elementText(el))).length, 4, '四个真实任务不能混入引导标题变成五步')
+  assert.equal(semanticSlides[7].elements.filter(el => /^[1-9]$/.test(elementText(el))).length, 2, '结构化步骤只编号两步')
+  assert(!semanticSlides[7].elements.map(elementText).join(' ').includes('旧正文'), '结构化内容不回退到旧bullets')
+  assert(semanticSlides[6].elements.filter(el => ['X', 'XX', '-'].includes(elementText(el))).length >= 8, '已有节奏必须产生可编辑符号')
+  assert(semanticSlides[5].elements.filter(el => el.type === 'shape' && el.width > 200).every(el => el.height < 230), '短任务卡片不应占据整页高度')
+  assert(textElement(0, '凤鸣山谷的故事').top >= 200, '缺主图封面重新组织标题位置')
+  const { modelPageContent } = await vite.ssrLoadModule('/src/utils/aiPptContent.ts')
+  const invalidGroups = structuredClone(semantic.pages[4])
+  invalidGroups.studentContent.comparisonGroups.push({ label: '第三组', points: ['第三组的完整内容'] })
+  assert(modelPageContent(invalidGroups).points.includes('第三组的完整内容'), '异常分组不能丢失正文')
+  console.log(JSON.stringify({ passed: failures.length === 0, scenarioCount: results.length, contractChecks: 13, imageTemplateChecks: AI_COURSEWARE_TEMPLATES.length, modelDesignChecks, semanticDesignChecks: 10,
     scenarios: courseVariants.map(item => item.id), ...(process.argv.includes("--verbose") ? { results } : {}), failures }, null, 2))
   if (failures.length) process.exitCode = 1
 } finally {

+ 4 - 2
scripts/aiPptVisualTest.html

@@ -23,6 +23,7 @@
       import "/src/assets/styles/prosemirror.scss"
       import { hydrateSlideBackgroundImages } from "/src/libs/jsonTool.ts"
       import { classroomFixture } from "./fixtures/aiPptClassroom.mjs"
+      import { semanticDesignFixture } from "./fixtures/aiPptSemanticDesign.mjs"
       import { modelDesignFixture } from "./fixtures/aiPptModelDesign.mjs"
 
       const id = new URLSearchParams(location.search).get("template") || "ethnic-flower"
@@ -44,15 +45,16 @@
       }
       const scenario = new URLSearchParams(location.search).get("scenario")
       const fixtureImage = '/ai-courseware-templates/extracted/bird-garden/background-01.jpg'
-      const fixture = scenario === 'model-design' ? modelDesignFixture(fixtureImage)
+      const fixture = 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 previewWidth = Math.floor(Math.min(720, (window.innerHeight - 96) * 8 / 9))
       const app = createApp({
         render: () => h("main", [
           h("h1", `${getAiCoursewareTemplate(id).name} · 通用版式视觉回归`),
           ...[0, 1, 2].map(index => h('button', { onClick: () => { group.value = index } }, `查看第${index * 4 + 1}—${Math.min(index * 4 + 4, result.slides.length)}页`)),
-          h("section", { class: "grid" }, result.slides.slice(group.value * 4, group.value * 4 + 4).map(slide => h("div", { class: "slide" }, [h(ThumbnailSlide, { slide, size: 720 })])))
+          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 })])))
         ])
       })
       const pinia = createPinia()

+ 25 - 0
scripts/fixtures/aiPptSemanticDesign.mjs

@@ -0,0 +1,25 @@
+import { modelDesignFixture } from './aiPptModelDesign.mjs'
+
+// 复现测试环境暴露的内容组织问题,图片仅为本地渲染测试资源。
+export function semanticDesignFixture(imageUrl) {
+  const outline = modelDesignFixture(imageUrl)
+  const set = (i, title, headline, points, composition, mode = 'text_only', presentation = 'plain') => {
+    const page = outline.pages[i]
+    Object.assign(page, { title, subtitle: '', bullets: points, studentContent: { headline, points }, classroomAction: {}, interaction: null,
+      image: mode === 'generated_image' ? { url: imageUrl, width: 960, height: 720 } : undefined, requiredVisual: false, resourceBindings: [],
+      visualPlan: { composition, visualMode: mode, textPresentation: presentation, visualShare: .55, imageNecessity: mode === 'generated_image' ? 'supporting' : 'none', imagePurpose: mode === 'generated_image' ? 'scene' : 'none' } })
+    return page
+  }
+  set(0, '凤鸣山谷的故事', '听听山谷里的声音', [], 'full_scene')
+  set(3, '风与虎(对比听)', '听听风和虎', ['风的声音:有时强,有时弱', '老虎的声音:又长又沉'], 'comparison', 'generated_image', 'cards')
+  const structured = set(4, '比较两种声音', '哪种声音更长?', ['说说你的发现'], 'comparison')
+  structured.studentContent.comparisonGroups = [{ label: '风', points: ['声音有时强,有时弱'] }, { label: '老虎', points: ['声音又长又沉'] }]
+  structured.classroomAction.studentTask = '用手势表现声音'
+  set(5, '模仿小演员', '学学它们的声音', ['学小鸟叫', '学蜜蜂嗡嗡', '学风吹树叶', '学老虎吼叫'], 'steps', 'native_diagram', 'steps')
+  set(6, '动物节奏队', '给动物配节奏', ['老虎:X - | X -', '蜜蜂:XX XX | XX XX'], 'concept_grid', 'native_diagram', 'grid')
+  const steps = set(7, '合作小乐队', '和伙伴一起试试', [], 'steps', 'native_diagram', 'steps')
+  steps.studentContent.steps = ['两人轮流拍一拍', '一起完成节奏']
+  steps.classroomAction.studentTask = '听到提示后一起开始'
+  steps.bullets = ['旧正文不应再次混入步骤']
+  return outline
+}

+ 2 - 0
src/api/aiCourseware.ts

@@ -104,6 +104,8 @@ export interface AiCoursewarePage {
   studentContent?: {
     headline?: string
     points?: string[]
+    steps?: string[]
+    comparisonGroups?: Array<{ label: string; points: string[] }>
   }
   activity?: {
     task?: string

+ 52 - 2
src/utils/aiPptContent.ts

@@ -15,13 +15,63 @@ export const isTeacherFacingText = (text?: string) => /(?:教师|老师)(?:讲
 
 export function teacherFacingTexts(page: AiCoursewarePage): string[] {
   return [page.studentContent?.headline, page.classroomAction?.studentTask, page.subtitle,
-    ...(page.studentContent?.points || []), ...(page.bullets || [])]
+    ...(page.studentContent?.points || []), ...(page.studentContent?.steps || []),
+    ...(page.studentContent?.comparisonGroups || []).flatMap(group => [group.label, ...(group.points || [])]), ...(page.bullets || [])]
     .filter((text): text is string => Boolean(text) && isTeacherFacingText(text))
 }
 
 export function studentTexts(page: AiCoursewarePage): string[] {
   return [page.title, page.studentContent?.headline, page.classroomAction?.studentTask,
-    ...(page.studentContent?.points?.length ? page.studentContent.points : page.bullets || []),
+    ...bodyPoints(page),
+    ...(page.studentContent?.steps || []),
+    ...(page.studentContent?.comparisonGroups || []).flatMap(group => [group.label, ...(group.points || [])]),
     page.interaction?.question, ...(page.interaction?.options || [])]
     .map(text => String(text || "").trim()).filter(text => Boolean(text) && !isTeacherFacingText(text))
 }
+
+const textKey = (text: string) => text.replace(/[\s,。!?、;:,.!?;:]+/g, '')
+const visible = (text?: string) => String(text || '').trim()
+const distinct = (texts: string[]) => texts.filter((text, i) => text && !isTeacherFacingText(text) && texts.findIndex(other => textKey(other) === textKey(text)) === i)
+const bodyPoints = (page: AiCoursewarePage) => page.studentContent?.points?.length || page.studentContent?.steps?.length || page.studentContent?.comparisonGroups?.length
+  ? page.studentContent?.points || [] : page.bullets || []
+
+/** 引导语、课堂任务和正文保持各自语义,不把标题追加为步骤或对比项。 */
+export function modelPageContent(page: AiCoursewarePage) {
+  const clean = (text?: string) => !isTeacherFacingText(text) && textKey(visible(text)) !== textKey(page.title) ? visible(text) : ''
+  const heading = clean(page.interaction?.question) || clean(page.studentContent?.headline || page.subtitle) || clean(page.classroomAction?.studentTask)
+  const task = clean(page.classroomAction?.studentTask)
+  const headers = [page.title, heading, task, clean(page.studentContent?.headline || page.subtitle)].map(textKey)
+  const body = distinct(bodyPoints(page).map(visible)).filter(text => !headers.includes(textKey(text)))
+  const steps = distinct((page.studentContent?.steps || []).map(visible)).filter(text => !headers.includes(textKey(text)))
+  let groups = (page.studentContent?.comparisonGroups || [])
+    .map(group => ({ label: clean(group.label), points: distinct((group.points || []).map(visible)) }))
+  if (!groups.length && page.visualPlan?.composition === 'comparison') {
+    // 旧数据只有两项时一项对应一侧;多项仅在明确的“对象:描述”中归组,不再对半截断。
+    if (body.length === 2) groups = body.map(text => ({ label: '', points: [text] }))
+    else {
+      const labelled = body.map(text => text.match(/^([^::]{1,20})[::]/)?.[1])
+      const labels = [...new Set(labelled)]
+      if (labels.length === 2 && labels.every(Boolean)) groups = labels.map(label => ({ label: '', points: body.filter((_, i) => labelled[i] === label) }))
+    }
+  }
+  if (groups.length !== 2 || page.visualPlan?.composition !== 'comparison') {
+    body.push(...groups.flatMap(group => [group.label, ...group.points]).filter(Boolean))
+    groups = []
+  }
+  const grouped = groups.flatMap(group => [group.label, ...group.points]).map(textKey)
+  const points = distinct([...body.filter(text => !grouped.includes(textKey(text)) && !steps.some(step => textKey(step) === textKey(text))), ...(page.interaction?.options || [])])
+  const secondary = distinct([task, clean(page.studentContent?.headline || page.subtitle)])
+    .filter(text => textKey(text) !== textKey(heading)).join(' · ')
+  return { heading, task: secondary, points: groups.length ? distinct([...points, ...steps]) : points, steps, groups }
+}
+
+/** 仅逐字绘制已有 X/XX/-/小节线,不推断拍号或补造节奏。 */
+export function rhythmRows(points: string[]) {
+  return points.map(source => {
+    const match = source.match(/^([^::]{1,20})[::]\s*([Xx\s||\-—]+)$/)
+    if (!match || !/[Xx]/.test(match[2])) return undefined
+    const tokens = match[2].trim().split(/\s+|(?=[||])|(?<=[||])/).filter(Boolean)
+    if (tokens.length > 16 || !tokens.every(token => /^(?:[Xx]{1,4}|[-—]+|[||])$/.test(token))) return undefined
+    return { source, label: match[1], tokens }
+  }).filter((row): row is {source: string; label: string; tokens: string[]} => Boolean(row))
+}

+ 25 - 3
src/utils/aiPptPageDesign.ts

@@ -1,5 +1,6 @@
 import type { AiCoursewarePage } from "@/api/aiCourseware"
 import type { TemplateMaster, TemplateMasterPage, TemplateSlot } from "@/config/aiCoursewareTemplateMasters"
+import { modelPageContent } from '@/utils/aiPptContent'
 
 export const PAGE_COMPOSITIONS = ['full_scene', 'image_left', 'image_right', 'image_top', 'comparison', 'steps', 'focus_question', 'concept_grid'] as const
 export const VISUAL_MODES = ['generated_image', 'reference_image', 'native_diagram', 'text_only'] as const
@@ -17,13 +18,16 @@ export function compilePageDesign(page: AiCoursewarePage, index: number, master:
   const composition = plan.composition!
   const share = Number.isFinite(plan.visualShare) ? Math.round(Math.min(.70, Math.max(.35, plan.visualShare!)) * 20) / 20 : .55
   const cover = index === 0
+  const content = modelPageContent(page)
   const slot = (id: string, left: number, top: number, width: number, height: number, font = 34): TemplateSlot => ({
     id, kind: id as TemplateSlot['kind'], left, top, width, height, preferredFontSize: font, minimumFontSize: 28
   })
   const slots: TemplateSlot[] = [slot('title', 80, 64, 1440, cover ? 100 : 86, cover ? 68 : 50)]
   if (cover) slots.push(slot('meta', 80, 174, 1440, 48, 28))
   if (hasHeadline) slots.push(slot('headline', 80, cover ? 236 : 174, 1440, 82, composition === 'focus_question' ? 44 : 36))
-  const top = cover ? hasHeadline ? 340 : 250 : hasHeadline ? 280 : 190
+  const baseTop = cover ? hasHeadline ? 340 : 250 : hasHeadline ? 280 : 190
+  if (content.task) slots.push({ ...slot('instruction', 80, baseTop - 4, 1440, 62, 30), kind: 'headline' })
+  const top = baseTop + (content.task ? 76 : 0)
   const bottom = showMedia ? 730 : 830
   const height = bottom - top
   if (showMedia) slots.push(slot('media', 80, 758, 1440, 72, 30))
@@ -37,16 +41,34 @@ export function compilePageDesign(page: AiCoursewarePage, index: number, master:
       body = slot('body', composition === 'image_right' ? 80 : 80 + imageWidth + 36, top, bodyWidth, height)
     } else {
       // 短句必须保留可读空间,不能靠缩小字号挤到图片下面。
-      const imageHeight = Math.min(height * share, height - 180 - 28)
+      const groupHeight = content.groups.length === 2 ? Math.max(...content.groups.map(group =>
+        (group.label ? 66 : 0) + group.points.reduce((sum, text) => sum + Math.max(56, Math.ceil(text.length / 17) * 44 + 12), 0)
+          + Math.max(0, group.points.length - 1) * 16)) + (content.points.length ? 120 : 0) : 180
+      const imageHeight = Math.max(80, Math.min(height * share, height - Math.max(180, groupHeight) - 28))
       const imageWidth = Math.min(1440, imageHeight * 2.8)
       slots.push(slot('image', 80 + (1440 - imageWidth) / 2, top, imageWidth, imageHeight))
       body = slot('body', 80, top + imageHeight + 28, 1440, height - imageHeight - 28)
     }
   }
-  if (composition === 'comparison') {
+  if (composition === 'comparison' && content.groups.length === 2) {
+    if (content.points.length) {
+      const sharedHeight = Math.min(100, body.height * .3)
+      slots.push({ ...body, top: body.top + body.height - sharedHeight, height: sharedHeight })
+      body = { ...body, height: body.height - sharedHeight - 20 }
+    }
     slots.push({ ...body, id: 'left', kind: 'left', width: (body.width - 40) / 2 })
     slots.push({ ...body, id: 'right', kind: 'right', left: body.left + (body.width + 40) / 2, width: (body.width - 40) / 2 })
   } else slots.push(body)
+  if (cover && !hasVisual && !showMedia) {
+    // 主图缺失时重新组织成文字封面,不把原图位置变成整页空洞。
+    for (const item of slots) {
+      if (item.id === 'title') { item.top = 230; item.height = 110; item.preferredFontSize = 76 }
+      else if (item.id === 'meta') item.top = 365
+      else if (item.id === 'headline') item.top = 445
+      else if (item.id === 'instruction') item.top = 545
+      else if (item.id === 'body') { item.top = content.task ? 625 : 550; item.height = 180 }
+    }
+  }
   return {
     id: `model-${composition}`, layout: cover ? 'cover' : 'text', roles: [cover ? 'cover' : 'content'],
     // 模型设计使用模板配色;固定插画背景不再限制模型构图,也不冒充教学主图。

+ 47 - 12
src/utils/aiPptTemplateEngine.ts

@@ -16,7 +16,7 @@ import type {
   SlideTheme
 } from "@/types/slides"
 import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
-import { audioBindings, scoreBinding, isScorePage, usableResourceUrl, isTeacherFacingText, teacherFacingTexts } from "@/utils/aiPptContent"
+import { audioBindings, scoreBinding, isScorePage, usableResourceUrl, isTeacherFacingText, teacherFacingTexts, modelPageContent, rhythmRows } from "@/utils/aiPptContent"
 import { compilePageDesign, hasModelPageDesign, externalVisual } from "@/utils/aiPptPageDesign"
 import {
   AI_COURSEWARE_TEMPLATES,
@@ -111,7 +111,7 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
   const adapted = adaptMasterPage(selectTemplateMasterPageVariant(baseMasterPage, occurrence), intent)
   const nativeVisual = isScorePage(page) || (role === "rhythm" && adapted.layout === "diagram")
   const designed = hasModelPageDesign(page) && !isScorePage(page)
-  const headline = cleanText(page.interaction?.question) || activityTask(page) || studentHeadline(page)
+  const headline = designed ? modelPageContent(page).heading : cleanText(page.interaction?.question) || activityTask(page) || studentHeadline(page)
   const 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)
@@ -147,40 +147,75 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
     }
   }
   ratios.model_design_v1 = 1 // 服务端依据本版编译器的安全区域和模型比例计算图片尺寸。
+  ratios.model_design_v2 = 1
   return ratios
 }
 
 function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number, headline: string, intent: PageRenderIntent): PPTElement[] {
   const elements: PPTElement[] = []
-  const points = uniqueVisibleTexts([activityTask(page), ...studentPoints(page), ...(page.interaction?.options || [])]).filter(point => !sameVisibleText(point, headline))
+  const content = modelPageContent(page)
+  const points = content.points
   addSlotText(elements, slotOf(layout, 'title'), page.title, master.titleColor, true)
   if (index === 0) addSlotText(elements, slotOf(layout, 'meta'), [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(' · '), master.mutedColor)
   addSlotText(elements, slotOf(layout, 'headline'), headline, master.textColor, true)
+  addSlotText(elements, layout.slots.find(slot => slot.id === 'instruction'), content.task, master.mutedColor)
   addVisualOrResource(elements, slotOf(layout, 'image'), page, master, palette)
   const composition = page.visualPlan!.composition
   const presentation = page.visualPlan!.textPresentation || 'plain'
   const paintText = (slot: TemplateSlot | undefined, values: string[]) => {
+    if (slot) slot = compactTextSlot(slot, values, presentation)
     if (presentation === 'steps') addStepGrid(elements, slot, values, master, palette)
     else if (presentation === 'grid') addCardGrid(elements, slot, values, master, palette)
     else addSlotList(elements, slot, values, master, palette, presentation === 'cards')
   }
-  if (composition === 'comparison') {
-    const midpoint = Math.ceil(points.length / 2)
-    paintText(slotOf(layout, 'left'), points.slice(0, midpoint))
-    paintText(slotOf(layout, 'right'), points.slice(midpoint))
+  if (composition === 'comparison' && content.groups.length === 2) {
+    content.groups.forEach((group, i) => {
+      const slot = slotOf(layout, i === 0 ? 'left' : 'right')!
+      if (group.label) addSlotText(elements, { ...slot, height: 52 }, group.label, master.titleColor, true)
+      paintText(group.label ? { ...slot, top: slot.top + 66, height: slot.height - 66 } : slot, group.points)
+    })
+    paintText(slotOf(layout, 'body'), points)
   } else {
     const body = slotOf(layout, 'body')!
+    const rows = page.visualPlan?.visualMode === 'native_diagram' ? rhythmRows(points) : []
     const pattern = page.visualPlan?.visualMode === 'native_diagram' && pageRole(page, index) === 'rhythm'
       ? [headline, ...points].join(' ').match(/[短长](?:\s*[-—·、]\s*[短长]){1,7}/)?.[0].match(/[短长]/g) : undefined
-    if (pattern) {
+    if (!content.steps.length && rows.length && rows.length === points.length && body.height >= rows.length * 160) {
+      addRhythmRows(elements, body, rows, master, palette)
+    } else if (pattern && !content.steps.length) {
       addDurationDiagram(elements, { ...body, height: body.height * .55 }, pattern, master, palette)
       paintText({ ...body, top: body.top + body.height * .60, height: body.height * .40 }, points)
-    } else paintText(body, points)
+    } else paintText(body, [...content.steps, ...points])
   }
   addInlineMedia(elements, slotOf(layout, 'media'), page, master, palette, intent.showMedia)
   return elements
 }
 
+function compactTextSlot(slot: TemplateSlot, values: string[], presentation: string): TemplateSlot {
+  const grid = presentation === 'grid' || presentation === 'steps'
+  const columns = grid ? Math.max(1, Math.min(presentation === 'steps' ? 4 : 3, values.length, Math.floor(slot.width / 300))) : 1
+  const rows = Math.max(1, Math.ceil(values.length / columns))
+  const width = (slot.width - 22 * (columns - 1)) / columns
+  const lines = Math.max(1, ...values.map(text => Math.ceil(text.length / Math.max(4, (width - 68) / 34))))
+  const rowHeight = Math.max(grid ? 190 : 80, lines * 44 + (presentation === 'steps' ? 84 : presentation === 'plain' ? 16 : 48))
+  const height = Math.min(slot.height, rowHeight * rows + 22 * (rows - 1))
+  return { ...slot, top: slot.top + (grid ? Math.min(100, (slot.height - height) / 2) : 0), height, preferredFontSize: Math.max(slot.preferredFontSize || 34, 38) }
+}
+
+function addRhythmRows(elements: PPTElement[], slot: TemplateSlot, rows: ReturnType<typeof rhythmRows>, master: TemplateMaster, palette: Palette) {
+  const rowHeight = Math.min(220, (slot.height - 24 * (rows.length - 1)) / rows.length)
+  rows.forEach((row, i) => {
+    const top = slot.top + i * (rowHeight + 24)
+    addSlotText(elements, { ...slot, top, height: 58, preferredFontSize: 38 }, row.source, master.textColor, true)
+    const width = Math.min(160, (slot.width - 12 * (row.tokens.length - 1)) / row.tokens.length)
+    row.tokens.forEach((token, j) => {
+      const cell = { left: slot.left + j * (width + 12), top: top + 76, width, height: Math.max(48, rowHeight - 82) }
+      if (!/[||]/.test(token)) elements.push(shapeElement({ ...cell, fill: palette.soft, radius: 10 }))
+      addSlotText(elements, { ...slot, ...cell, preferredFontSize: 44 }, token, master.textColor, true)
+    })
+  })
+}
+
 function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, role: PageRole = masterPage.roles[0], intent = pageRenderIntent(page, role)): PPTElement[] {
   master = {
     ...master,
@@ -506,7 +541,7 @@ function addCardGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sou
   if (!slot) return
   const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
   if (!items.length) return
-  const columns = items.length === 1 ? 1 : Math.min(3, items.length)
+  const columns = items.length === 1 ? 1 : Math.max(1, Math.min(3, items.length, (slot.minimumFontSize || 0) >= 28 ? Math.floor(slot.width / 300) : 3))
   const rows = Math.ceil(items.length / columns)
   const gap = collectionGap(slot.height, rows, 22)
   const width = (slot.width - gap * (columns - 1)) / columns
@@ -595,7 +630,7 @@ function addStepGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sou
   if (!slot) return
   const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
   if (!items.length) return
-  const columns = Math.min(4, items.length)
+  const columns = Math.max(1, Math.min(4, items.length, (slot.minimumFontSize || 0) >= 28 ? Math.floor(slot.width / 300) : 4))
   const rows = Math.ceil(items.length / columns)
   const gap = collectionGap(slot.height, rows, 22)
   const width = (slot.width - gap * (columns - 1)) / columns
@@ -641,7 +676,7 @@ function addStepGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sou
         width: Math.max(36, width - textInset * 2),
         height: textHeight,
         content: item,
-        fontSize: fitFont(item, width - textInset * 2, textHeight, 29, minimumFontSize),
+        fontSize: fitFont(item, width - textInset * 2, textHeight, (slot.minimumFontSize || 0) >= 28 ? 40 : 29, minimumFontSize),
         minimumFontSize,
         color: master.textColor,
         bold: true

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