From 63252fa30f10bee53e60a76bad73d96f80be8427 Mon Sep 17 00:00:00 2001
From: 18278715334 <18278715334@163.com>
Date: Wed, 3 Dec 2025 12:03:42 +0800
Subject: [PATCH] 18278715334@163.cmo
---
go.mod | 1 +
go.sum | 2 +
.../documentation_application_service.go | 110 +
.../dto/commands/documentation_commands.go | 2 +
.../dto/responses/documentation_responses.go | 2 +
internal/container/container.go | 19 +
.../processors/qygl/qyglb4c0_processor.go | 2 +-
.../product/entities/product_documentation.go | 1 +
.../services/product_documentation_service.go | 12 +
.../gorm_recharge_record_repository.go | 108 +-
.../http/handlers/product_handler.go | 201 +-
.../http/routes/product_routes.go | 1 +
internal/shared/pdf/LOG_VIEWING_GUIDE.md | 168 ++
internal/shared/pdf/database_table_reader.go | 618 +++++
.../shared/pdf/database_table_renderer.go | 535 ++++
internal/shared/pdf/font_manager.go | 217 ++
internal/shared/pdf/json_processor.go | 155 ++
internal/shared/pdf/markdown_converter.go | 658 +++++
internal/shared/pdf/markdown_processor.go | 355 +++
internal/shared/pdf/page_builder.go | 733 ++++++
internal/shared/pdf/pdf_finder.go | 242 ++
internal/shared/pdf/pdf_generator.go | 2197 +++++++++++++++++
.../shared/pdf/pdf_generator_refactored.go | 185 ++
internal/shared/pdf/table_parser.go | 208 ++
internal/shared/pdf/table_renderer.go | 340 +++
internal/shared/pdf/text_processor.go | 131 +
internal/shared/pdf/天远数据.png | Bin 0 -> 145160 bytes
27 files changed, 7167 insertions(+), 36 deletions(-)
create mode 100644 internal/shared/pdf/LOG_VIEWING_GUIDE.md
create mode 100644 internal/shared/pdf/database_table_reader.go
create mode 100644 internal/shared/pdf/database_table_renderer.go
create mode 100644 internal/shared/pdf/font_manager.go
create mode 100644 internal/shared/pdf/json_processor.go
create mode 100644 internal/shared/pdf/markdown_converter.go
create mode 100644 internal/shared/pdf/markdown_processor.go
create mode 100644 internal/shared/pdf/page_builder.go
create mode 100644 internal/shared/pdf/pdf_finder.go
create mode 100644 internal/shared/pdf/pdf_generator.go
create mode 100644 internal/shared/pdf/pdf_generator_refactored.go
create mode 100644 internal/shared/pdf/table_parser.go
create mode 100644 internal/shared/pdf/table_renderer.go
create mode 100644 internal/shared/pdf/text_processor.go
create mode 100644 internal/shared/pdf/天远数据.png
diff --git a/go.mod b/go.mod
index df81c1a..733a38b 100644
--- a/go.mod
+++ b/go.mod
@@ -12,6 +12,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/hibiken/asynq v0.25.1
+ github.com/jung-kurt/gofpdf/v2 v2.17.3
github.com/prometheus/client_golang v1.22.0
github.com/qiniu/go-sdk/v7 v7.25.4
github.com/redis/go-redis/v9 v9.11.0
diff --git a/go.sum b/go.sum
index 0ef7dc3..5e821d0 100644
--- a/go.sum
+++ b/go.sum
@@ -133,6 +133,8 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
+github.com/jung-kurt/gofpdf/v2 v2.17.3 h1:otZXZby2gXJ7uU6pzprXHq/R57lsHLi0WtH79VabWxY=
+github.com/jung-kurt/gofpdf/v2 v2.17.3/go.mod h1:Qx8ZNg4cNsO5i6uLDiBngnm+ii/FjtAqjRNO6drsoYU=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
diff --git a/internal/application/product/documentation_application_service.go b/internal/application/product/documentation_application_service.go
index 50385c0..95dc07a 100644
--- a/internal/application/product/documentation_application_service.go
+++ b/internal/application/product/documentation_application_service.go
@@ -2,6 +2,8 @@ package product
import (
"context"
+ "fmt"
+ "strings"
"tyapi-server/internal/application/product/dto/commands"
"tyapi-server/internal/application/product/dto/responses"
@@ -28,6 +30,9 @@ type DocumentationApplicationServiceInterface interface {
// GetDocumentationsByProductIDs 批量获取文档
GetDocumentationsByProductIDs(ctx context.Context, productIDs []string) ([]responses.DocumentationResponse, error)
+
+ // GenerateFullDocumentation 生成完整的接口文档(Markdown格式)
+ GenerateFullDocumentation(ctx context.Context, productID string) (string, error)
}
// DocumentationApplicationService 文档应用服务
@@ -53,6 +58,7 @@ func (s *DocumentationApplicationService) CreateDocumentation(ctx context.Contex
ResponseFields: cmd.ResponseFields,
ResponseExample: cmd.ResponseExample,
ErrorCodes: cmd.ErrorCodes,
+ PDFFilePath: cmd.PDFFilePath,
}
// 调用领域服务创建文档
@@ -88,6 +94,20 @@ func (s *DocumentationApplicationService) UpdateDocumentation(ctx context.Contex
return nil, err
}
+ // 更新PDF文件路径(如果提供)
+ if cmd.PDFFilePath != "" {
+ doc.PDFFilePath = cmd.PDFFilePath
+ err = s.docService.UpdateDocumentationEntity(ctx, doc)
+ if err != nil {
+ return nil, fmt.Errorf("更新PDF文件路径失败: %w", err)
+ }
+ // 重新获取更新后的文档以确保获取最新数据
+ doc, err = s.docService.GetDocumentation(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ }
+
// 返回响应
resp := responses.NewDocumentationResponse(doc)
return &resp, nil
@@ -136,3 +156,93 @@ func (s *DocumentationApplicationService) GetDocumentationsByProductIDs(ctx cont
return docResponses, nil
}
+
+// GenerateFullDocumentation 生成完整的接口文档(Markdown格式)
+func (s *DocumentationApplicationService) GenerateFullDocumentation(ctx context.Context, productID string) (string, error) {
+ // 通过产品ID获取文档
+ doc, err := s.docService.GetDocumentationByProductID(ctx, productID)
+ if err != nil {
+ return "", fmt.Errorf("获取文档失败: %w", err)
+ }
+
+ // 获取文档时已经包含了产品信息(通过GetDocumentationWithProduct)
+ // 如果没有产品信息,通过文档ID获取
+ if doc.Product == nil && doc.ID != "" {
+ docWithProduct, err := s.docService.GetDocumentationWithProduct(ctx, doc.ID)
+ if err == nil && docWithProduct != nil {
+ doc = docWithProduct
+ }
+ }
+
+ var markdown strings.Builder
+
+ // 添加文档标题
+ productName := "产品"
+ if doc.Product != nil {
+ productName = doc.Product.Name
+ }
+ markdown.WriteString(fmt.Sprintf("# %s 接口文档\n\n", productName))
+
+ // 添加产品基本信息
+ if doc.Product != nil {
+ markdown.WriteString("## 产品信息\n\n")
+ markdown.WriteString(fmt.Sprintf("- **产品名称**: %s\n", doc.Product.Name))
+ markdown.WriteString(fmt.Sprintf("- **产品编号**: %s\n", doc.Product.Code))
+ if doc.Product.Description != "" {
+ markdown.WriteString(fmt.Sprintf("- **产品描述**: %s\n", doc.Product.Description))
+ }
+ markdown.WriteString("\n")
+ }
+
+ // 添加请求方式
+ markdown.WriteString("## 请求方式\n\n")
+ if doc.RequestURL != "" {
+ markdown.WriteString(fmt.Sprintf("- **请求方法**: %s\n", doc.RequestMethod))
+ markdown.WriteString(fmt.Sprintf("- **请求地址**: %s\n", doc.RequestURL))
+ markdown.WriteString("\n")
+ }
+
+ // 添加请求方式详细说明
+ if doc.BasicInfo != "" {
+ markdown.WriteString("### 请求方式说明\n\n")
+ markdown.WriteString(doc.BasicInfo)
+ markdown.WriteString("\n\n")
+ }
+
+ // 添加请求参数
+ if doc.RequestParams != "" {
+ markdown.WriteString("## 请求参数\n\n")
+ markdown.WriteString(doc.RequestParams)
+ markdown.WriteString("\n\n")
+ }
+
+ // 添加返回字段说明
+ if doc.ResponseFields != "" {
+ markdown.WriteString("## 返回字段说明\n\n")
+ markdown.WriteString(doc.ResponseFields)
+ markdown.WriteString("\n\n")
+ }
+
+ // 添加响应示例
+ if doc.ResponseExample != "" {
+ markdown.WriteString("## 响应示例\n\n")
+ markdown.WriteString(doc.ResponseExample)
+ markdown.WriteString("\n\n")
+ }
+
+ // 添加错误代码
+ if doc.ErrorCodes != "" {
+ markdown.WriteString("## 错误代码\n\n")
+ markdown.WriteString(doc.ErrorCodes)
+ markdown.WriteString("\n\n")
+ }
+
+ // 添加文档版本信息
+ markdown.WriteString("---\n\n")
+ markdown.WriteString(fmt.Sprintf("**文档版本**: %s\n\n", doc.Version))
+ if doc.UpdatedAt.Year() > 1900 {
+ markdown.WriteString(fmt.Sprintf("**更新时间**: %s\n", doc.UpdatedAt.Format("2006-01-02 15:04:05")))
+ }
+
+ return markdown.String(), nil
+}
diff --git a/internal/application/product/dto/commands/documentation_commands.go b/internal/application/product/dto/commands/documentation_commands.go
index d70f90b..84f19e9 100644
--- a/internal/application/product/dto/commands/documentation_commands.go
+++ b/internal/application/product/dto/commands/documentation_commands.go
@@ -10,6 +10,7 @@ type CreateDocumentationCommand struct {
ResponseFields string `json:"response_fields"`
ResponseExample string `json:"response_example"`
ErrorCodes string `json:"error_codes"`
+ PDFFilePath string `json:"pdf_file_path,omitempty"`
}
// UpdateDocumentationCommand 更新文档命令
@@ -21,4 +22,5 @@ type UpdateDocumentationCommand struct {
ResponseFields string `json:"response_fields"`
ResponseExample string `json:"response_example"`
ErrorCodes string `json:"error_codes"`
+ PDFFilePath string `json:"pdf_file_path,omitempty"`
}
diff --git a/internal/application/product/dto/responses/documentation_responses.go b/internal/application/product/dto/responses/documentation_responses.go
index cc9efd5..0d88e4e 100644
--- a/internal/application/product/dto/responses/documentation_responses.go
+++ b/internal/application/product/dto/responses/documentation_responses.go
@@ -18,6 +18,7 @@ type DocumentationResponse struct {
ResponseExample string `json:"response_example"`
ErrorCodes string `json:"error_codes"`
Version string `json:"version"`
+ PDFFilePath string `json:"pdf_file_path,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -35,6 +36,7 @@ func NewDocumentationResponse(doc *entities.ProductDocumentation) DocumentationR
ResponseExample: doc.ResponseExample,
ErrorCodes: doc.ErrorCodes,
Version: doc.Version,
+ PDFFilePath: doc.PDFFilePath,
CreatedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
}
diff --git a/internal/container/container.go b/internal/container/container.go
index d5b7a39..440f963 100644
--- a/internal/container/container.go
+++ b/internal/container/container.go
@@ -66,6 +66,7 @@ import (
"tyapi-server/internal/shared/middleware"
sharedOCR "tyapi-server/internal/shared/ocr"
"tyapi-server/internal/shared/payment"
+ "tyapi-server/internal/shared/pdf"
"tyapi-server/internal/shared/resilience"
"tyapi-server/internal/shared/saga"
"tyapi-server/internal/shared/tracing"
@@ -980,6 +981,24 @@ func NewContainer() *Container {
),
),
+ // PDF查找服务
+ fx.Provide(
+ func(logger *zap.Logger) (*pdf.PDFFinder, error) {
+ docDir, err := pdf.GetDocumentationDir()
+ if err != nil {
+ logger.Warn("未找到接口文档文件夹,PDF自动查找功能将不可用", zap.Error(err))
+ return nil, nil // 返回nil,handler中会检查
+ }
+ logger.Info("PDF查找服务已初始化", zap.String("documentation_dir", docDir))
+ return pdf.NewPDFFinder(docDir, logger), nil
+ },
+ ),
+ // PDF生成器
+ fx.Provide(
+ func(logger *zap.Logger) *pdf.PDFGenerator {
+ return pdf.NewPDFGenerator(logger)
+ },
+ ),
// HTTP处理器
fx.Provide(
// 用户HTTP处理器
diff --git a/internal/domains/api/services/processors/qygl/qyglb4c0_processor.go b/internal/domains/api/services/processors/qygl/qyglb4c0_processor.go
index 3eb1bbb..2c17156 100644
--- a/internal/domains/api/services/processors/qygl/qyglb4c0_processor.go
+++ b/internal/domains/api/services/processors/qygl/qyglb4c0_processor.go
@@ -34,7 +34,7 @@ func ProcessQYGLB4C0Request(ctx context.Context, params []byte, deps *processors
// 数据源错误
if errors.Is(err, westdex.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
- }else if errors.Is(err, westdex.ErrNotFound) {
+ } else if errors.Is(err, westdex.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
// 其他系统错误
diff --git a/internal/domains/product/entities/product_documentation.go b/internal/domains/product/entities/product_documentation.go
index fe4da35..970cbce 100644
--- a/internal/domains/product/entities/product_documentation.go
+++ b/internal/domains/product/entities/product_documentation.go
@@ -22,6 +22,7 @@ type ProductDocumentation struct {
ResponseExample string `gorm:"type:text" comment:"响应示例"`
ErrorCodes string `gorm:"type:text" comment:"错误代码"`
Version string `gorm:"type:varchar(20);default:'1.0'" comment:"文档版本"`
+ PDFFilePath string `gorm:"type:varchar(500)" comment:"PDF文档文件路径或URL"`
// 关联关系
Product *Product `gorm:"foreignKey:ProductID" comment:"产品"`
diff --git a/internal/domains/product/services/product_documentation_service.go b/internal/domains/product/services/product_documentation_service.go
index b837947..206a1d4 100644
--- a/internal/domains/product/services/product_documentation_service.go
+++ b/internal/domains/product/services/product_documentation_service.go
@@ -114,3 +114,15 @@ func (s *ProductDocumentationService) GetDocumentationWithProduct(ctx context.Co
func (s *ProductDocumentationService) GetDocumentationsByProductIDs(ctx context.Context, productIDs []string) ([]*entities.ProductDocumentation, error) {
return s.docRepo.FindByProductIDs(ctx, productIDs)
}
+
+// UpdateDocumentationEntity 更新文档实体(用于更新PDFFilePath等字段)
+func (s *ProductDocumentationService) UpdateDocumentationEntity(ctx context.Context, doc *entities.ProductDocumentation) error {
+ // 验证文档是否存在
+ _, err := s.docRepo.FindByID(ctx, doc.ID)
+ if err != nil {
+ return fmt.Errorf("文档不存在: %w", err)
+ }
+
+ // 保存更新
+ return s.docRepo.Update(ctx, doc)
+}
diff --git a/internal/infrastructure/database/repositories/finance/gorm_recharge_record_repository.go b/internal/infrastructure/database/repositories/finance/gorm_recharge_record_repository.go
index 9d78307..ba00a68 100644
--- a/internal/infrastructure/database/repositories/finance/gorm_recharge_record_repository.go
+++ b/internal/infrastructure/database/repositories/finance/gorm_recharge_record_repository.go
@@ -3,6 +3,7 @@ package repositories
import (
"context"
"errors"
+ "fmt"
"strings"
"time"
"tyapi-server/internal/domains/finance/entities"
@@ -10,6 +11,7 @@ import (
"tyapi-server/internal/shared/database"
"tyapi-server/internal/shared/interfaces"
+ "github.com/shopspring/decimal"
"go.uber.org/zap"
"gorm.io/gorm"
)
@@ -90,11 +92,33 @@ func (r *GormRechargeRecordRepository) Count(ctx context.Context, options interf
query := r.GetDB(ctx).Model(&entities.RechargeRecord{})
if options.Filters != nil {
for key, value := range options.Filters {
- query = query.Where(key+" = ?", value)
+ // 特殊处理时间范围过滤器
+ if key == "start_time" {
+ if startTime, ok := value.(time.Time); ok {
+ query = query.Where("created_at >= ?", startTime)
+ }
+ } else if key == "end_time" {
+ if endTime, ok := value.(time.Time); ok {
+ query = query.Where("created_at <= ?", endTime)
+ }
+ } else if key == "min_amount" {
+ // 处理最小金额,支持string、int、int64类型
+ if amount, err := r.parseAmount(value); err == nil {
+ query = query.Where("amount >= ?", amount)
+ }
+ } else if key == "max_amount" {
+ // 处理最大金额,支持string、int、int64类型
+ if amount, err := r.parseAmount(value); err == nil {
+ query = query.Where("amount <= ?", amount)
+ }
+ } else {
+ // 其他过滤器使用等值查询
+ query = query.Where(key+" = ?", value)
+ }
}
}
if options.Search != "" {
- query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ?",
+ query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ?",
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
}
return count, query.Count(&count).Error
@@ -109,7 +133,7 @@ func (r *GormRechargeRecordRepository) Exists(ctx context.Context, id string) (b
func (r *GormRechargeRecordRepository) List(ctx context.Context, options interfaces.ListOptions) ([]entities.RechargeRecord, error) {
var records []entities.RechargeRecord
query := r.GetDB(ctx).Model(&entities.RechargeRecord{})
-
+
if options.Filters != nil {
for key, value := range options.Filters {
// 特殊处理 user_ids 过滤器
@@ -117,17 +141,38 @@ func (r *GormRechargeRecordRepository) List(ctx context.Context, options interfa
if userIds, ok := value.(string); ok && userIds != "" {
query = query.Where("user_id IN ?", strings.Split(userIds, ","))
}
+ } else if key == "start_time" {
+ // 处理开始时间范围
+ if startTime, ok := value.(time.Time); ok {
+ query = query.Where("created_at >= ?", startTime)
+ }
+ } else if key == "end_time" {
+ // 处理结束时间范围
+ if endTime, ok := value.(time.Time); ok {
+ query = query.Where("created_at <= ?", endTime)
+ }
+ } else if key == "min_amount" {
+ // 处理最小金额,支持string、int、int64类型
+ if amount, err := r.parseAmount(value); err == nil {
+ query = query.Where("amount >= ?", amount)
+ }
+ } else if key == "max_amount" {
+ // 处理最大金额,支持string、int、int64类型
+ if amount, err := r.parseAmount(value); err == nil {
+ query = query.Where("amount <= ?", amount)
+ }
} else {
+ // 其他过滤器使用等值查询
query = query.Where(key+" = ?", value)
}
}
}
-
+
if options.Search != "" {
- query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ?",
+ query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ?",
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
}
-
+
if options.Sort != "" {
order := "ASC"
if options.Order == "desc" || options.Order == "DESC" {
@@ -137,12 +182,12 @@ func (r *GormRechargeRecordRepository) List(ctx context.Context, options interfa
} else {
query = query.Order("created_at DESC")
}
-
+
if options.Page > 0 && options.PageSize > 0 {
offset := (options.Page - 1) * options.PageSize
query = query.Offset(offset).Limit(options.PageSize)
}
-
+
err := query.Find(&records).Error
return records, err
}
@@ -209,7 +254,7 @@ func (r *GormRechargeRecordRepository) GetTotalAmountByUserIdAndDateRange(ctx co
// GetDailyStatsByUserId 获取用户每日充值统计(排除赠送)
func (r *GormRechargeRecordRepository) GetDailyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
-
+
// 构建SQL查询 - 使用PostgreSQL语法,使用具体的日期范围
sql := `
SELECT
@@ -224,19 +269,19 @@ func (r *GormRechargeRecordRepository) GetDailyStatsByUserId(ctx context.Context
GROUP BY DATE(created_at)
ORDER BY date ASC
`
-
+
err := r.GetDB(ctx).Raw(sql, userId, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
if err != nil {
return nil, err
}
-
+
return results, nil
}
// GetMonthlyStatsByUserId 获取用户每月充值统计(排除赠送)
func (r *GormRechargeRecordRepository) GetMonthlyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
-
+
// 构建SQL查询 - 使用PostgreSQL语法,使用具体的日期范围
sql := `
SELECT
@@ -251,12 +296,12 @@ func (r *GormRechargeRecordRepository) GetMonthlyStatsByUserId(ctx context.Conte
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month ASC
`
-
+
err := r.GetDB(ctx).Raw(sql, userId, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
-
+
return results, nil
}
@@ -283,7 +328,7 @@ func (r *GormRechargeRecordRepository) GetSystemAmountByDateRange(ctx context.Co
// GetSystemDailyStats 获取系统每日充值统计(排除赠送)
func (r *GormRechargeRecordRepository) GetSystemDailyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
-
+
sql := `
SELECT
DATE(created_at) as date,
@@ -296,19 +341,19 @@ func (r *GormRechargeRecordRepository) GetSystemDailyStats(ctx context.Context,
GROUP BY DATE(created_at)
ORDER BY date ASC
`
-
+
err := r.GetDB(ctx).Raw(sql, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
if err != nil {
return nil, err
}
-
+
return results, nil
}
// GetSystemMonthlyStats 获取系统每月充值统计(排除赠送)
func (r *GormRechargeRecordRepository) GetSystemMonthlyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
-
+
sql := `
SELECT
TO_CHAR(created_at, 'YYYY-MM') as month,
@@ -321,11 +366,32 @@ func (r *GormRechargeRecordRepository) GetSystemMonthlyStats(ctx context.Context
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month ASC
`
-
+
err := r.GetDB(ctx).Raw(sql, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
-
+
return results, nil
-}
\ No newline at end of file
+}
+
+// parseAmount 解析金额值,支持string、int、int64类型,转换为decimal.Decimal
+func (r *GormRechargeRecordRepository) parseAmount(value interface{}) (decimal.Decimal, error) {
+ switch v := value.(type) {
+ case string:
+ if v == "" {
+ return decimal.Zero, fmt.Errorf("empty string")
+ }
+ return decimal.NewFromString(v)
+ case int:
+ return decimal.NewFromInt(int64(v)), nil
+ case int64:
+ return decimal.NewFromInt(v), nil
+ case float64:
+ return decimal.NewFromFloat(v), nil
+ case decimal.Decimal:
+ return v, nil
+ default:
+ return decimal.Zero, fmt.Errorf("unsupported type: %T", value)
+ }
+}
diff --git a/internal/infrastructure/http/handlers/product_handler.go b/internal/infrastructure/http/handlers/product_handler.go
index 69cb7b2..3343068 100644
--- a/internal/infrastructure/http/handlers/product_handler.go
+++ b/internal/infrastructure/http/handlers/product_handler.go
@@ -2,12 +2,17 @@
package handlers
import (
+ "fmt"
+ "net/http"
"strconv"
+ "strings"
"tyapi-server/internal/application/product"
"tyapi-server/internal/application/product/dto/commands"
"tyapi-server/internal/application/product/dto/queries"
_ "tyapi-server/internal/application/product/dto/responses"
+ "tyapi-server/internal/domains/product/entities"
"tyapi-server/internal/shared/interfaces"
+ "tyapi-server/internal/shared/pdf"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -15,14 +20,15 @@ import (
// ProductHandler 产品相关HTTP处理器
type ProductHandler struct {
- appService product.ProductApplicationService
- apiConfigService product.ProductApiConfigApplicationService
- categoryService product.CategoryApplicationService
- subAppService product.SubscriptionApplicationService
+ appService product.ProductApplicationService
+ apiConfigService product.ProductApiConfigApplicationService
+ categoryService product.CategoryApplicationService
+ subAppService product.SubscriptionApplicationService
documentationAppService product.DocumentationApplicationServiceInterface
- responseBuilder interfaces.ResponseBuilder
- validator interfaces.RequestValidator
- logger *zap.Logger
+ responseBuilder interfaces.ResponseBuilder
+ validator interfaces.RequestValidator
+ pdfGenerator *pdf.PDFGenerator
+ logger *zap.Logger
}
// NewProductHandler 创建产品HTTP处理器
@@ -34,17 +40,19 @@ func NewProductHandler(
documentationAppService product.DocumentationApplicationServiceInterface,
responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator,
+ pdfGenerator *pdf.PDFGenerator,
logger *zap.Logger,
) *ProductHandler {
return &ProductHandler{
- appService: appService,
- apiConfigService: apiConfigService,
- categoryService: categoryService,
- subAppService: subAppService,
+ appService: appService,
+ apiConfigService: apiConfigService,
+ categoryService: categoryService,
+ subAppService: subAppService,
documentationAppService: documentationAppService,
- responseBuilder: responseBuilder,
- validator: validator,
- logger: logger,
+ responseBuilder: responseBuilder,
+ validator: validator,
+ pdfGenerator: pdfGenerator,
+ logger: logger,
}
}
@@ -630,3 +638,168 @@ func (h *ProductHandler) GetProductDocumentation(c *gin.Context) {
h.responseBuilder.Success(c, doc, "获取文档成功")
}
+
+// DownloadProductDocumentation 下载产品接口文档(PDF文件)
+// @Summary 下载产品接口文档
+// @Description 根据产品ID从数据库获取产品信息和文档信息,动态生成PDF文档并下载。
+// @Tags 数据大厅
+// @Accept json
+// @Produce application/pdf
+// @Param id path string true "产品ID"
+// @Success 200 {file} file "PDF文档文件"
+// @Failure 400 {object} map[string]interface{} "请求参数错误"
+// @Failure 404 {object} map[string]interface{} "产品不存在"
+// @Failure 500 {object} map[string]interface{} "服务器内部错误"
+// @Router /api/v1/products/{id}/documentation/download [get]
+func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
+ productID := c.Param("id")
+ if productID == "" {
+ h.responseBuilder.BadRequest(c, "产品ID不能为空")
+ return
+ }
+
+ // 检查PDF生成器是否可用
+ if h.pdfGenerator == nil {
+ h.logger.Error("PDF生成器未初始化")
+ h.responseBuilder.InternalError(c, "PDF生成器未初始化")
+ return
+ }
+
+ // 获取产品信息
+ product, err := h.appService.GetProductByID(c.Request.Context(), &queries.GetProductQuery{ID: productID})
+ if err != nil {
+ h.logger.Error("获取产品信息失败", zap.Error(err))
+ h.responseBuilder.NotFound(c, "产品不存在")
+ return
+ }
+
+ // 检查产品编码是否存在
+ if product.Code == "" {
+ h.logger.Warn("产品编码为空", zap.String("product_id", productID))
+ h.responseBuilder.BadRequest(c, "产品编码不存在")
+ return
+ }
+
+ h.logger.Info("开始生成PDF文档",
+ zap.String("product_id", productID),
+ zap.String("product_code", product.Code),
+ zap.String("product_name", product.Name),
+ )
+
+ // 获取产品文档信息
+ doc, docErr := h.documentationAppService.GetDocumentationByProductID(c.Request.Context(), productID)
+ if docErr != nil {
+ h.logger.Warn("获取产品文档失败,将只生成产品基本信息",
+ zap.String("product_id", productID),
+ zap.Error(docErr),
+ )
+ }
+
+ // 将响应类型转换为entity类型
+ var docEntity *entities.ProductDocumentation
+ if doc != nil {
+ docEntity = &entities.ProductDocumentation{
+ ID: doc.ID,
+ ProductID: doc.ProductID,
+ RequestURL: doc.RequestURL,
+ RequestMethod: doc.RequestMethod,
+ BasicInfo: doc.BasicInfo,
+ RequestParams: doc.RequestParams,
+ ResponseFields: doc.ResponseFields,
+ ResponseExample: doc.ResponseExample,
+ ErrorCodes: doc.ErrorCodes,
+ Version: doc.Version,
+ }
+ }
+
+ // 使用数据库数据生成PDF
+ h.logger.Info("准备调用PDF生成器",
+ zap.String("product_id", productID),
+ zap.String("product_name", product.Name),
+ zap.Bool("has_doc", docEntity != nil),
+ )
+
+ defer func() {
+ if r := recover(); r != nil {
+ h.logger.Error("PDF生成过程中发生panic",
+ zap.String("product_id", productID),
+ zap.Any("panic_value", r),
+ )
+ // 确保在panic时也能返回响应
+ if !c.Writer.Written() {
+ h.responseBuilder.InternalError(c, fmt.Sprintf("生成PDF文档时发生错误: %v", r))
+ }
+ }
+ }()
+
+ // 直接调用PDF生成器(简化版本,不使用goroutine)
+ h.logger.Info("开始调用PDF生成器")
+ pdfBytes, genErr := h.pdfGenerator.GenerateProductPDF(
+ c.Request.Context(),
+ product.ID,
+ product.Name,
+ product.Code,
+ product.Description,
+ product.Content,
+ product.Price,
+ docEntity,
+ )
+ h.logger.Info("PDF生成器调用返回",
+ zap.String("product_id", productID),
+ zap.Bool("has_error", genErr != nil),
+ zap.Int("pdf_size", len(pdfBytes)),
+ )
+
+ if genErr != nil {
+ h.logger.Error("生成PDF文档失败",
+ zap.String("product_id", productID),
+ zap.String("product_code", product.Code),
+ zap.Error(genErr),
+ )
+ h.responseBuilder.InternalError(c, fmt.Sprintf("生成PDF文档失败: %s", genErr.Error()))
+ return
+ }
+
+ h.logger.Info("PDF生成器调用完成",
+ zap.String("product_id", productID),
+ zap.Int("pdf_size", len(pdfBytes)),
+ )
+
+ if len(pdfBytes) == 0 {
+ h.logger.Error("生成的PDF文档为空",
+ zap.String("product_id", productID),
+ zap.String("product_code", product.Code),
+ )
+ h.responseBuilder.InternalError(c, "生成的PDF文档为空")
+ return
+ }
+
+ // 生成文件名(清理文件名中的非法字符)
+ fileName := fmt.Sprintf("%s_接口文档.pdf", product.Name)
+ if product.Name == "" {
+ fileName = fmt.Sprintf("%s_接口文档.pdf", product.Code)
+ }
+ // 清理文件名中的非法字符
+ fileName = strings.ReplaceAll(fileName, "/", "_")
+ fileName = strings.ReplaceAll(fileName, "\\", "_")
+ fileName = strings.ReplaceAll(fileName, ":", "_")
+ fileName = strings.ReplaceAll(fileName, "*", "_")
+ fileName = strings.ReplaceAll(fileName, "?", "_")
+ fileName = strings.ReplaceAll(fileName, "\"", "_")
+ fileName = strings.ReplaceAll(fileName, "<", "_")
+ fileName = strings.ReplaceAll(fileName, ">", "_")
+ fileName = strings.ReplaceAll(fileName, "|", "_")
+
+ h.logger.Info("成功生成PDF文档",
+ zap.String("product_id", productID),
+ zap.String("product_code", product.Code),
+ zap.String("file_name", fileName),
+ zap.Int("file_size", len(pdfBytes)),
+ )
+
+ // 设置响应头并返回PDF文件
+ c.Header("Content-Type", "application/pdf")
+ c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
+ c.Header("Content-Length", fmt.Sprintf("%d", len(pdfBytes)))
+ c.Data(http.StatusOK, "application/pdf", pdfBytes)
+}
diff --git a/internal/infrastructure/http/routes/product_routes.go b/internal/infrastructure/http/routes/product_routes.go
index 43a6b74..caa3126 100644
--- a/internal/infrastructure/http/routes/product_routes.go
+++ b/internal/infrastructure/http/routes/product_routes.go
@@ -51,6 +51,7 @@ func (r *ProductRoutes) Register(router *sharedhttp.GinRouter) {
products.GET("/:id", r.productHandler.GetProductDetail)
products.GET("/:id/api-config", r.productHandler.GetProductApiConfig)
products.GET("/:id/documentation", r.productHandler.GetProductDocumentation)
+ products.GET("/:id/documentation/download", r.productHandler.DownloadProductDocumentation)
// 订阅产品(需要认证)
products.POST("/:id/subscribe", r.auth.Handle(), r.productHandler.SubscribeProduct)
diff --git a/internal/shared/pdf/LOG_VIEWING_GUIDE.md b/internal/shared/pdf/LOG_VIEWING_GUIDE.md
new file mode 100644
index 0000000..cecdaed
--- /dev/null
+++ b/internal/shared/pdf/LOG_VIEWING_GUIDE.md
@@ -0,0 +1,168 @@
+# 📋 PDF表格转换日志查看指南
+
+## 📍 日志文件位置
+
+### 1. 开发环境
+日志文件存储在项目根目录的 `logs/` 目录下:
+
+```
+tyapi-server/
+└── logs/
+ ├── 2024-12-02/ # 按日期分包(如果启用)
+ │ ├── debug.log # Debug级别日志(包含JSON转换详情)
+ │ ├── info.log # Info级别日志(包含转换流程)
+ │ └── error.log # Error级别日志(包含错误信息)
+ └── app.log # 传统模式(如果未启用按日分包)
+```
+
+### 2. 生产环境(Docker)
+日志文件存储在容器的 `/app/logs/` 目录,映射到宿主机的 `./logs/` 目录:
+
+```bash
+# 查看宿主机日志
+./logs/2024-12-02/info.log
+./logs/2024-12-02/debug.log
+```
+
+## 🔍 如何查看转换日志
+
+### 方法1:实时查看日志(推荐)
+
+```bash
+# 查看Info级别日志(转换流程)
+tail -f logs/2024-12-02/info.log | grep "表格\|JSON\|markdown"
+
+# 查看Debug级别日志(详细JSON数据)
+tail -f logs/2024-12-02/debug.log | grep "JSON\|表格"
+
+# 查看所有PDF相关日志
+tail -f logs/2024-12-02/*.log | grep -i "pdf\|table\|json"
+```
+
+### 方法2:使用Docker查看
+
+```bash
+# 查看容器实时日志
+docker logs -f tyapi-app-prod | grep -i "表格\|json\|markdown"
+
+# 查看最近的100行日志
+docker logs --tail 100 tyapi-app-prod | grep -i "表格\|json"
+```
+
+### 方法3:搜索特定字段类型
+
+```bash
+# 查看请求参数的转换日志
+grep "request_params" logs/2024-12-02/info.log
+
+# 查看响应字段的转换日志
+grep "response_fields" logs/2024-12-02/info.log
+
+# 查看错误代码的转换日志
+grep "error_codes" logs/2024-12-02/info.log
+```
+
+## 📊 日志级别说明
+
+### Info级别日志(info.log)
+包含转换流程的关键步骤:
+- ✅ 数据格式检测(JSON/Markdown)
+- ✅ Markdown表格解析开始
+- ✅ 表格解析成功(表头数量、行数)
+- ✅ JSON转换完成
+
+**示例日志:**
+```
+2024-12-02T10:30:15Z INFO 开始解析markdown表格并转换为JSON {"field_type": "request_params", "content_length": 1234}
+2024-12-02T10:30:15Z INFO markdown表格解析成功 {"field_type": "request_params", "header_count": 3, "row_count": 5}
+2024-12-02T10:30:15Z INFO 表格数据已转换为JSON格式 {"field_type": "request_params", "json_array_length": 5}
+```
+
+### Debug级别日志(debug.log)
+包含详细的转换数据:
+- 🔍 原始内容预览
+- 🔍 解析后的表头列表
+- 🔍 转换后的完整JSON数据(前1000字符)
+- 🔍 每行的转换详情
+
+**示例日志:**
+```
+2024-12-02T10:30:15Z DEBUG 转换后的JSON数据预览 {"field_type": "request_params", "json_length": 2345, "json_preview": "[{\"字段名\":\"name\",\"类型\":\"string\",\"说明\":\"姓名\"}...]"}
+```
+
+### Error级别日志(error.log)
+包含转换过程中的错误:
+- ❌ Markdown解析失败
+- ❌ JSON序列化失败
+- ❌ 数据格式错误
+
+**示例日志:**
+```
+2024-12-02T10:30:15Z ERROR 解析markdown表格失败 {"field_type": "request_params", "error": "无法解析表格:未找到表头", "content_preview": "..."}
+```
+
+## 🔎 日志关键词搜索
+
+### 转换流程关键词
+- `开始解析markdown表格` - 转换开始
+- `markdown表格解析成功` - 解析完成
+- `表格数据已转换为JSON格式` - JSON转换完成
+- `转换后的JSON数据预览` - JSON数据详情
+
+### 数据格式关键词
+- `数据已经是JSON格式` - 数据源是JSON
+- `从JSON对象中提取数组数据` - 从JSON对象提取
+- `解析markdown表格并转换为JSON` - Markdown转JSON
+
+### 错误关键词
+- `解析markdown表格失败` - 解析错误
+- `JSON序列化失败` - JSON错误
+- `字段内容为空` - 空数据
+
+## 📝 日志配置
+
+确保日志级别设置为 `debug` 才能看到详细的JSON转换日志:
+
+```yaml
+# config.yaml 或 configs/env.development.yaml
+logger:
+ level: "debug" # 开发环境使用debug级别
+ format: "console" # 或 "json"
+ output: "file" # 输出到文件
+ log_dir: "logs" # 日志目录
+ use_daily: true # 启用按日分包
+```
+
+## 🛠️ 常用命令
+
+```bash
+# 查看今天的Info日志
+cat logs/$(date +%Y-%m-%d)/info.log | grep "表格\|JSON"
+
+# 查看最近的转换日志(最后50行)
+tail -n 50 logs/$(date +%Y-%m-%d)/info.log
+
+# 搜索特定产品的转换日志
+grep "product_id.*xxx" logs/$(date +%Y-%m-%d)/info.log
+
+# 查看所有错误
+grep "ERROR" logs/$(date +%Y-%m-%d)/error.log
+
+# 统计转换次数
+grep "表格数据已转换为JSON格式" logs/$(date +%Y-%m-%d)/info.log | wc -l
+```
+
+## 💡 调试技巧
+
+1. **查看完整JSON数据**:如果JSON数据超过1000字符,查看debug.log获取完整内容
+2. **追踪转换流程**:使用 `field_type` 字段过滤特定字段的转换日志
+3. **定位错误**:查看error.log中的 `content_preview` 字段了解原始数据
+4. **性能监控**:统计转换次数和耗时,优化转换逻辑
+
+## 📌 注意事项
+
+- Debug级别日志可能包含大量数据,注意日志文件大小
+- 生产环境建议使用 `info` 级别,减少日志量
+- JSON预览限制在1000字符,完整数据请查看debug日志
+- 日志文件按日期自动分包,便于管理和查找
+
diff --git a/internal/shared/pdf/database_table_reader.go b/internal/shared/pdf/database_table_reader.go
new file mode 100644
index 0000000..8de0611
--- /dev/null
+++ b/internal/shared/pdf/database_table_reader.go
@@ -0,0 +1,618 @@
+package pdf
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "strings"
+
+ "tyapi-server/internal/domains/product/entities"
+
+ "go.uber.org/zap"
+)
+
+// DatabaseTableReader 数据库表格数据读取器
+type DatabaseTableReader struct {
+ logger *zap.Logger
+}
+
+// NewDatabaseTableReader 创建数据库表格数据读取器
+func NewDatabaseTableReader(logger *zap.Logger) *DatabaseTableReader {
+ return &DatabaseTableReader{
+ logger: logger,
+ }
+}
+
+// TableData 表格数据
+type TableData struct {
+ Headers []string
+ Rows [][]string
+}
+
+// TableWithTitle 带标题的表格
+type TableWithTitle struct {
+ Title string // 表格标题(markdown标题)
+ Table *TableData // 表格数据
+}
+
+// ReadTableFromDocumentation 从产品文档中读取表格数据
+// 先将markdown表格转换为JSON格式,然后再转换为表格数据
+func (r *DatabaseTableReader) ReadTableFromDocumentation(ctx context.Context, doc *entities.ProductDocumentation, fieldType string) (*TableData, error) {
+ var content string
+
+ switch fieldType {
+ case "request_params":
+ content = doc.RequestParams
+ case "response_fields":
+ content = doc.ResponseFields
+ case "response_example":
+ content = doc.ResponseExample
+ case "error_codes":
+ content = doc.ErrorCodes
+ default:
+ return nil, fmt.Errorf("未知的字段类型: %s", fieldType)
+ }
+
+ // 检查内容是否为空(去除空白字符后)
+ trimmedContent := strings.TrimSpace(content)
+ if trimmedContent == "" {
+ return nil, fmt.Errorf("字段 %s 内容为空", fieldType)
+ }
+
+ // 先尝试解析为JSON数组(如果已经是JSON格式)
+ var jsonArray []map[string]interface{}
+ if err := json.Unmarshal([]byte(content), &jsonArray); err == nil && len(jsonArray) > 0 {
+ r.logger.Info("数据已经是JSON格式,直接使用",
+ zap.String("field_type", fieldType),
+ zap.Int("json_array_length", len(jsonArray)))
+ return r.convertJSONArrayToTable(jsonArray), nil
+ }
+
+ // 尝试解析为单个JSON对象(包含数组字段)
+ var jsonObj map[string]interface{}
+ if err := json.Unmarshal([]byte(content), &jsonObj); err == nil {
+ // 查找包含数组的字段
+ for _, value := range jsonObj {
+ if arr, ok := value.([]interface{}); ok && len(arr) > 0 {
+ // 转换为map数组
+ mapArray := make([]map[string]interface{}, 0, len(arr))
+ for _, item := range arr {
+ if itemMap, ok := item.(map[string]interface{}); ok {
+ mapArray = append(mapArray, itemMap)
+ }
+ }
+ if len(mapArray) > 0 {
+ r.logger.Info("从JSON对象中提取数组数据", zap.String("field_type", fieldType))
+ return r.convertJSONArrayToTable(mapArray), nil
+ }
+ }
+ }
+ }
+
+ // 如果不是JSON格式,先解析为markdown表格,然后转换为JSON格式
+ r.logger.Info("开始解析markdown表格并转换为JSON",
+ zap.String("field_type", fieldType),
+ zap.Int("content_length", len(content)),
+ zap.String("content_preview", r.getContentPreview(content, 200)))
+
+ tableData, err := r.parseMarkdownTable(content)
+ if err != nil {
+ r.logger.Error("解析markdown表格失败",
+ zap.String("field_type", fieldType),
+ zap.Error(err),
+ zap.String("content_preview", r.getContentPreview(content, 500)))
+ return nil, fmt.Errorf("解析markdown表格失败: %w", err)
+ }
+
+ r.logger.Info("markdown表格解析成功",
+ zap.String("field_type", fieldType),
+ zap.Int("header_count", len(tableData.Headers)),
+ zap.Int("row_count", len(tableData.Rows)),
+ zap.Strings("headers", tableData.Headers))
+
+ // 将markdown表格数据转换为JSON格式(保持列顺序)
+ r.logger.Debug("开始将表格数据转换为JSON格式", zap.String("field_type", fieldType))
+ jsonArray = r.convertTableDataToJSON(tableData)
+
+ r.logger.Info("表格数据已转换为JSON格式",
+ zap.String("field_type", fieldType),
+ zap.Int("json_array_length", len(jsonArray)))
+
+ // 记录转换后的JSON(用于调试)
+ jsonBytes, marshalErr := json.MarshalIndent(jsonArray, "", " ")
+ if marshalErr != nil {
+ r.logger.Warn("JSON序列化失败",
+ zap.String("field_type", fieldType),
+ zap.Error(marshalErr))
+ } else {
+ previewLen := len(jsonBytes)
+ if previewLen > 1000 {
+ previewLen = 1000
+ }
+ r.logger.Debug("转换后的JSON数据预览",
+ zap.String("field_type", fieldType),
+ zap.Int("json_length", len(jsonBytes)),
+ zap.String("json_preview", string(jsonBytes[:previewLen])))
+
+ // 如果JSON数据较大,记录完整路径提示
+ if len(jsonBytes) > 1000 {
+ r.logger.Info("JSON数据较大,完整内容请查看debug级别日志",
+ zap.String("field_type", fieldType),
+ zap.Int("json_length", len(jsonBytes)))
+ }
+ }
+
+ // 将JSON数据转换回表格数据用于渲染(使用原始表头顺序保持列顺序)
+ return r.convertJSONArrayToTableWithOrder(jsonArray, tableData.Headers), nil
+}
+
+// convertJSONArrayToTable 将JSON数组转换为表格数据(用于已经是JSON格式的数据)
+func (r *DatabaseTableReader) convertJSONArrayToTable(data []map[string]interface{}) *TableData {
+ if len(data) == 0 {
+ return &TableData{
+ Headers: []string{},
+ Rows: [][]string{},
+ }
+ }
+
+ // 收集所有列名(按第一次出现的顺序)
+ columnSet := make(map[string]bool)
+ columns := make([]string, 0)
+
+ // 从第一行开始收集列名,保持第一次出现的顺序
+ for _, row := range data {
+ for key := range row {
+ if !columnSet[key] {
+ columns = append(columns, key)
+ columnSet[key] = true
+ }
+ }
+ // 只从第一行收集,保持顺序
+ if len(columns) > 0 {
+ break
+ }
+ }
+
+ // 如果第一行没有收集到所有列,继续收集(但顺序可能不稳定)
+ if len(columns) == 0 {
+ for _, row := range data {
+ for key := range row {
+ if !columnSet[key] {
+ columns = append(columns, key)
+ columnSet[key] = true
+ }
+ }
+ }
+ }
+
+ // 构建表头
+ headers := make([]string, len(columns))
+ copy(headers, columns)
+
+ // 构建数据行
+ rows := make([][]string, 0, len(data))
+ for _, row := range data {
+ rowData := make([]string, len(columns))
+ for i, col := range columns {
+ value := row[col]
+ rowData[i] = r.formatValue(value)
+ }
+ rows = append(rows, rowData)
+ }
+
+ return &TableData{
+ Headers: headers,
+ Rows: rows,
+ }
+}
+
+// convertJSONArrayToTableWithOrder 将JSON数组转换为表格数据(使用指定的列顺序)
+func (r *DatabaseTableReader) convertJSONArrayToTableWithOrder(data []map[string]interface{}, originalHeaders []string) *TableData {
+ if len(data) == 0 {
+ return &TableData{
+ Headers: originalHeaders,
+ Rows: [][]string{},
+ }
+ }
+
+ // 使用原始表头顺序
+ headers := make([]string, len(originalHeaders))
+ copy(headers, originalHeaders)
+
+ // 构建数据行,按照原始表头顺序
+ rows := make([][]string, 0, len(data))
+ for _, row := range data {
+ rowData := make([]string, len(headers))
+ for i, header := range headers {
+ value := row[header]
+ rowData[i] = r.formatValue(value)
+ }
+ rows = append(rows, rowData)
+ }
+
+ r.logger.Debug("JSON转表格完成(保持列顺序)",
+ zap.Int("header_count", len(headers)),
+ zap.Int("row_count", len(rows)),
+ zap.Strings("headers", headers))
+
+ return &TableData{
+ Headers: headers,
+ Rows: rows,
+ }
+}
+
+// parseMarkdownTablesWithTitles 解析markdown格式的表格(支持多个表格,保留标题)
+func (r *DatabaseTableReader) parseMarkdownTablesWithTitles(content string) ([]TableWithTitle, error) {
+ lines := strings.Split(content, "\n")
+ var result []TableWithTitle
+ var currentTitle string
+ var currentHeaders []string
+ var currentRows [][]string
+ inTable := false
+ hasValidHeader := false
+ nonTableLineCount := 0
+ maxNonTableLines := 3 // 允许最多3个连续非表格行
+
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+
+ // 处理markdown标题行(以#开头)- 保存标题
+ if strings.HasPrefix(line, "#") {
+ // 如果当前有表格,先保存
+ if inTable && len(currentHeaders) > 0 {
+ result = append(result, TableWithTitle{
+ Title: currentTitle,
+ Table: &TableData{
+ Headers: currentHeaders,
+ Rows: currentRows,
+ },
+ })
+ currentHeaders = nil
+ currentRows = nil
+ inTable = false
+ hasValidHeader = false
+ }
+ // 提取标题(移除#和空格)
+ currentTitle = strings.TrimSpace(strings.TrimPrefix(line, "#"))
+ currentTitle = strings.TrimSpace(strings.TrimPrefix(currentTitle, "#"))
+ currentTitle = strings.TrimSpace(strings.TrimPrefix(currentTitle, "#"))
+ nonTableLineCount = 0
+ continue
+ }
+
+ // 跳过空行
+ if line == "" {
+ if inTable {
+ nonTableLineCount++
+ if nonTableLineCount > maxNonTableLines {
+ // 当前表格结束,保存并重置
+ if len(currentHeaders) > 0 {
+ result = append(result, TableWithTitle{
+ Title: currentTitle,
+ Table: &TableData{
+ Headers: currentHeaders,
+ Rows: currentRows,
+ },
+ })
+ currentHeaders = nil
+ currentRows = nil
+ currentTitle = ""
+ }
+ inTable = false
+ hasValidHeader = false
+ nonTableLineCount = 0
+ }
+ }
+ continue
+ }
+
+ // 检查是否是markdown表格行
+ if !strings.Contains(line, "|") {
+ // 如果已经在表格中,遇到非表格行则计数
+ if inTable {
+ nonTableLineCount++
+ // 如果连续非表格行过多,表格结束
+ if nonTableLineCount > maxNonTableLines {
+ // 当前表格结束,保存并重置
+ if len(currentHeaders) > 0 {
+ result = append(result, TableWithTitle{
+ Title: currentTitle,
+ Table: &TableData{
+ Headers: currentHeaders,
+ Rows: currentRows,
+ },
+ })
+ currentHeaders = nil
+ currentRows = nil
+ currentTitle = ""
+ }
+ inTable = false
+ hasValidHeader = false
+ nonTableLineCount = 0
+ }
+ }
+ continue
+ }
+
+ // 重置非表格行计数(遇到表格行了)
+ nonTableLineCount = 0
+
+ // 跳过分隔行
+ if r.isSeparatorLine(line) {
+ // 分隔行后应该开始数据行
+ if hasValidHeader {
+ continue
+ }
+ // 如果还没有表头,跳过分隔行
+ continue
+ }
+
+ // 解析表格行
+ cells := strings.Split(line, "|")
+ // 清理首尾空元素
+ if len(cells) > 0 && strings.TrimSpace(cells[0]) == "" {
+ cells = cells[1:]
+ }
+ if len(cells) > 0 && strings.TrimSpace(cells[len(cells)-1]) == "" {
+ cells = cells[:len(cells)-1]
+ }
+
+ // 清理每个单元格,过滤空字符
+ cleanedCells := make([]string, 0, len(cells))
+ for _, cell := range cells {
+ cleaned := strings.TrimSpace(cell)
+ // 移除HTML标签(如
)
+ cleaned = r.removeHTMLTags(cleaned)
+ cleanedCells = append(cleanedCells, cleaned)
+ }
+
+ // 检查这一行是否有有效内容
+ hasContent := false
+ for _, cell := range cleanedCells {
+ if strings.TrimSpace(cell) != "" {
+ hasContent = true
+ break
+ }
+ }
+
+ if !hasContent || len(cleanedCells) == 0 {
+ continue
+ }
+
+ if !inTable {
+ // 第一行作为表头
+ currentHeaders = cleanedCells
+ inTable = true
+ hasValidHeader = true
+ } else {
+ // 数据行,确保列数与表头一致
+ row := make([]string, len(currentHeaders))
+ for i := range row {
+ if i < len(cleanedCells) {
+ row[i] = cleanedCells[i]
+ } else {
+ row[i] = ""
+ }
+ }
+ // 检查数据行是否有有效内容(至少有一个非空单元格)
+ hasData := false
+ for _, cell := range row {
+ if strings.TrimSpace(cell) != "" {
+ hasData = true
+ break
+ }
+ }
+ // 只添加有有效内容的数据行
+ if hasData {
+ currentRows = append(currentRows, row)
+ }
+ }
+ }
+
+ // 处理最后一个表格
+ if len(currentHeaders) > 0 {
+ result = append(result, TableWithTitle{
+ Title: currentTitle,
+ Table: &TableData{
+ Headers: currentHeaders,
+ Rows: currentRows,
+ },
+ })
+ }
+
+ if len(result) == 0 {
+ return nil, fmt.Errorf("无法解析表格:未找到表头")
+ }
+
+ r.logger.Info("解析多个表格完成",
+ zap.Int("table_count", len(result)))
+
+ return result, nil
+}
+
+// parseMarkdownTable 解析markdown格式的表格(兼容方法,调用新方法)
+func (r *DatabaseTableReader) parseMarkdownTable(content string) (*TableData, error) {
+ tablesWithTitles, err := r.parseMarkdownTablesWithTitles(content)
+ if err != nil {
+ return nil, err
+ }
+ if len(tablesWithTitles) == 0 {
+ return nil, fmt.Errorf("未找到任何表格")
+ }
+ // 返回第一个表格(向后兼容)
+ return tablesWithTitles[0].Table, nil
+}
+
+// mergeTables 合并多个表格(使用最宽的表头)
+func (r *DatabaseTableReader) mergeTables(existingHeaders []string, existingRows [][]string, newHeaders []string, newRows [][]string) ([]string, [][]string) {
+ // 如果这是第一个表格,直接返回
+ if len(existingHeaders) == 0 {
+ return newHeaders, newRows
+ }
+
+ // 使用最宽的表头(列数最多的)
+ var finalHeaders []string
+ if len(newHeaders) > len(existingHeaders) {
+ finalHeaders = make([]string, len(newHeaders))
+ copy(finalHeaders, newHeaders)
+ } else {
+ finalHeaders = make([]string, len(existingHeaders))
+ copy(finalHeaders, existingHeaders)
+ }
+
+ // 合并所有行,确保列数与最终表头一致
+ mergedRows := make([][]string, 0, len(existingRows)+len(newRows))
+
+ // 添加已有行
+ for _, row := range existingRows {
+ adjustedRow := make([]string, len(finalHeaders))
+ copy(adjustedRow, row)
+ mergedRows = append(mergedRows, adjustedRow)
+ }
+
+ // 添加新行
+ for _, row := range newRows {
+ adjustedRow := make([]string, len(finalHeaders))
+ for i := range adjustedRow {
+ if i < len(row) {
+ adjustedRow[i] = row[i]
+ } else {
+ adjustedRow[i] = ""
+ }
+ }
+ mergedRows = append(mergedRows, adjustedRow)
+ }
+
+ return finalHeaders, mergedRows
+}
+
+// removeHTMLTags 移除HTML标签(如
)和样式信息
+func (r *DatabaseTableReader) removeHTMLTags(text string) string {
+ // 先移除所有HTML标签(包括带样式的标签,如 )
+ // 使用正则表达式移除所有HTML标签及其内容
+ re := regexp.MustCompile(`<[^>]+>`)
+ text = re.ReplaceAllString(text, "")
+
+ // 替换常见的HTML换行标签为空格
+ text = strings.ReplaceAll(text, "
", " ")
+ text = strings.ReplaceAll(text, "
", " ")
+ text = strings.ReplaceAll(text, "
", " ")
+ text = strings.ReplaceAll(text, "\n", " ")
+
+ // 移除HTML实体
+ text = strings.ReplaceAll(text, " ", " ")
+ text = strings.ReplaceAll(text, "&", "&")
+ text = strings.ReplaceAll(text, "<", "<")
+ text = strings.ReplaceAll(text, ">", ">")
+ text = strings.ReplaceAll(text, """, "\"")
+ text = strings.ReplaceAll(text, "'", "'")
+
+ return strings.TrimSpace(text)
+}
+
+// isSeparatorLine 检查是否是markdown表格的分隔行
+func (r *DatabaseTableReader) isSeparatorLine(line string) bool {
+ if !strings.Contains(line, "-") {
+ return false
+ }
+ for _, r := range line {
+ if r != '|' && r != '-' && r != ':' && r != ' ' {
+ return false
+ }
+ }
+ return true
+}
+
+// convertTableDataToJSON 将表格数据转换为JSON数组格式
+func (r *DatabaseTableReader) convertTableDataToJSON(tableData *TableData) []map[string]interface{} {
+ if tableData == nil || len(tableData.Headers) == 0 {
+ r.logger.Warn("表格数据为空,无法转换为JSON")
+ return []map[string]interface{}{}
+ }
+
+ jsonArray := make([]map[string]interface{}, 0, len(tableData.Rows))
+ validRowCount := 0
+
+ for rowIndex, row := range tableData.Rows {
+ rowObj := make(map[string]interface{})
+ for i, header := range tableData.Headers {
+ // 获取对应的单元格值
+ var cellValue string
+ if i < len(row) {
+ cellValue = strings.TrimSpace(row[i])
+ }
+ // 将表头作为key,单元格值作为value
+ header = strings.TrimSpace(header)
+ if header != "" {
+ rowObj[header] = cellValue
+ }
+ }
+ // 只添加有有效数据的行
+ if len(rowObj) > 0 {
+ jsonArray = append(jsonArray, rowObj)
+ validRowCount++
+ } else {
+ r.logger.Debug("跳过空行",
+ zap.Int("row_index", rowIndex))
+ }
+ }
+
+ r.logger.Debug("表格转JSON完成",
+ zap.Int("total_rows", len(tableData.Rows)),
+ zap.Int("valid_rows", validRowCount),
+ zap.Int("json_array_length", len(jsonArray)))
+
+ return jsonArray
+}
+
+// getContentPreview 获取内容预览(用于日志记录)
+func (r *DatabaseTableReader) getContentPreview(content string, maxLen int) string {
+ content = strings.TrimSpace(content)
+ if len(content) <= maxLen {
+ return content
+ }
+ return content[:maxLen] + "..."
+}
+
+// formatValue 格式化值为字符串
+func (r *DatabaseTableReader) formatValue(value interface{}) string {
+ if value == nil {
+ return ""
+ }
+
+ var result string
+ switch v := value.(type) {
+ case string:
+ result = strings.TrimSpace(v)
+ // 如果去除空白后为空,返回空字符串
+ if result == "" {
+ return ""
+ }
+ // 移除HTML标签和样式,确保数据干净
+ result = r.removeHTMLTags(result)
+ return result
+ case bool:
+ if v {
+ return "是"
+ }
+ return "否"
+ case float64:
+ if v == float64(int64(v)) {
+ return fmt.Sprintf("%.0f", v)
+ }
+ return fmt.Sprintf("%g", v)
+ case int, int8, int16, int32, int64:
+ return fmt.Sprintf("%d", v)
+ case uint, uint8, uint16, uint32, uint64:
+ return fmt.Sprintf("%d", v)
+ default:
+ result = fmt.Sprintf("%v", v)
+ // 去除空白字符
+ result = strings.TrimSpace(result)
+ if result == "" {
+ return ""
+ }
+ return result
+ }
+}
diff --git a/internal/shared/pdf/database_table_renderer.go b/internal/shared/pdf/database_table_renderer.go
new file mode 100644
index 0000000..9786c0b
--- /dev/null
+++ b/internal/shared/pdf/database_table_renderer.go
@@ -0,0 +1,535 @@
+package pdf
+
+import (
+ "math"
+ "strings"
+
+ "github.com/jung-kurt/gofpdf/v2"
+ "go.uber.org/zap"
+)
+
+// DatabaseTableRenderer 数据库表格渲染器
+type DatabaseTableRenderer struct {
+ logger *zap.Logger
+ fontManager *FontManager
+}
+
+// NewDatabaseTableRenderer 创建数据库表格渲染器
+func NewDatabaseTableRenderer(logger *zap.Logger, fontManager *FontManager) *DatabaseTableRenderer {
+ return &DatabaseTableRenderer{
+ logger: logger,
+ fontManager: fontManager,
+ }
+}
+
+// RenderTable 渲染表格到PDF
+func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableData) error {
+ if tableData == nil || len(tableData.Headers) == 0 {
+ r.logger.Warn("表格数据为空,跳过渲染")
+ return nil
+ }
+
+ r.logger.Info("开始渲染表格",
+ zap.Int("header_count", len(tableData.Headers)),
+ zap.Int("row_count", len(tableData.Rows)),
+ zap.Strings("headers", tableData.Headers))
+
+ // 检查表头是否有有效内容
+ hasValidHeader := false
+ for _, header := range tableData.Headers {
+ if strings.TrimSpace(header) != "" {
+ hasValidHeader = true
+ break
+ }
+ }
+
+ if !hasValidHeader {
+ r.logger.Warn("表头内容为空,跳过渲染")
+ return nil
+ }
+
+ // 检查是否有有效的数据行
+ hasValidRows := false
+ for _, row := range tableData.Rows {
+ for _, cell := range row {
+ if strings.TrimSpace(cell) != "" {
+ hasValidRows = true
+ break
+ }
+ }
+ if hasValidRows {
+ break
+ }
+ }
+
+ r.logger.Debug("表格验证通过",
+ zap.Bool("has_valid_header", hasValidHeader),
+ zap.Bool("has_valid_rows", hasValidRows),
+ zap.Int("row_count", len(tableData.Rows)))
+
+ // 即使没有数据行,也渲染表头(单行表格)
+ // 但如果没有表头也没有数据,则不渲染
+
+ // 设置字体
+ r.fontManager.SetFont(pdf, "", 9)
+ _, lineHt := pdf.GetFontSize()
+
+ // 计算页面可用宽度
+ pageWidth, _ := pdf.GetPageSize()
+ availableWidth := pageWidth - 30 // 减去左右边距(15mm * 2)
+
+ // 计算每列宽度
+ colWidths := r.calculateColumnWidths(pdf, tableData, availableWidth)
+
+ // 检查是否需要分页(在绘制表头前)
+ _, pageHeight := pdf.GetPageSize()
+ _, _, _, bottomMargin := pdf.GetMargins()
+ currentY := pdf.GetY()
+ estimatedHeaderHeight := lineHt * 2.5 // 估算表头高度
+
+ if currentY+estimatedHeaderHeight > pageHeight-bottomMargin {
+ r.logger.Debug("表头前需要分页", zap.Float64("current_y", currentY))
+ pdf.AddPage()
+ }
+
+ // 绘制表头
+ headerStartY := pdf.GetY()
+ headerHeight := r.renderHeader(pdf, tableData.Headers, colWidths, headerStartY)
+
+ // 移动到表头下方
+ pdf.SetXY(15.0, headerStartY+headerHeight)
+
+ // 绘制数据行(如果有数据行)
+ if len(tableData.Rows) > 0 {
+ r.logger.Debug("开始渲染数据行", zap.Int("row_count", len(tableData.Rows)))
+ r.renderRows(pdf, tableData.Rows, colWidths, lineHt, tableData.Headers, colWidths)
+ } else {
+ r.logger.Debug("没有数据行,只渲染表头")
+ }
+
+ r.logger.Info("表格渲染完成",
+ zap.Int("header_count", len(tableData.Headers)),
+ zap.Int("row_count", len(tableData.Rows)),
+ zap.Float64("current_y", pdf.GetY()))
+
+ return nil
+}
+
+// calculateColumnWidths 计算每列的宽度
+// 策略:先确保每列最短内容能完整显示(不换行),然后根据内容长度分配剩余空间
+func (r *DatabaseTableRenderer) calculateColumnWidths(pdf *gofpdf.Fpdf, tableData *TableData, availableWidth float64) []float64 {
+ numCols := len(tableData.Headers)
+ r.fontManager.SetFont(pdf, "", 9)
+
+ // 第一步:找到每列中最短的内容(包括表头),计算其完整显示所需的最小宽度
+ colMinWidths := make([]float64, numCols)
+ colMaxWidths := make([]float64, numCols)
+ colContentLengths := make([]float64, numCols) // 用于记录内容总长度,用于分配剩余空间
+
+ for i := 0; i < numCols; i++ {
+ minWidth := math.MaxFloat64
+ maxWidth := 0.0
+ totalLength := 0.0
+ count := 0
+
+ // 检查表头
+ if i < len(tableData.Headers) {
+ header := tableData.Headers[i]
+ textWidth := r.getTextWidth(pdf, header)
+ cellWidth := textWidth + 8 // 加上内边距
+ if cellWidth < minWidth {
+ minWidth = cellWidth
+ }
+ if cellWidth > maxWidth {
+ maxWidth = cellWidth
+ }
+ totalLength += textWidth
+ count++
+ }
+
+ // 检查所有数据行
+ for _, row := range tableData.Rows {
+ if i < len(row) {
+ cell := row[i]
+ textWidth := r.getTextWidth(pdf, cell)
+ cellWidth := textWidth + 8 // 加上内边距
+ if cellWidth < minWidth {
+ minWidth = cellWidth
+ }
+ if cellWidth > maxWidth {
+ maxWidth = cellWidth
+ }
+ totalLength += textWidth
+ count++
+ }
+ }
+
+ // 设置最小宽度(确保最短内容能完整显示)
+ if minWidth == math.MaxFloat64 {
+ colMinWidths[i] = 30.0 // 默认最小宽度
+ } else {
+ colMinWidths[i] = math.Max(minWidth, 25.0) // 至少25mm
+ }
+
+ colMaxWidths[i] = maxWidth
+ if count > 0 {
+ colContentLengths[i] = totalLength / float64(count) // 平均内容长度
+ } else {
+ colContentLengths[i] = colMinWidths[i]
+ }
+ }
+
+ // 第二步:计算总的最小宽度(确保所有最短内容都能显示)
+ totalMinWidth := 0.0
+ for _, w := range colMinWidths {
+ totalMinWidth += w
+ }
+
+ // 第三步:分配宽度
+ colWidths := make([]float64, numCols)
+ if totalMinWidth >= availableWidth {
+ // 如果最小宽度已经超过可用宽度,按比例缩放,但确保每列至少能显示最短内容
+ scale := availableWidth / totalMinWidth
+ for i := range colWidths {
+ colWidths[i] = colMinWidths[i] * scale
+ // 确保最小宽度,但允许稍微压缩
+ if colWidths[i] < colMinWidths[i]*0.8 {
+ colWidths[i] = colMinWidths[i] * 0.8
+ }
+ }
+ } else {
+ // 如果最小宽度小于可用宽度,先分配最小宽度,然后根据内容长度分配剩余空间
+ extraWidth := availableWidth - totalMinWidth
+
+ // 计算总的内容长度(用于按比例分配)
+ totalContentLength := 0.0
+ for _, length := range colContentLengths {
+ totalContentLength += length
+ }
+
+ // 如果总内容长度为0,平均分配
+ if totalContentLength < 0.1 {
+ extraPerCol := extraWidth / float64(numCols)
+ for i := range colWidths {
+ colWidths[i] = colMinWidths[i] + extraPerCol
+ }
+ } else {
+ // 根据内容长度按比例分配剩余空间
+ for i := range colWidths {
+ // 计算这一列应该分配多少额外空间(基于内容长度)
+ ratio := colContentLengths[i] / totalContentLength
+ extraForCol := extraWidth * ratio
+ colWidths[i] = colMinWidths[i] + extraForCol
+ }
+ }
+ }
+
+ return colWidths
+}
+
+// renderHeader 渲染表头
+func (r *DatabaseTableRenderer) renderHeader(pdf *gofpdf.Fpdf, headers []string, colWidths []float64, startY float64) float64 {
+ _, lineHt := pdf.GetFontSize()
+
+ // 计算表头的最大高度
+ maxHeaderHeight := lineHt * 2.0 // 使用合理的表头高度
+ for i, header := range headers {
+ if i >= len(colWidths) {
+ break
+ }
+ colW := colWidths[i]
+ headerLines := pdf.SplitText(header, colW-6) // 增加边距,从4增加到6
+ headerHeight := float64(len(headerLines)) * lineHt
+ // 添加上下内边距
+ headerHeight += lineHt * 0.8 // 上下各0.4倍行高的内边距
+ if headerHeight < lineHt*2.0 {
+ headerHeight = lineHt * 2.0
+ }
+ if headerHeight > maxHeaderHeight {
+ maxHeaderHeight = headerHeight
+ }
+ }
+
+ // 绘制表头背景和文本
+ pdf.SetFillColor(74, 144, 226) // 蓝色背景
+ pdf.SetTextColor(0, 0, 0) // 黑色文字
+ r.fontManager.SetFont(pdf, "B", 9)
+
+ currentX := 15.0
+ for i, header := range headers {
+ if i >= len(colWidths) {
+ break
+ }
+ colW := colWidths[i]
+
+ // 清理表头数据,移除任何残留的HTML标签和样式
+ header = strings.TrimSpace(header)
+ // 移除HTML标签(使用简单的替换)
+ header = strings.ReplaceAll(header, "
", " ")
+ header = strings.ReplaceAll(header, "
", " ")
+ header = strings.ReplaceAll(header, "
", " ")
+
+ // 绘制表头背景
+ pdf.Rect(currentX, startY, colW, maxHeaderHeight, "FD")
+
+ // 绘制表头文本
+ if header != "" {
+ // 计算文本的实际高度(减少内边距,给文本更多空间)
+ headerLines := pdf.SplitText(header, colW-4)
+ textHeight := float64(len(headerLines)) * lineHt
+ if textHeight < lineHt {
+ textHeight = lineHt
+ }
+
+ // 计算垂直居中的Y位置
+ cellCenterY := startY + maxHeaderHeight/2
+ textStartY := cellCenterY - textHeight/2
+
+ // 设置文本位置(水平居中,垂直居中,减少左边距)
+ pdf.SetXY(currentX+2, textStartY)
+ // 确保颜色为深黑色(在渲染前再次设置,防止被覆盖)
+ pdf.SetTextColor(0, 0, 0) // 表头是黑色文字
+ // 设置字体,确保颜色不会变淡
+ r.fontManager.SetFont(pdf, "B", 9)
+ // 再次确保颜色为深黑色(在渲染前最后一次设置)
+ pdf.SetTextColor(0, 0, 0)
+ // 使用正常的行高,文本已经垂直居中(减少内边距,给文本更多空间)
+ pdf.MultiCell(colW-4, lineHt, header, "", "C", false)
+ }
+
+ // 重置Y坐标
+ pdf.SetXY(currentX+colW, startY)
+ currentX += colW
+ }
+
+ return maxHeaderHeight
+}
+
+// renderRows 渲染数据行(支持自动分页)
+func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, colWidths []float64, lineHt float64, headers []string, headerColWidths []float64) {
+ numCols := len(colWidths)
+ pdf.SetFillColor(245, 245, 220) // 米色背景
+ pdf.SetTextColor(0, 0, 0) // 深黑色文字,确保清晰
+ r.fontManager.SetFont(pdf, "", 9)
+
+ // 获取页面尺寸和边距
+ _, pageHeight := pdf.GetPageSize()
+ _, _, _, bottomMargin := pdf.GetMargins()
+ minSpaceForRow := lineHt * 3 // 至少需要3倍行高的空间
+
+ validRowIndex := 0 // 用于交替填充的有效行索引
+ for rowIndex, row := range rows {
+ // 检查这一行是否有有效内容
+ hasContent := false
+ for _, cell := range row {
+ if strings.TrimSpace(cell) != "" {
+ hasContent = true
+ break
+ }
+ }
+
+ // 跳过完全为空的行
+ if !hasContent {
+ continue
+ }
+
+ // 检查是否需要分页
+ currentY := pdf.GetY()
+ if currentY+minSpaceForRow > pageHeight-bottomMargin {
+ // 需要分页
+ r.logger.Debug("表格需要分页",
+ zap.Int("row_index", rowIndex),
+ zap.Float64("current_y", currentY),
+ zap.Float64("page_height", pageHeight))
+ pdf.AddPage()
+ // 在新页面上重新绘制表头
+ if len(headers) > 0 && len(headerColWidths) > 0 {
+ newHeaderStartY := pdf.GetY()
+ headerHeight := r.renderHeader(pdf, headers, headerColWidths, newHeaderStartY)
+ pdf.SetXY(15.0, newHeaderStartY+headerHeight)
+ }
+ }
+
+ startY := pdf.GetY()
+ fill := (validRowIndex % 2) == 0 // 交替填充
+ validRowIndex++
+
+ // 计算这一行的最大高度
+ maxCellHeight := lineHt * 2.0 // 使用合理的最小高度
+ for j := 0; j < numCols && j < len(row); j++ {
+ cell := row[j]
+ cellWidth := colWidths[j] - 4 // 减少内边距到4mm,给文本更多空间
+
+ // 计算文本实际宽度,判断是否需要换行
+ textWidth := r.getTextWidth(pdf, cell)
+ var lines []string
+
+ // 只有当文本宽度超过单元格宽度时才换行
+ if textWidth > cellWidth {
+ // 文本需要换行
+ func() {
+ defer func() {
+ if rec := recover(); rec != nil {
+ r.logger.Warn("SplitText失败,使用估算",
+ zap.Any("error", rec),
+ zap.Int("row_index", rowIndex),
+ zap.Int("col_index", j))
+ // 使用估算值
+ charCount := len([]rune(cell))
+ estimatedLines := math.Max(1, math.Ceil(float64(charCount)/20))
+ lines = make([]string, int(estimatedLines))
+ for i := range lines {
+ lines[i] = ""
+ }
+ }
+ }()
+ lines = pdf.SplitText(cell, cellWidth)
+ }()
+ } else {
+ // 文本不需要换行,单行显示
+ lines = []string{cell}
+ }
+
+ cellHeight := float64(len(lines)) * lineHt
+ // 添加上下内边距
+ cellHeight += lineHt * 0.8 // 上下各0.4倍行高的内边距
+ if cellHeight < lineHt*2.0 {
+ cellHeight = lineHt * 2.0
+ }
+ if cellHeight > maxCellHeight {
+ maxCellHeight = cellHeight
+ }
+ }
+
+ // 再次检查分页(在计算完行高后)
+ if startY+maxCellHeight > pageHeight-bottomMargin {
+ r.logger.Debug("行高度超出页面,需要分页",
+ zap.Int("row_index", rowIndex),
+ zap.Float64("row_height", maxCellHeight))
+ pdf.AddPage()
+ startY = pdf.GetY()
+ }
+
+ // 绘制这一行的所有单元格
+ currentX := 15.0
+ for j := 0; j < numCols && j < len(row); j++ {
+ colW := colWidths[j]
+ cell := row[j]
+
+ // 清理单元格数据,移除任何残留的HTML标签和样式
+ cell = strings.TrimSpace(cell)
+ // 移除HTML标签(使用简单的正则表达式)
+ cell = strings.ReplaceAll(cell, "
", " ")
+ cell = strings.ReplaceAll(cell, "
", " ")
+ cell = strings.ReplaceAll(cell, "
", " ")
+
+ // 绘制单元格背景
+ if fill {
+ pdf.SetFillColor(250, 250, 235) // 稍深的米色
+ } else {
+ pdf.SetFillColor(255, 255, 255)
+ }
+ pdf.Rect(currentX, startY, colW, maxCellHeight, "FD")
+
+ // 绘制单元格文本(只绘制非空内容)
+ if cell != "" {
+ // 计算文本的实际宽度和单元格可用宽度
+ cellWidth := colW - 4
+ textWidth := r.getTextWidth(pdf, cell)
+
+ var textLines []string
+ // 只有当文本宽度超过单元格宽度时才换行
+ if textWidth > cellWidth {
+ // 文本需要换行
+ func() {
+ defer func() {
+ if rec := recover(); rec != nil {
+ // 如果SplitText失败,使用估算
+ charCount := len([]rune(cell))
+ estimatedLines := math.Max(1, math.Ceil(float64(charCount)/20))
+ textLines = make([]string, int(estimatedLines))
+ for i := range textLines {
+ textLines[i] = ""
+ }
+ }
+ }()
+ textLines = pdf.SplitText(cell, cellWidth)
+ }()
+ } else {
+ // 文本不需要换行,单行显示
+ textLines = []string{cell}
+ }
+
+ textHeight := float64(len(textLines)) * lineHt
+ if textHeight < lineHt {
+ textHeight = lineHt
+ }
+
+ // 计算垂直居中的Y位置
+ cellCenterY := startY + maxCellHeight/2
+ textStartY := cellCenterY - textHeight/2
+
+ // 设置文本位置(水平左对齐,垂直居中,减少左边距)
+ pdf.SetXY(currentX+2, textStartY)
+ // 再次确保颜色为深黑色(防止被其他设置覆盖)
+ pdf.SetTextColor(0, 0, 0)
+ // 设置字体,确保颜色不会变淡
+ r.fontManager.SetFont(pdf, "", 9)
+ // 再次确保颜色为深黑色(在渲染前最后一次设置)
+ pdf.SetTextColor(0, 0, 0)
+ // 安全地渲染文本,使用正常的行高
+ func() {
+ defer func() {
+ if rec := recover(); rec != nil {
+ r.logger.Warn("MultiCell渲染失败",
+ zap.Any("error", rec),
+ zap.Int("row_index", rowIndex),
+ zap.Int("col_index", j))
+ }
+ }()
+ // 使用正常的行高,文本已经垂直居中
+ pdf.MultiCell(cellWidth, lineHt, cell, "", "L", false)
+ }()
+ }
+
+ // 重置Y坐标
+ pdf.SetXY(currentX+colW, startY)
+ currentX += colW
+ }
+
+ // 移动到下一行
+ pdf.SetXY(15.0, startY+maxCellHeight)
+ }
+}
+
+// getTextWidth 获取文本宽度
+func (r *DatabaseTableRenderer) getTextWidth(pdf *gofpdf.Fpdf, text string) float64 {
+ if r.fontManager.IsChineseFontAvailable() {
+ width := pdf.GetStringWidth(text)
+ // 如果宽度为0或太小,使用更准确的估算
+ if width < 0.1 {
+ return r.estimateTextWidth(text)
+ }
+ return width
+ }
+ // 估算宽度
+ return r.estimateTextWidth(text)
+}
+
+// estimateTextWidth 估算文本宽度(处理中英文混合)
+func (r *DatabaseTableRenderer) estimateTextWidth(text string) float64 {
+ charCount := 0.0
+ for _, r := range text {
+ // 中文字符通常比英文字符宽
+ if r >= 0x4E00 && r <= 0x9FFF {
+ charCount += 1.8 // 中文字符约1.8倍宽度
+ } else if r >= 0x3400 && r <= 0x4DBF {
+ charCount += 1.8 // 扩展A
+ } else if r >= 0x20000 && r <= 0x2A6DF {
+ charCount += 1.8 // 扩展B
+ } else {
+ charCount += 1.0 // 英文字符和数字
+ }
+ }
+ return charCount * 3.0 // 基础宽度3mm
+}
diff --git a/internal/shared/pdf/font_manager.go b/internal/shared/pdf/font_manager.go
new file mode 100644
index 0000000..05a80b9
--- /dev/null
+++ b/internal/shared/pdf/font_manager.go
@@ -0,0 +1,217 @@
+package pdf
+
+import (
+ "os"
+ "runtime"
+
+ "github.com/jung-kurt/gofpdf/v2"
+ "go.uber.org/zap"
+)
+
+// FontManager 字体管理器
+type FontManager struct {
+ logger *zap.Logger
+ chineseFontName string
+ chineseFontPath string
+ chineseFontLoaded bool
+ watermarkFontName string
+ watermarkFontPath string
+ watermarkFontLoaded bool
+}
+
+// NewFontManager 创建字体管理器
+func NewFontManager(logger *zap.Logger) *FontManager {
+ return &FontManager{
+ logger: logger,
+ chineseFontName: "ChineseFont",
+ watermarkFontName: "WatermarkFont",
+ }
+}
+
+// LoadChineseFont 加载中文字体到PDF(只使用黑体)
+func (fm *FontManager) LoadChineseFont(pdf *gofpdf.Fpdf) bool {
+ if fm.chineseFontLoaded {
+ return true
+ }
+
+ fontPaths := fm.getHeiFontPaths() // 只获取黑体路径
+ if len(fontPaths) == 0 {
+ fm.logger.Warn("未找到黑体字体路径,PDF中的中文可能显示为空白")
+ return false
+ }
+
+ // 尝试添加黑体字体
+ for _, fontPath := range fontPaths {
+ if fm.tryAddFont(pdf, fontPath) {
+ fm.chineseFontPath = fontPath
+ fm.chineseFontLoaded = true
+ fm.logger.Info("成功添加黑体字体(常规和粗体)", zap.String("font_path", fontPath))
+ return true
+ }
+ }
+
+ fm.logger.Warn("未找到可用的黑体字体文件,PDF中的中文可能显示为空白")
+ return false
+}
+
+// LoadWatermarkFont 加载水印字体到PDF(使用宋体或其他非黑体字体)
+func (fm *FontManager) LoadWatermarkFont(pdf *gofpdf.Fpdf) bool {
+ if fm.watermarkFontLoaded {
+ return true
+ }
+
+ fontPaths := fm.getWatermarkFontPaths() // 获取水印字体路径(宋体等)
+ if len(fontPaths) == 0 {
+ // 如果找不到水印字体,使用主字体(黑体)
+ fm.logger.Warn("未找到水印字体,将使用主字体(黑体)")
+ return false
+ }
+
+ // 尝试添加水印字体
+ for _, fontPath := range fontPaths {
+ if fm.tryAddWatermarkFont(pdf, fontPath) {
+ fm.watermarkFontPath = fontPath
+ fm.watermarkFontLoaded = true
+ fm.logger.Info("成功添加水印字体", zap.String("font_path", fontPath))
+ return true
+ }
+ }
+
+ fm.logger.Warn("未找到可用的水印字体文件,将使用主字体(黑体)")
+ return false
+}
+
+// tryAddFont 尝试添加字体(捕获panic)
+func (fm *FontManager) tryAddFont(pdf *gofpdf.Fpdf, fontPath string) bool {
+ defer func() {
+ if r := recover(); r != nil {
+ fm.logger.Error("添加字体时发生panic", zap.Any("panic", r), zap.String("font_path", fontPath))
+ }
+ }()
+
+ // gofpdf v2使用AddUTF8Font添加支持UTF-8的字体
+ pdf.AddUTF8Font(fm.chineseFontName, "", fontPath) // 常规样式
+ pdf.AddUTF8Font(fm.chineseFontName, "B", fontPath) // 粗体样式
+ return true
+}
+
+// tryAddWatermarkFont 尝试添加水印字体(捕获panic)
+func (fm *FontManager) tryAddWatermarkFont(pdf *gofpdf.Fpdf, fontPath string) bool {
+ defer func() {
+ if r := recover(); r != nil {
+ fm.logger.Error("添加水印字体时发生panic", zap.Any("panic", r), zap.String("font_path", fontPath))
+ }
+ }()
+
+ // gofpdf v2使用AddUTF8Font添加支持UTF-8的字体
+ pdf.AddUTF8Font(fm.watermarkFontName, "", fontPath) // 常规样式
+ pdf.AddUTF8Font(fm.watermarkFontName, "B", fontPath) // 粗体样式
+ return true
+}
+
+// getHeiFontPaths 获取黑体字体路径(支持跨平台,只返回黑体)
+func (fm *FontManager) getHeiFontPaths() []string {
+ var fontPaths []string
+
+ // Windows系统
+ if runtime.GOOS == "windows" {
+ fontPaths = []string{
+ `C:\Windows\Fonts\simhei.ttf`, // 黑体(优先)
+ }
+ } else if runtime.GOOS == "linux" {
+ // Linux系统黑体字体路径
+ fontPaths = []string{
+ "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", // 文泉驿正黑(黑体)
+ "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", // 文泉驿微米黑(黑体)
+ }
+ } else if runtime.GOOS == "darwin" {
+ // macOS系统黑体字体路径
+ fontPaths = []string{
+ "/Library/Fonts/Microsoft/SimHei.ttf", // 黑体
+ "/System/Library/Fonts/STHeiti Light.ttc", // 黑体
+ }
+ }
+
+ // 过滤出实际存在的字体文件
+ var existingFonts []string
+ for _, fontPath := range fontPaths {
+ if _, err := os.Stat(fontPath); err == nil {
+ existingFonts = append(existingFonts, fontPath)
+ }
+ }
+
+ return existingFonts
+}
+
+// getWatermarkFontPaths 获取水印字体路径(支持跨平台,使用宋体或其他非黑体字体)
+func (fm *FontManager) getWatermarkFontPaths() []string {
+ var fontPaths []string
+
+ // Windows系统
+ if runtime.GOOS == "windows" {
+ fontPaths = []string{
+ `C:\Windows\Fonts\simsun.ttf`, // 宋体(优先用于水印)
+ `C:\Windows\Fonts\simkai.ttf`, // 楷体(备选)
+ }
+ } else if runtime.GOOS == "linux" {
+ // Linux系统宋体字体路径
+ fontPaths = []string{
+ "/usr/share/fonts/truetype/arphic/uming.ttc", // 文鼎PL UMing(宋体)
+ }
+ } else if runtime.GOOS == "darwin" {
+ // macOS系统宋体字体路径
+ fontPaths = []string{
+ "/System/Library/Fonts/STSong.ttc", // 宋体
+ }
+ }
+
+ // 过滤出实际存在的字体文件
+ var existingFonts []string
+ for _, fontPath := range fontPaths {
+ if _, err := os.Stat(fontPath); err == nil {
+ existingFonts = append(existingFonts, fontPath)
+ }
+ }
+
+ return existingFonts
+}
+
+// SetFont 设置字体(使用黑体)
+func (fm *FontManager) SetFont(pdf *gofpdf.Fpdf, style string, size float64) {
+ if fm.chineseFontLoaded {
+ pdf.SetFont(fm.chineseFontName, style, size)
+ } else {
+ // 如果没有黑体字体,使用Arial作为后备
+ pdf.SetFont("Arial", style, size)
+ }
+}
+
+// SetWatermarkFont 设置水印字体(使用宋体或其他非黑体字体)
+func (fm *FontManager) SetWatermarkFont(pdf *gofpdf.Fpdf, style string, size float64) {
+ if fm.watermarkFontLoaded {
+ pdf.SetFont(fm.watermarkFontName, style, size)
+ } else {
+ // 如果水印字体不可用,使用主字体(黑体)作为后备
+ fm.SetFont(pdf, style, size)
+ }
+}
+
+// IsChineseFontAvailable 检查中文字体是否可用
+func (fm *FontManager) IsChineseFontAvailable() bool {
+ return fm.chineseFontLoaded
+}
+
+// GetChineseFontName 获取中文字体名称
+func (fm *FontManager) GetChineseFontName() string {
+ return fm.chineseFontName
+}
+
+// GetWatermarkFontName 获取水印字体名称
+func (fm *FontManager) GetWatermarkFontName() string {
+ return fm.watermarkFontName
+}
+
+// IsWatermarkFontAvailable 检查水印字体是否可用
+func (fm *FontManager) IsWatermarkFontAvailable() bool {
+ return fm.watermarkFontLoaded
+}
diff --git a/internal/shared/pdf/json_processor.go b/internal/shared/pdf/json_processor.go
new file mode 100644
index 0000000..4095ba4
--- /dev/null
+++ b/internal/shared/pdf/json_processor.go
@@ -0,0 +1,155 @@
+package pdf
+
+import (
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// JSONProcessor JSON处理器
+type JSONProcessor struct{}
+
+// NewJSONProcessor 创建JSON处理器
+func NewJSONProcessor() *JSONProcessor {
+ return &JSONProcessor{}
+}
+
+// FormatJSON 格式化JSON字符串以便更好地显示
+func (jp *JSONProcessor) FormatJSON(jsonStr string) (string, error) {
+ var jsonObj interface{}
+ if err := json.Unmarshal([]byte(jsonStr), &jsonObj); err != nil {
+ return jsonStr, err // 如果解析失败,返回原始字符串
+ }
+
+ // 重新格式化JSON,使用缩进
+ formatted, err := json.MarshalIndent(jsonObj, "", " ")
+ if err != nil {
+ return jsonStr, err
+ }
+
+ return string(formatted), nil
+}
+
+// ExtractJSON 从文本中提取JSON
+func (jp *JSONProcessor) ExtractJSON(text string) string {
+ // 查找 ```json 代码块
+ re := regexp.MustCompile("(?s)```json\\s*\n(.*?)\n```")
+ matches := re.FindStringSubmatch(text)
+ if len(matches) > 1 {
+ return strings.TrimSpace(matches[1])
+ }
+
+ // 查找普通代码块
+ re = regexp.MustCompile("(?s)```\\s*\n(.*?)\n```")
+ matches = re.FindStringSubmatch(text)
+ if len(matches) > 1 {
+ content := strings.TrimSpace(matches[1])
+ // 检查是否是JSON
+ if strings.HasPrefix(content, "{") || strings.HasPrefix(content, "[") {
+ return content
+ }
+ }
+
+ return ""
+}
+
+// GenerateJSONExample 从请求参数表格生成JSON示例
+func (jp *JSONProcessor) GenerateJSONExample(requestParams string, tableParser *TableParser) string {
+ tableData := tableParser.ParseMarkdownTable(requestParams)
+ if len(tableData) < 2 {
+ return ""
+ }
+
+ // 查找字段名列和类型列
+ var fieldCol, typeCol int = -1, -1
+ header := tableData[0]
+ for i, h := range header {
+ hLower := strings.ToLower(h)
+ if strings.Contains(hLower, "字段") || strings.Contains(hLower, "参数") || strings.Contains(hLower, "field") {
+ fieldCol = i
+ }
+ if strings.Contains(hLower, "类型") || strings.Contains(hLower, "type") {
+ typeCol = i
+ }
+ }
+
+ if fieldCol == -1 {
+ return ""
+ }
+
+ // 生成JSON结构
+ jsonMap := make(map[string]interface{})
+ for i := 1; i < len(tableData); i++ {
+ row := tableData[i]
+ if fieldCol >= len(row) {
+ continue
+ }
+
+ fieldName := strings.TrimSpace(row[fieldCol])
+ if fieldName == "" {
+ continue
+ }
+
+ // 跳过表头行
+ if strings.Contains(strings.ToLower(fieldName), "字段") || strings.Contains(strings.ToLower(fieldName), "参数") {
+ continue
+ }
+
+ // 获取类型
+ fieldType := "string"
+ if typeCol >= 0 && typeCol < len(row) {
+ fieldType = strings.ToLower(strings.TrimSpace(row[typeCol]))
+ }
+
+ // 设置示例值
+ var value interface{}
+ if strings.Contains(fieldType, "int") || strings.Contains(fieldType, "number") {
+ value = 0
+ } else if strings.Contains(fieldType, "bool") {
+ value = true
+ } else if strings.Contains(fieldType, "array") || strings.Contains(fieldType, "list") {
+ value = []interface{}{}
+ } else if strings.Contains(fieldType, "object") || strings.Contains(fieldType, "dict") {
+ value = map[string]interface{}{}
+ } else {
+ // 根据字段名设置合理的示例值
+ fieldLower := strings.ToLower(fieldName)
+ if strings.Contains(fieldLower, "name") || strings.Contains(fieldName, "姓名") {
+ value = "张三"
+ } else if strings.Contains(fieldLower, "id_card") || strings.Contains(fieldLower, "idcard") || strings.Contains(fieldName, "身份证") {
+ value = "110101199001011234"
+ } else if strings.Contains(fieldLower, "phone") || strings.Contains(fieldLower, "mobile") || strings.Contains(fieldName, "手机") {
+ value = "13800138000"
+ } else if strings.Contains(fieldLower, "card") || strings.Contains(fieldName, "银行卡") {
+ value = "6222021234567890123"
+ } else {
+ value = "string"
+ }
+ }
+
+ // 处理嵌套字段(如 baseInfo.phone)
+ if strings.Contains(fieldName, ".") {
+ parts := strings.Split(fieldName, ".")
+ current := jsonMap
+ for j := 0; j < len(parts)-1; j++ {
+ if _, ok := current[parts[j]].(map[string]interface{}); !ok {
+ current[parts[j]] = make(map[string]interface{})
+ }
+ current = current[parts[j]].(map[string]interface{})
+ }
+ current[parts[len(parts)-1]] = value
+ } else {
+ jsonMap[fieldName] = value
+ }
+ }
+
+ // 使用encoding/json正确格式化JSON
+ jsonBytes, err := json.MarshalIndent(jsonMap, "", " ")
+ if err != nil {
+ // 如果JSON序列化失败,返回简单的字符串表示
+ return fmt.Sprintf("%v", jsonMap)
+ }
+
+ return string(jsonBytes)
+}
diff --git a/internal/shared/pdf/markdown_converter.go b/internal/shared/pdf/markdown_converter.go
new file mode 100644
index 0000000..7067618
--- /dev/null
+++ b/internal/shared/pdf/markdown_converter.go
@@ -0,0 +1,658 @@
+package pdf
+
+import (
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// MarkdownConverter Markdown转换器 - 将各种格式的markdown内容标准化
+type MarkdownConverter struct {
+ textProcessor *TextProcessor
+}
+
+// NewMarkdownConverter 创建Markdown转换器
+func NewMarkdownConverter(textProcessor *TextProcessor) *MarkdownConverter {
+ return &MarkdownConverter{
+ textProcessor: textProcessor,
+ }
+}
+
+// ConvertToStandardMarkdown 将各种格式的内容转换为标准的markdown格式
+// 这是第一步:预处理和标准化
+func (mc *MarkdownConverter) ConvertToStandardMarkdown(content string) string {
+ if strings.TrimSpace(content) == "" {
+ return content
+ }
+
+ // 1. 先清理HTML标签(保留内容)
+ content = mc.textProcessor.StripHTML(content)
+
+ // 2. 处理代码块 - 确保代码块格式正确
+ content = mc.normalizeCodeBlocks(content)
+
+ // 3. 处理表格 - 确保表格格式正确
+ content = mc.normalizeTables(content)
+
+ // 4. 处理列表 - 统一列表格式
+ content = mc.normalizeLists(content)
+
+ // 5. 处理JSON内容 - 尝试识别并格式化JSON
+ content = mc.normalizeJSONContent(content)
+
+ // 6. 处理链接和图片 - 转换为文本
+ content = mc.convertLinksToText(content)
+ content = mc.convertImagesToText(content)
+
+ // 7. 处理引用块
+ content = mc.normalizeBlockquotes(content)
+
+ // 8. 处理水平线
+ content = mc.normalizeHorizontalRules(content)
+
+ // 9. 清理多余空行(保留代码块内的空行)
+ content = mc.cleanupExtraBlankLines(content)
+
+ return content
+}
+
+// normalizeCodeBlocks 规范化代码块
+func (mc *MarkdownConverter) normalizeCodeBlocks(content string) string {
+ lines := strings.Split(content, "\n")
+ var result []string
+ inCodeBlock := false
+ codeBlockLang := ""
+
+ for i, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ // 检查是否是代码块开始
+ if strings.HasPrefix(trimmed, "```") {
+ if inCodeBlock {
+ // 代码块结束
+ result = append(result, line)
+ inCodeBlock = false
+ codeBlockLang = ""
+ } else {
+ // 代码块开始
+ inCodeBlock = true
+ // 提取语言标识
+ if len(trimmed) > 3 {
+ codeBlockLang = strings.TrimSpace(trimmed[3:])
+ if codeBlockLang != "" {
+ result = append(result, fmt.Sprintf("```%s", codeBlockLang))
+ } else {
+ result = append(result, "```")
+ }
+ } else {
+ result = append(result, "```")
+ }
+ }
+ } else if inCodeBlock {
+ // 在代码块中,保留原样
+ result = append(result, line)
+ } else {
+ // 不在代码块中,处理其他内容
+ result = append(result, line)
+ }
+
+ // 如果代码块没有正确关闭,在文件末尾自动关闭
+ if i == len(lines)-1 && inCodeBlock {
+ result = append(result, "```")
+ }
+ }
+
+ return strings.Join(result, "\n")
+}
+
+// normalizeTables 规范化表格格式
+func (mc *MarkdownConverter) normalizeTables(content string) string {
+ lines := strings.Split(content, "\n")
+ var result []string
+ inCodeBlock := false
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ // 检查是否在代码块中
+ if strings.HasPrefix(trimmed, "```") {
+ inCodeBlock = !inCodeBlock
+ result = append(result, line)
+ continue
+ }
+
+ if inCodeBlock {
+ // 代码块中的内容不处理
+ result = append(result, line)
+ continue
+ }
+
+ // 检查是否是表格行
+ if strings.Contains(trimmed, "|") {
+ // 检查是否是分隔行
+ isSeparator := mc.isTableSeparator(trimmed)
+ if isSeparator {
+ // 确保分隔行格式正确
+ cells := strings.Split(trimmed, "|")
+ // 清理首尾空元素
+ if len(cells) > 0 && cells[0] == "" {
+ cells = cells[1:]
+ }
+ if len(cells) > 0 && cells[len(cells)-1] == "" {
+ cells = cells[:len(cells)-1]
+ }
+ // 构建标准分隔行
+ separator := "|"
+ for range cells {
+ separator += " --- |"
+ }
+ result = append(result, separator)
+ } else {
+ // 普通表格行,确保格式正确
+ normalizedLine := mc.normalizeTableRow(line)
+ result = append(result, normalizedLine)
+ }
+ } else {
+ result = append(result, line)
+ }
+ }
+
+ return strings.Join(result, "\n")
+}
+
+// isTableSeparator 检查是否是表格分隔行
+func (mc *MarkdownConverter) isTableSeparator(line string) bool {
+ trimmed := strings.TrimSpace(line)
+ if !strings.Contains(trimmed, "-") {
+ return false
+ }
+
+ // 检查是否只包含 |、-、:、空格
+ for _, r := range trimmed {
+ if r != '|' && r != '-' && r != ':' && r != ' ' {
+ return false
+ }
+ }
+ return true
+}
+
+// normalizeTableRow 规范化表格行
+func (mc *MarkdownConverter) normalizeTableRow(line string) string {
+ trimmed := strings.TrimSpace(line)
+ if !strings.Contains(trimmed, "|") {
+ return line
+ }
+
+ cells := strings.Split(trimmed, "|")
+ // 清理首尾空元素
+ if len(cells) > 0 && cells[0] == "" {
+ cells = cells[1:]
+ }
+ if len(cells) > 0 && cells[len(cells)-1] == "" {
+ cells = cells[:len(cells)-1]
+ }
+
+ // 清理每个单元格
+ normalizedCells := make([]string, 0, len(cells))
+ for _, cell := range cells {
+ cell = strings.TrimSpace(cell)
+ // 移除markdown格式但保留内容
+ cell = mc.textProcessor.RemoveMarkdownSyntax(cell)
+ normalizedCells = append(normalizedCells, cell)
+ }
+
+ // 重新构建表格行
+ return "| " + strings.Join(normalizedCells, " | ") + " |"
+}
+
+// normalizeLists 规范化列表格式
+func (mc *MarkdownConverter) normalizeLists(content string) string {
+ lines := strings.Split(content, "\n")
+ var result []string
+ inCodeBlock := false
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ // 检查是否在代码块中
+ if strings.HasPrefix(trimmed, "```") {
+ inCodeBlock = !inCodeBlock
+ result = append(result, line)
+ continue
+ }
+
+ if inCodeBlock {
+ result = append(result, line)
+ continue
+ }
+
+ // 处理有序列表
+ if matched, _ := regexp.MatchString(`^\d+\.\s+`, trimmed); matched {
+ // 确保格式统一:数字. 空格
+ re := regexp.MustCompile(`^(\d+)\.\s*`)
+ trimmed = re.ReplaceAllString(trimmed, "$1. ")
+ result = append(result, trimmed)
+ } else if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "+ ") {
+ // 处理无序列表,统一使用 -
+ re := regexp.MustCompile(`^[-*+]\s*`)
+ trimmed = re.ReplaceAllString(trimmed, "- ")
+ result = append(result, trimmed)
+ } else {
+ result = append(result, line)
+ }
+ }
+
+ return strings.Join(result, "\n")
+}
+
+// normalizeJSONContent 规范化JSON内容
+func (mc *MarkdownConverter) normalizeJSONContent(content string) string {
+ // 尝试识别并格式化JSON代码块
+ jsonBlockRegex := regexp.MustCompile("(?s)```(?:json)?\\s*\n(.*?)\n```")
+ content = jsonBlockRegex.ReplaceAllStringFunc(content, func(match string) string {
+ // 提取JSON内容
+ submatch := jsonBlockRegex.FindStringSubmatch(match)
+ if len(submatch) < 2 {
+ return match
+ }
+
+ jsonStr := strings.TrimSpace(submatch[1])
+ // 尝试格式化JSON
+ var jsonObj interface{}
+ if err := json.Unmarshal([]byte(jsonStr), &jsonObj); err == nil {
+ // 格式化成功
+ formatted, err := json.MarshalIndent(jsonObj, "", " ")
+ if err == nil {
+ return fmt.Sprintf("```json\n%s\n```", string(formatted))
+ }
+ }
+ return match
+ })
+
+ return content
+}
+
+// convertLinksToText 将链接转换为文本
+func (mc *MarkdownConverter) convertLinksToText(content string) string {
+ // [text](url) -> text (url)
+ linkRegex := regexp.MustCompile(`\[([^\]]+)\]\(([^\)]+)\)`)
+ content = linkRegex.ReplaceAllString(content, "$1 ($2)")
+
+ // [text][ref] -> text
+ refLinkRegex := regexp.MustCompile(`\[([^\]]+)\]\[[^\]]+\]`)
+ content = refLinkRegex.ReplaceAllString(content, "$1")
+
+ return content
+}
+
+// convertImagesToText 将图片转换为文本
+func (mc *MarkdownConverter) convertImagesToText(content string) string {
+ //  -> [图片: alt]
+ imageRegex := regexp.MustCompile(`!\[([^\]]*)\]\([^\)]+\)`)
+ content = imageRegex.ReplaceAllString(content, "[图片: $1]")
+
+ return content
+}
+
+// normalizeBlockquotes 规范化引用块
+func (mc *MarkdownConverter) normalizeBlockquotes(content string) string {
+ lines := strings.Split(content, "\n")
+ var result []string
+ inCodeBlock := false
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ // 检查是否在代码块中
+ if strings.HasPrefix(trimmed, "```") {
+ inCodeBlock = !inCodeBlock
+ result = append(result, line)
+ continue
+ }
+
+ if inCodeBlock {
+ result = append(result, line)
+ continue
+ }
+
+ // 处理引用块 > text -> > text
+ if strings.HasPrefix(trimmed, ">") {
+ // 确保格式统一
+ quoteText := strings.TrimSpace(trimmed[1:])
+ if quoteText != "" {
+ result = append(result, "> "+quoteText)
+ } else {
+ result = append(result, ">")
+ }
+ } else {
+ result = append(result, line)
+ }
+ }
+
+ return strings.Join(result, "\n")
+}
+
+// normalizeHorizontalRules 规范化水平线
+func (mc *MarkdownConverter) normalizeHorizontalRules(content string) string {
+ // 统一水平线格式为 ---
+ hrRegex := regexp.MustCompile(`^[-*_]{3,}\s*$`)
+ lines := strings.Split(content, "\n")
+ var result []string
+ inCodeBlock := false
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ // 检查是否在代码块中
+ if strings.HasPrefix(trimmed, "```") {
+ inCodeBlock = !inCodeBlock
+ result = append(result, line)
+ continue
+ }
+
+ if inCodeBlock {
+ result = append(result, line)
+ continue
+ }
+
+ // 如果是水平线,统一格式
+ if hrRegex.MatchString(trimmed) {
+ result = append(result, "---")
+ } else {
+ result = append(result, line)
+ }
+ }
+
+ return strings.Join(result, "\n")
+}
+
+// cleanupExtraBlankLines 清理多余空行(保留代码块内的空行)
+func (mc *MarkdownConverter) cleanupExtraBlankLines(content string) string {
+ lines := strings.Split(content, "\n")
+ var result []string
+ inCodeBlock := false
+ lastWasBlank := false
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ // 检查是否在代码块中
+ if strings.HasPrefix(trimmed, "```") {
+ inCodeBlock = !inCodeBlock
+ result = append(result, line)
+ lastWasBlank = false
+ continue
+ }
+
+ if inCodeBlock {
+ // 代码块中的内容全部保留
+ result = append(result, line)
+ lastWasBlank = (trimmed == "")
+ continue
+ }
+
+ // 不在代码块中
+ if trimmed == "" {
+ // 空行:最多保留一个连续空行
+ if !lastWasBlank {
+ result = append(result, "")
+ lastWasBlank = true
+ }
+ } else {
+ result = append(result, line)
+ lastWasBlank = false
+ }
+ }
+
+ return strings.Join(result, "\n")
+}
+
+// PreprocessContent 预处理内容 - 这是主要的转换入口
+// 先转换,再解析
+func (mc *MarkdownConverter) PreprocessContent(content string) string {
+ if strings.TrimSpace(content) == "" {
+ return content
+ }
+
+ // 第一步:转换为标准markdown
+ content = mc.ConvertToStandardMarkdown(content)
+
+ // 第二步:尝试识别并转换JSON数组为表格
+ content = mc.convertJSONArrayToTable(content)
+
+ // 第三步:确保所有表格都有正确的分隔行
+ content = mc.ensureTableSeparators(content)
+
+ return content
+}
+
+// convertJSONArrayToTable 将JSON数组转换为markdown表格
+func (mc *MarkdownConverter) convertJSONArrayToTable(content string) string {
+ // 如果内容已经是表格格式,不处理
+ if strings.Contains(content, "|") {
+ lines := strings.Split(content, "\n")
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if strings.Contains(trimmed, "|") && !strings.HasPrefix(trimmed, "```") {
+ // 已经有表格,不转换
+ return content
+ }
+ }
+ }
+
+ // 尝试解析为JSON数组
+ trimmedContent := strings.TrimSpace(content)
+ if strings.HasPrefix(trimmedContent, "[") {
+ var jsonArray []map[string]interface{}
+ if err := json.Unmarshal([]byte(trimmedContent), &jsonArray); err == nil && len(jsonArray) > 0 {
+ // 转换为markdown表格
+ return mc.jsonArrayToMarkdownTable(jsonArray)
+ }
+ }
+
+ // 尝试解析为JSON对象(包含params或fields字段)
+ if strings.HasPrefix(trimmedContent, "{") {
+ var jsonObj map[string]interface{}
+ if err := json.Unmarshal([]byte(trimmedContent), &jsonObj); err == nil {
+ // 检查是否有params字段
+ if params, ok := jsonObj["params"].([]interface{}); ok {
+ paramMaps := make([]map[string]interface{}, 0, len(params))
+ for _, p := range params {
+ if pm, ok := p.(map[string]interface{}); ok {
+ paramMaps = append(paramMaps, pm)
+ }
+ }
+ if len(paramMaps) > 0 {
+ return mc.jsonArrayToMarkdownTable(paramMaps)
+ }
+ }
+ // 检查是否有fields字段
+ if fields, ok := jsonObj["fields"].([]interface{}); ok {
+ fieldMaps := make([]map[string]interface{}, 0, len(fields))
+ for _, f := range fields {
+ if fm, ok := f.(map[string]interface{}); ok {
+ fieldMaps = append(fieldMaps, fm)
+ }
+ }
+ if len(fieldMaps) > 0 {
+ return mc.jsonArrayToMarkdownTable(fieldMaps)
+ }
+ }
+ }
+ }
+
+ return content
+}
+
+// jsonArrayToMarkdownTable 将JSON数组转换为markdown表格
+func (mc *MarkdownConverter) jsonArrayToMarkdownTable(data []map[string]interface{}) string {
+ if len(data) == 0 {
+ return ""
+ }
+
+ var result strings.Builder
+
+ // 收集所有可能的列名(保持原始顺序)
+ // 使用map记录是否已添加,使用slice保持顺序
+ columnSet := make(map[string]bool)
+ columns := make([]string, 0)
+
+ // 遍历所有数据行,按第一次出现的顺序收集列名
+ for _, row := range data {
+ for key := range row {
+ if !columnSet[key] {
+ columns = append(columns, key)
+ columnSet[key] = true
+ }
+ }
+ }
+
+ if len(columns) == 0 {
+ return ""
+ }
+
+ // 构建表头(直接使用原始列名,不做映射)
+ result.WriteString("|")
+ for _, col := range columns {
+ result.WriteString(" ")
+ result.WriteString(col) // 直接使用原始列名
+ result.WriteString(" |")
+ }
+ result.WriteString("\n")
+
+ // 构建分隔行
+ result.WriteString("|")
+ for range columns {
+ result.WriteString(" --- |")
+ }
+ result.WriteString("\n")
+
+ // 构建数据行
+ for _, row := range data {
+ result.WriteString("|")
+ for _, col := range columns {
+ result.WriteString(" ")
+ value := mc.formatCellValue(row[col])
+ result.WriteString(value)
+ result.WriteString(" |")
+ }
+ result.WriteString("\n")
+ }
+
+ return result.String()
+}
+
+// formatColumnName 格式化列名(直接返回原始列名,不做映射)
+// 保持数据库原始数据的列名,不进行转换
+func (mc *MarkdownConverter) formatColumnName(name string) string {
+ // 直接返回原始列名,保持数据库数据的原始格式
+ return name
+}
+
+// formatCellValue 格式化单元格值
+func (mc *MarkdownConverter) formatCellValue(value interface{}) string {
+ if value == nil {
+ return ""
+ }
+
+ switch v := value.(type) {
+ case string:
+ v = strings.ReplaceAll(v, "\n", " ")
+ v = strings.ReplaceAll(v, "\r", " ")
+ v = strings.TrimSpace(v)
+ v = strings.ReplaceAll(v, "|", "\\|")
+ return v
+ case bool:
+ if v {
+ return "是"
+ }
+ return "否"
+ case float64:
+ if v == float64(int64(v)) {
+ return fmt.Sprintf("%.0f", v)
+ }
+ return fmt.Sprintf("%g", v)
+ case int, int8, int16, int32, int64:
+ return fmt.Sprintf("%d", v)
+ case uint, uint8, uint16, uint32, uint64:
+ return fmt.Sprintf("%d", v)
+ default:
+ str := fmt.Sprintf("%v", v)
+ str = strings.ReplaceAll(str, "\n", " ")
+ str = strings.ReplaceAll(str, "\r", " ")
+ str = strings.ReplaceAll(str, "|", "\\|")
+ return strings.TrimSpace(str)
+ }
+}
+
+// ensureTableSeparators 确保所有表格都有正确的分隔行
+func (mc *MarkdownConverter) ensureTableSeparators(content string) string {
+ lines := strings.Split(content, "\n")
+ var result []string
+ inCodeBlock := false
+ lastLineWasTableHeader := false
+
+ for i, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ // 检查是否在代码块中
+ if strings.HasPrefix(trimmed, "```") {
+ inCodeBlock = !inCodeBlock
+ result = append(result, line)
+ lastLineWasTableHeader = false
+ continue
+ }
+
+ if inCodeBlock {
+ result = append(result, line)
+ lastLineWasTableHeader = false
+ continue
+ }
+
+ // 检查是否是表格行
+ if strings.Contains(trimmed, "|") {
+ // 检查是否是分隔行
+ if mc.isTableSeparator(trimmed) {
+ result = append(result, line)
+ lastLineWasTableHeader = false
+ } else {
+ // 普通表格行
+ result = append(result, line)
+ // 检查上一行是否是表头
+ if lastLineWasTableHeader {
+ // 在表头后插入分隔行
+ cells := strings.Split(trimmed, "|")
+ if len(cells) > 0 && cells[0] == "" {
+ cells = cells[1:]
+ }
+ if len(cells) > 0 && cells[len(cells)-1] == "" {
+ cells = cells[:len(cells)-1]
+ }
+ separator := "|"
+ for range cells {
+ separator += " --- |"
+ }
+ // 在当前位置插入分隔行
+ result = append(result[:len(result)-1], separator, line)
+ } else {
+ // 检查是否是表头(第一行表格)
+ if i > 0 {
+ prevLine := strings.TrimSpace(lines[i-1])
+ if !strings.Contains(prevLine, "|") || mc.isTableSeparator(prevLine) {
+ // 这可能是表头
+ lastLineWasTableHeader = true
+ }
+ } else {
+ lastLineWasTableHeader = true
+ }
+ }
+ }
+ } else {
+ result = append(result, line)
+ lastLineWasTableHeader = false
+ }
+ }
+
+ return strings.Join(result, "\n")
+}
diff --git a/internal/shared/pdf/markdown_processor.go b/internal/shared/pdf/markdown_processor.go
new file mode 100644
index 0000000..9cca492
--- /dev/null
+++ b/internal/shared/pdf/markdown_processor.go
@@ -0,0 +1,355 @@
+package pdf
+
+import (
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// MarkdownProcessor Markdown处理器
+type MarkdownProcessor struct {
+ textProcessor *TextProcessor
+ markdownConverter *MarkdownConverter
+}
+
+// NewMarkdownProcessor 创建Markdown处理器
+func NewMarkdownProcessor(textProcessor *TextProcessor) *MarkdownProcessor {
+ converter := NewMarkdownConverter(textProcessor)
+ return &MarkdownProcessor{
+ textProcessor: textProcessor,
+ markdownConverter: converter,
+ }
+}
+
+// MarkdownSection 表示一个markdown章节
+type MarkdownSection struct {
+ Title string // 标题(包含#号)
+ Level int // 标题级别(## 是2, ### 是3, #### 是4)
+ Content string // 该章节的内容
+}
+
+// SplitByMarkdownHeaders 按markdown标题分割内容
+func (mp *MarkdownProcessor) SplitByMarkdownHeaders(content string) []MarkdownSection {
+ lines := strings.Split(content, "\n")
+ var sections []MarkdownSection
+ var currentSection MarkdownSection
+ var currentContent []string
+
+ // 标题正则:匹配 #, ##, ###, #### 等
+ headerRegex := regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
+
+ for _, line := range lines {
+ trimmedLine := strings.TrimSpace(line)
+
+ // 检查是否是标题行
+ if matches := headerRegex.FindStringSubmatch(trimmedLine); matches != nil {
+ // 如果之前有内容,先保存之前的章节
+ if currentSection.Title != "" || len(currentContent) > 0 {
+ if currentSection.Title != "" {
+ currentSection.Content = strings.Join(currentContent, "\n")
+ sections = append(sections, currentSection)
+ }
+ }
+
+ // 开始新章节
+ level := len(matches[1]) // #号的数量
+ currentSection = MarkdownSection{
+ Title: trimmedLine,
+ Level: level,
+ Content: "",
+ }
+ currentContent = []string{}
+ } else {
+ // 普通内容行,添加到当前章节
+ currentContent = append(currentContent, line)
+ }
+ }
+
+ // 保存最后一个章节
+ if currentSection.Title != "" || len(currentContent) > 0 {
+ if currentSection.Title != "" {
+ currentSection.Content = strings.Join(currentContent, "\n")
+ sections = append(sections, currentSection)
+ } else if len(currentContent) > 0 {
+ // 如果没有标题,但开头有内容,作为第一个章节
+ sections = append(sections, MarkdownSection{
+ Title: "",
+ Level: 0,
+ Content: strings.Join(currentContent, "\n"),
+ })
+ }
+ }
+
+ return sections
+}
+
+// FormatContentAsMarkdownTable 将数据库中的数据格式化为标准的markdown表格格式
+// 先进行预处理转换,再进行解析
+func (mp *MarkdownProcessor) FormatContentAsMarkdownTable(content string) string {
+ if strings.TrimSpace(content) == "" {
+ return content
+ }
+
+ // 第一步:预处理和转换(标准化markdown格式)
+ content = mp.markdownConverter.PreprocessContent(content)
+
+ // 如果内容已经是markdown表格格式(包含|符号),检查格式是否正确
+ if strings.Contains(content, "|") {
+ // 检查是否已经是有效的markdown表格
+ lines := strings.Split(content, "\n")
+ hasTableFormat := false
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ // 跳过代码块中的内容
+ if strings.HasPrefix(trimmed, "```") {
+ continue
+ }
+ if strings.Contains(trimmed, "|") && !strings.HasPrefix(trimmed, "#") {
+ hasTableFormat = true
+ break
+ }
+ }
+ if hasTableFormat {
+ return content
+ }
+ }
+
+ // 提取代码块(保留代码块不变)
+ codeBlocks := mp.ExtractCodeBlocks(content)
+
+ // 移除代码块,只处理非代码块部分
+ contentWithoutCodeBlocks := mp.RemoveCodeBlocks(content)
+
+ // 如果移除代码块后内容为空,说明只有代码块,直接返回原始内容
+ if strings.TrimSpace(contentWithoutCodeBlocks) == "" {
+ return content
+ }
+
+ // 尝试解析非代码块部分为JSON数组(仅当内容看起来像JSON时)
+ trimmedContent := strings.TrimSpace(contentWithoutCodeBlocks)
+
+ // 检查是否看起来像JSON(以[或{开头)
+ if strings.HasPrefix(trimmedContent, "[") || strings.HasPrefix(trimmedContent, "{") {
+ // 尝试解析为JSON数组
+ var requestParams []map[string]interface{}
+ if err := json.Unmarshal([]byte(trimmedContent), &requestParams); err == nil && len(requestParams) > 0 {
+ // 成功解析为JSON数组,转换为markdown表格
+ tableContent := mp.jsonArrayToMarkdownTable(requestParams)
+ // 如果有代码块,在表格后添加代码块
+ if len(codeBlocks) > 0 {
+ return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n")
+ }
+ return tableContent
+ }
+
+ // 尝试解析为单个JSON对象
+ var singleObj map[string]interface{}
+ if err := json.Unmarshal([]byte(trimmedContent), &singleObj); err == nil {
+ // 检查是否是包含数组字段的对象
+ if params, ok := singleObj["params"].([]interface{}); ok {
+ // 转换为map数组
+ paramMaps := make([]map[string]interface{}, 0, len(params))
+ for _, p := range params {
+ if pm, ok := p.(map[string]interface{}); ok {
+ paramMaps = append(paramMaps, pm)
+ }
+ }
+ if len(paramMaps) > 0 {
+ tableContent := mp.jsonArrayToMarkdownTable(paramMaps)
+ // 如果有代码块,在表格后添加代码块
+ if len(codeBlocks) > 0 {
+ return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n")
+ }
+ return tableContent
+ }
+ }
+ if fields, ok := singleObj["fields"].([]interface{}); ok {
+ // 转换为map数组
+ fieldMaps := make([]map[string]interface{}, 0, len(fields))
+ for _, f := range fields {
+ if fm, ok := f.(map[string]interface{}); ok {
+ fieldMaps = append(fieldMaps, fm)
+ }
+ }
+ if len(fieldMaps) > 0 {
+ tableContent := mp.jsonArrayToMarkdownTable(fieldMaps)
+ // 如果有代码块,在表格后添加代码块
+ if len(codeBlocks) > 0 {
+ return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n")
+ }
+ return tableContent
+ }
+ }
+ }
+ }
+
+ // 如果无法解析为JSON,返回原始内容(保留代码块)
+ return content
+}
+
+// ExtractCodeBlocks 提取内容中的所有代码块
+func (mp *MarkdownProcessor) ExtractCodeBlocks(content string) []string {
+ var codeBlocks []string
+ lines := strings.Split(content, "\n")
+ inCodeBlock := false
+ var currentBlock []string
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ // 检查是否是代码块开始
+ if strings.HasPrefix(trimmed, "```") {
+ if inCodeBlock {
+ // 代码块结束
+ currentBlock = append(currentBlock, line)
+ codeBlocks = append(codeBlocks, strings.Join(currentBlock, "\n"))
+ currentBlock = []string{}
+ inCodeBlock = false
+ } else {
+ // 代码块开始
+ inCodeBlock = true
+ currentBlock = []string{line}
+ }
+ } else if inCodeBlock {
+ // 在代码块中
+ currentBlock = append(currentBlock, line)
+ }
+ }
+
+ // 如果代码块没有正确关闭,也添加进去
+ if inCodeBlock && len(currentBlock) > 0 {
+ codeBlocks = append(codeBlocks, strings.Join(currentBlock, "\n"))
+ }
+
+ return codeBlocks
+}
+
+// RemoveCodeBlocks 移除内容中的所有代码块
+func (mp *MarkdownProcessor) RemoveCodeBlocks(content string) string {
+ lines := strings.Split(content, "\n")
+ var result []string
+ inCodeBlock := false
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ // 检查是否是代码块开始或结束
+ if strings.HasPrefix(trimmed, "```") {
+ inCodeBlock = !inCodeBlock
+ continue // 跳过代码块的标记行
+ }
+
+ // 如果不在代码块中,保留这一行
+ if !inCodeBlock {
+ result = append(result, line)
+ }
+ }
+
+ return strings.Join(result, "\n")
+}
+
+// jsonArrayToMarkdownTable 将JSON数组转换为标准的markdown表格
+func (mp *MarkdownProcessor) jsonArrayToMarkdownTable(data []map[string]interface{}) string {
+ if len(data) == 0 {
+ return ""
+ }
+
+ var result strings.Builder
+
+ // 收集所有可能的列名(保持原始顺序)
+ // 使用map记录是否已添加,使用slice保持顺序
+ columnSet := make(map[string]bool)
+ columns := make([]string, 0)
+
+ // 遍历所有数据行,按第一次出现的顺序收集列名
+ for _, row := range data {
+ for key := range row {
+ if !columnSet[key] {
+ columns = append(columns, key)
+ columnSet[key] = true
+ }
+ }
+ }
+
+ if len(columns) == 0 {
+ return ""
+ }
+
+ // 构建表头(直接使用原始列名,不做映射)
+ result.WriteString("|")
+ for _, col := range columns {
+ result.WriteString(" ")
+ result.WriteString(col) // 直接使用原始列名
+ result.WriteString(" |")
+ }
+ result.WriteString("\n")
+
+ // 构建分隔行
+ result.WriteString("|")
+ for range columns {
+ result.WriteString(" --- |")
+ }
+ result.WriteString("\n")
+
+ // 构建数据行
+ for _, row := range data {
+ result.WriteString("|")
+ for _, col := range columns {
+ result.WriteString(" ")
+ value := mp.formatCellValue(row[col])
+ result.WriteString(value)
+ result.WriteString(" |")
+ }
+ result.WriteString("\n")
+ }
+
+ return result.String()
+}
+
+// formatColumnName 格式化列名(直接返回原始列名,不做映射)
+// 保持数据库原始数据的列名,不进行转换
+func (mp *MarkdownProcessor) formatColumnName(name string) string {
+ // 直接返回原始列名,保持数据库数据的原始格式
+ return name
+}
+
+// formatCellValue 格式化单元格值
+func (mp *MarkdownProcessor) formatCellValue(value interface{}) string {
+ if value == nil {
+ return ""
+ }
+
+ switch v := value.(type) {
+ case string:
+ // 清理字符串,移除换行符和多余空格
+ v = strings.ReplaceAll(v, "\n", " ")
+ v = strings.ReplaceAll(v, "\r", " ")
+ v = strings.TrimSpace(v)
+ // 转义markdown特殊字符
+ v = strings.ReplaceAll(v, "|", "\\|")
+ return v
+ case bool:
+ if v {
+ return "是"
+ }
+ return "否"
+ case float64:
+ // 如果是整数,不显示小数点
+ if v == float64(int64(v)) {
+ return fmt.Sprintf("%.0f", v)
+ }
+ return fmt.Sprintf("%g", v)
+ case int, int8, int16, int32, int64:
+ return fmt.Sprintf("%d", v)
+ case uint, uint8, uint16, uint32, uint64:
+ return fmt.Sprintf("%d", v)
+ default:
+ // 对于其他类型,转换为字符串
+ str := fmt.Sprintf("%v", v)
+ str = strings.ReplaceAll(str, "\n", " ")
+ str = strings.ReplaceAll(str, "\r", " ")
+ str = strings.ReplaceAll(str, "|", "\\|")
+ return strings.TrimSpace(str)
+ }
+}
diff --git a/internal/shared/pdf/page_builder.go b/internal/shared/pdf/page_builder.go
new file mode 100644
index 0000000..6cd4d6a
--- /dev/null
+++ b/internal/shared/pdf/page_builder.go
@@ -0,0 +1,733 @@
+package pdf
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "os"
+ "strings"
+
+ "tyapi-server/internal/domains/product/entities"
+
+ "github.com/jung-kurt/gofpdf/v2"
+ "go.uber.org/zap"
+)
+
+// PageBuilder 页面构建器
+type PageBuilder struct {
+ logger *zap.Logger
+ fontManager *FontManager
+ textProcessor *TextProcessor
+ markdownProc *MarkdownProcessor
+ markdownConverter *MarkdownConverter
+ tableParser *TableParser
+ tableRenderer *TableRenderer
+ jsonProcessor *JSONProcessor
+ logoPath string
+ watermarkText string
+}
+
+// NewPageBuilder 创建页面构建器
+func NewPageBuilder(
+ logger *zap.Logger,
+ fontManager *FontManager,
+ textProcessor *TextProcessor,
+ markdownProc *MarkdownProcessor,
+ tableParser *TableParser,
+ tableRenderer *TableRenderer,
+ jsonProcessor *JSONProcessor,
+ logoPath string,
+ watermarkText string,
+) *PageBuilder {
+ markdownConverter := NewMarkdownConverter(textProcessor)
+ return &PageBuilder{
+ logger: logger,
+ fontManager: fontManager,
+ textProcessor: textProcessor,
+ markdownProc: markdownProc,
+ markdownConverter: markdownConverter,
+ tableParser: tableParser,
+ tableRenderer: tableRenderer,
+ jsonProcessor: jsonProcessor,
+ logoPath: logoPath,
+ watermarkText: watermarkText,
+ }
+}
+
+// AddFirstPage 添加第一页(封面页 - 产品功能简述)
+func (pb *PageBuilder) AddFirstPage(pdf *gofpdf.Fpdf, product *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
+ pdf.AddPage()
+
+ // 添加页眉(logo和文字)
+ pb.addHeader(pdf, chineseFontAvailable)
+
+ // 添加水印
+ pb.addWatermark(pdf, chineseFontAvailable)
+
+ // 封面页布局 - 居中显示
+ pageWidth, pageHeight := pdf.GetPageSize()
+
+ // 标题区域(页面中上部)
+ pdf.SetY(80)
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "B", 32)
+ _, lineHt := pdf.GetFontSize()
+
+ // 清理产品名称中的无效字符
+ cleanName := pb.textProcessor.CleanText(product.Name)
+ pdf.CellFormat(0, lineHt*1.5, cleanName, "", 1, "C", false, 0, "")
+
+ // 添加"接口文档"副标题
+ pdf.Ln(10)
+ pb.fontManager.SetFont(pdf, "", 18)
+ _, lineHt = pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, "接口文档", "", 1, "C", false, 0, "")
+
+ // 分隔线
+ pdf.Ln(20)
+ pdf.SetLineWidth(0.5)
+ pdf.Line(pageWidth*0.2, pdf.GetY(), pageWidth*0.8, pdf.GetY())
+
+ // 产品编码(居中)
+ pdf.Ln(30)
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "", 14)
+ _, lineHt = pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, fmt.Sprintf("产品编码:%s", product.Code), "", 1, "C", false, 0, "")
+
+ // 产品描述(居中显示,段落格式)
+ if product.Description != "" {
+ pdf.Ln(25)
+ desc := pb.textProcessor.StripHTML(product.Description)
+ desc = pb.textProcessor.CleanText(desc)
+ pb.fontManager.SetFont(pdf, "", 14)
+ _, lineHt = pdf.GetFontSize()
+ // 居中对齐的MultiCell(通过计算宽度实现)
+ descWidth := pageWidth * 0.7
+ descLines := pdf.SplitText(desc, descWidth)
+ currentX := (pageWidth - descWidth) / 2
+ for _, line := range descLines {
+ pdf.SetX(currentX)
+ pdf.CellFormat(descWidth, lineHt*1.5, line, "", 1, "C", false, 0, "")
+ }
+ }
+
+ // 产品详情(如果存在)
+ if product.Content != "" {
+ pdf.Ln(20)
+ content := pb.textProcessor.StripHTML(product.Content)
+ content = pb.textProcessor.CleanText(content)
+ pb.fontManager.SetFont(pdf, "", 12)
+ _, lineHt = pdf.GetFontSize()
+ contentWidth := pageWidth * 0.7
+ contentLines := pdf.SplitText(content, contentWidth)
+ currentX := (pageWidth - contentWidth) / 2
+ for _, line := range contentLines {
+ pdf.SetX(currentX)
+ pdf.CellFormat(contentWidth, lineHt*1.4, line, "", 1, "C", false, 0, "")
+ }
+ }
+
+ // 底部信息(价格等)
+ if !product.Price.IsZero() {
+ pdf.SetY(pageHeight - 60)
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "", 12)
+ _, lineHt = pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, fmt.Sprintf("价格:%s 元", product.Price.String()), "", 1, "C", false, 0, "")
+ }
+}
+
+// AddDocumentationPages 添加接口文档页面
+func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
+ // 创建自定义的AddPage函数,确保每页都有水印
+ addPageWithWatermark := func() {
+ pdf.AddPage()
+ pb.addHeader(pdf, chineseFontAvailable)
+ pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印
+ }
+
+ addPageWithWatermark()
+
+ pdf.SetY(45)
+ pb.fontManager.SetFont(pdf, "B", 18)
+ _, lineHt := pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "")
+
+ // 请求URL
+ pdf.Ln(8)
+ pdf.SetTextColor(0, 0, 0) // 确保深黑色
+ pb.fontManager.SetFont(pdf, "B", 12)
+ pdf.CellFormat(0, lineHt, "请求URL:", "", 1, "L", false, 0, "")
+ // URL使用黑体字体(可能包含中文字符)
+ // 先清理URL中的乱码
+ cleanURL := pb.textProcessor.CleanText(doc.RequestURL)
+ pb.fontManager.SetFont(pdf, "", 10) // 使用黑体
+ pdf.SetTextColor(0, 0, 0)
+ pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false)
+
+ // 请求方法
+ pdf.Ln(5)
+ pb.fontManager.SetFont(pdf, "B", 12)
+ pdf.CellFormat(0, lineHt, fmt.Sprintf("请求方法:%s", doc.RequestMethod), "", 1, "L", false, 0, "")
+
+ // 基本信息
+ if doc.BasicInfo != "" {
+ pdf.Ln(8)
+ pb.addSection(pdf, "基本信息", doc.BasicInfo, chineseFontAvailable)
+ }
+
+ // 请求参数
+ if doc.RequestParams != "" {
+ pdf.Ln(8)
+ // 显示标题
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "B", 14)
+ _, lineHt = pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, "请求参数:", "", 1, "L", false, 0, "")
+
+ // 使用新的数据库驱动方式处理请求参数
+ if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "request_params"); err != nil {
+ pb.logger.Warn("渲染请求参数表格失败,回退到文本显示", zap.Error(err))
+ // 如果表格渲染失败,显示为文本
+ text := pb.textProcessor.CleanText(doc.RequestParams)
+ if strings.TrimSpace(text) != "" {
+ pb.fontManager.SetFont(pdf, "", 10)
+ pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
+ }
+ }
+
+ // 生成JSON示例
+ if jsonExample := pb.jsonProcessor.GenerateJSONExample(doc.RequestParams, pb.tableParser); jsonExample != "" {
+ pdf.Ln(5)
+ pdf.SetTextColor(0, 0, 0) // 确保深黑色
+ pb.fontManager.SetFont(pdf, "B", 14)
+ pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "")
+ // JSON中可能包含中文值,使用黑体字体
+ pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON(支持中文)
+ pdf.SetTextColor(0, 0, 0)
+ pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false)
+ }
+ }
+
+ // 响应示例
+ if doc.ResponseExample != "" {
+ pdf.Ln(8)
+ pdf.SetTextColor(0, 0, 0) // 确保深黑色
+ pb.fontManager.SetFont(pdf, "B", 14)
+ _, lineHt = pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "")
+
+ // 优先尝试提取和格式化JSON
+ jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
+ if jsonContent != "" {
+ // 格式化JSON
+ formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
+ if err == nil {
+ jsonContent = formattedJSON
+ }
+ pdf.SetTextColor(0, 0, 0) // 确保深黑色
+ pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON(支持中文)
+ pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false)
+ } else {
+ // 如果没有JSON,尝试使用表格方式处理
+ if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil {
+ pb.logger.Warn("渲染响应示例表格失败,回退到文本显示", zap.Error(err))
+ // 如果表格渲染失败,显示为文本
+ text := pb.textProcessor.CleanText(doc.ResponseExample)
+ if strings.TrimSpace(text) != "" {
+ pb.fontManager.SetFont(pdf, "", 10)
+ pdf.SetTextColor(0, 0, 0) // 确保深黑色
+ pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
+ }
+ }
+ }
+ }
+
+ // 返回字段说明
+ if doc.ResponseFields != "" {
+ pdf.Ln(8)
+ // 显示标题
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "B", 14)
+ _, lineHt = pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, "返回字段说明:", "", 1, "L", false, 0, "")
+
+ // 使用新的数据库驱动方式处理返回字段(支持多个表格,带标题)
+ if err := pb.tableParser.ParseAndRenderTablesWithTitles(context.Background(), pdf, doc, "response_fields"); err != nil {
+ pb.logger.Warn("渲染返回字段表格失败,回退到文本显示",
+ zap.Error(err),
+ zap.String("content_preview", pb.getContentPreview(doc.ResponseFields, 200)))
+ // 如果表格渲染失败,显示为文本
+ text := pb.textProcessor.CleanText(doc.ResponseFields)
+ if strings.TrimSpace(text) != "" {
+ pb.fontManager.SetFont(pdf, "", 10)
+ pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
+ } else {
+ pb.logger.Warn("返回字段内容为空或只有空白字符")
+ }
+ } else {
+ pb.logger.Info("返回字段说明表格渲染成功")
+ }
+ } else {
+ pb.logger.Debug("返回字段内容为空,跳过渲染")
+ }
+
+ // 错误代码
+ if doc.ErrorCodes != "" {
+ pdf.Ln(8)
+ // 显示标题
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "B", 14)
+ _, lineHt = pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, "错误代码:", "", 1, "L", false, 0, "")
+
+ // 使用新的数据库驱动方式处理错误代码
+ if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "error_codes"); err != nil {
+ pb.logger.Warn("渲染错误代码表格失败,回退到文本显示", zap.Error(err))
+ // 如果表格渲染失败,显示为文本
+ text := pb.textProcessor.CleanText(doc.ErrorCodes)
+ if strings.TrimSpace(text) != "" {
+ pb.fontManager.SetFont(pdf, "", 10)
+ pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
+ }
+ }
+ }
+}
+
+// addSection 添加章节
+func (pb *PageBuilder) addSection(pdf *gofpdf.Fpdf, title, content string, chineseFontAvailable bool) {
+ _, lineHt := pdf.GetFontSize()
+ pb.fontManager.SetFont(pdf, "B", 14)
+ pdf.CellFormat(0, lineHt, title+":", "", 1, "L", false, 0, "")
+
+ // 第一步:预处理和转换(标准化markdown格式)
+ content = pb.markdownConverter.PreprocessContent(content)
+
+ // 第二步:将内容格式化为标准的markdown表格格式(如果还不是)
+ content = pb.markdownProc.FormatContentAsMarkdownTable(content)
+
+ // 先尝试提取JSON(如果是代码块格式)
+ if jsonContent := pb.jsonProcessor.ExtractJSON(content); jsonContent != "" {
+ // 格式化JSON
+ formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
+ if err == nil {
+ jsonContent = formattedJSON
+ }
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "", 9) // 使用黑体
+ pdf.MultiCell(0, lineHt*1.2, jsonContent, "", "L", false)
+ } else {
+ // 按#号标题分割内容,每个标题下的内容单独处理
+ sections := pb.markdownProc.SplitByMarkdownHeaders(content)
+ if len(sections) > 0 {
+ // 如果有多个章节,逐个处理
+ for i, section := range sections {
+ if i > 0 {
+ pdf.Ln(5) // 章节之间的间距
+ }
+ // 如果有标题,先显示标题
+ if section.Title != "" {
+ titleLevel := section.Level
+ fontSize := 14.0 - float64(titleLevel-2)*2 // ## 是14, ### 是12, #### 是10
+ if fontSize < 10 {
+ fontSize = 10
+ }
+ pb.fontManager.SetFont(pdf, "B", fontSize)
+ pdf.SetTextColor(0, 0, 0)
+ // 清理标题中的#号
+ cleanTitle := strings.TrimSpace(strings.TrimLeft(section.Title, "#"))
+ pdf.CellFormat(0, lineHt*1.2, cleanTitle, "", 1, "L", false, 0, "")
+ pdf.Ln(3)
+ }
+ // 处理该章节的内容(可能是表格或文本)
+ pb.processSectionContent(pdf, section.Content, chineseFontAvailable, lineHt)
+ }
+ } else {
+ // 如果没有标题分割,直接处理整个内容
+ pb.processSectionContent(pdf, content, chineseFontAvailable, lineHt)
+ }
+ }
+}
+
+// processRequestParams 处理请求参数:直接解析所有表格,确保表格能够正确渲染
+func (pb *PageBuilder) processRequestParams(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) {
+ // 第一步:预处理和转换(标准化markdown格式)
+ content = pb.markdownConverter.PreprocessContent(content)
+
+ // 第二步:将数据格式化为标准的markdown表格格式
+ processedContent := pb.markdownProc.FormatContentAsMarkdownTable(content)
+
+ // 解析并显示所有表格(不按标题分组)
+ // 将内容按表格分割,找到所有表格块
+ allTables := pb.tableParser.ExtractAllTables(processedContent)
+
+ if len(allTables) > 0 {
+ // 有表格,逐个渲染
+ for i, tableBlock := range allTables {
+ if i > 0 {
+ pdf.Ln(5) // 表格之间的间距
+ }
+
+ // 渲染表格前的说明文字(包括标题)
+ if tableBlock.BeforeText != "" {
+ beforeText := tableBlock.BeforeText
+ // 处理标题和文本
+ pb.renderTextWithTitles(pdf, beforeText, chineseFontAvailable, lineHt)
+ pdf.Ln(3)
+ }
+
+ // 渲染表格
+ if len(tableBlock.TableData) > 0 && pb.tableParser.IsValidTable(tableBlock.TableData) {
+ pb.tableRenderer.RenderTable(pdf, tableBlock.TableData)
+ }
+
+ // 渲染表格后的说明文字
+ if tableBlock.AfterText != "" {
+ afterText := pb.textProcessor.StripHTML(tableBlock.AfterText)
+ afterText = pb.textProcessor.CleanText(afterText)
+ if strings.TrimSpace(afterText) != "" {
+ pdf.Ln(3)
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "", 10)
+ pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
+ }
+ }
+ }
+ } else {
+ // 没有表格,显示为文本
+ text := pb.textProcessor.StripHTML(processedContent)
+ text = pb.textProcessor.CleanText(text)
+ if strings.TrimSpace(text) != "" {
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "", 10)
+ pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
+ }
+ }
+}
+
+// processResponseExample 处理响应示例:不按markdown标题分级,直接解析所有表格,但保留标题显示
+func (pb *PageBuilder) processResponseExample(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) {
+ // 第一步:预处理和转换(标准化markdown格式)
+ content = pb.markdownConverter.PreprocessContent(content)
+
+ // 第二步:将数据格式化为标准的markdown表格格式
+ processedContent := pb.markdownProc.FormatContentAsMarkdownTable(content)
+
+ // 尝试提取JSON内容(如果存在代码块)
+ jsonContent := pb.jsonProcessor.ExtractJSON(processedContent)
+ if jsonContent != "" {
+ pdf.SetTextColor(0, 0, 0)
+ formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
+ if err == nil {
+ jsonContent = formattedJSON
+ }
+ pb.fontManager.SetFont(pdf, "", 9) // 使用黑体
+ pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false)
+ pdf.Ln(5)
+ }
+
+ // 解析并显示所有表格(不按标题分组)
+ // 将内容按表格分割,找到所有表格块
+ allTables := pb.tableParser.ExtractAllTables(processedContent)
+
+ if len(allTables) > 0 {
+ // 有表格,逐个渲染
+ for i, tableBlock := range allTables {
+ if i > 0 {
+ pdf.Ln(5) // 表格之间的间距
+ }
+
+ // 渲染表格前的说明文字(包括标题)
+ if tableBlock.BeforeText != "" {
+ beforeText := tableBlock.BeforeText
+ // 处理标题和文本
+ pb.renderTextWithTitles(pdf, beforeText, chineseFontAvailable, lineHt)
+ pdf.Ln(3)
+ }
+
+ // 渲染表格
+ if len(tableBlock.TableData) > 0 && pb.tableParser.IsValidTable(tableBlock.TableData) {
+ pb.tableRenderer.RenderTable(pdf, tableBlock.TableData)
+ }
+
+ // 渲染表格后的说明文字
+ if tableBlock.AfterText != "" {
+ afterText := pb.textProcessor.StripHTML(tableBlock.AfterText)
+ afterText = pb.textProcessor.CleanText(afterText)
+ if strings.TrimSpace(afterText) != "" {
+ pdf.Ln(3)
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "", 10)
+ pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
+ }
+ }
+ }
+ } else {
+ // 没有表格,显示为文本
+ text := pb.textProcessor.StripHTML(processedContent)
+ text = pb.textProcessor.CleanText(text)
+ if strings.TrimSpace(text) != "" {
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "", 10)
+ pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
+ }
+ }
+}
+
+// processSectionContent 处理单个章节的内容(解析表格或显示文本)
+func (pb *PageBuilder) processSectionContent(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) {
+ // 尝试解析markdown表格
+ tableData := pb.tableParser.ParseMarkdownTable(content)
+
+ // 检查内容是否包含表格标记(|符号)
+ hasTableMarkers := strings.Contains(content, "|")
+
+ // 如果解析出了表格数据(即使验证失败),或者内容包含表格标记,都尝试渲染表格
+ // 放宽条件:支持只有表头的表格(单行表格)
+ if len(tableData) >= 1 && hasTableMarkers {
+ // 如果表格有效,或者至少有表头,都尝试渲染
+ if pb.tableParser.IsValidTable(tableData) {
+ // 如果是有效的表格,先检查表格前后是否有说明文字
+ // 提取表格前后的文本(用于显示说明)
+ lines := strings.Split(content, "\n")
+ var beforeTable []string
+ var afterTable []string
+ inTable := false
+ tableStartLine := -1
+ tableEndLine := -1
+
+ // 找到表格的起始和结束行
+ usePipeDelimiter := false
+ for _, line := range lines {
+ if strings.Contains(strings.TrimSpace(line), "|") {
+ usePipeDelimiter = true
+ break
+ }
+ }
+
+ for i, line := range lines {
+ trimmedLine := strings.TrimSpace(line)
+ if usePipeDelimiter && strings.Contains(trimmedLine, "|") {
+ if !inTable {
+ tableStartLine = i
+ inTable = true
+ }
+ tableEndLine = i
+ } else if inTable && usePipeDelimiter && !strings.Contains(trimmedLine, "|") {
+ // 表格可能结束了
+ if strings.HasPrefix(trimmedLine, "```") {
+ tableEndLine = i - 1
+ break
+ }
+ }
+ }
+
+ // 提取表格前的文本
+ if tableStartLine > 0 {
+ beforeTable = lines[0:tableStartLine]
+ }
+ // 提取表格后的文本
+ if tableEndLine >= 0 && tableEndLine < len(lines)-1 {
+ afterTable = lines[tableEndLine+1:]
+ }
+
+ // 显示表格前的说明文字
+ if len(beforeTable) > 0 {
+ beforeText := strings.Join(beforeTable, "\n")
+ beforeText = pb.textProcessor.StripHTML(beforeText)
+ beforeText = pb.textProcessor.CleanText(beforeText)
+ if strings.TrimSpace(beforeText) != "" {
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "", 10)
+ pdf.MultiCell(0, lineHt*1.3, beforeText, "", "L", false)
+ pdf.Ln(3)
+ }
+ }
+
+ // 渲染表格
+ pb.tableRenderer.RenderTable(pdf, tableData)
+
+ // 显示表格后的说明文字
+ if len(afterTable) > 0 {
+ afterText := strings.Join(afterTable, "\n")
+ afterText = pb.textProcessor.StripHTML(afterText)
+ afterText = pb.textProcessor.CleanText(afterText)
+ if strings.TrimSpace(afterText) != "" {
+ pdf.Ln(3)
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "", 10)
+ pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
+ }
+ }
+ }
+ } else {
+ // 如果不是有效表格,显示为文本(完整显示markdown内容)
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "", 10)
+ text := pb.textProcessor.StripHTML(content)
+ text = pb.textProcessor.CleanText(text) // 清理无效字符,保留中文
+ // 如果文本不为空,显示它
+ if strings.TrimSpace(text) != "" {
+ pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
+ }
+ }
+}
+
+// renderTextWithTitles 渲染包含markdown标题的文本
+func (pb *PageBuilder) renderTextWithTitles(pdf *gofpdf.Fpdf, text string, chineseFontAvailable bool, lineHt float64) {
+ lines := strings.Split(text, "\n")
+
+ for _, line := range lines {
+ trimmedLine := strings.TrimSpace(line)
+
+ // 检查是否是标题行
+ if strings.HasPrefix(trimmedLine, "#") {
+ // 计算标题级别
+ level := 0
+ for _, r := range trimmedLine {
+ if r == '#' {
+ level++
+ } else {
+ break
+ }
+ }
+
+ // 提取标题文本(移除#号)
+ titleText := strings.TrimSpace(trimmedLine[level:])
+ if titleText == "" {
+ continue
+ }
+
+ // 根据级别设置字体大小
+ fontSize := 14.0 - float64(level-2)*2
+ if fontSize < 10 {
+ fontSize = 10
+ }
+ if fontSize > 16 {
+ fontSize = 16
+ }
+
+ // 渲染标题
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "B", fontSize)
+ _, titleLineHt := pdf.GetFontSize()
+ pdf.CellFormat(0, titleLineHt*1.2, titleText, "", 1, "L", false, 0, "")
+ pdf.Ln(2)
+ } else if strings.TrimSpace(line) != "" {
+ // 普通文本行(只去除HTML标签,保留markdown格式)
+ cleanText := pb.textProcessor.StripHTML(line)
+ cleanText = pb.textProcessor.CleanTextPreservingMarkdown(cleanText)
+ if strings.TrimSpace(cleanText) != "" {
+ pdf.SetTextColor(0, 0, 0)
+ pb.fontManager.SetFont(pdf, "", 10)
+ pdf.MultiCell(0, lineHt*1.3, cleanText, "", "L", false)
+ }
+ } else {
+ // 空行,添加间距
+ pdf.Ln(2)
+ }
+ }
+}
+
+// addHeader 添加页眉(logo和文字)
+func (pb *PageBuilder) addHeader(pdf *gofpdf.Fpdf, chineseFontAvailable bool) {
+ pdf.SetY(5)
+
+ // 绘制logo(如果存在)
+ if pb.logoPath != "" {
+ if _, err := os.Stat(pb.logoPath); err == nil {
+ // gofpdf的ImageOptions方法(调整位置和大小,左边距是15mm)
+ pdf.ImageOptions(pb.logoPath, 15, 5, 15, 15, false, gofpdf.ImageOptions{}, 0, "")
+ pb.logger.Info("已添加logo", zap.String("path", pb.logoPath))
+ } else {
+ pb.logger.Warn("logo文件不存在", zap.String("path", pb.logoPath), zap.Error(err))
+ }
+ } else {
+ pb.logger.Warn("logo路径为空")
+ }
+
+ // 绘制"天远数据"文字(使用中文字体如果可用)
+ pdf.SetXY(33, 8)
+ pb.fontManager.SetFont(pdf, "B", 14)
+ pdf.CellFormat(0, 10, "天远数据", "", 0, "L", false, 0, "")
+
+ // 绘制下横线(优化位置,左边距是15mm)
+ pdf.Line(15, 22, 75, 22)
+}
+
+// addWatermark 添加水印(从左边开始向上倾斜45度,考虑可用区域)
+func (pb *PageBuilder) addWatermark(pdf *gofpdf.Fpdf, chineseFontAvailable bool) {
+ // 如果中文字体不可用,跳过水印(避免显示乱码)
+ if !chineseFontAvailable {
+ return
+ }
+
+ // 保存当前图形状态
+ pdf.TransformBegin()
+ defer pdf.TransformEnd()
+
+ // 获取页面尺寸和边距
+ _, pageHeight := pdf.GetPageSize()
+ leftMargin, topMargin, _, bottomMargin := pdf.GetMargins()
+
+ // 计算实际可用区域高度
+ usableHeight := pageHeight - topMargin - bottomMargin
+
+ // 设置水印样式(使用水印字体,非黑体)
+ fontSize := 45.0
+
+ pb.fontManager.SetWatermarkFont(pdf, "", fontSize)
+
+ // 设置灰色和透明度(加深水印,使其更明显)
+ pdf.SetTextColor(180, 180, 180) // 深一点的灰色
+ pdf.SetAlpha(0.25, "Normal") // 增加透明度,让水印更明显
+
+ // 计算文字宽度
+ textWidth := pdf.GetStringWidth(pb.watermarkText)
+ if textWidth == 0 {
+ // 如果无法获取宽度(字体未注册),使用估算值(中文字符大约每个 fontSize/3 mm)
+ textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0
+ }
+
+ // 从左边开始,计算起始位置
+ // 起始X:左边距
+ // 起始Y:考虑水印文字长度和旋转后需要的空间
+ startX := leftMargin
+ startY := topMargin + textWidth*0.5 // 为旋转留出空间
+
+ // 移动到起始位置
+ pdf.TransformTranslate(startX, startY)
+
+ // 向上倾斜45度(顺时针旋转45度,即-45度,或逆时针315度)
+ pdf.TransformRotate(-45, 0, 0)
+
+ // 检查文字是否会超出可用区域(旋转后的对角线长度)
+ rotatedDiagonal := math.Sqrt(textWidth*textWidth + fontSize*fontSize)
+ if rotatedDiagonal > usableHeight*0.8 {
+ // 如果太大,缩小字体
+ fontSize = fontSize * usableHeight * 0.8 / rotatedDiagonal
+ pb.fontManager.SetWatermarkFont(pdf, "", fontSize)
+ textWidth = pdf.GetStringWidth(pb.watermarkText)
+ if textWidth == 0 {
+ textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0
+ }
+ }
+
+ // 从左边开始绘制水印文字
+ pdf.SetXY(0, 0)
+ pdf.CellFormat(textWidth, fontSize, pb.watermarkText, "", 0, "L", false, 0, "")
+
+ // 恢复透明度和颜色
+ pdf.SetAlpha(1.0, "Normal")
+ pdf.SetTextColor(0, 0, 0) // 恢复为黑色
+}
+
+// getContentPreview 获取内容预览(用于日志记录)
+func (pb *PageBuilder) getContentPreview(content string, maxLen int) string {
+ content = strings.TrimSpace(content)
+ if len(content) <= maxLen {
+ return content
+ }
+ return content[:maxLen] + "..."
+}
diff --git a/internal/shared/pdf/pdf_finder.go b/internal/shared/pdf/pdf_finder.go
new file mode 100644
index 0000000..48bc0a5
--- /dev/null
+++ b/internal/shared/pdf/pdf_finder.go
@@ -0,0 +1,242 @@
+package pdf
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "go.uber.org/zap"
+)
+
+// GetDocumentationDir 获取接口文档文件夹路径
+// 会在当前目录及其父目录中查找"接口文档"文件夹
+func GetDocumentationDir() (string, error) {
+ // 获取当前工作目录
+ wd, err := os.Getwd()
+ if err != nil {
+ return "", fmt.Errorf("获取工作目录失败: %w", err)
+ }
+
+ // 搜索策略:从当前目录开始,向上查找"接口文档"文件夹
+ currentDir := wd
+ maxDepth := 10 // 增加搜索深度,确保能找到
+
+ var checkedDirs []string
+ for i := 0; i < maxDepth; i++ {
+ docDir := filepath.Join(currentDir, "接口文档")
+ checkedDirs = append(checkedDirs, docDir)
+
+ if info, err := os.Stat(docDir); err == nil && info.IsDir() {
+ absPath, err := filepath.Abs(docDir)
+ if err != nil {
+ return "", fmt.Errorf("获取绝对路径失败: %w", err)
+ }
+ return absPath, nil
+ }
+
+ // 尝试父目录
+ parentDir := filepath.Dir(currentDir)
+ if parentDir == currentDir {
+ break // 已到达根目录
+ }
+ currentDir = parentDir
+ }
+
+ return "", fmt.Errorf("未找到接口文档文件夹。已检查的路径: %v,当前工作目录: %s", checkedDirs, wd)
+}
+
+// PDFFinder PDF文件查找服务
+type PDFFinder struct {
+ documentationDir string
+ logger *zap.Logger
+}
+
+// NewPDFFinder 创建PDF查找服务
+func NewPDFFinder(documentationDir string, logger *zap.Logger) *PDFFinder {
+ return &PDFFinder{
+ documentationDir: documentationDir,
+ logger: logger,
+ }
+}
+
+// FindPDFByProductCode 根据产品代码查找PDF文件
+// 会在接口文档文件夹中递归搜索匹配的PDF文件
+// 文件名格式应为: *_{产品代码}.pdf
+func (f *PDFFinder) FindPDFByProductCode(productCode string) (string, error) {
+ if productCode == "" {
+ return "", fmt.Errorf("产品代码不能为空")
+ }
+
+ // 构建搜索模式:文件名以 _{产品代码}.pdf 结尾
+ searchPattern := fmt.Sprintf("*_%s.pdf", productCode)
+
+ f.logger.Info("开始搜索PDF文件",
+ zap.String("product_code", productCode),
+ zap.String("search_pattern", searchPattern),
+ zap.String("documentation_dir", f.documentationDir),
+ )
+
+ // 验证接口文档文件夹是否存在
+ if info, err := os.Stat(f.documentationDir); err != nil || !info.IsDir() {
+ f.logger.Error("接口文档文件夹不存在或无法访问",
+ zap.String("documentation_dir", f.documentationDir),
+ zap.Error(err),
+ )
+ return "", fmt.Errorf("接口文档文件夹不存在或无法访问: %s", f.documentationDir)
+ }
+
+ var foundPath string
+ var checkedFiles []string
+ err := filepath.Walk(f.documentationDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ f.logger.Debug("访问文件/目录时出错,跳过",
+ zap.String("path", path),
+ zap.Error(err),
+ )
+ return nil // 忽略访问错误,继续搜索
+ }
+
+ // 只处理PDF文件
+ if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".pdf") {
+ return nil
+ }
+
+ // 获取文件名(不包含路径)
+ fileName := info.Name()
+ checkedFiles = append(checkedFiles, fileName)
+
+ // 转换为小写进行大小写不敏感匹配
+ fileNameLower := strings.ToLower(fileName)
+ productCodeLower := strings.ToLower(productCode)
+
+ // 方式1: 检查文件名是否以 _{产品代码}.pdf 结尾(大小写不敏感)
+ suffixPattern := fmt.Sprintf("_%s.pdf", productCodeLower)
+ if strings.HasSuffix(fileNameLower, suffixPattern) {
+ foundPath = path
+ f.logger.Info("找到匹配的PDF文件(后缀匹配)",
+ zap.String("product_code", productCode),
+ zap.String("file_name", fileName),
+ zap.String("file_path", path),
+ )
+ return filepath.SkipAll // 找到后停止搜索
+ }
+
+ // 方式2: 使用filepath.Match进行模式匹配(作为备用)
+ matched, matchErr := filepath.Match(searchPattern, fileName)
+ if matchErr == nil && matched {
+ foundPath = path
+ f.logger.Info("找到匹配的PDF文件(模式匹配)",
+ zap.String("product_code", productCode),
+ zap.String("file_name", fileName),
+ zap.String("file_path", path),
+ )
+ return filepath.SkipAll // 找到后停止搜索
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ f.logger.Error("搜索PDF文件时出错",
+ zap.String("product_code", productCode),
+ zap.Error(err),
+ )
+ return "", fmt.Errorf("搜索PDF文件时出错: %w", err)
+ }
+
+ if foundPath == "" {
+ // 查找包含产品编码前缀的类似文件,用于调试
+ var similarFiles []string
+ if len(productCode) >= 4 {
+ productCodePrefix := productCode[:4] // 取前4个字符作为前缀(如JRZQ)
+ for _, fileName := range checkedFiles {
+ fileNameLower := strings.ToLower(fileName)
+ if strings.Contains(fileNameLower, strings.ToLower(productCodePrefix)) {
+ similarFiles = append(similarFiles, fileName)
+ if len(similarFiles) >= 5 {
+ break // 只显示最多5个类似文件
+ }
+ }
+ }
+ }
+
+ f.logger.Warn("未找到匹配的PDF文件",
+ zap.String("product_code", productCode),
+ zap.String("search_pattern", searchPattern),
+ zap.String("documentation_dir", f.documentationDir),
+ zap.Int("checked_files_count", len(checkedFiles)),
+ zap.Strings("similar_files_with_same_prefix", similarFiles),
+ zap.Strings("sample_files", func() []string {
+ if len(checkedFiles) > 10 {
+ return checkedFiles[:10]
+ }
+ return checkedFiles
+ }()),
+ )
+ return "", fmt.Errorf("未找到产品代码为 %s 的PDF文档", productCode)
+ }
+
+ // 转换为绝对路径
+ absPath, err := filepath.Abs(foundPath)
+ if err != nil {
+ f.logger.Error("获取文件绝对路径失败",
+ zap.String("file_path", foundPath),
+ zap.Error(err),
+ )
+ return "", fmt.Errorf("获取文件绝对路径失败: %w", err)
+ }
+
+ f.logger.Info("成功找到PDF文件",
+ zap.String("product_code", productCode),
+ zap.String("file_path", absPath),
+ )
+
+ return absPath, nil
+}
+
+// FindPDFByProductCodeWithFallback 根据产品代码查找PDF文件,支持多个可能的命名格式
+func (f *PDFFinder) FindPDFByProductCodeWithFallback(productCode string) (string, error) {
+ // 尝试多种可能的文件命名格式
+ patterns := []string{
+ fmt.Sprintf("*_%s.pdf", productCode), // 标准格式: 产品名称_{代码}.pdf
+ fmt.Sprintf("%s*.pdf", productCode), // 以代码开头
+ fmt.Sprintf("*%s*.pdf", productCode), // 包含代码
+ }
+
+ var foundPath string
+ for _, pattern := range patterns {
+ err := filepath.Walk(f.documentationDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+
+ if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".pdf") {
+ return nil
+ }
+
+ fileName := info.Name()
+ if matched, _ := filepath.Match(pattern, fileName); matched {
+ foundPath = path
+ return filepath.SkipAll
+ }
+
+ return nil
+ })
+
+ if err == nil && foundPath != "" {
+ break
+ }
+ }
+
+ if foundPath == "" {
+ return "", fmt.Errorf("未找到产品代码为 %s 的PDF文档", productCode)
+ }
+
+ absPath, err := filepath.Abs(foundPath)
+ if err != nil {
+ return "", fmt.Errorf("获取文件绝对路径失败: %w", err)
+ }
+
+ return absPath, nil
+}
diff --git a/internal/shared/pdf/pdf_generator.go b/internal/shared/pdf/pdf_generator.go
new file mode 100644
index 0000000..664bcba
--- /dev/null
+++ b/internal/shared/pdf/pdf_generator.go
@@ -0,0 +1,2197 @@
+package pdf
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "html"
+ "math"
+ "os"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+
+ "github.com/jung-kurt/gofpdf/v2"
+ "github.com/shopspring/decimal"
+ "go.uber.org/zap"
+
+ "tyapi-server/internal/domains/product/entities"
+)
+
+// PDFGenerator PDF生成器
+type PDFGenerator struct {
+ logger *zap.Logger
+ chineseFont string
+ logoPath string
+ watermarkText string
+}
+
+// NewPDFGenerator 创建PDF生成器
+func NewPDFGenerator(logger *zap.Logger) *PDFGenerator {
+ gen := &PDFGenerator{
+ logger: logger,
+ watermarkText: "海南海宇大数据有限公司",
+ }
+
+ // 尝试注册中文字体
+ chineseFont := gen.registerChineseFont()
+ gen.chineseFont = chineseFont
+
+ // 查找logo文件
+ gen.findLogo()
+
+ return gen
+}
+
+// registerChineseFont 注册中文字体
+// gofpdf v2 默认支持 UTF-8,但需要添加支持中文的字体文件
+func (g *PDFGenerator) registerChineseFont() string {
+ // 返回字体名称标识,实际在generatePDF中注册
+ return "ChineseFont"
+}
+
+// getChineseFontPaths 获取中文字体路径(支持跨平台)
+func (g *PDFGenerator) getChineseFontPaths() []string {
+ var fontPaths []string
+
+ // Windows系统
+ if runtime.GOOS == "windows" {
+ fontPaths = []string{
+ `C:\Windows\Fonts\simhei.ttf`, // 黑体(优先,常用)
+ `C:\Windows\Fonts\simsun.ttf`, // 宋体(如果存在单独的TTF文件)
+ `C:\Windows\Fonts\msyh.ttf`, // 微软雅黑(如果存在单独的TTF文件)
+ `C:\Windows\Fonts\simkai.ttf`, // 楷体
+ }
+ } else if runtime.GOOS == "linux" {
+ // Linux系统常见字体路径
+ fontPaths = []string{
+ "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
+ "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
+ "/usr/share/fonts/truetype/arphic/uming.ttc",
+ "/usr/share/fonts/truetype/arphic/ukai.ttc",
+ "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
+ }
+ } else if runtime.GOOS == "darwin" {
+ // macOS系统字体路径
+ fontPaths = []string{
+ "/System/Library/Fonts/PingFang.ttc",
+ "/System/Library/Fonts/STHeiti Light.ttc",
+ "/System/Library/Fonts/STSong.ttc",
+ "/Library/Fonts/Microsoft/SimHei.ttf",
+ }
+ }
+
+ // 过滤出实际存在的字体文件
+ var existingFonts []string
+ for _, fontPath := range fontPaths {
+ if _, err := os.Stat(fontPath); err == nil {
+ existingFonts = append(existingFonts, fontPath)
+ }
+ }
+
+ return existingFonts
+}
+
+// findLogo 查找logo文件
+func (g *PDFGenerator) findLogo() {
+ // 获取当前文件所在目录
+ _, filename, _, _ := runtime.Caller(0)
+ baseDir := filepath.Dir(filename)
+
+ logoPaths := []string{
+ filepath.Join(baseDir, "天远数据.png"), // 相对当前文件
+ "天远数据.png", // 当前目录
+ filepath.Join(baseDir, "..", "天远数据.png"), // 上一级目录
+ filepath.Join(baseDir, "..", "..", "天远数据.png"), // 上两级目录
+ }
+
+ for _, logoPath := range logoPaths {
+ if _, err := os.Stat(logoPath); err == nil {
+ absPath, err := filepath.Abs(logoPath)
+ if err == nil {
+ g.logoPath = absPath
+ g.logger.Info("找到logo文件", zap.String("logo_path", absPath))
+ return
+ }
+ }
+ }
+
+ g.logger.Warn("未找到logo文件", zap.Strings("尝试的路径", logoPaths))
+}
+
+// GenerateProductPDF 为产品生成PDF文档(接受响应类型,内部转换)
+func (g *PDFGenerator) GenerateProductPDF(ctx context.Context, productID string, productName, productCode, description, content string, price float64, doc *entities.ProductDocumentation) ([]byte, error) {
+ // 构建临时的 Product entity(仅用于PDF生成)
+ product := &entities.Product{
+ ID: productID,
+ Name: productName,
+ Code: productCode,
+ Description: description,
+ Content: content,
+ }
+
+ // 如果有价格信息,设置价格
+ if price > 0 {
+ product.Price = decimal.NewFromFloat(price)
+ }
+
+ return g.generatePDF(product, doc)
+}
+
+// GenerateProductPDFFromEntity 从entity类型生成PDF(推荐使用)
+func (g *PDFGenerator) GenerateProductPDFFromEntity(ctx context.Context, product *entities.Product, doc *entities.ProductDocumentation) ([]byte, error) {
+ return g.generatePDF(product, doc)
+}
+
+// generatePDF 内部PDF生成方法
+// 现在使用重构后的模块化组件
+func (g *PDFGenerator) generatePDF(product *entities.Product, doc *entities.ProductDocumentation) (result []byte, err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ g.logger.Error("PDF生成过程中发生panic",
+ zap.String("product_id", product.ID),
+ zap.String("product_name", product.Name),
+ zap.Any("panic_value", r),
+ )
+ // 将panic转换为error,而不是重新抛出
+ if e, ok := r.(error); ok {
+ err = fmt.Errorf("PDF生成panic: %w", e)
+ } else {
+ err = fmt.Errorf("PDF生成panic: %v", r)
+ }
+ result = nil
+ }
+ }()
+
+ g.logger.Info("开始生成PDF(使用重构后的模块化组件)",
+ zap.String("product_id", product.ID),
+ zap.String("product_name", product.Name),
+ zap.Bool("has_doc", doc != nil),
+ )
+
+ // 使用重构后的生成器
+ refactoredGen := NewPDFGeneratorRefactored(g.logger)
+ return refactoredGen.GenerateProductPDFFromEntity(context.Background(), product, doc)
+}
+
+// addFirstPage 添加第一页(封面页 - 产品功能简述)
+func (g *PDFGenerator) addFirstPage(pdf *gofpdf.Fpdf, product *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
+ pdf.AddPage()
+
+ // 添加页眉(logo和文字)
+ g.addHeader(pdf, chineseFontAvailable)
+
+ // 添加水印
+ g.addWatermark(pdf, chineseFontAvailable)
+
+ // 封面页布局 - 居中显示
+ pageWidth, pageHeight := pdf.GetPageSize()
+
+ // 标题区域(页面中上部)
+ pdf.SetY(80)
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "B", 32)
+ } else {
+ pdf.SetFont("Arial", "B", 32)
+ }
+ _, lineHt := pdf.GetFontSize()
+
+ // 清理产品名称中的无效字符
+ cleanName := g.cleanText(product.Name)
+ pdf.CellFormat(0, lineHt*1.5, cleanName, "", 1, "C", false, 0, "")
+
+ // 添加"接口文档"副标题
+ pdf.Ln(10)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 18)
+ } else {
+ pdf.SetFont("Arial", "", 18)
+ }
+ _, lineHt = pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, "接口文档", "", 1, "C", false, 0, "")
+
+ // 分隔线
+ pdf.Ln(20)
+ pdf.SetLineWidth(0.5)
+ pdf.Line(pageWidth*0.2, pdf.GetY(), pageWidth*0.8, pdf.GetY())
+
+ // 产品编码(居中)
+ pdf.Ln(30)
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 14)
+ } else {
+ pdf.SetFont("Arial", "", 14)
+ }
+ _, lineHt = pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, fmt.Sprintf("产品编码:%s", product.Code), "", 1, "C", false, 0, "")
+
+ // 产品描述(居中显示,段落格式)
+ if product.Description != "" {
+ pdf.Ln(25)
+ desc := g.stripHTML(product.Description)
+ desc = g.cleanText(desc)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 14)
+ } else {
+ pdf.SetFont("Arial", "", 14)
+ }
+ _, lineHt = pdf.GetFontSize()
+ // 居中对齐的MultiCell(通过计算宽度实现)
+ descWidth := pageWidth * 0.7
+ descLines := pdf.SplitText(desc, descWidth)
+ currentX := (pageWidth - descWidth) / 2
+ for _, line := range descLines {
+ pdf.SetX(currentX)
+ pdf.CellFormat(descWidth, lineHt*1.5, line, "", 1, "C", false, 0, "")
+ }
+ }
+
+ // 产品详情(如果存在)
+ if product.Content != "" {
+ pdf.Ln(20)
+ content := g.stripHTML(product.Content)
+ content = g.cleanText(content)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 12)
+ } else {
+ pdf.SetFont("Arial", "", 12)
+ }
+ _, lineHt = pdf.GetFontSize()
+ contentWidth := pageWidth * 0.7
+ contentLines := pdf.SplitText(content, contentWidth)
+ currentX := (pageWidth - contentWidth) / 2
+ for _, line := range contentLines {
+ pdf.SetX(currentX)
+ pdf.CellFormat(contentWidth, lineHt*1.4, line, "", 1, "C", false, 0, "")
+ }
+ }
+
+ // 底部信息(价格等)
+ if !product.Price.IsZero() {
+ pdf.SetY(pageHeight - 60)
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 12)
+ } else {
+ pdf.SetFont("Arial", "", 12)
+ }
+ _, lineHt = pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, fmt.Sprintf("价格:%s 元", product.Price.String()), "", 1, "C", false, 0, "")
+ }
+}
+
+// addDocumentationPages 添加接口文档页面
+func (g *PDFGenerator) addDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
+ // 创建自定义的AddPage函数,确保每页都有水印
+ addPageWithWatermark := func() {
+ pdf.AddPage()
+ g.addHeader(pdf, chineseFontAvailable)
+ g.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印
+ }
+
+ addPageWithWatermark()
+
+ pdf.SetY(45)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "B", 18)
+ } else {
+ pdf.SetFont("Arial", "B", 18)
+ }
+ _, lineHt := pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "")
+
+ // 请求URL
+ pdf.Ln(8)
+ pdf.SetTextColor(0, 0, 0) // 确保深黑色
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "B", 12)
+ } else {
+ pdf.SetFont("Arial", "B", 12)
+ }
+ pdf.CellFormat(0, lineHt, "请求URL:", "", 1, "L", false, 0, "")
+ // URL也需要使用中文字体(可能包含中文字符),但用Courier字体保持等宽效果
+ // 先清理URL中的乱码
+ cleanURL := g.cleanText(doc.RequestURL)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 10) // 使用中文字体
+ } else {
+ pdf.SetFont("Courier", "", 10)
+ }
+ pdf.SetTextColor(0, 0, 0)
+ pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false)
+
+ // 请求方法
+ pdf.Ln(5)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "B", 12)
+ } else {
+ pdf.SetFont("Arial", "B", 12)
+ }
+ pdf.CellFormat(0, lineHt, fmt.Sprintf("请求方法:%s", doc.RequestMethod), "", 1, "L", false, 0, "")
+
+ // 基本信息
+ if doc.BasicInfo != "" {
+ pdf.Ln(8)
+ g.addSection(pdf, "基本信息", doc.BasicInfo, chineseFontAvailable)
+ }
+
+ // 请求参数
+ if doc.RequestParams != "" {
+ pdf.Ln(8)
+ // 显示标题
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "B", 14)
+ } else {
+ pdf.SetFont("Arial", "B", 14)
+ }
+ _, lineHt = pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, "请求参数:", "", 1, "L", false, 0, "")
+
+ // 处理请求参数:直接解析所有表格,确保表格能够正确渲染
+ g.processRequestParams(pdf, doc.RequestParams, chineseFontAvailable, lineHt)
+
+ // 生成JSON示例
+ if jsonExample := g.generateJSONExample(doc.RequestParams); jsonExample != "" {
+ pdf.Ln(5)
+ pdf.SetTextColor(0, 0, 0) // 确保深黑色
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "B", 14)
+ } else {
+ pdf.SetFont("Arial", "B", 14)
+ }
+ pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "")
+ // JSON中可能包含中文值,必须使用中文字体
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 9) // 使用中文字体显示JSON(支持中文)
+ } else {
+ pdf.SetFont("Courier", "", 9)
+ }
+ pdf.SetTextColor(0, 0, 0)
+ pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false)
+ }
+ }
+
+ // 响应示例
+ if doc.ResponseExample != "" {
+ pdf.Ln(8)
+ pdf.SetTextColor(0, 0, 0) // 确保深黑色
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "B", 14)
+ } else {
+ pdf.SetFont("Arial", "B", 14)
+ }
+ _, lineHt = pdf.GetFontSize()
+ pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "")
+
+ // 处理响应示例:不按markdown标题分级,直接解析所有表格
+ // 确保所有数据字段都显示在表格中
+ g.processResponseExample(pdf, doc.ResponseExample, chineseFontAvailable, lineHt)
+ }
+
+ // 返回字段
+ if doc.ResponseFields != "" {
+ pdf.Ln(8)
+ // 先将数据格式化为标准的markdown表格格式
+ formattedFields := g.formatContentAsMarkdownTable(doc.ResponseFields)
+ g.addSection(pdf, "返回字段", formattedFields, chineseFontAvailable)
+ }
+
+ // 错误代码
+ if doc.ErrorCodes != "" {
+ pdf.Ln(8)
+ g.addSection(pdf, "错误代码", doc.ErrorCodes, chineseFontAvailable)
+ }
+}
+
+// addSection 添加章节
+func (g *PDFGenerator) addSection(pdf *gofpdf.Fpdf, title, content string, chineseFontAvailable bool) {
+ _, lineHt := pdf.GetFontSize()
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "B", 14)
+ } else {
+ pdf.SetFont("Arial", "B", 14)
+ }
+ pdf.CellFormat(0, lineHt, title+":", "", 1, "L", false, 0, "")
+
+ // 先将内容格式化为标准的markdown表格格式(如果还不是)
+ content = g.formatContentAsMarkdownTable(content)
+
+ // 先尝试提取JSON(如果是代码块格式)
+ if jsonContent := g.extractJSON(content); jsonContent != "" {
+ // 格式化JSON
+ formattedJSON, err := g.formatJSON(jsonContent)
+ if err == nil {
+ jsonContent = formattedJSON
+ }
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 9)
+ } else {
+ pdf.SetFont("Courier", "", 9)
+ }
+ pdf.MultiCell(0, lineHt*1.2, jsonContent, "", "L", false)
+ } else {
+ // 按#号标题分割内容,每个标题下的内容单独处理
+ sections := g.splitByMarkdownHeaders(content)
+ if len(sections) > 0 {
+ // 如果有多个章节,逐个处理
+ for i, section := range sections {
+ if i > 0 {
+ pdf.Ln(5) // 章节之间的间距
+ }
+ // 如果有标题,先显示标题
+ if section.Title != "" {
+ titleLevel := section.Level
+ fontSize := 14.0 - float64(titleLevel-2)*2 // ## 是14, ### 是12, #### 是10
+ if fontSize < 10 {
+ fontSize = 10
+ }
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "B", fontSize)
+ } else {
+ pdf.SetFont("Arial", "B", fontSize)
+ }
+ pdf.SetTextColor(0, 0, 0)
+ // 清理标题中的#号
+ cleanTitle := strings.TrimSpace(strings.TrimLeft(section.Title, "#"))
+ pdf.CellFormat(0, lineHt*1.2, cleanTitle, "", 1, "L", false, 0, "")
+ pdf.Ln(3)
+ }
+ // 处理该章节的内容(可能是表格或文本)
+ g.processSectionContent(pdf, section.Content, chineseFontAvailable, lineHt)
+ }
+ } else {
+ // 如果没有标题分割,直接处理整个内容
+ g.processSectionContent(pdf, content, chineseFontAvailable, lineHt)
+ }
+ }
+}
+
+// MarkdownSection 已在 markdown_processor.go 中定义
+
+// splitByMarkdownHeaders 按markdown标题分割内容
+func (g *PDFGenerator) splitByMarkdownHeaders(content string) []MarkdownSection {
+ lines := strings.Split(content, "\n")
+ var sections []MarkdownSection
+ var currentSection MarkdownSection
+ var currentContent []string
+
+ // 标题正则:匹配 #, ##, ###, #### 等
+ headerRegex := regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
+
+ for _, line := range lines {
+ trimmedLine := strings.TrimSpace(line)
+
+ // 检查是否是标题行
+ if matches := headerRegex.FindStringSubmatch(trimmedLine); matches != nil {
+ // 如果之前有内容,先保存之前的章节
+ if currentSection.Title != "" || len(currentContent) > 0 {
+ if currentSection.Title != "" {
+ currentSection.Content = strings.Join(currentContent, "\n")
+ sections = append(sections, currentSection)
+ }
+ }
+
+ // 开始新章节
+ level := len(matches[1]) // #号的数量
+ currentSection = MarkdownSection{
+ Title: trimmedLine,
+ Level: level,
+ Content: "",
+ }
+ currentContent = []string{}
+ } else {
+ // 普通内容行,添加到当前章节
+ currentContent = append(currentContent, line)
+ }
+ }
+
+ // 保存最后一个章节
+ if currentSection.Title != "" || len(currentContent) > 0 {
+ if currentSection.Title != "" {
+ currentSection.Content = strings.Join(currentContent, "\n")
+ sections = append(sections, currentSection)
+ } else if len(currentContent) > 0 {
+ // 如果没有标题,但开头有内容,作为第一个章节
+ sections = append(sections, MarkdownSection{
+ Title: "",
+ Level: 0,
+ Content: strings.Join(currentContent, "\n"),
+ })
+ }
+ }
+
+ return sections
+}
+
+// formatContentAsMarkdownTable 将数据库中的数据格式化为标准的markdown表格格式
+// 注意:数据库中的数据通常不是JSON格式(除了代码块中的示例),主要是文本或markdown格式
+func (g *PDFGenerator) formatContentAsMarkdownTable(content string) string {
+ if strings.TrimSpace(content) == "" {
+ return content
+ }
+
+ // 如果内容已经是markdown表格格式(包含|符号),直接返回
+ if strings.Contains(content, "|") {
+ // 检查是否已经是有效的markdown表格
+ lines := strings.Split(content, "\n")
+ hasTableFormat := false
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ // 跳过代码块中的内容
+ if strings.HasPrefix(trimmed, "```") {
+ continue
+ }
+ if strings.Contains(trimmed, "|") && !strings.HasPrefix(trimmed, "#") {
+ hasTableFormat = true
+ break
+ }
+ }
+ if hasTableFormat {
+ return content
+ }
+ }
+
+ // 提取代码块(保留代码块不变)
+ codeBlocks := g.extractCodeBlocks(content)
+
+ // 移除代码块,只处理非代码块部分
+ contentWithoutCodeBlocks := g.removeCodeBlocks(content)
+
+ // 如果移除代码块后内容为空,说明只有代码块,直接返回原始内容
+ if strings.TrimSpace(contentWithoutCodeBlocks) == "" {
+ return content
+ }
+
+ // 尝试解析非代码块部分为JSON数组(仅当内容看起来像JSON时)
+ trimmedContent := strings.TrimSpace(contentWithoutCodeBlocks)
+
+ // 检查是否看起来像JSON(以[或{开头)
+ if strings.HasPrefix(trimmedContent, "[") || strings.HasPrefix(trimmedContent, "{") {
+ // 尝试解析为JSON数组
+ var requestParams []map[string]interface{}
+ if err := json.Unmarshal([]byte(trimmedContent), &requestParams); err == nil && len(requestParams) > 0 {
+ // 成功解析为JSON数组,转换为markdown表格
+ tableContent := g.jsonArrayToMarkdownTable(requestParams)
+ // 如果有代码块,在表格后添加代码块
+ if len(codeBlocks) > 0 {
+ return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n")
+ }
+ return tableContent
+ }
+
+ // 尝试解析为单个JSON对象
+ var singleObj map[string]interface{}
+ if err := json.Unmarshal([]byte(trimmedContent), &singleObj); err == nil {
+ // 检查是否是包含数组字段的对象
+ if params, ok := singleObj["params"].([]interface{}); ok {
+ // 转换为map数组
+ paramMaps := make([]map[string]interface{}, 0, len(params))
+ for _, p := range params {
+ if pm, ok := p.(map[string]interface{}); ok {
+ paramMaps = append(paramMaps, pm)
+ }
+ }
+ if len(paramMaps) > 0 {
+ tableContent := g.jsonArrayToMarkdownTable(paramMaps)
+ // 如果有代码块,在表格后添加代码块
+ if len(codeBlocks) > 0 {
+ return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n")
+ }
+ return tableContent
+ }
+ }
+ if fields, ok := singleObj["fields"].([]interface{}); ok {
+ // 转换为map数组
+ fieldMaps := make([]map[string]interface{}, 0, len(fields))
+ for _, f := range fields {
+ if fm, ok := f.(map[string]interface{}); ok {
+ fieldMaps = append(fieldMaps, fm)
+ }
+ }
+ if len(fieldMaps) > 0 {
+ tableContent := g.jsonArrayToMarkdownTable(fieldMaps)
+ // 如果有代码块,在表格后添加代码块
+ if len(codeBlocks) > 0 {
+ return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n")
+ }
+ return tableContent
+ }
+ }
+ }
+ }
+
+ // 如果无法解析为JSON,返回原始内容(保留代码块)
+ return content
+}
+
+// extractCodeBlocks 提取内容中的所有代码块
+func (g *PDFGenerator) extractCodeBlocks(content string) []string {
+ var codeBlocks []string
+ lines := strings.Split(content, "\n")
+ inCodeBlock := false
+ var currentBlock []string
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ // 检查是否是代码块开始
+ if strings.HasPrefix(trimmed, "```") {
+ if inCodeBlock {
+ // 代码块结束
+ currentBlock = append(currentBlock, line)
+ codeBlocks = append(codeBlocks, strings.Join(currentBlock, "\n"))
+ currentBlock = []string{}
+ inCodeBlock = false
+ } else {
+ // 代码块开始
+ inCodeBlock = true
+ currentBlock = []string{line}
+ }
+ } else if inCodeBlock {
+ // 在代码块中
+ currentBlock = append(currentBlock, line)
+ }
+ }
+
+ // 如果代码块没有正确关闭,也添加进去
+ if inCodeBlock && len(currentBlock) > 0 {
+ codeBlocks = append(codeBlocks, strings.Join(currentBlock, "\n"))
+ }
+
+ return codeBlocks
+}
+
+// removeCodeBlocks 移除内容中的所有代码块
+func (g *PDFGenerator) removeCodeBlocks(content string) string {
+ lines := strings.Split(content, "\n")
+ var result []string
+ inCodeBlock := false
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ // 检查是否是代码块开始或结束
+ if strings.HasPrefix(trimmed, "```") {
+ inCodeBlock = !inCodeBlock
+ continue // 跳过代码块的标记行
+ }
+
+ // 如果不在代码块中,保留这一行
+ if !inCodeBlock {
+ result = append(result, line)
+ }
+ }
+
+ return strings.Join(result, "\n")
+}
+
+// jsonArrayToMarkdownTable 将JSON数组转换为标准的markdown表格
+func (g *PDFGenerator) jsonArrayToMarkdownTable(data []map[string]interface{}) string {
+ if len(data) == 0 {
+ return ""
+ }
+
+ var result strings.Builder
+
+ // 收集所有可能的列名(保持原始顺序)
+ // 使用map记录是否已添加,使用slice保持顺序
+ columnSet := make(map[string]bool)
+ columns := make([]string, 0)
+
+ // 遍历所有数据行,按第一次出现的顺序收集列名
+ for _, row := range data {
+ for key := range row {
+ if !columnSet[key] {
+ columns = append(columns, key)
+ columnSet[key] = true
+ }
+ }
+ }
+
+ if len(columns) == 0 {
+ return ""
+ }
+
+ // 构建表头(直接使用原始列名,不做映射)
+ result.WriteString("|")
+ for _, col := range columns {
+ result.WriteString(" ")
+ result.WriteString(col) // 直接使用原始列名
+ result.WriteString(" |")
+ }
+ result.WriteString("\n")
+
+ // 构建分隔行
+ result.WriteString("|")
+ for range columns {
+ result.WriteString(" --- |")
+ }
+ result.WriteString("\n")
+
+ // 构建数据行
+ for _, row := range data {
+ result.WriteString("|")
+ for _, col := range columns {
+ result.WriteString(" ")
+ value := g.formatCellValue(row[col])
+ result.WriteString(value)
+ result.WriteString(" |")
+ }
+ result.WriteString("\n")
+ }
+
+ return result.String()
+}
+
+// formatColumnName 格式化列名(直接返回原始列名,不做映射)
+// 保持数据库原始数据的列名,不进行转换
+func (g *PDFGenerator) formatColumnName(name string) string {
+ // 直接返回原始列名,保持数据库数据的原始格式
+ return name
+}
+
+// formatCellValue 格式化单元格值
+func (g *PDFGenerator) formatCellValue(value interface{}) string {
+ if value == nil {
+ return ""
+ }
+
+ switch v := value.(type) {
+ case string:
+ // 清理字符串,移除换行符和多余空格
+ v = strings.ReplaceAll(v, "\n", " ")
+ v = strings.ReplaceAll(v, "\r", " ")
+ v = strings.TrimSpace(v)
+ // 转义markdown特殊字符
+ v = strings.ReplaceAll(v, "|", "\\|")
+ return v
+ case bool:
+ if v {
+ return "是"
+ }
+ return "否"
+ case float64:
+ // 如果是整数,不显示小数点
+ if v == float64(int64(v)) {
+ return fmt.Sprintf("%.0f", v)
+ }
+ return fmt.Sprintf("%g", v)
+ case int, int8, int16, int32, int64:
+ return fmt.Sprintf("%d", v)
+ case uint, uint8, uint16, uint32, uint64:
+ return fmt.Sprintf("%d", v)
+ default:
+ // 对于其他类型,转换为字符串
+ str := fmt.Sprintf("%v", v)
+ str = strings.ReplaceAll(str, "\n", " ")
+ str = strings.ReplaceAll(str, "\r", " ")
+ str = strings.ReplaceAll(str, "|", "\\|")
+ return strings.TrimSpace(str)
+ }
+}
+
+// processRequestParams 处理请求参数:直接解析所有表格,确保表格能够正确渲染
+func (g *PDFGenerator) processRequestParams(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) {
+ // 先将数据格式化为标准的markdown表格格式
+ processedContent := g.formatContentAsMarkdownTable(content)
+
+ // 解析并显示所有表格(不按标题分组)
+ // 将内容按表格分割,找到所有表格块
+ allTables := g.extractAllTables(processedContent)
+
+ if len(allTables) > 0 {
+ // 有表格,逐个渲染
+ for i, tableBlock := range allTables {
+ if i > 0 {
+ pdf.Ln(5) // 表格之间的间距
+ }
+
+ // 渲染表格前的说明文字(包括标题)
+ if tableBlock.BeforeText != "" {
+ beforeText := tableBlock.BeforeText
+ // 处理标题和文本
+ g.renderTextWithTitles(pdf, beforeText, chineseFontAvailable, lineHt)
+ pdf.Ln(3)
+ }
+
+ // 渲染表格
+ if len(tableBlock.TableData) > 0 && g.isValidTable(tableBlock.TableData) {
+ g.addTable(pdf, tableBlock.TableData, chineseFontAvailable)
+ }
+
+ // 渲染表格后的说明文字
+ if tableBlock.AfterText != "" {
+ afterText := g.stripHTML(tableBlock.AfterText)
+ afterText = g.cleanText(afterText)
+ if strings.TrimSpace(afterText) != "" {
+ pdf.Ln(3)
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 10)
+ } else {
+ pdf.SetFont("Arial", "", 10)
+ }
+ pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
+ }
+ }
+ }
+ } else {
+ // 没有表格,显示为文本
+ text := g.stripHTML(processedContent)
+ text = g.cleanText(text)
+ if strings.TrimSpace(text) != "" {
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 10)
+ } else {
+ pdf.SetFont("Arial", "", 10)
+ }
+ pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
+ }
+ }
+}
+
+// processResponseExample 处理响应示例:不按markdown标题分级,直接解析所有表格,但保留标题显示
+func (g *PDFGenerator) processResponseExample(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) {
+ // 先将数据格式化为标准的markdown表格格式
+ processedContent := g.formatContentAsMarkdownTable(content)
+
+ // 尝试提取JSON内容(如果存在代码块)
+ jsonContent := g.extractJSON(processedContent)
+ if jsonContent != "" {
+ pdf.SetTextColor(0, 0, 0)
+ formattedJSON, err := g.formatJSON(jsonContent)
+ if err == nil {
+ jsonContent = formattedJSON
+ }
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 9)
+ } else {
+ pdf.SetFont("Courier", "", 9)
+ }
+ pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false)
+ pdf.Ln(5)
+ }
+
+ // 解析并显示所有表格(不按标题分组)
+ // 将内容按表格分割,找到所有表格块
+ allTables := g.extractAllTables(processedContent)
+
+ if len(allTables) > 0 {
+ // 有表格,逐个渲染
+ for i, tableBlock := range allTables {
+ if i > 0 {
+ pdf.Ln(5) // 表格之间的间距
+ }
+
+ // 渲染表格前的说明文字(包括标题)
+ if tableBlock.BeforeText != "" {
+ beforeText := tableBlock.BeforeText
+ // 处理标题和文本
+ g.renderTextWithTitles(pdf, beforeText, chineseFontAvailable, lineHt)
+ pdf.Ln(3)
+ }
+
+ // 渲染表格
+ if len(tableBlock.TableData) > 0 && g.isValidTable(tableBlock.TableData) {
+ g.addTable(pdf, tableBlock.TableData, chineseFontAvailable)
+ }
+
+ // 渲染表格后的说明文字
+ if tableBlock.AfterText != "" {
+ afterText := g.stripHTML(tableBlock.AfterText)
+ afterText = g.cleanText(afterText)
+ if strings.TrimSpace(afterText) != "" {
+ pdf.Ln(3)
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 10)
+ } else {
+ pdf.SetFont("Arial", "", 10)
+ }
+ pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
+ }
+ }
+ }
+ } else {
+ // 没有表格,显示为文本
+ text := g.stripHTML(processedContent)
+ text = g.cleanText(text)
+ if strings.TrimSpace(text) != "" {
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 10)
+ } else {
+ pdf.SetFont("Arial", "", 10)
+ }
+ pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
+ }
+ }
+}
+
+// TableBlock 已在 table_parser.go 中定义
+
+// extractAllTables 从内容中提取所有表格块(保留标题作为BeforeText的一部分)
+func (g *PDFGenerator) extractAllTables(content string) []TableBlock {
+ var blocks []TableBlock
+ lines := strings.Split(content, "\n")
+
+ var currentTableLines []string
+ var beforeTableLines []string
+ inTable := false
+ lastTableEnd := -1
+
+ for i, line := range lines {
+ trimmedLine := strings.TrimSpace(line)
+
+ // 保留标题行,不跳过(标题会作为BeforeText的一部分)
+
+ // 检查是否是表格行(包含|符号,且不是分隔行)
+ isSeparator := false
+ if strings.Contains(trimmedLine, "|") && strings.Contains(trimmedLine, "-") {
+ // 检查是否是分隔行(只包含|、-、:、空格)
+ isSeparator = true
+ for _, r := range trimmedLine {
+ if r != '|' && r != '-' && r != ':' && r != ' ' {
+ isSeparator = false
+ break
+ }
+ }
+ }
+
+ isTableLine := strings.Contains(trimmedLine, "|") && !isSeparator
+
+ if isTableLine {
+ if !inTable {
+ // 开始新表格,保存之前的文本(包括标题)
+ beforeTableLines = []string{}
+ if lastTableEnd >= 0 {
+ beforeTableLines = lines[lastTableEnd+1 : i]
+ } else {
+ beforeTableLines = lines[0:i]
+ }
+ inTable = true
+ currentTableLines = []string{}
+ }
+ currentTableLines = append(currentTableLines, line)
+ } else {
+ if inTable {
+ // 表格可能结束了(遇到空行或非表格内容)
+ // 检查是否是连续的空行(可能是表格真的结束了)
+ if trimmedLine == "" {
+ // 空行,继续收集(可能是表格内的空行)
+ currentTableLines = append(currentTableLines, line)
+ } else {
+ // 非空行,表格结束
+ // 解析并保存表格
+ tableContent := strings.Join(currentTableLines, "\n")
+ tableData := g.parseMarkdownTable(tableContent)
+ if len(tableData) > 0 && g.isValidTable(tableData) {
+ block := TableBlock{
+ BeforeText: strings.Join(beforeTableLines, "\n"),
+ TableData: tableData,
+ AfterText: "",
+ }
+ blocks = append(blocks, block)
+ lastTableEnd = i - 1
+ }
+ currentTableLines = []string{}
+ beforeTableLines = []string{}
+ inTable = false
+ }
+ } else {
+ // 不在表格中,这些行(包括标题)会被收集到下一个表格的BeforeText中
+ // 不需要特殊处理,它们会在开始新表格时被收集
+ }
+ }
+ }
+
+ // 处理最后一个表格(如果还在表格中)
+ if inTable && len(currentTableLines) > 0 {
+ tableContent := strings.Join(currentTableLines, "\n")
+ tableData := g.parseMarkdownTable(tableContent)
+ if len(tableData) > 0 && g.isValidTable(tableData) {
+ block := TableBlock{
+ BeforeText: strings.Join(beforeTableLines, "\n"),
+ TableData: tableData,
+ AfterText: "",
+ }
+ blocks = append(blocks, block)
+ }
+ }
+
+ return blocks
+}
+
+// renderTextWithTitles 渲染包含markdown标题的文本
+func (g *PDFGenerator) renderTextWithTitles(pdf *gofpdf.Fpdf, text string, chineseFontAvailable bool, lineHt float64) {
+ lines := strings.Split(text, "\n")
+
+ for _, line := range lines {
+ trimmedLine := strings.TrimSpace(line)
+
+ // 检查是否是标题行
+ if strings.HasPrefix(trimmedLine, "#") {
+ // 计算标题级别
+ level := 0
+ for _, r := range trimmedLine {
+ if r == '#' {
+ level++
+ } else {
+ break
+ }
+ }
+
+ // 提取标题文本(移除#号)
+ titleText := strings.TrimSpace(trimmedLine[level:])
+ if titleText == "" {
+ continue
+ }
+
+ // 根据级别设置字体大小
+ fontSize := 14.0 - float64(level-2)*2
+ if fontSize < 10 {
+ fontSize = 10
+ }
+ if fontSize > 16 {
+ fontSize = 16
+ }
+
+ // 渲染标题
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "B", fontSize)
+ } else {
+ pdf.SetFont("Arial", "B", fontSize)
+ }
+ _, titleLineHt := pdf.GetFontSize()
+ pdf.CellFormat(0, titleLineHt*1.2, titleText, "", 1, "L", false, 0, "")
+ pdf.Ln(2)
+ } else if strings.TrimSpace(line) != "" {
+ // 普通文本行(只去除HTML标签,保留markdown格式)
+ cleanText := g.stripHTML(line)
+ cleanText = g.cleanTextPreservingMarkdown(cleanText)
+ if strings.TrimSpace(cleanText) != "" {
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 10)
+ } else {
+ pdf.SetFont("Arial", "", 10)
+ }
+ pdf.MultiCell(0, lineHt*1.3, cleanText, "", "L", false)
+ }
+ } else {
+ // 空行,添加间距
+ pdf.Ln(2)
+ }
+ }
+}
+
+// processSectionContent 处理单个章节的内容(解析表格或显示文本)
+func (g *PDFGenerator) processSectionContent(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) {
+ // 尝试解析markdown表格
+ tableData := g.parseMarkdownTable(content)
+
+ // 记录解析结果用于调试
+ contentPreview := content
+ if len(contentPreview) > 100 {
+ contentPreview = contentPreview[:100] + "..."
+ }
+ g.logger.Info("解析表格结果",
+ zap.Int("table_rows", len(tableData)),
+ zap.Bool("is_valid", g.isValidTable(tableData)),
+ zap.String("content_preview", contentPreview))
+
+ // 检查内容是否包含表格标记(|符号)
+ hasTableMarkers := strings.Contains(content, "|")
+
+ // 如果解析出了表格数据(即使验证失败),或者内容包含表格标记,都尝试渲染表格
+ // 放宽条件:支持只有表头的表格(单行表格)
+ if len(tableData) >= 1 && hasTableMarkers {
+ // 如果表格数据不够完整,但包含表格标记,尝试强制解析
+ if !g.isValidTable(tableData) && hasTableMarkers && len(tableData) < 2 {
+ g.logger.Warn("表格验证失败但包含表格标记,尝试重新解析", zap.Int("rows", len(tableData)))
+ // 可以在这里添加更宽松的解析逻辑
+ }
+
+ // 如果表格有效,或者至少有表头,都尝试渲染
+ if g.isValidTable(tableData) {
+ // 如果是有效的表格,先检查表格前后是否有说明文字
+ // 提取表格前后的文本(用于显示说明)
+ lines := strings.Split(content, "\n")
+ var beforeTable []string
+ var afterTable []string
+ inTable := false
+ tableStartLine := -1
+ tableEndLine := -1
+
+ // 找到表格的起始和结束行
+ usePipeDelimiter := false
+ for _, line := range lines {
+ if strings.Contains(strings.TrimSpace(line), "|") {
+ usePipeDelimiter = true
+ break
+ }
+ }
+
+ for i, line := range lines {
+ trimmedLine := strings.TrimSpace(line)
+ if usePipeDelimiter && strings.Contains(trimmedLine, "|") {
+ if !inTable {
+ tableStartLine = i
+ inTable = true
+ }
+ tableEndLine = i
+ } else if inTable && usePipeDelimiter && !strings.Contains(trimmedLine, "|") {
+ // 表格可能结束了
+ if strings.HasPrefix(trimmedLine, "```") {
+ tableEndLine = i - 1
+ break
+ }
+ }
+ }
+
+ // 提取表格前的文本
+ if tableStartLine > 0 {
+ beforeTable = lines[0:tableStartLine]
+ }
+ // 提取表格后的文本
+ if tableEndLine >= 0 && tableEndLine < len(lines)-1 {
+ afterTable = lines[tableEndLine+1:]
+ }
+
+ // 显示表格前的说明文字
+ if len(beforeTable) > 0 {
+ beforeText := strings.Join(beforeTable, "\n")
+ beforeText = g.stripHTML(beforeText)
+ beforeText = g.cleanText(beforeText)
+ if strings.TrimSpace(beforeText) != "" {
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 10)
+ } else {
+ pdf.SetFont("Arial", "", 10)
+ }
+ pdf.MultiCell(0, lineHt*1.3, beforeText, "", "L", false)
+ pdf.Ln(3)
+ }
+ }
+
+ // 渲染表格
+ g.addTable(pdf, tableData, chineseFontAvailable)
+
+ // 显示表格后的说明文字
+ if len(afterTable) > 0 {
+ afterText := strings.Join(afterTable, "\n")
+ afterText = g.stripHTML(afterText)
+ afterText = g.cleanText(afterText)
+ if strings.TrimSpace(afterText) != "" {
+ pdf.Ln(3)
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 10)
+ } else {
+ pdf.SetFont("Arial", "", 10)
+ }
+ pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
+ }
+ }
+ }
+ } else {
+ // 如果不是有效表格,显示为文本(完整显示markdown内容)
+ pdf.SetTextColor(0, 0, 0)
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 10)
+ } else {
+ pdf.SetFont("Arial", "", 10)
+ }
+ text := g.stripHTML(content)
+ text = g.cleanText(text) // 清理无效字符,保留中文
+ // 如果文本不为空,显示它
+ if strings.TrimSpace(text) != "" {
+ pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
+ }
+ }
+}
+
+// isValidTable 验证表格是否有效
+func (g *PDFGenerator) isValidTable(tableData [][]string) bool {
+ // 至少需要表头(放宽条件,支持只有表头的情况)
+ if len(tableData) < 1 {
+ return false
+ }
+
+ // 表头必须至少1列(支持单列表格)
+ header := tableData[0]
+ if len(header) < 1 {
+ return false
+ }
+
+ // 检查表头是否包含有效内容(不是全部为空)
+ hasValidHeader := false
+ for _, cell := range header {
+ if strings.TrimSpace(cell) != "" {
+ hasValidHeader = true
+ break
+ }
+ }
+ if !hasValidHeader {
+ return false
+ }
+
+ // 如果只有表头,也认为是有效表格
+ if len(tableData) == 1 {
+ return true
+ }
+
+ // 检查数据行是否包含有效内容
+ hasValidData := false
+ validRowCount := 0
+ for i := 1; i < len(tableData); i++ {
+ row := tableData[i]
+ // 检查这一行是否包含有效内容
+ rowHasContent := false
+ for _, cell := range row {
+ if strings.TrimSpace(cell) != "" {
+ rowHasContent = true
+ break
+ }
+ }
+ if rowHasContent {
+ hasValidData = true
+ validRowCount++
+ }
+ }
+
+ // 如果有数据行,至少需要一行有效数据
+ if len(tableData) > 1 && !hasValidData {
+ return false
+ }
+
+ // 如果有效行数过多(超过100行),可能是解析错误,不认为是有效表格
+ if validRowCount > 100 {
+ g.logger.Warn("表格行数过多,可能是解析错误", zap.Int("row_count", validRowCount))
+ return false
+ }
+
+ return true
+}
+
+// addTable 添加表格
+func (g *PDFGenerator) addTable(pdf *gofpdf.Fpdf, tableData [][]string, chineseFontAvailable bool) {
+ if len(tableData) == 0 {
+ return
+ }
+
+ // 再次验证表格有效性,避免渲染无效表格
+ if !g.isValidTable(tableData) {
+ g.logger.Warn("尝试渲染无效表格,跳过", zap.Int("rows", len(tableData)))
+ return
+ }
+
+ // 支持只有表头的表格(单行表格)
+ if len(tableData) == 1 {
+ g.logger.Info("渲染单行表格(只有表头)", zap.Int("cols", len(tableData[0])))
+ }
+
+ _, lineHt := pdf.GetFontSize()
+ pdf.SetFont("Arial", "", 9)
+
+ // 计算列宽(简单平均分配)
+ pageWidth, _ := pdf.GetPageSize()
+ pageWidth = pageWidth - 40 // 减去左右边距
+ numCols := len(tableData[0])
+ colWidth := pageWidth / float64(numCols)
+
+ // 限制列宽,避免过窄
+ if colWidth < 30 {
+ colWidth = 30
+ }
+
+ // 绘制表头
+ header := tableData[0]
+ pdf.SetFillColor(74, 144, 226) // 蓝色背景
+ pdf.SetTextColor(255, 255, 255) // 白色文字
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "B", 9)
+ } else {
+ pdf.SetFont("Arial", "B", 9)
+ }
+
+ // 清理表头文本(只清理无效字符,保留markdown格式)
+ for i, cell := range header {
+ header[i] = g.cleanTextPreservingMarkdown(cell)
+ }
+
+ for _, cell := range header {
+ pdf.CellFormat(colWidth, lineHt*1.5, cell, "1", 0, "C", true, 0, "")
+ }
+ pdf.Ln(-1)
+
+ // 绘制数据行
+ pdf.SetFillColor(245, 245, 220) // 米色背景
+ pdf.SetTextColor(0, 0, 0) // 深黑色文字,确保清晰
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 9) // 增大字体
+ } else {
+ pdf.SetFont("Arial", "", 9)
+ }
+ _, lineHt = pdf.GetFontSize()
+
+ for i := 1; i < len(tableData); i++ {
+ row := tableData[i]
+ fill := (i % 2) == 0 // 交替填充
+
+ // 计算这一行的起始Y坐标
+ startY := pdf.GetY()
+
+ // 设置字体以计算文本宽度和高度
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 9)
+ } else {
+ pdf.SetFont("Arial", "", 9)
+ }
+ _, cellLineHt := pdf.GetFontSize()
+
+ // 先遍历一次,计算每列需要的最大高度
+ maxCellHeight := cellLineHt * 1.5 // 最小高度
+ cellWidth := colWidth - 4 // 减去左右边距
+
+ for j, cell := range row {
+ if j >= numCols {
+ break
+ }
+ // 清理单元格文本(只清理无效字符,保留markdown格式)
+ cleanCell := g.cleanTextPreservingMarkdown(cell)
+
+ // 使用SplitText准确计算需要的行数
+ var lines []string
+ if chineseFontAvailable {
+ // 对于中文字体,使用SplitText
+ lines = pdf.SplitText(cleanCell, cellWidth)
+ } else {
+ // 对于Arial字体,如果包含中文可能失败,使用估算
+ charCount := len([]rune(cleanCell))
+ if charCount == 0 {
+ lines = []string{""}
+ } else {
+ // 中文字符宽度大约是英文字符的2倍
+ estimatedWidth := 0.0
+ for _, r := range cleanCell {
+ if r >= 0x4E00 && r <= 0x9FFF {
+ estimatedWidth += 6.0 // 中文字符宽度
+ } else {
+ estimatedWidth += 3.0 // 英文字符宽度
+ }
+ }
+ estimatedLines := math.Ceil(estimatedWidth / cellWidth)
+ if estimatedLines < 1 {
+ estimatedLines = 1
+ }
+ lines = make([]string, int(estimatedLines))
+ // 简单分割文本
+ charsPerLine := int(math.Ceil(float64(charCount) / estimatedLines))
+ for k := 0; k < int(estimatedLines); k++ {
+ start := k * charsPerLine
+ end := start + charsPerLine
+ if end > charCount {
+ end = charCount
+ }
+ if start < charCount {
+ runes := []rune(cleanCell)
+ if start < len(runes) {
+ if end > len(runes) {
+ end = len(runes)
+ }
+ lines[k] = string(runes[start:end])
+ }
+ }
+ }
+ }
+ }
+
+ // 计算单元格高度
+ numLines := float64(len(lines))
+ if numLines == 0 {
+ numLines = 1
+ }
+ cellHeight := numLines * cellLineHt * 1.5
+ if cellHeight < cellLineHt*1.5 {
+ cellHeight = cellLineHt * 1.5
+ }
+ if cellHeight > maxCellHeight {
+ maxCellHeight = cellHeight
+ }
+ }
+
+ // 绘制这一行的所有单元格(左边距是15mm)
+ currentX := 15.0
+ for j, cell := range row {
+ if j >= numCols {
+ break
+ }
+
+ // 绘制单元格边框和背景
+ if fill {
+ pdf.SetFillColor(250, 250, 235) // 稍深的米色
+ } else {
+ pdf.SetFillColor(255, 255, 255)
+ }
+ pdf.Rect(currentX, startY, colWidth, maxCellHeight, "FD")
+
+ // 绘制文本(使用MultiCell支持换行)
+ pdf.SetTextColor(0, 0, 0) // 确保深黑色
+ // 只清理无效字符,保留markdown格式
+ cleanCell := g.cleanTextPreservingMarkdown(cell)
+
+ // 设置到单元格内,留出边距(每个单元格都从同一行开始)
+ pdf.SetXY(currentX+2, startY+2)
+
+ // 使用MultiCell自动换行,左对齐
+ if chineseFontAvailable {
+ pdf.SetFont("ChineseFont", "", 9)
+ } else {
+ pdf.SetFont("Arial", "", 9)
+ }
+
+ // 使用MultiCell,会自动换行(使用统一的行高)
+ // 限制高度,避免超出单元格
+ pdf.MultiCell(colWidth-4, cellLineHt*1.5, cleanCell, "", "L", false)
+
+ // MultiCell后Y坐标已经改变,必须重置以便下一列从同一行开始
+ // 这是关键:确保所有列都从同一个startY开始
+ pdf.SetXY(currentX+colWidth, startY)
+
+ // 移动到下一列
+ currentX += colWidth
+ }
+
+ // 移动到下一行的起始位置(使用计算好的最大高度)
+ pdf.SetXY(15.0, startY+maxCellHeight)
+ }
+}
+
+// calculateCellHeight 计算单元格高度(考虑换行)
+func (g *PDFGenerator) calculateCellHeight(pdf *gofpdf.Fpdf, text string, width, lineHeight float64) float64 {
+ // 移除中文字符避免Arial字体处理时panic
+ // 只保留ASCII字符和常见符号
+ safeText := g.removeNonASCII(text)
+ if safeText == "" {
+ // 如果全部是中文,使用一个估算值
+ // 中文字符通常比英文字符宽,按每行30个字符估算
+ charCount := len([]rune(text))
+ estimatedLines := (charCount / 30) + 1
+ if estimatedLines < 1 {
+ estimatedLines = 1
+ }
+ return float64(estimatedLines) * lineHeight
+ }
+
+ // 安全地调用SplitText
+ defer func() {
+ if r := recover(); r != nil {
+ g.logger.Warn("SplitText失败,使用估算高度", zap.Any("error", r))
+ }
+ }()
+
+ lines := pdf.SplitText(safeText, width)
+ if len(lines) == 0 {
+ return lineHeight
+ }
+ return float64(len(lines)) * lineHeight
+}
+
+// removeNonASCII 移除非ASCII字符(保留ASCII字符和常见符号)
+func (g *PDFGenerator) removeNonASCII(text string) string {
+ var result strings.Builder
+ for _, r := range text {
+ // 保留ASCII字符(0-127)
+ if r < 128 {
+ result.WriteRune(r)
+ } else {
+ // 中文字符替换为空格或跳过
+ result.WriteRune(' ')
+ }
+ }
+ return result.String()
+}
+
+// parseMarkdownTable 解析Markdown表格(支持|分隔和空格分隔)
+func (g *PDFGenerator) parseMarkdownTable(text string) [][]string {
+ lines := strings.Split(text, "\n")
+ var table [][]string
+ var header []string
+ inTable := false
+ usePipeDelimiter := false
+
+ // 先检查是否使用 | 分隔符
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ if strings.Contains(line, "|") {
+ usePipeDelimiter = true
+ break
+ }
+ }
+
+ // 记录解析开始
+ g.logger.Info("开始解析markdown表格",
+ zap.Int("total_lines", len(lines)),
+ zap.Bool("use_pipe_delimiter", usePipeDelimiter))
+
+ nonTableLineCount := 0 // 连续非表格行计数(不包括空行)
+ maxNonTableLines := 10 // 最多允许10个连续非表格行(增加容忍度)
+
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+
+ // 检查是否是明确的结束标记
+ if strings.HasPrefix(line, "```") || strings.HasPrefix(line, "##") || strings.HasPrefix(line, "###") || strings.HasPrefix(line, "####") {
+ // 如果遇到代码块或新的标题,停止解析
+ if inTable {
+ break
+ }
+ continue
+ }
+
+ if line == "" {
+ // 空行不影响非表格行计数,继续
+ continue
+ }
+
+ var cells []string
+
+ if usePipeDelimiter {
+ // 使用 | 分隔符的表格
+ if !strings.Contains(line, "|") {
+ // 如果已经在表格中,遇到非表格行
+ if inTable {
+ nonTableLineCount++
+ // 如果连续非表格行过多,可能表格已结束
+ if nonTableLineCount > maxNonTableLines {
+ // 但先检查后面是否还有表格行
+ hasMoreTableRows := false
+ for j := len(lines) - 1; j > 0 && j > len(lines)-20; j-- {
+ if strings.Contains(strings.TrimSpace(lines[j]), "|") {
+ hasMoreTableRows = true
+ break
+ }
+ }
+ if !hasMoreTableRows {
+ break
+ }
+ // 如果后面还有表格行,继续解析
+ nonTableLineCount = 0
+ }
+ continue
+ }
+ // 如果还没开始表格,跳过非表格行
+ continue
+ }
+ // 重置非表格行计数(遇到表格行了)
+ nonTableLineCount = 0
+
+ // 跳过分隔行(markdown表格的分隔行,如 |---|---| 或 |----------|----------|)
+ // 检查是否是分隔行:只包含 |、-、:、空格,且至少包含一个-
+ trimmedLineForCheck := strings.TrimSpace(line)
+ isSeparator := false
+ if strings.Contains(trimmedLineForCheck, "-") {
+ isSeparator = true
+ for _, r := range trimmedLineForCheck {
+ if r != '|' && r != '-' && r != ':' && r != ' ' {
+ isSeparator = false
+ break
+ }
+ }
+ }
+ if isSeparator {
+ // 这是分隔行,跳过(不管是否已有表头)
+ // 但如果还没有表头,这可能表示表头在下一行
+ if !inTable {
+ // 跳过分隔行,等待真正的表头
+ continue
+ }
+ // 如果已经有表头,这可能是格式错误,但继续解析(不停止)
+ continue
+ }
+
+ cells = strings.Split(line, "|")
+ // 清理首尾空元素
+ if len(cells) > 0 && cells[0] == "" {
+ cells = cells[1:]
+ }
+ if len(cells) > 0 && cells[len(cells)-1] == "" {
+ cells = cells[:len(cells)-1]
+ }
+
+ // 验证单元格数量:如果已经有表头,数据行的列数应该与表头一致(允许少量差异)
+ // 但不要因为列数不一致就停止,而是调整列数以匹配表头
+ if inTable && len(header) > 0 && len(cells) > 0 {
+ // 如果列数不一致,调整以匹配表头
+ if len(cells) < len(header) {
+ // 如果数据行列数少于表头,补齐空单元格
+ for len(cells) < len(header) {
+ cells = append(cells, "")
+ }
+ } else if len(cells) > len(header) {
+ // 如果数据行列数多于表头,截断(但记录警告)
+ if len(cells)-len(header) > 3 {
+ g.logger.Warn("表格列数差异较大,截断多余列",
+ zap.Int("header_cols", len(header)),
+ zap.Int("row_cols", len(cells)))
+ }
+ cells = cells[:len(header)]
+ }
+ }
+ } else {
+ // 使用空格/制表符分隔的表格
+ // 先尝试识别是否是表头行(中文表头,如"字段名类型说明")
+ if strings.ContainsAny(line, "字段类型说明") && !strings.Contains(line, " ") {
+ // 可能是连在一起的中文表头,需要手动分割
+ if strings.Contains(line, "字段名") {
+ cells = []string{"字段名", "类型", "说明"}
+ } else if strings.Contains(line, "字段") {
+ cells = []string{"字段", "类型", "说明"}
+ } else {
+ // 尝试智能分割
+ fields := strings.Fields(line)
+ cells = fields
+ }
+ } else {
+ // 尝试按多个连续空格或制表符分割
+ fields := strings.Fields(line)
+ if len(fields) >= 2 {
+ // 至少有两列,尝试智能分割
+ // 识别:字段名、类型、说明
+ fieldName := ""
+ fieldType := ""
+ description := ""
+
+ typeKeywords := []string{"object", "Object", "string", "String", "int", "Int", "bool", "Bool", "array", "Array", "number", "Number"}
+
+ // 第一个字段通常是字段名
+ fieldName = fields[0]
+
+ // 查找类型字段
+ for i := 1; i < len(fields); i++ {
+ isType := false
+ for _, kw := range typeKeywords {
+ if fields[i] == kw || strings.EqualFold(fields[i], kw) {
+ fieldType = fields[i]
+ // 剩余字段作为说明
+ if i+1 < len(fields) {
+ description = strings.Join(fields[i+1:], " ")
+ }
+ isType = true
+ break
+ }
+ }
+ if isType {
+ break
+ }
+ // 如果第二个字段看起来像类型(较短且是已知关键词的一部分)
+ if i == 1 && len(fields[i]) <= 10 {
+ fieldType = fields[i]
+ if i+1 < len(fields) {
+ description = strings.Join(fields[i+1:], " ")
+ }
+ break
+ }
+ }
+
+ // 如果没找到类型,假设第二个字段是类型
+ if fieldType == "" && len(fields) >= 2 {
+ fieldType = fields[1]
+ if len(fields) > 2 {
+ description = strings.Join(fields[2:], " ")
+ }
+ }
+
+ // 构建单元格数组
+ cells = []string{fieldName}
+ if fieldType != "" {
+ cells = append(cells, fieldType)
+ }
+ if description != "" {
+ cells = append(cells, description)
+ } else if len(fields) > 2 {
+ // 如果说明为空但还有更多字段,合并所有剩余字段
+ cells = append(cells, strings.Join(fields[2:], " "))
+ }
+ } else if len(fields) == 1 {
+ // 单列,可能是标题行或分隔
+ continue
+ } else {
+ continue
+ }
+ }
+ }
+
+ // 清理每个单元格(保留markdown格式,只清理HTML和无效字符)
+ cleanedCells := make([]string, 0, len(cells))
+ for _, cell := range cells {
+ cell = strings.TrimSpace(cell)
+ // 先清理HTML标签,但保留markdown格式(如**粗体**、*斜体*等)
+ cell = g.stripHTML(cell)
+ // 清理无效字符,但保留markdown语法字符(*、_、`、[]、()等)
+ cell = g.cleanTextPreservingMarkdown(cell)
+ cleanedCells = append(cleanedCells, cell)
+ }
+
+ // 检查这一行是否包含有效内容(至少有一个非空单元格)
+ hasValidContent := false
+ for _, cell := range cleanedCells {
+ if strings.TrimSpace(cell) != "" {
+ hasValidContent = true
+ break
+ }
+ }
+
+ // 如果这一行完全没有有效内容,跳过(但不停止解析)
+ if !hasValidContent {
+ // 空行不应该停止解析,继续查找下一行
+ continue
+ }
+
+ // 确保cleanedCells至少有1列(即使只有1列,也可能是有效数据)
+ if len(cleanedCells) == 0 {
+ continue
+ }
+
+ // 支持单列表格和多列表格(至少1列)
+ if len(cleanedCells) >= 1 {
+ if !inTable {
+ // 第一行作为表头
+ header = cleanedCells
+ // 如果表头只有1列,保持单列;如果2列,补齐为3列(字段名、类型、说明)
+ if len(header) == 2 {
+ header = append(header, "说明")
+ }
+ table = append(table, header)
+ inTable = true
+ g.logger.Debug("添加表头",
+ zap.Int("cols", len(header)),
+ zap.Strings("header", header))
+ } else {
+ // 数据行,确保列数与表头一致
+ row := make([]string, len(header))
+ for i := range row {
+ if i < len(cleanedCells) {
+ row[i] = cleanedCells[i]
+ } else {
+ row[i] = ""
+ }
+ }
+ table = append(table, row)
+ // 记录第一列内容用于调试
+ firstCellPreview := ""
+ if len(row) > 0 {
+ firstCellPreview = row[0]
+ if len(firstCellPreview) > 20 {
+ firstCellPreview = firstCellPreview[:20] + "..."
+ }
+ }
+ g.logger.Debug("添加数据行",
+ zap.Int("row_num", len(table)-1),
+ zap.Int("cols", len(row)),
+ zap.String("first_cell", firstCellPreview))
+ }
+ } else if inTable && len(cleanedCells) > 0 {
+ // 如果已经在表格中,但这一行列数不够,可能是说明行,合并到上一行
+ if len(table) > 0 {
+ lastRow := table[len(table)-1]
+ if len(lastRow) > 0 {
+ // 将内容追加到最后一列的说明中
+ lastRow[len(lastRow)-1] += " " + strings.Join(cleanedCells, " ")
+ table[len(table)-1] = lastRow
+ }
+ }
+ }
+ // 注意:不再因为不符合表格格式就停止解析,继续查找可能的表格行
+ }
+
+ // 记录解析结果
+ g.logger.Info("表格解析完成",
+ zap.Int("table_rows", len(table)),
+ zap.Bool("has_header", len(table) > 0),
+ zap.Int("data_rows", len(table)-1))
+
+ // 放宽验证条件:至少需要表头(允许只有表头的情况,或者表头+数据行)
+ if len(table) < 1 {
+ g.logger.Warn("表格数据不足,至少需要表头", zap.Int("rows", len(table)))
+ return nil
+ }
+
+ // 如果只有表头没有数据行,也认为是有效表格(可能是单行表格)
+ if len(table) == 1 {
+ g.logger.Info("表格只有表头,没有数据行", zap.Int("header_cols", len(table[0])))
+ // 仍然返回,让渲染函数处理
+ }
+
+ // 记录表头信息
+ if len(table) > 0 {
+ g.logger.Info("表格表头",
+ zap.Int("header_cols", len(table[0])),
+ zap.Strings("header", table[0]))
+ }
+
+ return table
+}
+
+// cleanText 清理文本中的无效字符和乱码
+func (g *PDFGenerator) cleanText(text string) string {
+ // 先解码HTML实体
+ text = html.UnescapeString(text)
+
+ // 移除或替换无效的UTF-8字符
+ var result strings.Builder
+ for _, r := range text {
+ // 保留:中文字符、英文字母、数字、常见标点符号、空格、换行符等
+ if (r >= 0x4E00 && r <= 0x9FFF) || // 中文字符范围
+ (r >= 0x3400 && r <= 0x4DBF) || // 扩展A
+ (r >= 0x20000 && r <= 0x2A6DF) || // 扩展B
+ (r >= 'A' && r <= 'Z') || // 大写字母
+ (r >= 'a' && r <= 'z') || // 小写字母
+ (r >= '0' && r <= '9') || // 数字
+ (r >= 0x0020 && r <= 0x007E) || // ASCII可打印字符
+ (r == '\n' || r == '\r' || r == '\t') || // 换行和制表符
+ (r >= 0x3000 && r <= 0x303F) || // CJK符号和标点
+ (r >= 0xFF00 && r <= 0xFFEF) { // 全角字符
+ result.WriteRune(r)
+ } else if r > 0x007F && r < 0x00A0 {
+ // 无效的控制字符,替换为空格
+ result.WriteRune(' ')
+ }
+ // 其他字符(如乱码)直接跳过
+ }
+
+ return result.String()
+}
+
+// cleanTextPreservingMarkdown 清理文本但保留markdown语法字符
+func (g *PDFGenerator) cleanTextPreservingMarkdown(text string) string {
+ // 先解码HTML实体
+ text = html.UnescapeString(text)
+
+ // 移除或替换无效的UTF-8字符,但保留markdown语法字符
+ var result strings.Builder
+ for _, r := range text {
+ // 保留:中文字符、英文字母、数字、常见标点符号、空格、换行符等
+ // 特别保留markdown语法字符:* _ ` [ ] ( ) # - | : !
+ if (r >= 0x4E00 && r <= 0x9FFF) || // 中文字符范围
+ (r >= 0x3400 && r <= 0x4DBF) || // 扩展A
+ (r >= 0x20000 && r <= 0x2A6DF) || // 扩展B
+ (r >= 'A' && r <= 'Z') || // 大写字母
+ (r >= 'a' && r <= 'z') || // 小写字母
+ (r >= '0' && r <= '9') || // 数字
+ (r >= 0x0020 && r <= 0x007E) || // ASCII可打印字符(包括markdown语法字符)
+ (r == '\n' || r == '\r' || r == '\t') || // 换行和制表符
+ (r >= 0x3000 && r <= 0x303F) || // CJK符号和标点
+ (r >= 0xFF00 && r <= 0xFFEF) { // 全角字符
+ result.WriteRune(r)
+ } else if r > 0x007F && r < 0x00A0 {
+ // 无效的控制字符,替换为空格
+ result.WriteRune(' ')
+ }
+ // 其他字符(如乱码)直接跳过
+ }
+
+ return result.String()
+}
+
+// removeMarkdownSyntax 移除markdown语法,保留纯文本
+func (g *PDFGenerator) removeMarkdownSyntax(text string) string {
+ // 移除粗体标记 **text** 或 __text__
+ text = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "$1")
+ text = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(text, "$1")
+
+ // 移除斜体标记 *text* 或 _text_
+ text = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(text, "$1")
+ text = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(text, "$1")
+
+ // 移除代码标记 `code`
+ text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1")
+
+ // 移除链接标记 [text](url) -> text
+ text = regexp.MustCompile(`\[([^\]]+)\]\([^\)]+\)`).ReplaceAllString(text, "$1")
+
+ // 移除图片标记  -> alt
+ text = regexp.MustCompile(`!\[([^\]]*)\]\([^\)]+\)`).ReplaceAllString(text, "$1")
+
+ // 移除标题标记 # text -> text
+ text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1")
+
+ return text
+}
+
+// stripHTML 去除HTML标签
+func (g *PDFGenerator) stripHTML(text string) string {
+ // 解码HTML实体
+ text = html.UnescapeString(text)
+
+ // 移除HTML标签
+ re := regexp.MustCompile(`<[^>]+>`)
+ text = re.ReplaceAllString(text, "")
+
+ // 处理换行
+ text = strings.ReplaceAll(text, "
", "\n")
+ text = strings.ReplaceAll(text, "
", "\n")
+ text = strings.ReplaceAll(text, "
Z_l_NBLgqcYn+VEodY7S?#ExyjcruZ}b%;oHmvqOR z?ENcfV5+HlO17c)P@WX(4Za(U3@uq&g`TMvuakP;3S(Ll1zj;Aj7l79*F#o&$9jig zoPisa@r)lKR}Ir7l^I+o?2BxNw1O?(;LVv=^H6M_$-7#T?zjfT%dFO|m1C016gd7P zWl9@$!RMLNDpcnva!LE#6sYP@iDx=KLd7UU$Ez`h;^7{wXrzgk8N)mD@#5aDg(208 zywR!9W91!K3cM=^pDJmDDRiOQeDKA&Ne zJlEl=U)?^^eXg+Pr@SW13~GwGmREO<)2gAr7h4PKVq (s`kPQ_EO~u zO`hIzXj-i4Y)49R#M;|a-T|jVfhFDT$~Uj%r5Ya@6@9zT`1-MlI`hq;h`Kj1Z|Z{G zXLP9@bJ#y~GK(gyY?yP*ioni=H$er=;X;~2OlT&}&w`8uuf=@Wai~4=w|3;s^BPsw zwUkpk>a{ftq99&pWK@%TYS%4Hlz~rkcQCcODg=nteu!uJ8>(3^>h;u^P9RZcUG+9& z${LO`HxIsVg+`WqWax{GI?ty$NybDq5?#xxClMK1Xl7SNR*f>ow4rSs+685F;zP}G zCC{21$vQeJO7>EDS~KcyaU`SVV7+Cydxg%dhH6IX8m-NPtDvBS@^iT4V2wFZ2c?ba z4BSvgQo)0%T5HE&af6$iJbF0(5*{BzyJFP7tya4?&kBk;QRBohA(w=aDjKH^Wu6`1 z%cXX|h6*LjYGK!ixovaFO-6YVHc=6w-g#B~Q!^ap2@#o0#D~?3>X=W)QxbJetR3A6 zLov5hY$sx(`;Xl*pC^6#$pW=wa+cuu*C}}ul%IU_>FT!&u;`_E#-#0b6Lr>_|4n@v zOtX1&-W3yts(NKk4xPCKP%YZA!al3A%vVcH$(R=JQ6HJ8e@@9Z)L8Fey4d2O94EB1 zmR>40jMZbd9YcpvEl=zC>}`B==}w{PQxgOyYONWmb~gvSSPlZtf3S~KTPtjHG7}FM zM5v94Y7L$os<~j*Rcz&FC$aOG^jM6qH=dP=Yt%}Mw-E)YzN5$8)uHROLL;YRr 2Ww_c$*07D~Ko$RG<_utg1!jfHBJ~8?GNi_l*6Cthm&g1tgDGQ{ zQKHrKW(%aS=)fI^2?d}OdqP*nj$k}HbQMflCY!mf#9^oACKw+jg|<0#!YSXROl9|+ z%)F6WQj66mue53*sFj 36_w70*yER=_ zX+z@p5z(ol#u?z^5^7lk)q?LPy544`fG#uilkm7LFsN3GA{_I!kqRvMS^?9KrLBfZ zQ0*CRB1dIN)dqT~0Wbb_R<1+Yvd#?6ae0{4X0R?-qB`2L X!u;7{ojTOw7r$+{Kuj^Yj8c1$)=7Gl$q~ zi)~i#J56W$IULhjy>IQBy#?k9BcuWl@k7IgsZdij!`i(03(imtGIR6e9&vS@c|Y$F z?2+j-CiYq{MXS$!;OQ^Id1E 7m7ss$TP&8RT`t~M`*F>^mVdIKw#Fwx-6YF6%gU4&=lNnA_h z+(fhcREvcgzdbubdYBH$t)*w;vhNVClNUxeCK(+nfR9snalLs}K`MvI#fFz@ zLnaEyTD-2oJ|EhWab(AO^GcN8E#*y^DS1xMziM@NG^v(NXe}*?KReGZv!hfh(Wm4a z5il-e7-gg+=l7y!l*H6nX~{OE5a*Yv=8fOlz2-naJ}SS|#JNtUtS~3KxV?<>;>Gw2 zW+o$EW@BDc__K~r?#ov`t%~Zw;54g6t{pmd1qz9gs-AOZmRVQrYb~?bao%ev*BNU? z4K@EvnjTGP?>}ZU@-EFvQB%zdRnYQTZ8b((3AJY2h<=>ME$uMvLiQRjIV)-z){ 9+I6DGiZ^k)5L4TyOwA9cMpR}w)5NfqS?laoA~gynXpEUspEab&7C<1V zP>i3%uBwuPRNcl7L`3eol7NB&v|pG;Y-sepPBGvrgEb}xDt~F3SlDvPpmx!AHhI`a zGO Wwo-pTFDrmN(vbBF^$CLsPHLkRdpTE(UbLQe>VAoTxFO4R2ngg{<$ zcdPVEPhvnb(7#alwsFD{`;{_#sq82XPOY8je_Ra{GNSASjLUWA!Re(uW+L27x()MR z;7m{u0j;V@OH7P`6nLXiLQV*HQ1}gjWP^Y;g-l681zfs$3JzEeG~{8SI75-DE>gW| z@Q@}-)3gdNuQT$lt7ht*ipuI28a}wQh``Q5-eR0X6LtBgc0kVQ`Si5jDVVelp~l%i zMF;CR*U=c`DSRe_d8m^yD8|Ax(5ywRwMhIBvMM4H4j&4CcjbWr*8Vk0+J{2c7jI6W zjjJ*i)x3Kuj6yXd{M1bM^J7PuaxK#biA09Vyf}cg^)>N)iEU1v4{M%R !)f`kE@}PBpK`^!|Iwy;C>vSF6#}bHU*I4hmam;c-k=(C64xA3k?;$k#QnOW z94)F41z@<(XNV&H27tqgNkbKM)C$VzjnG^~$zR4=bOoyKq0T{>&zAIvsl_{MOny0j zX$#C=)>#bObg*msIY|PtH~4u}VyG@&$!7dYPOylxyOj9i_gRM$!0I@K5)=1D6Ebmb zD4|M^l)&zu;FDEl0zp-kT%iF|A*<|KqLdb)G6{6V&+ h@!{ z8{<5$5qnHP;qQP#CD6ce& IR0v=_ls0gnFEwvf<{I3f9Wf&M9~2$NC8o(l8B0`h=@pA)Qb96 zeXBhyJ!^YbeBb&aJ*XA2Rz=j6E~+9b0tJ|enLL8WFoqk3J _ zYA|_*4HJnN0udN6;oGu;pd7i%xmcS>k{Z39wc&`-g?! OlyDEXRm*c%mi^MKI^fhQOw5Cy;#i(pX*NcvHb0F?XQ%Q3B* zLYNuqq!v}Es8tYw6eLh3^h89-!t|_mtaN mm$%Me zynJ<|{leAG)^=}mtKI2zt*9u(@gpTMjJ}Y?f!yKP7X$ t3f$(!y9S1CykOu ztnw3eVTv9V>An^5hf=G0xz3mp`e0FeVc_>N*XbMP0gyy4XQU)SB#blCt22KDF*Gq} zG8Y&VQcPG leA@_r!kl#f91Oy4>iGFlXqyi*TfeK(Dz9B$- zf`YA5r@`crU1`w1mbcQRwbiAA`&W(}Ub*Jvf$Oe0cI-go;ELJbH2a%oWr>@H46`9L zh&&>;VwebGt+3;WVXzU0{@JciS)C_D-wm8X^ `n@oUHXAy`YN&*GQigrZ1 z)^>bd-`xgw14TEBP*wl?LT#V-^t@gP90r2yx%*pY?d45K+`N4Uh>#5 z7(}F`%1#}p&MxHo&vi0KtQvs}gHTMm3F3zWK_?P`iiUgWgejOmlbhQM@nxb|aGf2A zJWu68U()e=V{!==hu$1Ll9Y8sVM7Iv6Li#RekcmTbdJMHLS!ivh!hsuRqa}7Tix 5f2Cp`` zY1m^jL>|GYvMNEXp(u2SLpd(sH%`Q%_|S%2Cq3N5XJdIE`jVd0V`kQvK0Vp>Ja3M; zPAy%$2f0oK>0#8m#(BJgUr90VR9FjPFf^jtssunHm5>t_EMP%BH9gU`FWbIsb?t=> z|Ai~ QxEbBKixilYs)RQz2Ba z#!bo|LFJCECT> E1@@rpz>y0nH>G*ZW_aE4A4mEkj zqb8FFG=i`rh4bjJJ`-~+1rWdxCvy(xIyB%PS1sD6Ao{8T*I5vs$zg)B&SKamgh|MA zlgq3+thG`czCL(Wz)EBQQUO#{lt{_+RIQS)Xj^Rb w+_U(glrTqMu2{ps?&M7@Sc9#wa?)If_9;P5a_DnUR~7}+XK`X zCh?8!HMGP2)h2fi@Ar-!S$+M>Prc^l*WPgLffENC2V2<3w9M)`&dWwLcwHy=gj|{n zxlS(P6lPS6Ig=M$X9}pmb*SX|I*VbO5O$Upu>#l0l!5vW1Uf5)s(=LC18 Ok61Xd=2Q)3At zM>J57pfj}LD8-_ARFxu^uYe(iW}Za$nb^OP(Bq1sD70u$) WNG+*PupkAI5Gw$yObmdmWfh}cMvqMww!{ABO2a>XsCnbbl~>(- z Y?d7=j4u{GrAvfG%0p{x`-`}R-C(Lg~JC;A9d(FANK@SD?^-*6DA8F^{P zUM&74aQ=#UfyYD{O{EWrX)lu@FmXRP#P}9PZU|97$BF;~0S^-C00Vb(Qg`Hn0Lmah zL@Y6}Y#P`lq9$w$ofd7r_|)o6r}n+{=HsuvVc)5vtNT|Ptp>H28U$8hjb1+x4s~FJ zljgeks8=!h)PkvTon3FdDL?_&iElTCIGMI0jgAMi(!olPmKB~Q zPvG?gN=5++vezVC#zIGHRn6O6t7?MlBwXoq;B%x+>L<%dt)1GEbcDTF(u9`0Ej0YZ zON;nvR6I%S9yQV<6;@>xqrC?COAR4^+wWdIvbufiEhlfe{_yKwe$7j+TRpnk+~439 zGgLi8D#GxIl)y?vN`VG4Fp>ps* GD8$J8{rQVMpzw(1e&j0X{XP-E8rLC #HDz!m`R7e4}tQOiM z6@q0b*>=PD%hX+KY~OtC>g!*A&8@c_yZO2U#}6*;Z K z<`$JgHLf#JR9j+IDhgcnf*4hTGD#?<2p~q4ZP&xrTC=;av32sW_u5z9@Y-9ByyAvK zM^_qaEwg0UBLdU_3}KBQ$Lh}SCKr>coS|D;aGhO)>2aO%4!8@hGY-adbgt~2nqQ+p zosYApVLty*e VB9g93K2kHe4yj&M%O>L?mv3^(sv&|f6tGedE(6WmF|j~!(H)& zJtZS#6rNL{sSDR9262?rMYGgU#a&U13V}zp3pWK#Dv7F1a+wMOwIne^1*!^R=Musm z3CQ{l_Km;M@_bL*hnKf+IKKSqn~uEUl_y_(-NB=4jb+cQdfa3(5{C6c0D%&L9ruZB zZ^ds~3zJxcZA>3kxK4SLS*1h6=@(ulBcZO$G~*yyuOll^mku)Y5Fn_M3+y!R)R>-1 zN3rS4)wci4nX7l*f9AUnKK;m rU2jbq`>ZVa)>DQhAvOvE8Fhwx6{si~i5x5m z$lt6?ni=dqP;fGkYUo1PKafsCJtj!Sk_y8z3yE!dzVSD7>+F%`?n|y)``Mqq@n>#5 z@uFk |*%D)eE&b*7KexX#e6?%>TsYh8?Py%@i5 zM;+f#5{(`o)G);y)tn=*SP+3dRrY}%bi?X~Z_jRYzw?80cYgcF4?J|{+@`m*dfYc_ zn|{NWF#A6n5DT+92ZerXoY5$8jwd1aMW$w7DKAIPIpX9X(2Pajg~Fg+C5UtB6IOLG zRknxjW&_ rJ+yZhlv~6|K zkOf_u4rar37MN#L CBNH-2#UgU_A0YC5vk z<>elG&?eK873a^ZAObVgQ#GOKIW!Vc !@mGc<_Sj>;+WgIt3ZG6ow!!yNp%%WIEs;aJ46AHacJX)=$3p zjVGQur&oF_wsD|?rlp1&qpU<8EUPL9mTRLLW&BJ=yaY|s{<#4o`udNGiOP|+H=~C4 z4Q17IlxpB*kor|YzA7M01ViFZJ#zje9$8PjE!i^IT*BoSU(@(!@3{4?uf6`-wdR_~ z25Q{lJno>>SoD lx%Ab$9{$I>p1! }P3Mj#1} z$0Q)gMgu5ExdQ+q5+WN>KzA-o%yoPCOO?mo<2o*)CqrxL0zzHd?{F(XBp?Lm?=^^h z-zpf)fT-OzMp?QBFz7DP_CDG?=xzMmt4{vP+ird7^#`xnZ#=*QjDkobStnAuMUB$F zU;H9sJsYFuIh+F0O|rHZd@5?(Y$AsjTGyGw!n(*Bl5hk9dyPOziNqcHB|WGRH3Y`} zXdyktMvtDp)Vb#;FMQ!!kKFU%)3khITMh~u$_;^M!3v`s<`N^yRHvj6v?I{CMKP%h zOcui(S!1j|^b4RcWr9M*h*ec0%hL69yT#j0yWU-Y;q5>F>R)-wsn^_c;?(}u0gpVV z*(37B8bcnGRG=0_3eS=WQz``dw?0P-m=Ynj<5+|P&O;9r$T|zIvp^b}_`k;O%BlvC zP`+^n04plOjSZozUHk0i?e9Lc{*h0A_1TNi=FzQQQ@ADE3Ialji9H3m^K%(rWdHym z07*naR6mg))S1FWV=x!ULz(c2%%}Rd`Myh94G8&Nk9=ddjchL&zu}+1`DF8VKk(Mq z+;ZU5p{4yzUMBWjhHOKsKEt|0k6Z?ARwWA00gtsS#dQ_|7z?hG-GYJw7hI eI$_*<-0#cAFwS76?wvWEBedl+dy5pW_PM_~~8;3Tr+NBjso{BZBDkAVg zwLDTD*9)#Q&lsEQ1U}Y5em-InrNCE2pw>gT$sG^t%d~N7zkSCWZhqJ6Z@%T)<<*wA zY`6umC{dX5BCsFDPGF%}jp1Pu)n00sxn)m{>x__Ss?13T#d`Ar*QRup^T~CJTRXXN znp>_DwjsWI)G)UVMW+}CCS+A*wa_kT%i{93ed28UEB8G8#cx0K{5tz|sE1Y$Ez3)m z8WOnIx &ztET(HsRQ(` zpMT{q{oKtrpIkY<;;k~g{*37nK-!2Z8Weg;HZT5ld~s(ULQn^D#7wRBJjPRs>kJIW z$Xq9{qsyz;CcRlG=FC&$I Zw*5`>ii zwY^Q2ttQ$HS-<`;-usqUzWZlyy5&Uc;F4(>@&Ld$1gKGUhX{ewt|)o4J-BHKJG@!R zH}jxUOEsfr4ckPy&g3w3`@cK5PH8D8JMu;WkO6EYjtI0XR@w9f>s>r?q4U-6KL4@5 zzx#4`U(f9K(Gs&_(GampMyUZ8b{^P}gQPeae$9Q?xCM48!XxsW%XRQmwAD~0wX90Y z%$|}ktjc!NSR FhJ<`>^^&B?XKe$T8jJc5O|Gc(xvqQrJfg rB6cMl~}|wDx)CI)P#hO6mh5#66g{@)h4c{`}_Wzx~)}@A$#R-oZ^- zgSRH|tg b)16CsnuvnLuO@JP0&j2m> zo?UO@;)%81d*A%BUw+dqw;XTnYp_RRhz#86=80NV`%^l(#1>p<2F=)8TxYOJGl_Ve zRXE#-TqkbMasRToBMHz`qAtu8pB_Eiz2iGi{@qvaedOvspI3!egjcOL6h@iD5K 85wS@s^v8ue3Z~0vgchSUsn#ji1d*{W+#7u2YRklr&&Z+9(jmL=6Ph$X9i) z6F!!Ef_0gUpCvv02U$XmeK%6clN+|;cO0|c=<%|gHM)x(gK^;w3W$miCM5%~0Nd(s zS$^VN`z!aJ{?gq~K6HBXN~h7`!(tk28sLVK2N n`)=1=lHfaK>C`wx*PsbDfecsa$WF z^-x3&DbRcoDbxob#9iU8(xt851CL$))EDo+>yay0=*R|Ed|rmKsv2r4RFy=P9LsSV zN^s^$DSL$eF1XHcWSxzXcPliISD;`wMQztJBzD_F`=IHr$%Qw);@H3VmDj!EMXf{2 zUJKGNDo}=TN7jOTKr9$A1m-{>T5z3lD_wA%jCx14ImJtuXRcGrM0bU_jUbaK*eWYQ zAga18w9%EHJiYnZyC46`KRtS>vwxEgw`s*UO>ycEr7*` V=vV;1UlR;jyYHjk}ry!q8ffBjvzzU0K}+7dMl5tx+# zA+mrfm|*f;+lCT^yqhP=bw-WGjh?EP>y+Qi<~>*?N;GDU8{W$mmf1`~GAG)AYSX3m z!ix%t>D39=*>nWl6 Oz4B4{UQTd zCqYy8IN4obY=m4#!`R~R^mA4a1h%u}`JTPHV$Z$gsD9vGKmGQfz2W46=CWs+(1v^1 zN#*Pg%FbSKbqT4Nq;^aSGlfWxYVSLeJ*@xK_i@7A&~Vr0cD^9yIZZf<*FRf$9^r zeVp6$zyH|9zxu+1_dI@SgV#E`W~HfU5;cO8PLv6(>In--`-k1cLq2)Ibrz@z&RQ78 zc`2zn0Z5sZfga!kV6iQ9m+Zy;X5;5xb@T(j{IXkbJaS^Wv5(agWhD?KNDf-Sb!wQm zli)hzXu4`CfBbqAy-_>Y$?Wb~1&8K2%T1`dym}|<$XHh153*nZVQ8KJs!o$-Hhp{9 z*C#J*-Tsxw{_c*ST%jYIX1{M*#x_-rG7E(X1k`OF*`{79lAIP?XMy_Q+|L5 9y~F+ly~L+&W~i#Ka&0F(m((^90u^sa8rE zdnTuxpOYw@YM8jJle(>p+w^pLb5t+MxkY)dlXz3LNCauZLPgXEwS}MG?0(@pr$6{t zcW#*z+c?yr70{BeWW)oLGD$z9fD(Fu2HK0L_&DQPXxJ903y$lA2kHpLGAF4*g`H`V zj6evMwbOw0baNkHZFZmfcmLC`zwYKk$Cu51kDGu| ~mUlUpt=Q&eWMh6UG|5F#QFbXVpH)Q&l+h4l(>|fk=?-d;1q7|Vfkg-CFh5$-TsZg8+CV&Ok850F;LLsa|N K@Dth zTw%X}z@(|6DsQ*anJFNb>kyf|sXuBg?nM2D%VS3~q^hbp4Khe87@{f!BgDR9!`El7 zc0Tj1M?U%G2T!l>@6Zv;Egv4xgfXG(L3sKv=@g7M^Pt+Aas#H8R<6X%nMOlu%T+P7 zqVz;1R%L`KZAqbBLRbAQPuk1=m0OPb|IKgw?Au;@>e!06LTJJVl!zfp1hLi_cYcD& z)V<9lcgaLCP4(SGxXzpip}UvsM*UnTPJCRLPrz`(UsRNY zpe(+JcEi7XqP6+{x4q>3Z+-ENN1FQ@Xc95N5CerO2Vp|2l;LYV%@D)sGoA(4sg7OE zb+Si6LVp`lRq{Kko8q0_(z_o#^U=@!=%MHACJuFZ%}2wKff`Gy#z%^g1!Jc{*2Rfo z!F8rkR~}|7a!r`)glDBIQN)GR$vng)BHInJP1|mD&b;NP5B=7AUiDKiK5(>UR+yO7 zgOFGgah-U~^RV|CA2Z}Slfy#8wlh$T>!@? Nol zYs*rTU_b<@f?TeU!K#6YANRT7I#Z}C6SM8=Npl^j3(8?7f`UlMEes+eB~e4{Ti=j0 zWOJWhJkh%JJHPfb?|#EI*B)rDc*Y}Xst|+_9*|x7y~f9a>x4HgxXvt4*#3}Ds5@35 zI1E%Fs-)nq(v_b5?h~7T{Fh(6@2QU8I@N6+>iPsUR6X)M)gH(~J(31E;SUrHU+f^P z31RQqb%=r(#aVKlnRoT1xelua3bsJfu>`dUwsE8c0fE6}{Vn5PS;NK}U3us05C8T* zd+p1vJA9x)OAya_w*@LXckC%B%EuGbmu5)g>v&3L9@$xxt&|U2g(+KLCf!*bH2(;A zf^(6ES}3!s3XA89sz+_5=ePXN-SzZG{_gwFZth$6m({EYErU%EfK9P6(X=6U9Evc~ zSWkAtJH=)ctry9=QUgby!}pz+dSfTnswQ; DSf8b5&@ N~)v+FqkAGf%UwRyE)`K z-Xuz2VA9Yq+a`gaA}T`cvtH=YcOQS@L$}>~?_+j*>E KGDDw@Gi%uh}+_qlNHhb-*kAL~$XU<;wm;d}VKXv_qeS{{285*oP0t|vv zcR^4cP!%jN6s2UHQUxt!ohd^5Dg;%rJ;tW;g?8_Y-#h!CZu|Cg>-( !b_c1F}v@t}|b<4j6&iMhL1Z5H&2GcJ?*eOTDw#uWkPizx|ds zz4GL-Wh^rrCDvff0*fbv)Ph*E@)yhx=1|rd!gU7s#d*lo!qwRNIaHWV8BwpQwam^^ z%7AvXw@%(uS9<^X@9+EjZ=OBh*}sjIE>^%x%tqnT4shT_T3B*r%-nW858N)(?QmBn zb}Qf8vvQWpuUA0|jku0`*y#c+V2z5W*3 k z4sf>w3pGQX+=_E_6|bEF&zGG`4=2KPCebf@oa-beuKEDEn71y;Rm%@PzwzPQANc0| zm#*sZ4z2ji!Uk%f9K_4Hxa$&ySHptqEVxc4=9TMEm{B?qTY^i@%%en5!@U(;In3vN z@iiy^?Qgv1+5^qC2Ac2<0C*ZCLl9E8tkNAq)IT?29IjKKoY=YaaA$Cxo!-miBRK@V zs9I`l1LwN-u7}S2Uw?hiL#LOv>3Ek{g-Hp@q-s %4a(Mo%g@+ryu{?bDR4*dc@aNX)Y_kP=J(J z$p9c#h6s38T&Lc;#%w@cZ74|#W@WpY2d* kt3s+u!`k l5Ex1kr=AG;8H20Xg5%L;=RTto<=n=LA zr#E`Hef5bC-TvLHIJ$*pF-s&3EUhXGgIELqP9+Ww+#$+K<+ iWV6Kp=>UUmtY}1&m-IG7=LR;uUxZy B)|d^!51!ll(Cy#3^MMyOy=&WgfW21uh@0h$;F|>&s0Tz0 zC_E!(siSKbdicaYJhr*j`)~iNpE 3_NV{%8H|#*4OPKX}y&L9IeC-P1qkZ38uF?6vnzq2TrB zLDZXsHZLrblvvT|v8S|rq1XEOmmj~lzWv|-i#NXH_{vIf>YHIwhEHk%7C {A=FIQCN_Y5{ajTZIW|VzV_g`Kl{knp187agAQ7=uV-7t9ut8?p+Xe* z*PN|6HxD~k!!{2Fl@z8)?x*(p<;S=7*N+N}bBq3Yx+;cM{C3l>@AJ>T<&`V{-EaNE zYj4=M>XE11fHEwg3KC+4WsM(^5G>4vhAo@x6d!k3(i&DwtarLKY$ytrNAaEg-{C8i zrJ ;=RTu0#(ZHk`W=>GMWAHMC5$1Ze_Y{ U~*V+ooPW!Ie?{f+Bw!ur>M&=Ajx$SPr1X~T>4M(j3ti% z>RM`3ud|Hx7wzBpgZIDoy+3>1i4|^h1YWwp<-AQnzJTfYvJjI?IhME bgFaSX B%e%gC(3}?Lk<%2kXddRp@E*%-<&dWWF+W9l@=DO;>PfH`3eOMeCv=aM z5N+s`*jNi<_I)AK+7v#$Wj=KK_kG{~>YHykv7$|=OS{7wrr=4pWFE$2X2{?=gIzPK z)8tUo->A01@i8b}O^3;av1a+Q9raHICDJa$!n8#ny4d-%|8nowzW3Z# x6(6Rx48f*w2o#JpeOQ=@UUXQbWu(@)-GmYTozrc;NT#Bk^RIwv0& zAgW>h7n$f%Ew7HMmj(_kExKWE#p{$Llywcp(CSo;f|Z0oBu1ei-BdhzzVpW)`R2DC z+OSJEZFg6EtN faFCBMe&ExCk~x5du_>2tv> zju>geRhd*7+S(R08mIo`Ki}E)-}D=Ad+|}$mJ*bdtg;0_>V+rs)UbGW^qy}vTxUmj zSxF4OV}`utHbj6Z5g^cQ(BtQ}|BwIl^}8PHZ8omm_E&6U1@KgTf{k`fQ-7NUW`?pN z=3Ff))u26z#wSe3=}8=8JgEz*k_TUuC~2S0=Q%~D6(l+_Qj0k|caAN9m^`&;nig^M zs+u-m`@!4pZEtt})w^GD%wrEiB!Tcmphes$u;*l*siA K2%}spG_W{|hwzr*vA=|a7 1!>B3RDW*AJ^f;^)h9{y#Y`N*vfh)ovz5V;$ zE&mUG_142n-V(%9f=diaJpz%E4is4iv)Zhew7!=QQY^)FO6HtSRa0Kgm#S!Ti2#ag zp8-^%R*8LJ3-sd`Hvi;fcYXVj?q=(nj$MK_$Q_%jeh>>fp*EK;cBc!eB@K$v+^F!r zwOhZ+mXKkx(B$ROP{dlY=u1eTL)Xfn*1m9FhR_ZYxIu^5h||Q= 2Zn{j5<3S{hO{hs>%b?!^0 OCzW5S_|B22 zUnO;A1~Prv7MME7D4DLx2I#Si?f>&*cYpJT?W=UO=dCC_mL^fa5rR{9D{YY$SfCOL za%H7 5^7|LsD1ju_z!*ggEa5jfP+KW&X6FIMimXZx)D+Gk^*4( zAgch>ka|X0i957*-qK%v@i8Iq_ul)`>sDxqEGdPDDdgI>_6R1(bxJB^cKY^t90;fj zTvmGceCLlo^0n_hVz |`f$AK=ez>F~ f;8;oP zXZa=tpL5)!3!ZWuX>=LF1BpTa1`NmuRJftCvdJet`Hv6TUg!6J?We9?Z7jhWC4fPc z5+p0=uFI>l<`9#Wb!K7-*ePsG0fB5 (Ae0}{ F&d05nj3?D2#c6iF&hxg15e zXrTvKsEJNgn!HQ{7bxd*@@v=?Nx=w_MCT+Jmr;Zi1bATT63}Q}($=TH@?^uK-+uSY zPA+o`mc^w{v|$7fE5T(yfW~u+OmBZVO`b G2k;g7}KK$AHzy5>EUGJ2S29Y6cD0#x7q<~3hjiL$hfr2vUn}FtH5z`uAP&kfp zfdab==S;T9ywR^fmHJ#H3X9f0w+KoauxErMHYu5eUe|piO7_@Ajf*Tpkt{Nh1*dNs zN=M$5SWlFwEq)v|6kWu>1=kFD1~5LTqvm|qe(KAQ?%Usb|1aElqKRdp@Ccq@eG8OH zpaf-5S`Qg}kqm0QgSP2=G)i=8NKASq3XW5^q(tiZz^3Zy4gZs0xc~ECd#u~Mu}7<9 zSga7SkgBmLw&pIdKxt5N*BmmPB5F0jS_VuYiA%wcQ4D{`1|r>Cmb-xlENX~-sAxyj z-=P=8jHXJ{GWVs3dtm@jUIC Ti5h@3%E!u`(Tu$%>I zK|*9sD8cfHW}Znu>0p+Kc8GhU4Q8K;bFx4H(~NvJ$@0@y_xRU1%P9!Dh+ooyGV_pV ze}SSgpIt&c0)cH04Ic-gjX(X%Z?()X{?Z$c954j1CyrHH1|;yGD{9-Wk=J*jtTS&J zMU@mpqymKybr@$m_O?5o`0KAcewDA?rb8X;DTJ62oR>pU*uw&qv8T+l)ll3&l;wOp z_{e~?!Ktg^deo%wu3>;*np+fTkkYj$IZe@>IRzFV6+l{CLWb^3AutT*sY>ECx2bWy ziw}PC8_m{R-}$P;s|;o_YF)@^Xgb;xzCvEg={|*#xNCtXeehBl=gVpsf5O#UuM!kO zuqxVNbEZq5z5Cg}`oa&_^<-OCdhDrTA|(h1XBGgmH|9h?1pojb07*naR3z;QW7q5r z<47H@9ACehlkzfMLhKT!6lY26$l$YCi#j{qb96PcA;DSe;WdddB;q6o%3rdSe$6th zF|-tA%m8mdkiU)G`BJ2agT|kX*pJJ>W)%!2W(99sG2Ys9?al}P`kTw<7vA`?!v`Be zDl7zLA`ZM&)AeTa;|ip#%$aAE3olc3guNcGGzF-t54A9D#(AH<_M>xu`tg4{*FCiE zFIjF1E3t6jw5X(DF{BIi+5$r`=#>*8@w@Ys0t}HM;_xdE?5pF?QWD8~+=1!hQaUBS zW;85(QbF#3+)%0ycK4dw@*&b#s#O^!j6y@R-*<#(;#~(2`5@~u7!W+qYgw<^Hfs;R zAph|bcRX-< &eE2G}1(7f`PXyNI)Vp;PBeJ6%jKhzwTr)k~Y+K)JI7lH*k zO6#IueemqR`;T9}gzGnS)rzO&xdc+=94K9$4FxGA$Spmc<`(QHccHkjqfzpd%mhV7 zaF#|D d0i>g8aq_L+5>GCdv}`Gag2V>~ zFsjPbW!r|oafmLx`bEwE ;dbx)`nBgES&(H}YCDX;_SU=s)5vH11lx0Ub4#P8)EiJT?~1SL `t-{_LJ(7XKkY=`_}2$51-Zl_tW2f`r`JcsLx`FWKi1KrCUBP@@*ds|LT7l*f{jn zm!-q*O4|A=pk4>|LAIYPKc*l~LPAR*0V=^}P>6hB-RdJRbpPxVcRzJ?b(8m7EP*^x z0x=<2qlp@aII|SAK7Wl+sa`7Y $4$G!I-b+o_z*EQj_A{7iGRP!vF~ L5bb{N8v*}%RZ8pZ716wR4J{Y>1Nn=G8- !JB{eIe z03{c}5S$E}x3n`_m~EPJFO|pPobt#-O(KF7kZCeEI^=B_@0&k4d-Tgc`MvkudgEGi zg#l#+7&c6r+V5V4Dugq1q5;YXE3-0q4^De8CYYC%dj=gA3$W9bX{8C45=3Bv^QhS! z=R`ed8+iI^@6SK^?faf=uhUU6%LI>MBv7eEmfPUSr=Ylsq`y*EQ{rR2Bs#U*$TM1& znM>oeVI~2>mESiWOfyQtR~leBA^pw?K8b~M4w~YVBsu0{5T3T72zUEs-iwM%7qNFN zH&XGJi2X~+uW`f%BCpUQ?PXqr^9y6QUGON}$|(_yAt+I!t)z7P_OCyB>`?1B-};i1 zEp9QZvfFV4QBTpU(2%-z3f%PA4W83;z||BGQek2Vwp6MLA#^~`Zg&6T_WSO9;H BT8sktZm7uPksJJCl0K>=MC2#W;8)UM&o;p zz%P-g$c(kaup79}ED&EC8hW3Bpd8vNg``8~+_wMeFF*9@uRW>DH*d(AFoVbh8^yG6 zJ`blhEihNe^2q9c&UAE+8C}8;1LQCEErZGjHw^eK#0utOM_Wf>DcRi6?19)>HiIaX zQDT}v_e1}te4WY&>I%w!om2+4Oi%HwdXq&YghG$0(eqXG(DPfJzqswLYp;FZ8?N8S zfw3!!p%svj5~&$Y=$&tr(yD0;d!9)3J%v zc?is0omO1+2uSl0fYZ6iuqVI^Bv{&Hy1xK9Wx7HN38rAwA(D5dS^0nP*Y? z(2W4>EJX3Yz` K!Ai;h&B2H1%x=3zazJ=%0OZOMYTxbUv3*xU{3rc z-j_YAmORx)quV@sdi}t^|KR5yz1ZF&C@jM_YFV7Qi!{{GrRea^1&R3K=i2<);?9xt zp#hr+RO1T =l=H(fAjhFv2{Ht*eBF-IhUMwG$`QVWi8C5+LW*N z$RFNGOyV+wo~}3E_<@aLPBppa`XP1IiueeQFx#b|@fqZ7(2pRlAW8%}B&_qflrfua zL{-p`cgf>Au^UoSLO+^k*l rDb%vEg4B|# z2Q=;Ws(t>AFY*4jfAg+e_EAfFMwzss3N;F{P-ZppB=iOIyuzg^WB9PmNkiukl5`k# zN7@=C?vidY-~Y_zzxdR5POq !eB$>LgSq`B?5P6c3Jzg|Ae8W;{M{F<37-;qH1G* z0b((NS>hTiaa_RaN42_!hRvn87?k|067xtvysl)ufuNR*hfR0yK#92m6H!mucm3-F zJ! F@pUG vhD-pnN|3$>ja7LzkMt14Zpbu^43 R{Lf}2S-1{bzMNf}@$z&1PsF-SPlgI@OOAHMU<=e~7% z-CiT!nj%nPM8~>C85b3tFbmbOH!pG!N}7BoDhQ zzZ7P-9NhayY)7(QhSEWd$_NDNn8S4RoTd%+{f_IOIODik)bOY%9muAWdRGNNLW4Q9 zvbscLfv9~?P73{l)00<;`UwdWy+p+)P{rRjMzD(}h9a>8j=u=!ub2WKHp!{WxgWW~ zSHKcO$@67(LwuEk|M#cvI{9zkZq^!yjj|HKC?Eue4a?(j+cBKY=Up0=O4o=*Mx2qL zPD>VoLS8}u`OtOY`<}S`(cAC2&^_3pRZB+6xP&)R+5(CeJ(KbbhP?kOnkGjJ)DIU1 z68=%hgbCo_yuZvpL>Xf^B qjG@$$7QNx-n!IKdJVyC{`Im=f;>mdZz|Vk~$Pj@@hy=vS z%^odv&Cw^%c^|&*-ZPuMZB>OhCs~0gg)VS9?z)|=EqM{IGXlb)me3)L5t?)aXe*w% zZ2$5z-+Sg#vqQ_m3<3#=Ah}w^8YK5r3u{g-P^}sHiAMGs5`#S;Iw$f7Bp%Xu#3DtC zwd6K|%4vQ|re+wDE@5 6%A>`<*X4-lAq84Tox#osR|A836=m$rW{eEpVz1RZvfHsU!d2osZpl z|COtH(r@e|@|2j!u+sE0H_x8qERaBvn?TW)UHgWXm=2o~TklwzIWdG0{T)gt2Z_EW zH`H-2?ZH~2LIy)4hg2Ng!%-7lkzvv4s^X<;x)79NIXR0lZRX0TH}C7n$n)<|hLz^J z?RMqhZD0Dq%WgdS=2sj)V5kA8*nEGH9fk$h84V1h=mFMg0-z# =H>KEXa9_gM4GQ3OO+Be zbok5}^nOwuEWA`|hAcn8{_lfyDV1?Q^G+QlbMIh0gQyQ 7RwLq2|#G?f>wJJ0Cgkb-Y7@rO4D6 zZrT=VuicGYuM7&@bt0#-luS@~;50>#WFVPoBAqw74D#ZHiti8VD{_BI#igd&nOAb! z#SY2unKd%^IZ(=1FwgC_P@qXaTPml1T+tqs9URqAMy;Nufb~RGz+Lj%%y-=P{B@_6 zf9)4;@;o9@BCaUP%@bzNb#@Sx+@ew?A~8zTRlLyl|KaY_Uw`oOR^vsgONKpP1WZsN zcXkmKyUy$e?DS<&7cz)Qew; N=ninWCW5QOnwBsx)C!i|3c_~;40>?X61r6%1{_UNQ-hBPh zH{5if0VdrI$NP@#Ih}SDESGsglNhhf)lt3;IYP21i6s@V5OpoqeZB9I3!nbS`#Q~2 zJ!-LOLX?f?9I6@CsH6?7lZECteu2p%a|x6jiSld8rSOX~Y({_OGZGKz#70OPlPo8C zw#d#=@Lur``FBcADjF$R&Y5+UdDGPWsUJ%|JM@u^@GMsy5e$PwRfV SF6t zpL^iUm9B-RLN|6Oycpvx6*N@Ml9tkb&MFkY z;F+yM+q4Q^=?R6hHUlc|Agd|9Y2UMU@^z+E8k4Q8!v3z7#The?5vN+=h+xckoi(fD zX)GyuW~@owN=l&;E}26!X+WZQ*7MAJGG3#w1B+;7rn-sxUJNb_DL%(Ju5S#*0u3*r zJfBMujgknx3DVljq2+|(K@kt5lyH+-V5Z70ygyffbE)F`U&CSvU?Bo4gW-XVRl6nK zdmeq^vv-~T*KfaZWeJ|52{i&hm0^hhcP0;zrHY(GV(XPuU)GMf$^ny}Rf#npzmF8e zWUSgAnRA=H+rIjf`yN~Gnv-IeKwj)@P^1$7F0dm}WS@5_g_VeGV2Ll-b-Di~zb1G_ zf3HAMhMBT$95&A=O@{Iq%z%qTdP_d2xk?oiF{xM~86 *#DvH8*xD#t8j2=Gks`Q&qyUfvu@OrHXb|0K z^!oPqsw(d}^W)ykyt%%5Yd-*e;-OKMm3i~#&CIiY=bWoOlij9Gc>L5ybz~&Tm63kq zC+ENX@XJe>9ck>V54`o3@hwYf6q(wU_xzWjLV0+&|C#ckLgCcndTPR92j|TlH`Slo zx1^Mppw2$GfCB0UDLARZ % zp$T^A0 G{F``JaCH+S=^U%qALv<7*;kPm?wour<4uH&l2= zaidbOU%)!xIZIK;&4mgw0i-$XpbQIMU)c}1YQt5G(S@i!hE!*8RDDX7P=9FXgo3C1 zZ#YKN)omsdrP%FR3+TbI44rjtg}NP5;ZCTUs%0@>xUO)js_a_1sRtpE8Ck A7fV_9fZ_n` $An|L(m!FKNp$*N@542zl(HZt?E4lD+}vyZwJ4Y2vhl4e;> zwW*G?MP)R#9SedVq0e$tfaaQcx~BDYL8Dd?jx52b76g&j>vJXYSP3v4n8<3+PIUh2 z+dsSOmIL?SwtvQG Jx@s$0x|*`0c#Syw$lIK{`8^a z|MIi9&PD_qQ3SYVqh)MLt+BW{8k0qj-@z-hb+(^n?gjg98&=%?OHfryyrT5Xxt0Ir z|M~m1&J7lmz%JHUyGB=)hyrAz*D!0|v`TV`WZHMtb6XNQ4d-p*`)%)Rg$qmZmu9|P zH+V&ENjCrV*X*+i&xL0HPhot{=9! CHID>5Lr* S%=w6n|NF zVb>9H>ykqL-8N)98LvJj+?1tky z<6Bs7U`jcP0%?O3SfZ6g{^on9zVYapVgDTo_0+8%DO9d?D%ulNshhWPRUnU!kS}dg z<=16z7C9GFxW!eZ$h>8_A3qa0sWc$U?V-Y1>bEqyyJ@`=JL`>H`P>*^X(^mowgUoz zS8UZ;YKBp%78LlzLi`0PvY|SfGfacQQT*JrGC_PlT#)vf4`VPr=U>&*<59b(E3&2@ zrAI1CLJ;@ef(^0gBv0S;=U;vN_~+l>KiD(Ka-_;vyl1n;L#b1;@q>;?p*`4aVU6!b zyeb(&>yhN|5{xhJ2_cB8TBQY;Q;Tc=$DcoGCvF;|s}%c=q9P#LywGV>Qg*S7&K4D= zYNb&vv*fK#qe-RII l9Fb;)wMaAJ;fCbuWuzj7eHx}0RGOw4hb%l- zc*8UJYbdG1#5K?W^~Y)fW}S^t3|SPIkZLVgN&^hEp_jMG2=F6_Eo|4qepKY^n(st; zv$tT53ILKFMd_vbZ!S8b?h-X0kTegy1Q)C+l-i%Q%*xyhplPE Kp8j6RSqsr{NX||NXpK5+BaqQ-2x)PQs zf*tY*O*4O~?hpu~+GzveLM$U(;Y=iDa|ncWai}Jr9|eb{IP}WY(2i*Eee =Fh+K<9FO}VDgrkNrv%ZM^2NKHh^i|ecCHhXQ$C? z>z%p*{NuuVM9Ws6I=%exPc9^MQ({Rea_Xq3U5eygqm588$Gxz ?wX{M6EZoG{4Dp|D<6$fb&Qi2M2&GxRy^q0Q= z_zl-RaAKNcB~?zPva8)BV`*MjgSM)tZ>Rv@9;AB2r>a84yGk%noS7f~Uw`)fWjd13 zM1okdx<^^^#WlJ*v>rPsM|yH+O;J YXD-mEu=q%IBV=vA<{M2hp3EMOV=3xco zq|~@&$ak5dT8PDt(wO?xF3+TB##)f5MhYY v zyZ2_agH)FXo^CI0DeI5f_LA5rm^-2ODL*z3tQ1@}WzJGKU7blmHF(^#Y-I~jEsyhu zbt+p8nsv0^S~P)YV>Rx&JWidC+&tTcbQZq=ToXy!7H+VPU|y#af#$8aeYkD|Z&h&t zvmr}aFJod53h;4fMJpn30@EE>J10|r`d=P>_pL{!4)vJTh)vyB4hY2+puwJ?sQn(S z8)F>TB)>|MrtAl9#@-b6g0-4hVIf`xKll2|fBfnbb9Q!>BT)m2pze*4)O}3Q-AmkQ zyD3)%Kw0lH%B+T+K6~>|)fIAYZ>1$#DJoIRg {B27A`-HI&3aRQre!S)P$!fa{2Q5 zG!2*);Ab{@jr=y;Tj7G4xrxa%>bT`A;!xNWmR(@M1S??CgiSoq{wtRj|KGp)>A(8j z`=W??WQo|f11G2wDwf|SXxn*pwCQozJ*1`O?MBbF>!eJP8Xkc!EGK{dcTYZZE*ZpA zq69L5n?X~+%dUFa_8$Ci4^x#l2eu0pc`ncvQ(?z>LwVr;ueEhOyl#+J9LRQF*X(Fh z0ZX5YBi9aDQ$QiGw=~$NFhWxb2vHc)MCsYLm7VK`Sf>p^F6m3qzWSoqwdRym&YanW z)NtOC6ci$^YiF=dTkZ{346i45r8S8@ 7)F^GC0(#D@nkD#S!7;#K^84a1MuXirh2 zvKbgK0bYPuYr#dSpz`Ld-?@;xo-~}F*o<%7gdVf8{7waqjM|#_O_#I&;A0-U<)O+^ zkb+u3d5h0Xx@6{vh;^>8stj&zG?3eHrOB2&mbu_5}EeMb;*XnOKbu{>fJ!fBPSPv^NpQDFzB22(EiDw6jU6Q~PXfpk=@=)?TYU zY1wKnr>sRH5W@NS;g`Sp^o6DVFrENMMx&t3t>*6St9WM{5Ls7Y8ap}qrjDYJeUmSM zmaDC#8bqFb!cFQ@$*S4}!)4{8wXPalUgWd
Qo?Bopsm #hBEmF2(W1;da zdpLF7#86t+{BHP|MWcnZ1rmY)25KNMrZd8D4gD9+T>kqXzVzF_aI#A-YT~9N3RUHG zBY(S05x}K;6IzH(OEpe0(5oU^ny55kQB|KHC#hl0>Jrry0wS`@)_nc3v;Xko@(NwY zIx(^&CL+lg6O}U%3eK0YAF-DF$FYJmV=A~iOr`tVoGZ|#qiJ1Xe46Fe$UE%%`Mem;h#HC=u8fG7*1) z8aaNQK3bWCa5c1)iB(mhVB`AODnwY=_nTxU1C{iuIP>OFF^{;ECF)??l>byxxiaZi zgK*1ss^1Ps>23!ij3A^r1Ug8`$%Sq8uC+brTzx8hYG#W;!wt1>(yp)B_x!-14qLFN zb7eHMFD^l%3qrT}78HgJT(V^mD5XP+6Ez@?0F^{ZfrwxPc4*9BfA7@$-hKU@`?`}p zrX%Vrb%m{6`RcM_ca1f+&K70zRTnTDC(5-!bqX(8&}(yRU;Xwomsfcd8K#KTi6tr? zc#W<)QIUE0j||I5oE&l@g1oUqq5={D2+AO3#{~?On1w``nV75{GApyFSdd24Ayy`< zGBPR*VX+L2O(b?`j8Rf%W(G){z=11kVK}i+G6EJdsw4s-1C9X@*h<385G$&2WE3D9 z5j!CzfK_&e7=a}7@>N-Gpwd=Q;7!r^BAklGs^&nYEXQzc)+X(t4an1CNR Kpne|Tj7ARq)`uyvwfAOuK&Dn$K^?}%`kjIY%RCW;U^~-+~QZ8~dWJ5%uHt0h- zoF;l2m*wNxiYB8qBFk!7lNckTwi{EVBep9sh7m@y{m$XTvqukg4;|cpV5YZ!rn_&d zH`AY(o$U3y6cJ4tF#E7Ju}jWK=fpa)I !t(I^!f<|J^}@yZ za~I~XER2T3jz)da4meK42t_*TL5ZVI%H PM~GI!F_^vTAzOxn#btY$|?$N|>fH z!Qw^ck6q=80$!>mgNOXt%b>?h8f~-`o+C@~P$thWE&rGAKYPRH?&@~P=z4WacM)b> zI#E>mZSPBtDvdMk$_@(OhmGAAQUTN|5;9kY`0DpxI(5-3MbnmgUUgN=TFGb+SF>1D z_RT`$X-X9^P?HSaN!WJ^?Z|Xd-{IF~K!8aZN~+AHF0cb7j3#^1r9q4}mVuF_E+>Z% zOdZ|dKYsYgZ8si%+wmiZW;#=o+#&9EI3h9x17-3#cnpe!vW#KxCrRfd00EYuKq9eH zUjPe`VxVe82P0V?*h>rY@@rR4pS^JU?Bxsd^Ou%&aYcluB$^&-0*(?9Q4z$}^An#f zMC$4QN_ge!C5@n-k*`C!VbPSv*PQ+1QyBvXLKfsHEDVJ*sF;>pHyQt1*vQV~LGlyj z|1WJTjy87Np2kJfpqevr-TGAQwvzX(*4nF@LF2zHqOcSV%*+p;KL3v%xOw{MREM2T zy|*}#yMxg ZS?KKcv+k=@W&R&>HCcTaoj}B-1WT&1fsiG!v1&}iuC8=t;Xr?Q zXi9HCdGvjE-E+&0Gc%KMzhh#eE+Zz5pa#kUf;d+WrQM5N&|XR4OsLAl3>ZM VCO9?}T^h;!(4IcK`qO7#{_!(s&d=Ed6C>(aidU=|k|?o- z0U4smVOmri!%|gIfKtONM;+JQk<>mY6Qy=ksi1N|nITnn;86*amezjOsv^>mz}Si} zuQ;so2?xep!*g3rhcAU*N~pckY%}6eW>nOza9&N<;` iV~p5j zsOHjY_iw)Q+>O6-Prt_<5vl|Psx4@%gYPJ*>Rze-x$ES##mo(^1XYUGl;;!q(l?$t zzuF)6CdhOlM%6k-n$^5-7_!~baE*3|Tq8y24iYXUe|T+FBNCEfY^ gwF# zY5m0y-udzS?|a*kc(&7t8515`1{E(wIMYj924n{QVsbb}03aKLR|=GMUec^oy=zQN zNLUzRKn4_p1W>RVI$(_SqT8ms58i&y^5^bZ9F5K|B;WqwD?fPb>DTA&Fg{>49#Lm# zBc%zeCQ&dMVyGm{q|P}uUVMehcr@e>a)mnwmzZhgwSk|)*V;7u+I_|+cSz{)=0X^z zaaOe{oSU$Fa%-j}biGYeKVEIlQ1QU_m|6HuL!g}`!c2oi=A`qzpT7Q!?_Zv|ab}h& zsz5)QoI^WmB$O<23-IX8ItDdM-X%>GGNNcs=;7z){^)=H>$T1;E4<$# zR%Nh)5`Z8bci{SCm!HM4rdN38TCC$;wRJY*Wgf5cP? yeM#_qLNKrVr0dOm=hv5`khwYGEi#n$fIH3&2XA!;nww zaY>mOI82Ixhja*X09Iu8GOI(agv5JzU3-PJri*j`i8>6MRUHuzL2JOuNM66P^5bXc zzw^VNzI<+Zanu``={4yl8Y^No48yJ~7@0`YX;Y)>4+o$qS|*{BM~eJ993!n~TxDM2 z=k>(NCSKc+ac3)A4asb1TsO%Un2=Dd`R+2QnlG>d2(Mj@&80b3>>jWx7!k{qUirm4 zdjI@a-*v-u+*L7!Co@3bT@7~Kh@9Tyh#9q*Z+pY}7U~@ez$#r@O}_Ni@2}E Qiiq4jHegi&tV@^?AB#yvfbSNj3WrD)hZab=jS^VPF} 4pD)?Sf~&vCNh%rOsB7yiTUv1$&>s0zkKh}S1v3*@zUIPfAr!DXI@_# z8q=K~(8QW_U}9pU-o#Q;u41~V;G2O~L`CUC%~x&Qv!rm;qKE4^!1&5d9+z+Q|7lHH zXVXk_(b+JCFj7< r!K%2_uA$mBOg;L{*{ANAKX~WC zh=_~b8YqpnT@{|}&JqRo*9N*Cw4fU|did6_cKonY;q`!1Vi Gxqs7W6ot7EQGQO!x)RW&~M>(ic)(?u#>V^P;nH@2CfZA(#298BL$_ue=t zfRa@bc5w`=%pgg+Y<09e$;(sG%5{g#J+~iu|6NDldGoPjGf|hhXSm~=#pa2)6bzpA zg;j-QIIa|nlqP9vFrgYqQZVh}HHOKXse43GC}76k9cP^EVrHmDsu4(_U6z?19lBxW z&f_z`bngwP&dope vcf-Dq( zVI@c`nh-{Dhpi4-haFv-<>meRhVMMlzyDo#zW1)1j_r$PI;L;XbuK60+q(8vQbF2j zxp-pmI5pb=MQY3WYYCfIlxr8kHlWBv0D92A;pqs6CimZU)9m7kedguMKYZrQQ!ib< zusU2APK p0XB`YME7GR1Jt!yip>oMi7Y@w$W&Lf>(OH zu&=xL;CpZS ~w81UF~qRuEQsax+0AwFVk9 zPj&gajyd+uBlq8N_?0W8Z$EzKp~qglyb`Y@{h{=U`&Nk|RxC$MhD9aBUZ({&GMI#q zl2B2hu1+jte}#^40g||^s;_sYC4O3W`QuU(QmeXKbq+05ozg}*L~0?06L1*y_Wq)7 z@8CnQGXbJ5OF)%iA;j@=5`Xu{r|-G_$kZeN=7i$TaAy#me8R7&i4_0$0FwsCaF0 z^q;@+#6q%f6!n#3ms9BBo&i$ks!sZ?Nh(B2OdwJvbqOj05+zF}>6$@bS0-uU!~yxx zy*GX2y*J%{bb4RU^neJ&l<5v6fRJTkDk6`HtY@q_ZG;1cg=#kQnx~x3JNN|Rz}lq5 zL J##5BhZfKoQEWvb!~|ln@Au_Ji=-vo z8Y4I3eD;ByvZr-iro4_@VuMRJj>y^EfaBXe<0u)Mn5Q}|p*XfdT7Z^>*$_IbN&jcB zE MbZZ(8`Q>Q;$Mz>zxfz@TjC%2Ja_v^E2WhSW1v7 zre2p19i6=G$i)43UiXuiuKeJMR~|j}`jXCC?pPQok1U87B$Qh@4p6MAr4s&|)+#NC zq?&r2T5A24)(NOv6Cc e$k7N!p32P z7?1lO$mflzstTDA_0KPjzV+y-_uh73VkR<>;L *>Dan~k>$oT9+gb(Go{Y&0>v ~C~?S~&db?I-v|H8}XE-l!p6)V7m1xUr@CEeib*0~`m zKSS+GFgISjuGiDODK?hXZ)ve4YJF~1HI5oIn3@|M?i9S GH#mzdACv4#hx8i3?!Eoc0QPe)CdM@Q~BuhXku?jewHXNvuul<=c+XuYCMHAAa}o z8}>(iLmdUfg2a`;filvLd&puKj>U@WwzbZ!tP?6%$4D(&84mA|!CV}8Ow{vCQX+*A zDamMpOouTw#Yf(K O*6vmWY^ZxAAY=(oy*U35Po7;G9+Q|roU4182j&`LdKbu{ zqrEQX?i`aQ1Z&8~JnHdMXZ7rF|MK0x@rifbe0X9eq8@=jqC#q!lt8M?FpmHFgdW!b zz(;cg@Toq{l% UdDjPD>(?g*knXRzGYM#|*UR!K!g*{E_1ib${T;dA#LyZ0Tx z{LROH{#W0AYCc&K>=WvP3^71#!?w7ZckE7eY9EHK @G|ecV$R|{;+AO(B^b6P&`T5m{Y9ApV~CJQw5I)U zKl1GRZh8O21TiG7#G0ROcS1Yix3Q$om@K|}rfo!$jHv-tCLw8(2GS==Kvn?~79$sj z^q;@?!ue!oXu4fyg&Atv$US@e^3k@z$+pLE1VB~iDFD1Sb-j6C#?b4nS=lNr#MsII zLm*g%5HS=C#FQ9WnxK^lx%kn$kNwuiK6~$N2WGl#h$6s !`ezH<_# zxj&Sj$ep$Rv1#mepHFvF5AzuL`D0OSR3z1tyzZMm@I)XI@9rlRL(+}R^nuRd2T#8D zj-%gr_=)fQ^wOfW3H1gxmZ&4jhDcQ-!;+9fl+>XWoG|lJ&oq{g@n%?!wbZV6N-20A z>`*S^lDn-muZ&j^g9Jj!QlV p)x()s?lVINz#u zKbt&wnRsjA8>&hQ2CI56E7(|9VjTwDStI$$OXr_CGr#}j>?HZ3wd^dOd=L<=|Kx9u zDwjrYF%MfFKvqD98olt^(&Nva6EmYW7T3(Xg%w=0dbUcg0yF?XC4I?NvmRwih71Xl zTBGw5W?{lC-F%pT?{gpd@LflaPngLx2+HbeNEZZH7!NL;_stZOtGf~{{X%sbJ_AIS zfc+7_^ZLn~f9<~ay=&?Jd+6z>U%jM!V5o7InFv-?L u;eX6HJk?+?(Buu{fcx9?;(>Pm`aqRbOhOX2NyH50| z=BR)-1yYTa%n+T$B>vVTFTC@Hdpli+wl}E~c6Hd ~Ki1L;9yHZvVF_4etTkKOxM-+kfhkDj?QT(i_? z)3IvUm<;ZhvQ*KuF LQOXvC+L)^yJcN1W%F5S%@WOy^5cCPL1 NxporbMasQ@vq%`a{rNus7L6))j(rSAvnlOIA6P8?)?j;LGWVc? EYLwmRIZ)MKg&+LYyjXu91Wg1bH#Z!in?Gg>KJ8 z0dBdJP9IYE%4vadw_I(3Z*DF+QsmUxTpJCe%!UXzhZ86UBP1}V!ts3}qT0F!RYY7E zs%X#oD B-5Ok7-xzx%}Ln~&VqGnCe2*$kuGmVC_(SHvluuT}mxPH6X2O}#Rs z1RxO=V-^Ho|KaIH-M^}niAIV@wrFyW*A(EbO<5xx5&|i!4{s8|lCE|z=wo4$&VBZy zC%^EiPrYrnyH7b1kgM4bqrM@GlQa?5t8bH^_$sMKWAee4h;GwQ>%~ojEoxnQtP%l6 zeGOA(RYT~(9`BibCueT?XCMF4-#_)x<7Zb4FN+=0goOb`jMTYI%DJnk9%^MMjB)4~ z<_!RFsa8a89-x?^St;>c@Vn)Te>lC;9ViJk=O`<$ScyupKrLQxVSeF>!V1~p^<{Zc zbquGW^;}i_G#EE1A1JUCp}YSdo;>}@_uo7-8+Ql<3& K{I@9+8E<+ITSi`s zu_`cTRrJil=->Y3cb4O~k0e$Ym(l@l&_J%y_E3r{^A@KNl#u{7(&0qBGA)b8`%8cD zTMvBvz8em7c@l^v%c*v~YJ5E#R`$9YH{@_%qXPNVB}qd71XW^!L=XcT8Sd}v{NZQs zzU!8wfBDdJFI-xPqC-QS5G92XZ>y-wxpi2;(xO*=TTR@kl(b*sTA7&1U2VZdlmd4F zNL6!ts*EYCS2&8sM=rstnKz|YaCL}*?6>tFMRE;$T9LO6iauBMbyW?m$ZBCwV~mPf zOZe=S(GxFTI`Qu7CL&NBbbEKmcd*{vk=s3)oh4uNgGh--KtlZ8pS(K2!9*s&v5M2@ z1SBrW;~MP{F;r4afgwbcP_h{5V1ia>^zs9D9rm9OimF;P*MSlSL=M?Z(#)~ z!F3kj^N(SR=V$k#eMG)uPGAG5Pt0*$N7*Qh+9MHv;$7Fj?fAZb_m#( |7}9R;p*yynaQPK=?~Jhi09n%Z@634L((k zulo4~g^fUI3vd^l%Ds!}zoq7z=?GL=Nk6x2jH>bBdaY~JrHLco`iB=Dxc$&{6vfnX z2X>sIV6I~w@P?B&89BC nPst^Ly19#70ZQA1#2R&`z<-9#PPYqW79K*$b^E_Lmd zNwa#iKlr`R-uvl~o;=z!9T`N3TnNXC8skt0EFdB#fx3)HqV~sebBO;PrK>b0)-=4s zNEwlz5PJtFDFB0{4|B(%iGTI^5B}-j{`6aqywc?Zi`J}YhvLZl=#*VW*7Bv*ow(Lh z)Xqb)%p12pW?X)g)@x`ud+o%Ew<8oMT6D=ap0y1kQSt2gF#`u+bz}vnC8%q}dKG3} zlJyml0}CakxHB=(_|)0?m(C9l+-RmFW6Ekn!GvqP;O(Gj_g~0uKV4gt$Qj%&8^C1% z5{*QD{M@<6pMM>b`!zD8zBTdo&guTrwNPac1jfCrN&qoP1g=x&HkQ|{T*x*(731WV?p(57hLBs8U3TRF6me#ZTUI=gFJ?! b`q~Nvs|pK4%IYkn3?q %+%(f9NsL#if=f)&aoP*xLfhbZ#Re#WM@WTi@=X+vYy02Vh{VJXv<0Ahi% z`s{QoYMGY=fAY7_%EXBgI&8Y)69X0MQoeS#0CTyGSV~);!-k|gd53s$tDp@eTR%kt zSVX{SNwmC6CYaP%N?;_3G3wDuUoU*>ffK*~nY(YluHSbp#~=!3mx`g*xS}@VHfYwI zN|o93yvECHc~;k3j*ZdQANA5WEw55jvSB*~jxCUrKKaTiK%~ZaOd$~<&_fY_;MRSI zzHt9veB-H~ynJb1W`;atNkO6_AXb>vRs0)u3y<03jdO%X*_SFkJ(!kXk&=b=JX zzzkR*8dCpzPd)#skKcZ9VrGIsDqvQyLI~E)Npom*^$c~F*_1-mY}W4;G{Mejd5Qug z6GX7Ug++PvsnZ 1Jw?BUSiRrk<#JO%)QgW#_Lu6YPYo|TN)pxHAZnWMATc1O^$*Z_!=AQ9B zB0#5O+ 7AyXeDj5r)z#}#0rApEzWwNn3$~-&A>T7}M>0d-%H- TD5hu7)x1IDhrtPtkqf+M$I&UYN}RtD)sRL21#OxG5MVzJpJLjuHS!X!gw5? z1rgL$fpE~!osa9ROIr_twv4c&?;-hEnzvd7JfGNaK5}Xp&kjb!CMGgL5nq85QP )j{?lK3-^br`{6Lp_EF_|^<)<+R6E-?hw(|UJ<-yzB zGhAglo1VaSjZhshD=Nbf0!5&h5KWlReelMqnfR|BdhXJSSrn5ntVYVLuE$swZ9}6( z->9vVa`gJz&uCFc#z`HnMxmrw<)>~+nRSv&f?`!3Eq5QTN)D`>XH&kK96Mm^eg(iL zRoU`Mrq@KQlr^a2NM!o_{N Sj;H)!FvPY^zy9V^WK(+FYJ# z>#2gMU>fXnyl++L<@3ptuPlwsG1#uE5tk(II$G_5tkJ6nTWC&}+vJp5vNKfxC9n{w zD41d vim#q@poT;XnN`|zWn2tmUNY5O`}A+WFi$(r&AYMIgRh> zs_QYPh5yx%6u5i&==ONH?}FCMa|#7Ib2lWp)?8tot xiL@Wqvbb9r*G0MJW#|D#Wzx&N-~r+RUR7-Ch0*)%zP#)R99Z; 58IBC z%gPo|LNq_L-}u3ER|egr*H?-FgS@-ZuGlwM(ZRBtrzTe!MU)H}%z|XGi{)v)^8Q<+ zKmPnj?mjU!!)o9|(7yX;h=$sZIybxK&RX}~+dGc6bt%=v2@-*PQ)C9ZihYJZaB_C` zbMO84-+1bU3l|I~2R!k+^nL&UAOJ~3K~yucs)&@>^$S-do5A2V^V$~SQCQTn8A;>B zfeMio(!gLSo24yt5AQg0Igv9PpqjXv+C0Uqm1ci(2De5Y?MYA&Q7N?IcS8U|n`;<1`vpV|;aR$1@^n0}9XfpWJ2PK%wA7>C*D( zJCD6GqJ2pcDMd=G>*ai0qfL<^FaUl)fxnS53A2rKsV5g__3Zt(#Q(!T`GtFKnw~K* zutefB7Yo(9oRfO5i=6Stf5SarUPbpIXaQLQ3^*2@G4!sRrhosl_uPFVp2d~kXfBd9 zlA%hXuu8TL&0P5-mui*%vF8$1UQrFL=erT*o-``_H2!)Q_D%(r-xdzuJPn1G{?H@< z3iHc!tZ}EBu1C6T#Kpf%dKH%1xywj}T~;rN5DgJ84!V#0^yOvIBoKO b+?s$Ag4R8a+0EFCt5 IeSp>rXuT!XoQ%6(+5m>vn7ow~Vnq?Sd!VUTH3uQ_0y?9=|?*qd{f1 z;%Lp%p|G=OHAo@;WxsLhs4z-YKVao-wW9&9eConbk0u0h4a6ZJDL$C)N!ez{W7|56 zS*dy)^l%BPPy_hUf>>Dp)HP-8v*FSlQpz@DB|nHQXlP|EvP-jk=~q5{;-CN4dvBPE zJE}|oVlVBzUdZW!H@ TPIB zX_+-HBBRuRUG?f7jc%OoeEt{T9-F5hdg`p@gUSaIbgU*IAr@ES&B1n!>t+!^23qG( zxCI4oL % zsO`{bhUb6z{n!8DuibNEGVTKgg{tzU4DI6R8=mL(;F6o)^Rk}3$KHd@LHpVVEO~yi zuNDkMtaNlX{`HUFfhhXU6Bib2FJTAK1-SqR8}faUvpzlb(=Omn$ENJ4^2HX 1NPyBp6hvlfRyj{v zXyK{mb1P0Vh4X;{b;*ka10d1P+#q`D)TLwZJ{CEP(l-^^fZe<~+D@snIak~EzOHo$ zMHMnHzP9lA3zwEKZHYk41iQy{)i)@S_YAPRlLX6}Oz@y%&)jn}|98LtiJNERNpc=d z@s-(1BQ!fm$DKp1FMe}Fwe-0tb{;DkR$Dyp_%vpQ#CE(^9ID%4Z|R5`s!)lc2DpAQ z`ogE*wffhOJ^u3C5)LHjDHy&=8#JxsUJX+RSb55Xx`%k>RhifK;gr=)@>RC|ISSIQ zrCh9J@t!g@r`a(=K~=DoOU~&Ox&~Esk6Bo#jRA22qa}f~eroDtZ!Ptf(jn6-hN6O@ z#EZl3cYgHz19u&pj56@}&bob^pEjxr)$}3Q&_MOcXxlpBbtaHe_6R!nt^z9-PrrQW z)l0(>I%$5+o6*?W-HaTN=6WeH04Bj`&@+Q+T)g%A;lKW8zjDW+c!rr(G*5;obBc{V zC$&kMRnawmD5$U}2LPvB?L>un<+MGXz1A_?X+w$bo2aN;73xOZ4OIX}wQuZ^e)Pr9 z+&lMgzjbP9bpSvbP+vq0zE}zU7Ts?nM?h@F7{Q{<+kmhO`vN8>IPftF*ZV+%hT1rbu@ND?=to zPfW*u{9k?OP-k(Pmtt8F89Bzv3W6nw&&*NO{AE8uJOUy8Yq{Pk1^YIwxz ^IR^c z>UcYZ!jznFdl9} 9- zh5}5w0W8BNd}ZSg*tT22F(3TDF#_$KAe4G_#-Ho9M`bpIpWZaNiup`nh-*)(3c~;- z0^NJl?8-0S{qMi_v{H9PBUR(-f+{F4NsV$cV;T(C!g)|$_ZFO9QM=#Fb)|4Gk1FuF zFZEtQ$}(dzuT*vE+fwu@n7i8j(p+5>qWrZOw#tF|X`J6r#UTlE2!bj=BaKhJa(-cD zxNo{+$>HLely+1 YN#qXBL()=-T;1@%(Rn{H|ZR@A#}iBo4SF z@Q*NgwKz4ew?)~{)8e%1t!Qx;NP2+tT;$~vMV&vwxA#+pRX{}*!=Y96KX_gQ5EFsA z9d>%9;Z$LVPxEH5x+1=mMYt8DP;S%nn!h#GB^!S|x7f6l;4Vc{er8k?hA@$e%rf79 z$FU0w!>>N_DkWxBy27zIJD1XGU~U%f7AV7`4k`|{^&ilBUR@KJ%Qm!VKKaTP)r?Y+ z(A+=ozGona{7K9YL}6QC$Vk4qfSfMlp~A$^^rw>a@!~0jJBPbz7C2H#qhIdM5p%RW zz =+U3AB9=G2@jfw?o wpM&q3B5BO* zWibJw(}o^=&kgfSiw`|<(ei;IN9hhW%R5%*rE_u_EU>kG`$__`c!5nKDsQ-%`lgD@ zlF628_Dl};A`c!(ZJC-80&^m@98dtEsg~cdT&%glQ*A8G^wdJ9)9j#BmIO9p5m2B| z$ n7FCof!l|7}Mmn4m0_H9N=*(5+_8mlBVnjXtTHOGeuq zsCvv1fDD9~T575av1Hbio_^u=%kzm)lvY`4YP)xh>d3EEDWg^aL`;dU!5CRvo#ZR` z-8TJ&UwPkkQ*n<3fi+o7f-8=_WiDBGt;y%Hr*%n`DFp|R@nVqJ0#-@ER&;G>*M_5o z#pT82mEkB6(L_`=bHlT*Ovwx)h&r1Zszl^kYD=oJTj am0~!QMu7S>N$yEv7tlxWgC}_qEC1KG?ijMW7 zPd{+m<;9;qcWD7ML&hZD+2rtIN)U1)yqG9c%61GpJ=Z6r)NgE |-~z5AJpgdxC>1Xoa6Z$yJx4!ARDI z! ;+F)%k9El-{lvp$tRgeJ_vk_5|v@0XSbGnoxinSRZ^r4 rr-B;2M zd=qOra?dAol!zS)R+7=>eAE z>jV@6o1^TJYmb3tLaWvjSnvim%Aq{?SSy^0XRlFS{c{Hj9g@uwFef2r5*n+WHSIog z`r>WJXS+tbY5VGG^#+wo^aNn&Qb9FQ1#2geOEz+NOGM zzqWghylLo^CQ>yFYe&qaNMbvhrVF3@*bNW9_xN;&41v-T#nnX)82I*0POax-o_S>T z!%ZSs9VN>v!?}g!`Niddg(z8&AZD _A8EBZO*wuZyj3cohVp1FXV^E* z%4K?Jj<|S*Yqyyrff+=L1I(?gT%23!b$GfzF+DjkJJsuTqL>l6=A0_3%0!ufb02DI zrHuUswbwpxdKuSsSb5G1_(o#JkYd`<9oJ8P@`G>xvu{3YZ6_jv8ZZqLh2b;+uB@cZ zJqx(`Z4G0y8)I2+aS>#HI%i1$6L8s@c^IXEd=Nj|udufHMU`BI3URqXCBftR!sUZ! zK~Yr~V$Bk(L-_-!Ktj|*DD|rAl4h-vG7(H-&6@Uq_VUGlbpOej$W*U)bII4W+uh-B z23rT2Pu72NOwX*2^zrA;uc4d3C^&_1q=g8tg?GlHf;W=LVFoQjNtUOu{NA@kU-;CW zhbIw}=H)$iy0At*xsZ%i70}4qrRC)-i-WnP!Sc$g5`&{ep)d+4af*2Kt}=p;)&LB$ znS+avm+AL |lQF()>y%Hv9KY@1LHW z>2e%1lpPu~OI_LfM82^S5hw!o#q@! kg@#Po9OB30+bK zDfzJq&nSppCOomVAc~-}mgu>Q%NH?w_1ExPN+jI_enL_s$vyL$~f2XkE;08HhkE z5GH|0kE4Sf{?z?9U%GJV*^8@0#zHJu;fzaEmUaV_;UvSpIN6V5Otn~DtwE$!y?n)* zZ-o<$X%+-e0u@hNEsz?{E{|U~p}N{_*)p_8L8KJXP@{!G^zv)-w;ejvGs3N4imlTd z#@1;Qgeg`%h0d~yB#blX7f-!%c|=Df5>-nu0I3?Vh3=L%{)VS4)!GBYh%6{E81{ze zzxX@%zw1Q5PoqdZcT^H_Xy%6H-LCXcH;#ZrAgap)dvR{*^5V+c&?ZWP4meh)NvBmT z{l#dRQ=}jO1b|d8BGS1s;Hm;^zFQ!nkaGy1)R$#EaL!f;CFRHi;K0NxN)##7iU{%C z@@Qe@!sLWGbYN=#Oh0B;R~>TG>$O`%PzacnVBN)cKsV1spT7U(nXjE%Oh&3B1D4cr zxc-3kjzs6rkW7?s=uvWRDhg93t7DYDg(nV&YMyPd#XnSqV2po;Gz1jXLiJK~ABvHi zstHw^7oqrHIJ%paDqLakB#DOTJon CDmu&qwv7lJB=g5`mon_szfWqxsW2#%B^CF68BB^PL+ zOqzHcMA0u&%~Z<+P(}ZCxPxY66de00A4{M8aF8PIh!RN*Gl*P-K>=pN#Hun7ON7Dl zU}1GIJHLEr-}LNcuVctCNG%alm<#DUc53>u=xHwTo%+??3YRH!yWC*~C7=sp;5~0U z{Ly! I;;XQ4)01+ zbR>HkLp>%tFXy|#%keC{LwQst+m_8LY*1MdT`9-QmdxEAWdl;;XfW!WIyW~rl>MCu zsdet-T-)vX+HqTFU7x_VBy9xmnJ8F^RuX;ul}p2TQj#vgcw>kFB+jMM`kpmUsU_dD zWyHNGX##4Qkt%sVuZpWdsKY+5Ob^fgH~;uI_s6C~uA8E3O6_v1n_SG; sGBJ4vd%4BobAWg9h}`a-HReJKA!Bcn3=w%>Kde&;QRvUx>1u7 zSh;D3mI3lX{3@9=hZGFy5=_Lu^xhLM|NQ(jbIDLOP26E$R;OWja%ocjVltaCGcUOa zEDjHBzv6(hh<)zsOBr`&nKVB$#7%A2VaqPNdcUmy%a5y|2~xKjLA1u+t*W%8`J}ae zw1mA@1zTc;7&c-iFRUz`T}ZC)^Mu1b %`ZQG30KS@3?%;x3&Qy07k4Uwgq9b9kS8dg+KiK{cpeVz=X?^5n)4l4J$1`cQ)|6 z6uj7FK}`%L1EoZDBzpG3+_?*vEjo!Xa||*n&Is^AYDXQ@E!31jS>2PF!{}}}274bA z2xP4Q@#1o_x^iKn7u|Tn(SDB&5HnO00+WUcZ#axI^<+1n_@;OR1Ba(OpZVyWr~l%S zH7Fvoh QYln5GwK_mMm1rnAL&2diLVohp(5oF!@britWb68z!#PN{qC?hC&lf z)-*bOVfpn7BkjgO S6BBeyhm2^l91`}Ai?K=LAkG-R>=r}FfxEh7No-fpU zpEifV1=6C%*%ws{Vltu_iC&yrIrI9Z)uAf)tPsJtKIP2Dy4NyK^%(nw+(~NT3ngf* z-ya}i0;{(Nj0h!4R_$ Z6crt+ {sYd?N%4xIrJlnE?Or6`9^oOVxaYLLV^%S_9LmfTtGx^}o*P?RY5fV9ZGDk;W! zqzh!AX)U(&jR+({`Rtjmw3vg_o|2QAo-IUap*R7l9YN52`R5l`?>eSkvz1q5-DQ7s zNS&M%q(lR&KYI4HL}#tWfKf88Re0b<<+cVV%@~!p8u?D`ssxQ#Ce7+T`}+U%N1s02 zH62y YWoT*YSLGc zlvO67AA8RYr_Nk{;`~YiBVZ*~7$OM_3;&@iIqHN#dU>0@z=CZ74s53q-YfW0;13|w z!^VoUt?uIL-e+KXr>ZfP_!4>7SXd36D&nWKO2HL5T_i2b1}V5An3%*>R)AsE?n|#; zzOp(zG8wxP@hM0!Xzsn9L>Wh=+p5%QPaJDGaEn-`8t|gyf21Q|ZipvNofn=NicvPc zx>3CwqH*>OC%*cx@2(OPTP8_B5@9%zT>jM$oqYF+nLZ)%7!EBYg4X~{H>5V^js_8c z*@{|Vb(EZW?b7ABr9_S8jz}bkl$d?U&ZR(P5a_(5bnRIxvFq?_x$oTgw$Ye~)zE*E z6+0*%>%x=deJV_8nKq9#7B$NQdF8dah2`PVL(|g}am232oG LIHZ2+fzBr2(Fvl|om;f8 zUmo6kpdT~XLG7#uZz_%Dwr=kc+*X@l7btx6x15C_9-0IQAyIR2Nlu+x9q}PWq-2m) z&e~x_;G2UATq&^(ViJS330=7D5We`Cdk)4fr$2A3*xXlsqfKW#iINgoU?4ibw0ip0 z3yG+ujwK^%l$gN5p#II|K+Nf0z1daGw6X`%I{3coLZRyq5tOY)5V<@*TwXqZ?7Dpi zX8W cU6>V&<-gU)3JCDb7ID)Hkm_YZ4ix zLxZ1k<3a;BiBkCJDK$y)3h*e52IarGp6QZr2yA_&VnS#sVp>K~*DE)LkqZDhg$0OU zK$Z+!p@GC_&Myp3&i2@+({FV3cf;1%3XLy#T7Pxb1HGqIfRWI-ORHxt4U$f5!9*s- zeOxPsGcE $RHV!#IL+Ix41Goeq TbCpv|z>k?XFtPCs zo%DkSPqM2eQQo QW$8h&%NS`0J-plpSWMfdE* zrInG)7#E#gzhU+!XqTi;69{A+!84vWAdf(fM4x`{%uqXth!TiR8lCW|I$7RFIH4_E z+?$iU1tX%76|7B0s}H>M`VZZGWXcdJ$cP1*R81tLHn>aN+ZqYjip(#soqp}Y%D@VD z6GQ~#`V2b;!TdZ`vivaa{@C`^-Hea;ePPu *x#Gb{Jl-0i*jU}nC>l37_ 8??Gu95)>%_03ZNKL_t*AE>>J^7@v3K2&*Kai%x*6!U9{iN(!T`!wg`S6$CtP z?-!HNji(+xzj1EGk&X0a8ZUg{17Z*g;0axYJ#jAk-XA`9^PfH%72b5B!5}e@nw+My zOqXm7osFe=e2M(p)C;5?h>n`^x1_3$7pmR8 Dv%Ep$=F>jlQ) zrn(ah(HYDE9)veD$* _3d3{ zrp&!bjJ2Z1i1N@V8`T<{_S5vb7w;}h24Y_JEmrw)=3m-8IxB&4NetvNiZ-7pbk|vS z(xaykP`bP@J)G(XSGON~=)&dms~OP~foUrARjH!Y|ISkHKgo8JqVXXVg|0b!?dglJ zJaYZ$vr)-hKyN}wsB5MSiOV%q<7)<)wAyUWt7g<*@|MMEusF3S7kr*KcWReUYc 5Yn*R{nx@dGkh~O~q@r^ka52!J0tt{l=sjv$k~j9p+eg#q zFAo4S^Twb$I|PjTVyoM1cDMznvB`B7W4abQgip@jX0JM-J7)pHr*Z4#9#f+?ZXf9f zH;z;;fjuB=XL@S0*rmRR2Tb=5-5W(bnxhF|F>y1zz~Bm}e#Q3x{hz+@`m^U(L?v3? zM8qfgujLwTN21Ld2P*cTm<{1WVpdZ%9!<+nZ|raHjf|^hh@cdq!Zg5~k}bQsVar@z zJ<&4pe#yu-A_FUqn4Sd-*IP#lo%`z@`tXyv1$7`h0KhjHe8@r_2B~1?JwCp+JuUqs z7uF@Gqc9Fd?Vv!-zY_nyKeIjFQVePo2YYD6{kzwn`t4`GI^w`vF6_ZXOqCXrw5F6J zO`oAP+iUi_kv0!jnJ15Zw53*kTX0rqd#%z|H@N#k3 4>f zp|gmbFu(&6-k68&4gL1ch|)_*ORTLOf1&6;@joI;lxDP4sepjOrag==t&IM+@4j?l zC3B{rf|^30?rKQQj1rB<3VM@+(e#6luN_UG(hEs}Emc`K9rEf<=|MrRPY#w_DDwp- z>3W}C-x`m`Pd<8ieISC6$UtYdrU&qt6)y;7M0@13k8ZyF#QEKi_dL%d9~fv9P*eDf z6NA>Z2)ccaY;(q}a_sJS5=;I`GOEnV%@yi;?f_e@f-md9B8-4QY! ;duZ;>qdSK>5?P*((N#vdp7toT^?*2x1-)$j %x*hnFOh)EB2Vc`DbU%a}>L(vIgbX0*6LK8qB zp~&ij_ffN%&PXo0;?&fAjy8vDkj*{6dEsmiJHIv?pM5S7A*&DJy@qN%=&-VC(`%eO zZ-^QL>|8Fc4;gIljE*K+Hd0Deeu=a%-qrj$4c9px*uSDBEp(1|u3AhHH>KdH)L*}S zrO*|xPGo8NslZgKzxzh_^uC?Om;ALT*{i8Z*D;zpl(|28YSaJu8;@VieU4J1RoBgN z)!aF05ZuVgw7hbC=c6lIV=ske%FN)*NV;aOt$U=1EZSmDce}e;kazQdW}rA`d5Nq* z@2If%KfSTNJ1W$CjUx}WyhDk(`z=z1%6UMPBxu$*eRw7R({DW0w~@1nqz#mHWnf9B zvYC SgTU9mZr1NU_b_;w8O>Wda%}k&I z6`DYyCA6e@nuqz>0c_mR(!ZwOyoqX7TE2BK-P)fjXcVikIlPNl+%_F!VN1Q6 A|UyqVGWneiV~gUonO^r&JPdnjr@;)`9AZ dyd~UaQG;_EU%ulkdd9itCe@%E zIymvrN$Eemc1z^p3mXGR(o`67N2T(HC#EH9cDVtkeLU0;jaw#EbMm~){_GE4`03mK zyko<$ 1=H>Eml=u`*dI5>>CBlOw`r zab))pIep(u Oiz0$V0k?s*S>&9(`#E+8s>#Vfk9UC2 zWXkF|r %4M$VGa$|>bX=AMiO3rFGjP>r$icZXUV1zd~ z3Pc^Tmhpf6!xw(_?p1H4`Yh~!N*L7UnbGFAJI||s=U1IZLu#C9C($4~12y|~<^~ny zHYgj`WIP=<(~09*+|~L3nIi2ZBVdqH-HqD^Q*Utb_o`3byw#_oyPbARd~WxRBxEZx zE4*F3u|M@$VPINLqdZ3gHMx z%R~SX*Yn6nB|f@(bAMPUqK~IVXE#5Oe+9-Uv9pSQF)=*6mi@siPv@A@7t#bupH?kf z=*8jUzXc%j +P^g<)@T9esF(Htw`rNsw>OeBSF?q8>;i?#xW6ZX9 zkH!-Ps3{ivPT$0m!O3E@-@@gzP*V$ZI>UFTbDi^-xNsUr0<1O^tupoMojWY_y=z;g z%PD!x1=2Qmw7#;j%h-&9ekN4Sq{gxCu-sGUO#>%iL(bYp1_O+lnx*=fC0IK8fj{^U zfAp1C9v-a3%+eO)Kce|ZQn2Cdo2shw!pyw)&N(DQbSO=A`(X0HXIrC^Dg%YUyrd;U z0Lo55wyuO0EemZywf(u~q@mDhGr ^O@`uLB3 z^W(njO+bUAg#|2dW~QblLJ xV} 1m1gsl7-y6;bMsm zfGN{9H3mOc=};A>Bxo8#@603?x#>hnND6{r3eHVKVC@H~pPS2;Tg%^oU zQha)S>-i@xuk@UVcvVN qIM@!_9*Fq&ZC1Jc){%G^wW znL+_okYb>fFz-mff~g|_ `sGF3A;1+f(|P}B{tM(f_CQD?kyS 2Nc_JD8=;C7T#*Q*kQ>Q8)@+lpEF22o;?; zXO4iD@Y4(>HTZxCsKBH$HmP}-Stz9HJm8Q?mQBrh>BF(~5XsfN8bK!^nii@?r3A!` zrQ8{=)|2A^85{$+3$@L%%T#IsztS^OD>K7Yn_-x;P0Z*JO%+$GV&@?;v=Jm?9X1tf zSa%d4EWojZ%cM}-+Bq!r(m*1F^iG%Pl1=`> xBxOn|c!` z$=Jzz5DRLU+b98Z3J}U9AghT{04a?w%hgyIJhGysF)x9m(e$X89v=Dao!#Bt{iD&i zD78>6O0Ql?&FHdB#4*pgzB0IUe&h0`OBc@dHv9Q%FH@c7R7Wq+00>4Mzaq*YT{DJo za&g^=&Z9I>Ya5e0Km#-L5RdHk!6du2|I{O!q=OZx-qZcJR@D77XSxD=?xFS9p1%Aq zpA1WiNuCisv&@uQLZ`WuqGQ3N?`&gzaB*|>(#FQ-#<|Unb1VJ+pif7LASjip@P<;Y zRE#FNe{is~yMKFUck5udGuoL<)tH$Ll>L%U(Nm?-DFl=%P&(*j0+W=ARjDLmhTE9W z6jexBm)WNe5f5E6E(_sEv*3jYhl}Z$Dtlx~!`BoEnoyLbZWksaVAI@=dAQIji>P`S zvTIv=1!AS<>QPQNQ966 ;xZK#bzc3k3;13!o+UMB zs#DJXuBV`wT{?Vrw4B3lym;|z&u#W%VdT3cQP2ZNqvCh(Ul|sTSq{mZ1R%DiE}ZdH zvBtmYNaTZ_LV^~aE@@&S2J!jo2+runh2B1xe(>>)53k(VKP-mhqL7}6gE)`|F*rrc z<&Xu>ZE2u7LiS5uznS4yCbllc`HhulpL+E9rye=i8}yy#C>%UdkYzwS3&@Do3$pI4 zt1LajGOx)p!oxtM|`gk)ytME>;k7vKBj$MinSR-`Ol zag_VR_0`@>FFo_}vyWdsFKc}_$Xzd!fkdEvFnndT3Dv@JMJ&9bK&dMx55rKJ4ZTiE zzdJ0i-JZPv@r~boc>Q4P;d)*MUiykm&{Jfdp`4}(%;+=~XBVgmNP#JXdDd#4G52M4 z(2Q*7`04PTx-w~{{G`)O=CHI1PPBv= Ozg~nyoO}FqEXvFXQF7$%>edMPIc9xtgw{&voJ(d9gr~UmY`cq&bgj8(OG{qC z2{>+y7mEu(9VZtC#b5mK*UzswH?PFV+(x=CQzIEGqbQ-k(WHFu JCd@cya;^{mym3*pt<4}M5-p8L_y|sTd9haOUH}q6ivFf~r zRGPsbLuH6ePhJEXpk&zcRA42H)G> f}k>x$WGbyGghfcN?)6UjZx6uj1Z?x_KnZ4UAU~1NC7Z0`}*UfV}oixHE z+Z&Dc$9f@mL0z(#(&D6#Id_vkn<&u}3SuX`>XeKJ@Mgn8KfAeGO0J>88?r@N>gFH| z&V-LYDD3!M-?v(%!Xmf5$2a}AzH+(8knrO~12e~2#Q^W3g5$z}dhPb8q(^Sd>K Zy%efAg;T&F zE@ntbx=6i}Dv6XvC_^GK3-HRq4QQZPDHu;Cd+*+Q`;%)=J+}GP7oT|Y!g^24YZ &JqLv&;YyEMrE=HGwo za9V6`ZmjiXMJPgH$Te#Y7T^H^dohy|JiQP`yRjjNJ#?@n>Kipg0#QK|`kp=1%bwrZ z96q)AXJ31C_ptcv*3NsM9=!M2*6kxJxuVi5U?#bT^Cr&3Oc?BKDN1wh?$;6>+CRA# zhz-r4RGs5vu9l0=-m9L4>1#u&s)o68U~6!GrE?Z!i54;RhEwhwmd|cvi_A8so-+@! zmgrfL_g