fix: preserve ppt text styles during replacement
This commit is contained in:
@@ -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, '<')
|
||||||
|
.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 {
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user