From 203f3b9ec1c5ee12e9dc95447f7df65fd92e4f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E5=AF=92?= <2596194220@qq.com> Date: Fri, 26 Jun 2026 17:16:46 +0800 Subject: [PATCH] fix: preserve ppt text styles during replacement --- src/main/services/reportService.ts | 244 ++++++++++++++++++++++++----- 1 file changed, 206 insertions(+), 38 deletions(-) diff --git a/src/main/services/reportService.ts b/src/main/services/reportService.ts index 2212429..4e7a9c1 100644 --- a/src/main/services/reportService.ts +++ b/src/main/services/reportService.ts @@ -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, ''') +} + +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(/]*>/)?.[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(/^(]*)?>)([\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(//g, (shapeXml: string) => { + if (!hasShapeName(shapeXml, shapeName)) { + return shapeXml + } + + const escapedText = escapeXml(text) + const textNodes = [...shapeXml.matchAll(/]*)?>[\s\S]*?<\/a:t>/g)] + + if (textNodes.length === 0) { + return shapeXml + } + + let replacedFirstText = false + + return shapeXml.replace(/]*)?>[\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(/]*)?>[\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(/^]*)?>([\s\S]*?)<\/a:t>$/)?.[1] ?? '' +} + +function replaceShapeInlinePlaceholdersPreservingStyle( + slideXml: string, + replacements: Record +): string { + return slideXml.replace(//g, (shapeXml: string) => { + const textNodes = [...shapeXml.matchAll(/]*)?>[\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(/]*)?>[\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, + traceId: string +): Promise { + 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 { - 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