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 0000000..0336cd8 Binary files /dev/null and b/internal/shared/pdf/天远数据.png differ