mirror of
https://github.com/ZeroHawkeye/wordZero.git
synced 2026-04-22 23:57:30 +08:00
[WIP] Fix document structure issue when rendering Word templates (#64)
* Initial plan * Fix template rendering to preserve document structure (headers, footers, etc.) Co-authored-by: ZeroHawkeye <161401688+ZeroHawkeye@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ZeroHawkeye <161401688+ZeroHawkeye@users.noreply.github.com>
This commit is contained in:
+114
-8
@@ -849,6 +849,10 @@ func (te *TemplateEngine) cloneDocument(source *Document) *Document {
|
||||
clonedTable := te.cloneTable(elem)
|
||||
doc.Body.Elements = append(doc.Body.Elements, clonedTable)
|
||||
|
||||
case *SectionProperties:
|
||||
clonedSectPr := te.cloneSectionProperties(elem)
|
||||
doc.Body.Elements = append(doc.Body.Elements, clonedSectPr)
|
||||
|
||||
default:
|
||||
// 其他类型暂时直接复制引用
|
||||
doc.Body.Elements = append(doc.Body.Elements, element)
|
||||
@@ -862,16 +866,11 @@ func (te *TemplateEngine) cloneDocument(source *Document) *Document {
|
||||
// 如需统一行距,请在模板中显式设置,而非由代码层面硬编码。
|
||||
}
|
||||
|
||||
// 复制 styles.xml 等样式相关部件,确保 docDefaults 等信息完整保留
|
||||
// 复制所有文档部件,确保完整保留原文档结构
|
||||
if doc.parts == nil {
|
||||
doc.parts = make(map[string][]byte)
|
||||
}
|
||||
if data, ok := source.parts["word/styles.xml"]; ok {
|
||||
doc.parts["word/styles.xml"] = data
|
||||
}
|
||||
|
||||
// 复制页眉页脚部件
|
||||
te.cloneHeaderFooterParts(source, doc)
|
||||
te.cloneAllDocumentParts(source, doc)
|
||||
|
||||
// 复制文档关系(包含页眉页脚引用)
|
||||
if source.documentRelationships != nil {
|
||||
@@ -893,10 +892,117 @@ func (te *TemplateEngine) cloneDocument(source *Document) *Document {
|
||||
copy(doc.contentTypes.Overrides, source.contentTypes.Overrides)
|
||||
}
|
||||
|
||||
// 复制图片ID计数器
|
||||
doc.nextImageID = source.nextImageID
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
// cloneHeaderFooterParts 复制页眉页脚部件
|
||||
// cloneAllDocumentParts 复制所有文档部件,确保完整保留原文档结构
|
||||
func (te *TemplateEngine) cloneAllDocumentParts(source, dest *Document) {
|
||||
if source.parts == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for partName, partData := range source.parts {
|
||||
// 跳过 word/document.xml 因为它会在保存时重新生成
|
||||
if partName == "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 复制部件数据
|
||||
dest.parts[partName] = make([]byte, len(partData))
|
||||
copy(dest.parts[partName], partData)
|
||||
}
|
||||
}
|
||||
|
||||
// cloneSectionProperties 深度复制节属性
|
||||
func (te *TemplateEngine) cloneSectionProperties(source *SectionProperties) *SectionProperties {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sectPr := &SectionProperties{
|
||||
XmlnsR: source.XmlnsR,
|
||||
}
|
||||
|
||||
// 复制页面尺寸
|
||||
if source.PageSize != nil {
|
||||
sectPr.PageSize = &PageSizeXML{
|
||||
W: source.PageSize.W,
|
||||
H: source.PageSize.H,
|
||||
Orient: source.PageSize.Orient,
|
||||
}
|
||||
}
|
||||
|
||||
// 复制页面边距
|
||||
if source.PageMargins != nil {
|
||||
sectPr.PageMargins = &PageMargin{
|
||||
Top: source.PageMargins.Top,
|
||||
Right: source.PageMargins.Right,
|
||||
Bottom: source.PageMargins.Bottom,
|
||||
Left: source.PageMargins.Left,
|
||||
Header: source.PageMargins.Header,
|
||||
Footer: source.PageMargins.Footer,
|
||||
Gutter: source.PageMargins.Gutter,
|
||||
}
|
||||
}
|
||||
|
||||
// 复制分栏设置
|
||||
if source.Columns != nil {
|
||||
sectPr.Columns = &Columns{
|
||||
Space: source.Columns.Space,
|
||||
Num: source.Columns.Num,
|
||||
}
|
||||
}
|
||||
|
||||
// 复制页眉引用
|
||||
if source.HeaderReferences != nil {
|
||||
sectPr.HeaderReferences = make([]*HeaderFooterReference, len(source.HeaderReferences))
|
||||
for i, ref := range source.HeaderReferences {
|
||||
sectPr.HeaderReferences[i] = &HeaderFooterReference{
|
||||
Type: ref.Type,
|
||||
ID: ref.ID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 复制页脚引用
|
||||
if source.FooterReferences != nil {
|
||||
sectPr.FooterReferences = make([]*FooterReference, len(source.FooterReferences))
|
||||
for i, ref := range source.FooterReferences {
|
||||
sectPr.FooterReferences[i] = &FooterReference{
|
||||
Type: ref.Type,
|
||||
ID: ref.ID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 复制首页不同设置
|
||||
if source.TitlePage != nil {
|
||||
sectPr.TitlePage = &TitlePage{}
|
||||
}
|
||||
|
||||
// 复制页码类型
|
||||
if source.PageNumType != nil {
|
||||
sectPr.PageNumType = &PageNumType{
|
||||
Fmt: source.PageNumType.Fmt,
|
||||
}
|
||||
}
|
||||
|
||||
// 复制文档网格
|
||||
if source.DocGrid != nil {
|
||||
sectPr.DocGrid = &DocGrid{
|
||||
Type: source.DocGrid.Type,
|
||||
LinePitch: source.DocGrid.LinePitch,
|
||||
CharSpace: source.DocGrid.CharSpace,
|
||||
}
|
||||
}
|
||||
|
||||
return sectPr
|
||||
}
|
||||
|
||||
// cloneHeaderFooterParts 复制页眉页脚部件 (保留以兼容旧代码,现在由cloneAllDocumentParts处理)
|
||||
func (te *TemplateEngine) cloneHeaderFooterParts(source, dest *Document) {
|
||||
if source.parts == nil {
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -1017,3 +1018,209 @@ func TestHeaderFooterVariableReplacement(t *testing.T) {
|
||||
t.Error("页脚中的变量应该被替换")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateDocumentPartsPreservation 测试模板渲染时文档部件的完整保留
|
||||
func TestTemplateDocumentPartsPreservation(t *testing.T) {
|
||||
// 创建包含多种文档部件的源文档
|
||||
doc := New()
|
||||
|
||||
// 添加页眉和页脚
|
||||
err := doc.AddHeader(HeaderFooterTypeDefault, "Template Header - {{headerVar}}")
|
||||
if err != nil {
|
||||
t.Fatalf("添加页眉失败: %v", err)
|
||||
}
|
||||
|
||||
err = doc.AddFooter(HeaderFooterTypeDefault, "Template Footer - {{footerVar}}")
|
||||
if err != nil {
|
||||
t.Fatalf("添加页脚失败: %v", err)
|
||||
}
|
||||
|
||||
// 设置页面设置
|
||||
settings := DefaultPageSettings()
|
||||
settings.Size = PageSizeA4
|
||||
settings.Orientation = OrientationPortrait
|
||||
err = doc.SetPageSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("设置页面设置失败: %v", err)
|
||||
}
|
||||
|
||||
// 添加标题和内容
|
||||
doc.AddHeadingParagraph("{{docTitle}}", 1)
|
||||
doc.AddParagraph("Content with {{variable1}} and more text.")
|
||||
|
||||
// 保存原文档
|
||||
originalPath := "test_parts_preservation_original.docx"
|
||||
err = doc.Save(originalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("保存原文档失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Remove(originalPath); err != nil {
|
||||
t.Logf("清理原文档失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 打开原文档作为模板
|
||||
templateDoc, err := Open(originalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("打开模板文档失败: %v", err)
|
||||
}
|
||||
|
||||
// 记录原文档的parts
|
||||
originalParts := make(map[string]bool)
|
||||
for partName := range templateDoc.parts {
|
||||
originalParts[partName] = true
|
||||
}
|
||||
|
||||
// 创建模板引擎并加载模板
|
||||
engine := NewTemplateEngine()
|
||||
_, err = engine.LoadTemplateFromDocument("parts_test", templateDoc)
|
||||
if err != nil {
|
||||
t.Fatalf("加载模板失败: %v", err)
|
||||
}
|
||||
|
||||
// 渲染模板
|
||||
data := NewTemplateData()
|
||||
data.SetVariable("headerVar", "Header Value")
|
||||
data.SetVariable("footerVar", "Footer Value")
|
||||
data.SetVariable("docTitle", "Document Title")
|
||||
data.SetVariable("variable1", "Variable 1 Value")
|
||||
|
||||
renderedDoc, err := engine.RenderTemplateToDocument("parts_test", data)
|
||||
if err != nil {
|
||||
t.Fatalf("渲染模板失败: %v", err)
|
||||
}
|
||||
|
||||
// 保存渲染后的文档
|
||||
renderedPath := "test_parts_preservation_rendered.docx"
|
||||
err = renderedDoc.Save(renderedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("保存渲染后的文档失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Remove(renderedPath); err != nil {
|
||||
t.Logf("清理渲染文档失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 检查渲染后文档的parts
|
||||
renderedParts := make(map[string]bool)
|
||||
for partName := range renderedDoc.parts {
|
||||
renderedParts[partName] = true
|
||||
}
|
||||
|
||||
// 验证关键部件被保留
|
||||
criticalParts := []string{
|
||||
"word/styles.xml",
|
||||
"word/header1.xml",
|
||||
"word/footer1.xml",
|
||||
}
|
||||
|
||||
for _, part := range criticalParts {
|
||||
if originalParts[part] && !renderedParts[part] {
|
||||
t.Errorf("关键部件 %s 在原文档中存在但在渲染后的文档中丢失", part)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证页眉页脚变量被替换
|
||||
headerContent := string(renderedDoc.parts["word/header1.xml"])
|
||||
if strings.Contains(headerContent, "{{headerVar}}") {
|
||||
t.Error("页眉中的变量应该被替换")
|
||||
}
|
||||
if !strings.Contains(headerContent, "Header Value") {
|
||||
t.Error("页眉中应该包含替换后的值")
|
||||
}
|
||||
|
||||
footerContent := string(renderedDoc.parts["word/footer1.xml"])
|
||||
if strings.Contains(footerContent, "{{footerVar}}") {
|
||||
t.Error("页脚中的变量应该被替换")
|
||||
}
|
||||
if !strings.Contains(footerContent, "Footer Value") {
|
||||
t.Error("页脚中应该包含替换后的值")
|
||||
}
|
||||
|
||||
t.Log("文档部件保留测试通过")
|
||||
}
|
||||
|
||||
// TestTemplateSectionPropertiesPreservation 测试节属性在模板渲染时的保留
|
||||
func TestTemplateSectionPropertiesPreservation(t *testing.T) {
|
||||
// 创建包含节属性的源文档
|
||||
doc := New()
|
||||
|
||||
// 设置页面设置(这会创建SectionProperties)
|
||||
settings := DefaultPageSettings()
|
||||
settings.Size = PageSizeA4
|
||||
settings.MarginTop = 30.0
|
||||
settings.MarginBottom = 25.0
|
||||
settings.MarginLeft = 20.0
|
||||
settings.MarginRight = 20.0
|
||||
err := doc.SetPageSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("设置页面设置失败: %v", err)
|
||||
}
|
||||
|
||||
// 添加内容
|
||||
doc.AddParagraph("Content with {{variable}}")
|
||||
|
||||
// 保存原文档
|
||||
originalPath := "test_section_props_original.docx"
|
||||
err = doc.Save(originalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("保存原文档失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Remove(originalPath); err != nil {
|
||||
t.Logf("清理原文档失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 打开原文档作为模板
|
||||
templateDoc, err := Open(originalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("打开模板文档失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取原文档的页面设置
|
||||
originalSettings := templateDoc.GetPageSettings()
|
||||
|
||||
// 创建模板引擎并加载模板
|
||||
engine := NewTemplateEngine()
|
||||
_, err = engine.LoadTemplateFromDocument("section_test", templateDoc)
|
||||
if err != nil {
|
||||
t.Fatalf("加载模板失败: %v", err)
|
||||
}
|
||||
|
||||
// 渲染模板
|
||||
data := NewTemplateData()
|
||||
data.SetVariable("variable", "Value")
|
||||
|
||||
renderedDoc, err := engine.RenderTemplateToDocument("section_test", data)
|
||||
if err != nil {
|
||||
t.Fatalf("渲染模板失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取渲染后文档的页面设置
|
||||
renderedSettings := renderedDoc.GetPageSettings()
|
||||
|
||||
// 验证页面设置被保留
|
||||
if renderedSettings.Size != originalSettings.Size {
|
||||
t.Errorf("页面大小不匹配: 期望 %v, 实际 %v", originalSettings.Size, renderedSettings.Size)
|
||||
}
|
||||
|
||||
// 允许1mm的误差
|
||||
tolerance := 1.0
|
||||
if abs(renderedSettings.MarginTop-originalSettings.MarginTop) > tolerance {
|
||||
t.Errorf("上边距不匹配: 期望 %.1f, 实际 %.1f", originalSettings.MarginTop, renderedSettings.MarginTop)
|
||||
}
|
||||
if abs(renderedSettings.MarginBottom-originalSettings.MarginBottom) > tolerance {
|
||||
t.Errorf("下边距不匹配: 期望 %.1f, 实际 %.1f", originalSettings.MarginBottom, renderedSettings.MarginBottom)
|
||||
}
|
||||
if abs(renderedSettings.MarginLeft-originalSettings.MarginLeft) > tolerance {
|
||||
t.Errorf("左边距不匹配: 期望 %.1f, 实际 %.1f", originalSettings.MarginLeft, renderedSettings.MarginLeft)
|
||||
}
|
||||
if abs(renderedSettings.MarginRight-originalSettings.MarginRight) > tolerance {
|
||||
t.Errorf("右边距不匹配: 期望 %.1f, 实际 %.1f", originalSettings.MarginRight, renderedSettings.MarginRight)
|
||||
}
|
||||
|
||||
t.Log("节属性保留测试通过")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user