fix: preserve ppt text styles during replacement
This commit is contained in:
@@ -55,7 +55,6 @@ type ReportStudentData = {
|
||||
comment: string
|
||||
comments: string
|
||||
teacherName: string
|
||||
teacher_name: string
|
||||
}
|
||||
|
||||
function getReportStoragePath(): string {
|
||||
@@ -136,6 +135,28 @@ function getFormatFromExtension(extension: string): ReportFormat {
|
||||
return 'ppt'
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function decodeXml(value: string): string {
|
||||
return value
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function getStudentData(student: StudentProfileEntity, classItem: ClassEntity): ReportStudentData {
|
||||
const friends = parseJsonList(student.friends).join('、') || ' '
|
||||
const hobbies = parseJsonList(student.hobbies).join('、') || ' '
|
||||
@@ -168,8 +189,7 @@ function getStudentData(student: StudentProfileEntity, classItem: ClassEntity):
|
||||
traits: student.traits || ' ',
|
||||
comment: student.comment || '暂无评语',
|
||||
comments: student.comment || '暂无评语',
|
||||
teacherName,
|
||||
teacher_name: teacherName
|
||||
teacherName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +218,7 @@ function buildTextReplacements(studentData: ReportStudentData, classItem: ClassE
|
||||
comments: studentData.comments,
|
||||
comment: studentData.comment,
|
||||
teacherName: studentData.teacherName,
|
||||
teacher_name: studentData.teacher_name,
|
||||
teacher_name: studentData.teacherName,
|
||||
englishName: studentData.englishName,
|
||||
english_name: studentData.englishName,
|
||||
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(
|
||||
ppt: PPTXTemplater,
|
||||
slideNumber: number,
|
||||
@@ -478,24 +679,6 @@ function findImageReplacementTargets(
|
||||
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(
|
||||
ppt: PPTXTemplater,
|
||||
replacementPlan: PptImageReplacementPlan,
|
||||
@@ -698,22 +881,6 @@ async function buildPptxReport(
|
||||
})
|
||||
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(
|
||||
ppt,
|
||||
buildImageReplacements(),
|
||||
@@ -729,6 +896,7 @@ async function buildPptxReport(
|
||||
}
|
||||
|
||||
await ppt.saveToFile(outputPath)
|
||||
await replacePptxTextPreservingStyle(outputPath, textReplacements, traceId)
|
||||
logger.info(`[${traceId}] PPTX 报告保存完成`, {
|
||||
outputPath,
|
||||
replacedImageCount: replacedImageKeys.size
|
||||
|
||||
Reference in New Issue
Block a user