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](url) -> [图片: 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](url) -> 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, "

", "\n") + text = strings.ReplaceAll(text, "", "\n") + + // 清理多余空白 + text = strings.TrimSpace(text) + lines := strings.Split(text, "\n") + var cleanedLines []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + cleanedLines = append(cleanedLines, line) + } + } + + return strings.Join(cleanedLines, "\n") +} + +// formatJSON 格式化JSON字符串以便更好地显示 +func (g *PDFGenerator) 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 (g *PDFGenerator) 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 (g *PDFGenerator) generateJSONExample(requestParams string) string { + tableData := g.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) +} + +// addHeader 添加页眉(logo和文字) +func (g *PDFGenerator) addHeader(pdf *gofpdf.Fpdf, chineseFontAvailable bool) { + pdf.SetY(5) + + // 绘制logo(如果存在) + if g.logoPath != "" { + if _, err := os.Stat(g.logoPath); err == nil { + // gofpdf的ImageOptions方法(调整位置和大小,左边距是15mm) + pdf.ImageOptions(g.logoPath, 15, 5, 15, 15, false, gofpdf.ImageOptions{}, 0, "") + g.logger.Info("已添加logo", zap.String("path", g.logoPath)) + } else { + g.logger.Warn("logo文件不存在", zap.String("path", g.logoPath), zap.Error(err)) + } + } else { + g.logger.Warn("logo路径为空") + } + + // 绘制"天远数据"文字(使用中文字体如果可用) + pdf.SetXY(33, 8) + if chineseFontAvailable { + pdf.SetFont("ChineseFont", "B", 14) + } else { + pdf.SetFont("Arial", "B", 14) + } + pdf.CellFormat(0, 10, "天远数据", "", 0, "L", false, 0, "") + + // 绘制下横线(优化位置,左边距是15mm) + pdf.Line(15, 22, 75, 22) +} + +// addWatermark 添加水印(从左边开始向上倾斜45度,考虑可用区域) +func (g *PDFGenerator) 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 + + pdf.SetFont("ChineseFont", "", fontSize) + + // 设置灰色和透明度(加深水印,使其更明显) + pdf.SetTextColor(180, 180, 180) // 深一点的灰色 + pdf.SetAlpha(0.25, "Normal") // 增加透明度,让水印更明显 + + // 计算文字宽度 + textWidth := pdf.GetStringWidth(g.watermarkText) + if textWidth == 0 { + // 如果无法获取宽度(字体未注册),使用估算值(中文字符大约每个 fontSize/3 mm) + textWidth = float64(len([]rune(g.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 + pdf.SetFont("ChineseFont", "", fontSize) + textWidth = pdf.GetStringWidth(g.watermarkText) + if textWidth == 0 { + textWidth = float64(len([]rune(g.watermarkText))) * fontSize / 3.0 + } + } + + // 从左边开始绘制水印文字 + pdf.SetXY(0, 0) + pdf.CellFormat(textWidth, fontSize, g.watermarkText, "", 0, "L", false, 0, "") + + // 恢复透明度和颜色 + pdf.SetAlpha(1.0, "Normal") + pdf.SetTextColor(0, 0, 0) // 恢复为黑色 +} diff --git a/internal/shared/pdf/pdf_generator_refactored.go b/internal/shared/pdf/pdf_generator_refactored.go new file mode 100644 index 0000000..8d907fe --- /dev/null +++ b/internal/shared/pdf/pdf_generator_refactored.go @@ -0,0 +1,185 @@ +package pdf + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "runtime" + + "tyapi-server/internal/domains/product/entities" + + "github.com/jung-kurt/gofpdf/v2" + "github.com/shopspring/decimal" + "go.uber.org/zap" +) + +// PDFGeneratorRefactored 重构后的PDF生成器 +type PDFGeneratorRefactored struct { + logger *zap.Logger + fontManager *FontManager + textProcessor *TextProcessor + markdownProc *MarkdownProcessor + tableParser *TableParser + tableRenderer *TableRenderer + jsonProcessor *JSONProcessor + logoPath string + watermarkText string +} + +// NewPDFGeneratorRefactored 创建重构后的PDF生成器 +func NewPDFGeneratorRefactored(logger *zap.Logger) *PDFGeneratorRefactored { + // 初始化各个模块 + textProcessor := NewTextProcessor() + fontManager := NewFontManager(logger) + markdownProc := NewMarkdownProcessor(textProcessor) + tableParser := NewTableParser(logger, fontManager) + tableRenderer := NewTableRenderer(logger, fontManager, textProcessor) + jsonProcessor := NewJSONProcessor() + + gen := &PDFGeneratorRefactored{ + logger: logger, + fontManager: fontManager, + textProcessor: textProcessor, + markdownProc: markdownProc, + tableParser: tableParser, + tableRenderer: tableRenderer, + jsonProcessor: jsonProcessor, + watermarkText: "海南海宇大数据有限公司", + } + + // 查找logo文件 + gen.findLogo() + + return gen +} + +// findLogo 查找logo文件 +func (g *PDFGeneratorRefactored) 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 *PDFGeneratorRefactored) 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 *PDFGeneratorRefactored) GenerateProductPDFFromEntity(ctx context.Context, product *entities.Product, doc *entities.ProductDocumentation) ([]byte, error) { + return g.generatePDF(product, doc) +} + +// generatePDF 内部PDF生成方法 +func (g *PDFGeneratorRefactored) 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), + ) + + // 创建PDF文档 (A4大小,gofpdf v2 默认支持UTF-8) + pdf := gofpdf.New("P", "mm", "A4", "") + // 优化边距,减少空白 + pdf.SetMargins(15, 25, 15) + + // 加载黑体字体(用于所有内容,除了水印) + chineseFontAvailable := g.fontManager.LoadChineseFont(pdf) + + // 加载水印字体(使用宋体或其他非黑体字体) + g.fontManager.LoadWatermarkFont(pdf) + + // 设置文档信息 + pdf.SetTitle("Product Documentation", true) + pdf.SetAuthor("TYAPI Server", true) + pdf.SetCreator("TYAPI Server", true) + + g.logger.Info("PDF文档基本信息设置完成") + + // 创建页面构建器 + pageBuilder := NewPageBuilder(g.logger, g.fontManager, g.textProcessor, g.markdownProc, g.tableParser, g.tableRenderer, g.jsonProcessor, g.logoPath, g.watermarkText) + + // 添加第一页(产品信息) + g.logger.Info("开始添加第一页") + pageBuilder.AddFirstPage(pdf, product, doc, chineseFontAvailable) + g.logger.Info("第一页添加完成") + + // 如果有关联的文档,添加接口文档页面 + if doc != nil { + g.logger.Info("开始添加文档页面") + pageBuilder.AddDocumentationPages(pdf, doc, chineseFontAvailable) + g.logger.Info("文档页面添加完成") + } else { + g.logger.Info("没有文档信息,跳过文档页面") + } + + // 生成PDF字节流 + g.logger.Info("开始生成PDF字节流") + var buf bytes.Buffer + err = pdf.Output(&buf) + if err != nil { + g.logger.Error("PDF输出失败", zap.Error(err)) + return nil, fmt.Errorf("生成PDF失败: %w", err) + } + + pdfBytes := buf.Bytes() + g.logger.Info("PDF生成成功", + zap.String("product_id", product.ID), + zap.Int("pdf_size", len(pdfBytes)), + ) + + return pdfBytes, nil +} diff --git a/internal/shared/pdf/table_parser.go b/internal/shared/pdf/table_parser.go new file mode 100644 index 0000000..5030313 --- /dev/null +++ b/internal/shared/pdf/table_parser.go @@ -0,0 +1,208 @@ +package pdf + +import ( + "context" + "fmt" + "strings" + + "tyapi-server/internal/domains/product/entities" + + "github.com/jung-kurt/gofpdf/v2" + "go.uber.org/zap" +) + +// TableBlock 表格块(用于向后兼容) +type TableBlock struct { + BeforeText string + TableData [][]string + AfterText string +} + +// TableParser 表格解析器 +// 从数据库读取数据并转换为表格格式 +type TableParser struct { + logger *zap.Logger + fontManager *FontManager + databaseReader *DatabaseTableReader + databaseRenderer *DatabaseTableRenderer +} + +// NewTableParser 创建表格解析器 +func NewTableParser(logger *zap.Logger, fontManager *FontManager) *TableParser { + reader := NewDatabaseTableReader(logger) + renderer := NewDatabaseTableRenderer(logger, fontManager) + return &TableParser{ + logger: logger, + fontManager: fontManager, + databaseReader: reader, + databaseRenderer: renderer, + } +} + +// ParseAndRenderTable 从产品文档中解析并渲染表格(支持多个表格,带标题) +func (tp *TableParser) ParseAndRenderTable(ctx context.Context, pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, fieldType string) error { + // 从数据库读取表格数据(支持多个表格) + tableData, err := tp.databaseReader.ReadTableFromDocumentation(ctx, doc, fieldType) + if err != nil { + // 如果内容为空,不渲染,也不报错(静默跳过) + if strings.Contains(err.Error(), "内容为空") { + tp.logger.Debug("表格内容为空,跳过渲染", zap.String("field_type", fieldType)) + return nil + } + return fmt.Errorf("读取表格数据失败: %w", err) + } + + // 检查表格数据是否有效 + if tableData == nil || len(tableData.Headers) == 0 { + tp.logger.Warn("表格数据无效,跳过渲染", + zap.String("field_type", fieldType), + zap.Bool("is_nil", tableData == nil)) + return nil + } + + tp.logger.Info("准备渲染表格", + zap.String("field_type", fieldType), + zap.Int("header_count", len(tableData.Headers)), + zap.Int("row_count", len(tableData.Rows)), + zap.Float64("pdf_current_y", pdf.GetY())) + + // 渲染表格到PDF + if err := tp.databaseRenderer.RenderTable(pdf, tableData); err != nil { + tp.logger.Error("渲染表格失败", + zap.String("field_type", fieldType), + zap.Error(err)) + return fmt.Errorf("渲染表格失败: %w", err) + } + + tp.logger.Info("表格渲染成功", + zap.String("field_type", fieldType), + zap.Float64("pdf_final_y", pdf.GetY())) + + return nil +} + +// ParseAndRenderTablesWithTitles 从产品文档中解析并渲染多个表格(带标题) +func (tp *TableParser) ParseAndRenderTablesWithTitles(ctx context.Context, pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, fieldType string) 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 fmt.Errorf("未知的字段类型: %s", fieldType) + } + + if strings.TrimSpace(content) == "" { + return nil + } + + // 解析多个表格(带标题) + tablesWithTitles, err := tp.databaseReader.parseMarkdownTablesWithTitles(content) + if err != nil { + tp.logger.Warn("解析表格失败,回退到单个表格", zap.Error(err)) + // 回退到单个表格渲染 + return tp.ParseAndRenderTable(ctx, pdf, doc, fieldType) + } + + if len(tablesWithTitles) == 0 { + return nil + } + + // 分别渲染每个表格,并在表格前显示标题 + _, lineHt := pdf.GetFontSize() + for i, twt := range tablesWithTitles { + if twt.Table == nil || len(twt.Table.Headers) == 0 { + continue + } + + // 如果不是第一个表格,添加间距 + if i > 0 { + pdf.Ln(5) + } + + // 如果有标题,显示标题 + if strings.TrimSpace(twt.Title) != "" { + pdf.SetTextColor(0, 0, 0) + tp.fontManager.SetFont(pdf, "B", 12) + _, lineHt = pdf.GetFontSize() + pdf.CellFormat(0, lineHt, twt.Title, "", 1, "L", false, 0, "") + pdf.Ln(2) + } + + // 渲染表格 + if err := tp.databaseRenderer.RenderTable(pdf, twt.Table); err != nil { + tp.logger.Warn("渲染表格失败", zap.Error(err), zap.String("title", twt.Title)) + continue + } + } + + return nil +} + +// ParseTableData 仅解析表格数据,不渲染 +func (tp *TableParser) ParseTableData(ctx context.Context, doc *entities.ProductDocumentation, fieldType string) (*TableData, error) { + return tp.databaseReader.ReadTableFromDocumentation(ctx, doc, fieldType) +} + +// ParseMarkdownTable 解析Markdown表格(兼容方法) +func (tp *TableParser) ParseMarkdownTable(text string) [][]string { + // 使用数据库读取器的markdown解析功能 + tableData, err := tp.databaseReader.parseMarkdownTable(text) + if err != nil { + tp.logger.Warn("解析markdown表格失败", zap.Error(err)) + return nil + } + + // 转换为旧格式 [][]string + result := make([][]string, 0, len(tableData.Rows)+1) + result = append(result, tableData.Headers) + result = append(result, tableData.Rows...) + return result +} + +// ExtractAllTables 提取所有表格块(兼容方法) +func (tp *TableParser) ExtractAllTables(content string) []TableBlock { + // 使用数据库读取器解析markdown表格 + tableData, err := tp.databaseReader.parseMarkdownTable(content) + if err != nil { + return []TableBlock{} + } + + // 转换为TableBlock格式 + if len(tableData.Headers) > 0 { + rows := make([][]string, 0, len(tableData.Rows)+1) + rows = append(rows, tableData.Headers) + rows = append(rows, tableData.Rows...) + return []TableBlock{ + { + BeforeText: "", + TableData: rows, + AfterText: "", + }, + } + } + return []TableBlock{} +} + +// IsValidTable 验证表格是否有效(兼容方法) +func (tp *TableParser) IsValidTable(tableData [][]string) bool { + if len(tableData) == 0 { + return false + } + if len(tableData[0]) == 0 { + return false + } + // 检查表头是否有有效内容 + for _, cell := range tableData[0] { + if strings.TrimSpace(cell) != "" { + return true + } + } + return false +} diff --git a/internal/shared/pdf/table_renderer.go b/internal/shared/pdf/table_renderer.go new file mode 100644 index 0000000..3bd0392 --- /dev/null +++ b/internal/shared/pdf/table_renderer.go @@ -0,0 +1,340 @@ +package pdf + +import ( + "math" + "strings" + + "github.com/jung-kurt/gofpdf/v2" + "go.uber.org/zap" +) + +// min 返回两个整数中的较小值 +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// TableRenderer 表格渲染器 +type TableRenderer struct { + logger *zap.Logger + fontManager *FontManager + textProcessor *TextProcessor +} + +// NewTableRenderer 创建表格渲染器 +func NewTableRenderer(logger *zap.Logger, fontManager *FontManager, textProcessor *TextProcessor) *TableRenderer { + return &TableRenderer{ + logger: logger, + fontManager: fontManager, + textProcessor: textProcessor, + } +} + +// RenderTable 渲染表格 +func (tr *TableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData [][]string) { + if len(tableData) == 0 { + return + } + + // 支持只有表头的表格(单行表格) + if len(tableData) == 1 { + tr.logger.Info("渲染单行表格(只有表头)", zap.Int("cols", len(tableData[0]))) + } + + _, lineHt := pdf.GetFontSize() + tr.fontManager.SetFont(pdf, "", 9) + + // 计算列宽(根据内容动态计算,确保所有列都能显示) + pageWidth, _ := pdf.GetPageSize() + availableWidth := pageWidth - 30 // 减去左右边距(15mm * 2) + numCols := len(tableData[0]) + + // 计算每列的最小宽度(根据内容) + colMinWidths := make([]float64, numCols) + tr.fontManager.SetFont(pdf, "", 9) + + // 遍历所有行,计算每列的最大内容宽度 + for i, row := range tableData { + for j := 0; j < numCols && j < len(row); j++ { + cell := tr.textProcessor.CleanTextPreservingMarkdown(row[j]) + // 计算文本宽度 + var textWidth float64 + if tr.fontManager.IsChineseFontAvailable() { + textWidth = pdf.GetStringWidth(cell) + } else { + // 估算宽度 + charCount := len([]rune(cell)) + textWidth = float64(charCount) * 3.0 // 估算每个字符3mm + } + // 加上边距(左右各4mm,进一步增加边距让内容更舒适) + cellWidth := textWidth + 8 + // 最小宽度(表头可能需要更多空间) + if i == 0 { + cellWidth = math.Max(cellWidth, 30) // 表头最小30mm(从25mm增加) + } else { + cellWidth = math.Max(cellWidth, 25) // 数据行最小25mm(从20mm增加) + } + if cellWidth > colMinWidths[j] { + colMinWidths[j] = cellWidth + } + } + } + + // 确保所有列的最小宽度一致(避免宽度差异过大) + minColWidth := 25.0 + for i := range colMinWidths { + if colMinWidths[i] < minColWidth { + colMinWidths[i] = minColWidth + } + } + + // 计算总的最小宽度 + totalMinWidth := 0.0 + for _, w := range colMinWidths { + totalMinWidth += w + } + + // 计算每列的实际宽度 + colWidths := make([]float64, numCols) + if totalMinWidth <= availableWidth { + // 如果总宽度不超过可用宽度,使用计算的最小宽度,剩余空间平均分配 + extraWidth := availableWidth - totalMinWidth + extraPerCol := extraWidth / float64(numCols) + for i := range colWidths { + colWidths[i] = colMinWidths[i] + extraPerCol + } + } else { + // 如果总宽度超过可用宽度,按比例缩放 + scale := availableWidth / totalMinWidth + for i := range colWidths { + colWidths[i] = colMinWidths[i] * scale + // 确保最小宽度 + if colWidths[i] < 10 { + colWidths[i] = 10 + } + } + // 重新调整以确保总宽度不超过可用宽度 + actualTotal := 0.0 + for _, w := range colWidths { + actualTotal += w + } + if actualTotal > availableWidth { + scale = availableWidth / actualTotal + for i := range colWidths { + colWidths[i] *= scale + } + } + } + + // 绘制表头 + header := tableData[0] + pdf.SetFillColor(74, 144, 226) // 蓝色背景 + pdf.SetTextColor(0, 0, 0) // 黑色文字 + tr.fontManager.SetFont(pdf, "B", 9) + + // 清理表头文本(只清理无效字符,保留markdown格式) + for i, cell := range header { + header[i] = tr.textProcessor.CleanTextPreservingMarkdown(cell) + } + + // 先计算表头的最大高度 + headerStartY := pdf.GetY() + maxHeaderHeight := lineHt * 2.5 // 进一步增加表头高度,从2.0倍增加到2.5倍 + for i, cell := range header { + if i >= len(colWidths) { + break + } + colW := colWidths[i] + headerLines := pdf.SplitText(cell, colW-6) // 增加边距,从4增加到6 + headerHeight := float64(len(headerLines)) * lineHt * 2.5 // 进一步增加表头行高 + if headerHeight < lineHt*2.5 { + headerHeight = lineHt * 2.5 + } + if headerHeight > maxHeaderHeight { + maxHeaderHeight = headerHeight + } + } + + // 绘制表头(使用动态计算的列宽) + currentX := 15.0 + for i, cell := range header { + if i >= len(colWidths) { + break + } + colW := colWidths[i] + // 绘制表头背景 + pdf.Rect(currentX, headerStartY, colW, maxHeaderHeight, "FD") + + // 绘制表头文本(不使用ClipRect,直接使用MultiCell,它会自动处理换行) + // 确保文本不为空 + if strings.TrimSpace(cell) != "" { + // 增加内边距,从2增加到3 + pdf.SetXY(currentX+3, headerStartY+3) + // 确保表头文字为黑色 + pdf.SetTextColor(0, 0, 0) + // 进一步增加表头行高,从2.0倍增加到2.5倍 + pdf.MultiCell(colW-6, lineHt*2.5, cell, "", "C", false) + } else { + // 如果单元格为空,记录警告 + tr.logger.Warn("表头单元格为空", zap.Int("col_index", i), zap.String("header", strings.Join(header, ","))) + } + + // 重置Y坐标,确保下一列从同一行开始 + pdf.SetXY(currentX+colW, headerStartY) + currentX += colW + } + // 移动到下一行(使用计算好的最大表头高度) + pdf.SetXY(15.0, headerStartY+maxHeaderHeight) + + // 绘制数据行 + pdf.SetFillColor(245, 245, 220) // 米色背景 + pdf.SetTextColor(0, 0, 0) // 深黑色文字,确保清晰 + tr.fontManager.SetFont(pdf, "", 9) + _, lineHt = pdf.GetFontSize() + + for i := 1; i < len(tableData); i++ { + row := tableData[i] + fill := (i % 2) == 0 // 交替填充 + + // 计算这一行的起始Y坐标 + startY := pdf.GetY() + + // 设置字体以计算文本宽度和高度 + tr.fontManager.SetFont(pdf, "", 9) + _, cellLineHt := pdf.GetFontSize() + + // 先遍历一次,计算每列需要的最大高度 + maxCellHeight := cellLineHt * 2.5 // 进一步增加最小高度,从2.0倍增加到2.5倍 + + for j, cell := range row { + if j >= numCols || j >= len(colWidths) { + break + } + // 清理单元格文本(只清理无效字符,保留markdown格式) + cleanCell := tr.textProcessor.CleanTextPreservingMarkdown(cell) + cellWidth := colWidths[j] - 6 // 使用动态计算的列宽,减去左右边距(从4增加到6) + + // 使用SplitText准确计算需要的行数 + var lines []string + if tr.fontManager.IsChineseFontAvailable() { + // 对于中文字体,使用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 * 2.5 // 进一步增加行高,从2.0倍增加到2.5倍 + if cellHeight < cellLineHt*2.5 { + cellHeight = cellLineHt * 2.5 + } + // 为多行内容添加额外间距 + if len(lines) > 1 { + cellHeight += cellLineHt * 0.5 // 多行时额外增加0.5倍行高 + } + if cellHeight > maxCellHeight { + maxCellHeight = cellHeight + } + } + + // 绘制这一行的所有单元格(左边距是15mm) + currentX := 15.0 + for j, cell := range row { + if j >= numCols || j >= len(colWidths) { + break + } + + colW := colWidths[j] // 使用动态计算的列宽 + + // 绘制单元格边框和背景 + if fill { + pdf.SetFillColor(250, 250, 235) // 稍深的米色 + } else { + pdf.SetFillColor(255, 255, 255) + } + pdf.Rect(currentX, startY, colW, maxCellHeight, "FD") + + // 绘制文本(使用MultiCell支持换行,并限制在单元格内) + pdf.SetTextColor(0, 0, 0) // 确保深黑色 + // 只清理无效字符,保留markdown格式 + cleanCell := tr.textProcessor.CleanTextPreservingMarkdown(cell) + + // 确保文本不为空才渲染 + if strings.TrimSpace(cleanCell) != "" { + // 设置到单元格内,增加边距(从2增加到3),让内容更舒适 + pdf.SetXY(currentX+3, startY+3) + + // 使用MultiCell自动换行,左对齐 + tr.fontManager.SetFont(pdf, "", 9) + // 再次确保颜色为深黑色(防止被其他设置覆盖) + pdf.SetTextColor(0, 0, 0) + // 设置字体后再次确保颜色 + pdf.SetTextColor(0, 0, 0) + + // 使用MultiCell,会自动处理换行(使用统一的行高) + // MultiCell会自动处理换行,不需要ClipRect + // 进一步增加行高,从2.0倍增加到2.5倍,让内容更舒适 + pdf.MultiCell(colW-6, cellLineHt*2.5, cleanCell, "", "L", false) + } else if strings.TrimSpace(cell) != "" { + // 如果原始单元格不为空但清理后为空,记录警告 + tr.logger.Warn("单元格文本清理后为空", + zap.Int("row", i), + zap.Int("col", j), + zap.String("original", cell[:min(len(cell), 50)])) + } + + // MultiCell后Y坐标已经改变,必须重置以便下一列从同一行开始 + // 这是关键:确保所有列都从同一个startY开始 + pdf.SetXY(currentX+colW, startY) + + // 移动到下一列 + currentX += colW + } + + // 移动到下一行的起始位置(使用计算好的最大高度) + pdf.SetXY(15.0, startY+maxCellHeight) + } +} diff --git a/internal/shared/pdf/text_processor.go b/internal/shared/pdf/text_processor.go new file mode 100644 index 0000000..47a784f --- /dev/null +++ b/internal/shared/pdf/text_processor.go @@ -0,0 +1,131 @@ +package pdf + +import ( + "html" + "regexp" + "strings" +) + +// TextProcessor 文本处理器 +type TextProcessor struct{} + +// NewTextProcessor 创建文本处理器 +func NewTextProcessor() *TextProcessor { + return &TextProcessor{} +} + +// CleanText 清理文本中的无效字符和乱码 +func (tp *TextProcessor) 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 (tp *TextProcessor) 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() +} + +// StripHTML 去除HTML标签(不转换换行,直接移除标签) +func (tp *TextProcessor) StripHTML(text string) string { + // 解码HTML实体 + text = html.UnescapeString(text) + + // 直接移除所有HTML标签,不进行换行转换 + re := regexp.MustCompile(`<[^>]+>`) + text = re.ReplaceAllString(text, "") + + // 清理多余空白 + text = strings.TrimSpace(text) + + return text +} + +// RemoveMarkdownSyntax 移除markdown语法,保留纯文本 +func (tp *TextProcessor) 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](url) -> alt + text = regexp.MustCompile(`!\[([^\]]*)\]\([^\)]+\)`).ReplaceAllString(text, "$1") + + // 移除标题标记 # text -> text + text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1") + + return text +} + +// RemoveNonASCII 移除非ASCII字符(保留ASCII字符和常见符号) +func (tp *TextProcessor) 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() +} diff --git a/internal/shared/pdf/天远数据.png b/internal/shared/pdf/天远数据.png new file mode 100644 index 0000000000000000000000000000000000000000..0336cd846bc505797f67bb75f5bf8b40175d8c06 GIT binary patch literal 145160 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

rn=U2S7Tbd0MFRERERb$}|=0plgz&Vl-;A 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

+*hyKi1+neMNBj#Od>dE@PmKRsq8 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