// // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // Package io provides a PDF writer backed by signintech/gopdf. // // WritePDF renders the supplied content with gopdf. The writer registers a // Latin font and, when available, a separate CJK fallback font, then switches // fonts per text segment. This avoids the blank-page failure where ASCII text // is sent through a CJK fallback font that does not expose ASCII glyphs. package io import ( "errors" "fmt" "os" "path/filepath" "strings" "time" "unicode" "github.com/signintech/gopdf" ) // PDFOptions is the public contract for the PDF writer. type PDFOptions struct { FontSize int HeaderText string FooterText string WatermarkText string AddPageNumbers bool AddTimestamp bool FontFamily string } var ErrPDFFontNotConfigured = errors.New("PDF font not configured: install a TTF such as DejaVu Sans or Noto Sans CJK SC") const ( pdfLatinFontFamily = "RAGFlowLatin" pdfCJKFontFamily = "RAGFlowCJK" ) var defaultPDFLatinFontPaths = []string{ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", } var defaultPDFCJKFontPaths = []string{ "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf", "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "/usr/share/fonts/truetype/arphic/uming.ttc", "/usr/share/fonts/truetype/arphic/ukai.ttc", } type pdfFontSet struct { latinFamily string cjkFamily string hasCJK bool } // WritePDF renders the content to a PDF byte stream. func WritePDF(content string, opts PDFOptions) ([]byte, error) { if opts.FontSize <= 0 { opts.FontSize = 12 } pdf := &gopdf.GoPdf{} pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4}) pdf.AddPage() fonts, err := ensurePDFFonts(pdf, opts.FontSize) if err != nil { return nil, err } drawHeader(pdf, fonts, opts) bodyX := 36.0 bodyY := 72.0 lineHeight := float64(opts.FontSize) * 1.5 pdf.SetX(bodyX) pdf.SetY(bodyY) pageNumber := 1 for _, line := range splitLines(content) { if line == "" { bodyY += lineHeight if bodyY > 760 { drawFooter(pdf, fonts, opts, pageNumber) pdf.AddPage() drawHeader(pdf, fonts, opts) bodyY = 72.0 pageNumber++ } pdf.SetX(bodyX) pdf.SetY(bodyY) continue } if bodyY > 760 { drawFooter(pdf, fonts, opts, pageNumber) pdf.AddPage() drawHeader(pdf, fonts, opts) bodyY = 72.0 pageNumber++ } pdf.SetX(bodyX) pdf.SetY(bodyY) if err := drawPDFText(pdf, fonts, line, opts.FontSize); err != nil { return nil, fmt.Errorf("PDF: body text: %w", err) } bodyY += lineHeight } if opts.WatermarkText != "" { drawWatermark(pdf, fonts, opts) } drawFooter(pdf, fonts, opts, pageNumber) return writePDFToBytes(pdf) } func ensurePDFFonts(pdf *gopdf.GoPdf, size int) (pdfFontSet, error) { latinPath := resolvePDFLatinFontPath() if latinPath == "" { return pdfFontSet{}, ErrPDFFontNotConfigured } if err := pdf.AddTTFFont(pdfLatinFontFamily, latinPath); err != nil { return pdfFontSet{}, fmt.Errorf("PDF: add latin font from %s: %w", latinPath, err) } if err := pdf.SetFont(pdfLatinFontFamily, "", size); err != nil { return pdfFontSet{}, fmt.Errorf("PDF: set latin font: %w", err) } fonts := pdfFontSet{latinFamily: pdfLatinFontFamily, cjkFamily: pdfLatinFontFamily} cjkPath := resolvePDFCJKFontPath() if cjkPath == "" || cjkPath == latinPath { return fonts, nil } if err := pdf.AddTTFFont(pdfCJKFontFamily, cjkPath); err != nil { return fonts, nil } if err := pdf.SetFont(pdfCJKFontFamily, "", size); err != nil { return fonts, nil } fonts.cjkFamily = pdfCJKFontFamily fonts.hasCJK = true _ = pdf.SetFont(pdfLatinFontFamily, "", size) return fonts, nil } func resolvePDFLatinFontPath() string { return resolvePDFFontPath("RAGFLOW_PDF_LATIN_FONT_PATH", defaultPDFLatinFontPaths) } func resolvePDFCJKFontPath() string { if explicit := strings.TrimSpace(os.Getenv("RAGFLOW_PDF_FONT_PATH")); explicit != "" { if path := normalizeExistingFontPath(explicit); path != "" { return path } } return resolvePDFFontPath("RAGFLOW_PDF_CJK_FONT_PATH", defaultPDFCJKFontPaths) } func resolvePDFFontPath(envKey string, candidates []string) string { if explicit := strings.TrimSpace(os.Getenv(envKey)); explicit != "" { if path := normalizeExistingFontPath(explicit); path != "" { return path } } for _, candidate := range candidates { if path := normalizeExistingFontPath(candidate); path != "" { return path } } return "" } func normalizeExistingFontPath(candidate string) string { path := strings.TrimSpace(candidate) if path == "" { return "" } if !filepath.IsAbs(path) { if abs, err := filepath.Abs(path); err == nil { path = abs } } info, err := os.Stat(path) if err == nil && !info.IsDir() { return path } return "" } func drawHeader(pdf *gopdf.GoPdf, fonts pdfFontSet, opts PDFOptions) { if opts.HeaderText == "" { return } size := overlayFontSize(opts) pdf.SetX(36) pdf.SetY(24) _ = drawPDFText(pdf, fonts, opts.HeaderText, size) } func drawFooter(pdf *gopdf.GoPdf, fonts pdfFontSet, opts PDFOptions, pageNumber int) { if opts.FooterText == "" && !opts.AddTimestamp && !opts.AddPageNumbers { return } size := overlayFontSize(opts) pdf.SetX(36) pdf.SetY(800) parts := []string{} if opts.FooterText != "" { parts = append(parts, opts.FooterText) } if opts.AddTimestamp { parts = append(parts, time.Now().UTC().Format("2006-01-02 15:04")) } if opts.AddPageNumbers { parts = append(parts, fmt.Sprintf("Pages %d", pageNumber)) } _ = drawPDFText(pdf, fonts, strings.Join(parts, " | "), size) } func drawWatermark(pdf *gopdf.GoPdf, fonts pdfFontSet, opts PDFOptions) { if opts.WatermarkText == "" { return } pdf.SetTextColor(200, 200, 200) pdf.SetX(120) pdf.SetY(360) _ = drawPDFText(pdf, fonts, opts.WatermarkText, 48) pdf.SetTextColor(0, 0, 0) } func overlayFontSize(opts PDFOptions) int { size := opts.FontSize - 2 if size < 1 { return 1 } return size } func drawPDFText(pdf *gopdf.GoPdf, fonts pdfFontSet, text string, size int) error { if text == "" { return nil } for _, segment := range splitByPDFFont(text) { family := fonts.latinFamily if segment.cjk && fonts.hasCJK { family = fonts.cjkFamily } if err := pdf.SetFont(family, "", size); err != nil { return err } if err := pdf.Text(segment.text); err != nil { return err } } return nil } type pdfTextSegment struct { text string cjk bool } func splitByPDFFont(text string) []pdfTextSegment { runes := []rune(text) if len(runes) == 0 { return nil } out := []pdfTextSegment{} start := 0 current := needsCJKFont(runes[0]) for i := 1; i < len(runes); i++ { next := needsCJKFont(runes[i]) if next == current { continue } out = append(out, pdfTextSegment{text: string(runes[start:i]), cjk: current}) start = i current = next } out = append(out, pdfTextSegment{text: string(runes[start:]), cjk: current}) return out } func needsCJKFont(r rune) bool { return unicode.In(r, unicode.Han, unicode.Hangul, unicode.Hiragana, unicode.Katakana) || (r >= 0x3000 && r <= 0x303f) || (r >= 0xff00 && r <= 0xffef) } func writePDFToBytes(pdf *gopdf.GoPdf) ([]byte, error) { tmp, err := os.CreateTemp("", "ragflow-pdf-*.pdf") if err != nil { return nil, fmt.Errorf("PDF: tmpfile: %w", err) } tmpName := tmp.Name() if err := pdf.Write(tmp); err != nil { _ = tmp.Close() _ = os.Remove(tmpName) return nil, fmt.Errorf("PDF: write: %w", err) } if err := tmp.Close(); err != nil { _ = os.Remove(tmpName) return nil, fmt.Errorf("PDF: close: %w", err) } defer os.Remove(tmpName) return os.ReadFile(tmpName) } // splitLines is a conservative wrapper that splits on \n and // preserves blank lines as empty strings. func splitLines(content string) []string { if content == "" { return []string{""} } lines := strings.Split(content, "\n") for i, l := range lines { lines[i] = strings.TrimRight(l, "\r") } return lines }