fix: preserve ppt text styles during replacement

This commit is contained in:
2026-06-26 17:16:46 +08:00
parent 0b2a8b0190
commit 203f3b9ec1
+206 -38
View File
@@ -55,7 +55,6 @@ type ReportStudentData = {
comment: string comment: string
comments: string comments: string
teacherName: string teacherName: string
teacher_name: string
} }
function getReportStoragePath(): string { function getReportStoragePath(): string {
@@ -136,6 +135,28 @@ function getFormatFromExtension(extension: string): ReportFormat {
return 'ppt' return 'ppt'
} }
function escapeXml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
function decodeXml(value: string): string {
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&')
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function getStudentData(student: StudentProfileEntity, classItem: ClassEntity): ReportStudentData { function getStudentData(student: StudentProfileEntity, classItem: ClassEntity): ReportStudentData {
const friends = parseJsonList(student.friends).join('、') || ' ' const friends = parseJsonList(student.friends).join('、') || ' '
const hobbies = parseJsonList(student.hobbies).join('、') || ' ' const hobbies = parseJsonList(student.hobbies).join('、') || ' '
@@ -168,8 +189,7 @@ function getStudentData(student: StudentProfileEntity, classItem: ClassEntity):
traits: student.traits || ' ', traits: student.traits || ' ',
comment: student.comment || '暂无评语', comment: student.comment || '暂无评语',
comments: student.comment || '暂无评语', comments: student.comment || '暂无评语',
teacherName, teacherName
teacher_name: teacherName
} }
} }
@@ -198,7 +218,7 @@ function buildTextReplacements(studentData: ReportStudentData, classItem: ClassE
comments: studentData.comments, comments: studentData.comments,
comment: studentData.comment, comment: studentData.comment,
teacherName: studentData.teacherName, teacherName: studentData.teacherName,
teacher_name: studentData.teacher_name, teacher_name: studentData.teacherName,
englishName: studentData.englishName, englishName: studentData.englishName,
english_name: studentData.englishName, english_name: studentData.englishName,
sex: studentData.sex, sex: studentData.sex,
@@ -400,6 +420,187 @@ function logSlideObjects(ppt: PPTXTemplater, traceId: string): void {
} }
} }
function getSlidePaths(zip: JSZip): string[] {
return Object.keys(zip.files)
.filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path))
.sort((first, second) => {
const firstNumber = Number(first.match(/slide(\d+)\.xml$/)?.[1] ?? 0)
const secondNumber = Number(second.match(/slide(\d+)\.xml$/)?.[1] ?? 0)
return firstNumber - secondNumber
})
}
function hasShapeName(shapeXml: string, shapeName: string): boolean {
const normalizedShapeName = normalizePptObjectName(shapeName)
const shapeProperties = shapeXml.match(/<p:cNvPr[^>]*>/)?.[0] ?? ''
const candidateNames = ['name', 'descr', 'title']
.map((attributeName) => getXmlAttributeValue(shapeProperties, attributeName))
.filter((value): value is string => Boolean(value))
return candidateNames.some(
(candidateName) => normalizePptObjectName(decodeXml(candidateName)) === normalizedShapeName
)
}
function getXmlAttributeValue(xml: string, attributeName: string): string | null {
const match = xml.match(new RegExp(`\\s${attributeName}="([^"]*)"`))
return match?.[1] ?? null
}
function replaceTextNodeValue(
textNode: string,
replacer: (value: string) => string | null
): string {
const match = textNode.match(/^(<a:t(?:\s[^>]*)?>)([\s\S]*?)(<\/a:t>)$/)
if (!match) {
return textNode
}
const nextValue = replacer(match[2])
if (nextValue === null) {
return textNode
}
return `${match[1]}${nextValue}${match[3]}`
}
function replaceShapeTextPreservingStyle(slideXml: string, shapeName: string, text: string): string {
return slideXml.replace(/<p:sp[\s\S]*?<\/p:sp>/g, (shapeXml: string) => {
if (!hasShapeName(shapeXml, shapeName)) {
return shapeXml
}
const escapedText = escapeXml(text)
const textNodes = [...shapeXml.matchAll(/<a:t(?:\s[^>]*)?>[\s\S]*?<\/a:t>/g)]
if (textNodes.length === 0) {
return shapeXml
}
let replacedFirstText = false
return shapeXml.replace(/<a:t(?:\s[^>]*)?>[\s\S]*?<\/a:t>/g, (textNode: string) => {
if (replacedFirstText) {
return replaceTextNodeValue(textNode, () => '')
}
replacedFirstText = true
return replaceTextNodeValue(textNode, () => escapedText)
})
})
}
function replaceInlineTextPlaceholderPreservingStyle(
slideXml: string,
placeholder: string,
text: string
): string {
const placeholderPattern = new RegExp(`\\{\\{\\s*${escapeRegExp(placeholder)}\\s*\\}\\}`, 'g')
return slideXml.replace(/<a:t(?:\s[^>]*)?>[\s\S]*?<\/a:t>/g, (textNode: string) =>
replaceTextNodeValue(textNode, (value) => {
placeholderPattern.lastIndex = 0
if (!placeholderPattern.test(value)) {
return null
}
placeholderPattern.lastIndex = 0
return value.replace(placeholderPattern, escapeXml(text))
})
)
}
function getTextNodeValue(textNode: string): string {
return textNode.match(/^<a:t(?:\s[^>]*)?>([\s\S]*?)<\/a:t>$/)?.[1] ?? ''
}
function replaceShapeInlinePlaceholdersPreservingStyle(
slideXml: string,
replacements: Record<string, string>
): string {
return slideXml.replace(/<p:sp[\s\S]*?<\/p:sp>/g, (shapeXml: string) => {
const textNodes = [...shapeXml.matchAll(/<a:t(?:\s[^>]*)?>[\s\S]*?<\/a:t>/g)]
if (textNodes.length === 0) {
return shapeXml
}
const combinedText = textNodes.map((match) => getTextNodeValue(match[0])).join('')
let nextCombinedText = combinedText
for (const [placeholder, text] of Object.entries(replacements)) {
const placeholderPattern = new RegExp(
`\\{\\{\\s*${escapeRegExp(placeholder)}\\s*\\}\\}`,
'g'
)
nextCombinedText = nextCombinedText.replace(placeholderPattern, escapeXml(text))
}
if (nextCombinedText === combinedText) {
return shapeXml
}
let replacedFirstText = false
return shapeXml.replace(/<a:t(?:\s[^>]*)?>[\s\S]*?<\/a:t>/g, (textNode: string) => {
if (replacedFirstText) {
return replaceTextNodeValue(textNode, () => '')
}
replacedFirstText = true
return replaceTextNodeValue(textNode, () => nextCombinedText)
})
})
}
async function replacePptxTextPreservingStyle(
filePath: string,
replacements: Record<string, string>,
traceId: string
): Promise<void> {
const fileBuffer = await readFile(filePath)
const zip = await JSZip.loadAsync(fileBuffer)
let replacementCount = 0
for (const slidePath of getSlidePaths(zip)) {
const slideFile = zip.file(slidePath)
if (!slideFile) {
continue
}
const originalSlideXml = await slideFile.async('text')
let slideXml = originalSlideXml
for (const [placeholder, text] of Object.entries(replacements)) {
slideXml = replaceInlineTextPlaceholderPreservingStyle(slideXml, placeholder, text)
slideXml = replaceShapeTextPreservingStyle(slideXml, placeholder, text)
}
slideXml = replaceShapeInlinePlaceholdersPreservingStyle(slideXml, replacements)
if (slideXml !== originalSlideXml) {
replacementCount += 1
zip.file(slidePath, slideXml)
}
}
const outputBuffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE'
})
await writeFile(filePath, outputBuffer)
logger.debug(`[${traceId}] 保留样式的 PPTX 文本替换完成`, {
touchedSlideCount: replacementCount,
replacementKeys: Object.keys(replacements)
})
}
function findImageReplacementTargets( function findImageReplacementTargets(
ppt: PPTXTemplater, ppt: PPTXTemplater,
slideNumber: number, slideNumber: number,
@@ -478,24 +679,6 @@ function findImageReplacementTargets(
return fallbackTargets return fallbackTargets
} }
async function updateShapeTextIfExists(
ppt: PPTXTemplater,
shapeName: string,
text: string
): Promise<void> {
for (let slideNumber = 1; slideNumber <= ppt.slideCount; slideNumber += 1) {
try {
await ppt.useSlide(slideNumber).updateShape(shapeName, { text })
} catch (error) {
const message = error instanceof Error ? error.message : ''
if (!message.includes('not found')) {
throw error
}
}
}
}
async function replaceImageIfExists( async function replaceImageIfExists(
ppt: PPTXTemplater, ppt: PPTXTemplater,
replacementPlan: PptImageReplacementPlan, replacementPlan: PptImageReplacementPlan,
@@ -698,22 +881,6 @@ async function buildPptxReport(
}) })
logSlideObjects(ppt, traceId) logSlideObjects(ppt, traceId)
// 文本替换分两步:
// 1. replaceText({ '{{name}}': '张三' }) 处理文本框里的 Mustache 风格占位符;
// 2. updateShape('name', { text }) 处理“形状对象名就是字段名”的模板。
ppt.replaceText(
Object.fromEntries(
Object.entries(textReplacements).map(([placeholder, value]) => [`{{${placeholder}}}`, value])
)
)
for (const [shapeName, text] of Object.entries(textReplacements)) {
await updateShapeTextIfExists(ppt, shapeName, text)
}
logger.debug(`[${traceId}] 文本替换完成`, {
replacementCount: Object.keys(textReplacements).length
})
const imageReplacementPlans = buildImageReplacementPlans( const imageReplacementPlans = buildImageReplacementPlans(
ppt, ppt,
buildImageReplacements(), buildImageReplacements(),
@@ -729,6 +896,7 @@ async function buildPptxReport(
} }
await ppt.saveToFile(outputPath) await ppt.saveToFile(outputPath)
await replacePptxTextPreservingStyle(outputPath, textReplacements, traceId)
logger.info(`[${traceId}] PPTX 报告保存完成`, { logger.info(`[${traceId}] PPTX 报告保存完成`, {
outputPath, outputPath,
replacedImageCount: replacedImageKeys.size replacedImageCount: replacedImageKeys.size