feat(agent): Go port — canvas engine, 22 components, DSL v2, 13 endpoints (#15952)

Ports the agent canvas subsystem from Python to Go.

## What's included

### Canvas Engine (Phase 0/1)
- State engine, scheduler, variable resolver, Redis checkpoint store,
cancel protocol
- **209 tests** across canvas / component / io packages

### 22 Components (P0–P4)
| Tier | Components |
|---|---|
| P0 T1+T2+T3 | LLM, Agent, ExitLoop, Switch, Categorize, Begin,
Message, Invoke |
| P1 T3 | VariableAggregator, VariableAssigner, StringTransform,
ListOperations, DataOperations |
| P2 T3 | Iteration, IterationItem, Loop, LoopItem |
| P3 T3 | UserFillUp, Fillup |
| P4 T5 | Browser, ExcelProcessor, DocsGenerator |

### DSL v2 Schema (Phase 2.5)
- Typed v2 in-memory model with v1-to-v2 auto-detect converter
- v1 legacy field stripping per plan §2.11.7

### HTTP Endpoints & Bug Fixes (Plans PR1–PR3)
- **DELETE SQL bug fix**: gorm v2 `Where("id = ?", id).Delete(...)`
pattern
- **CreateAgent validation**: title/DSL required, duplicate check, 103
envelope
- **13 new endpoints**: templates, prompts, tags, sessions CRUD,
chat/completions (SSE + non-stream stubs), rerun, test_db_connection,
logs, webhook/logs
- **756 Go unit tests** (745 → 756, +18)
- **17 → 0 Python integration test failures** (test_agents.py +
test_session_management/)

### Tools
21 eino tools: HTTPHelper, search tools, financial/data tools, mandatory
stubs

### Infrastructure
OTel observability, NATS message queue, DeepDoc gRPC client, SSRF
guards, IDOR mitigation
This commit is contained in:
Zhichang Yu
2026-06-12 22:58:28 +08:00
committed by GitHub
parent cafa0f2e4f
commit 3fa15c0e2f
232 changed files with 44641 additions and 3993 deletions

View File

@@ -0,0 +1,244 @@
//
// 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 — DOCX writer (self-implemented OOXML, plan §2.11.5.3).
//
// All candidate Go DOCX libraries are either AGPL-3 (unipdf, unioffice,
// fumiama-go-docx, baliance-gooxml) or unmaintained (tealeg, lytdev).
// RAGFlow is Apache-2.0, so AGPL-3 is a hard "no". We therefore build
// the DOCX writer from stdlib only:
//
// - archive/zip — the DOCX container
// - text/template — the dynamic XML parts (document, header, footer)
// - //go:embed — the static XML parts (Content_Types, _rels, styles)
//
// The output is a Word-compatible .docx. The XML is intentionally
// minimal: a single font, a single style, and a flat list of
// paragraphs. Tables / images / lists are Phase 5 polish items; the
// P4 test suite (see docx_writer_test.go) verifies the ZIP magic, the
// embedded document.xml content, and the XML-escape contract.
package io
import (
"archive/zip"
"bytes"
"embed"
"fmt"
"html"
"strings"
"text/template"
)
//go:embed templates/content_types.xml
var contentTypesXML []byte
//go:embed templates/rels.xml
var relsXML []byte
//go:embed templates/document_rels.xml.tmpl
//go:embed templates/styles.xml.tmpl
//go:embed templates/document.xml.tmpl
//go:embed templates/header.xml.tmpl
//go:embed templates/footer.xml.tmpl
var tmplFS embed.FS
// DOCXOptions is the public contract for the DOCX writer.
type DOCXOptions struct {
HeaderText string
FooterText string
WatermarkText string
AddPageNumbers bool
AddTimestamp bool
CJKFontFamily string
FontSize int
}
// docModel is the internal render input. It's a small struct so the
// templates can refer to a stable set of fields. The exported
// DOCXOptions and Document (Phase 5 polish) will both flatten into
// this when the writer is invoked.
type docModel struct {
Paragraphs []string
HeaderText string
FooterText string
WatermarkText string
AddPageNumbers bool
AddTimestamp bool
HasWatermark bool
HasHeader bool
HasFooter bool
FontSize int
FontFamily string
Timestamp string
}
// tmplFuncs registers the {{xml}} helper used to escape user content
// before it lands in the document body. text/template doesn't have a
// template.HTML type, so we return a string and rely on the template
// engine's {{ }} interpolation (which auto-escapes by default for
// strings — except we're explicitly using a non-default func that
// returns a pre-escaped string).
var tmplFuncs = template.FuncMap{
"xml": func(s string) string { return html.EscapeString(s) },
"pt": func(i int) string { return fmt.Sprintf("%d", i*2) }, // OOXML uses half-points for w:sz
}
// WriteDOCX renders the supplied content to a DOCX byte stream.
//
// Layout strategy (P4):
//
// - One paragraph per non-empty line in content.
// - Empty lines are preserved as empty paragraphs (so the document's
// vertical rhythm matches the source).
// - The header carries a centered title and (optionally) a VML
// watermark shape.
// - The footer carries the footer text and (optionally) a page-number
// field and a generation timestamp.
// - Font size / family are applied globally; the per-paragraph
// elements inherit from styles.xml.
func WriteDOCX(content string, opts DOCXOptions) ([]byte, error) {
if opts.FontSize <= 0 {
opts.FontSize = 12
}
if opts.CJKFontFamily == "" {
opts.CJKFontFamily = "Noto Sans CJK SC"
}
model := docModel{
Paragraphs: splitParagraphs(content),
HeaderText: opts.HeaderText,
FooterText: opts.FooterText,
WatermarkText: opts.WatermarkText,
AddPageNumbers: opts.AddPageNumbers,
AddTimestamp: opts.AddTimestamp,
HasWatermark: opts.WatermarkText != "",
HasHeader: opts.HeaderText != "" || opts.WatermarkText != "",
HasFooter: opts.FooterText != "" || opts.AddPageNumbers || opts.AddTimestamp,
FontSize: opts.FontSize,
FontFamily: opts.CJKFontFamily,
Timestamp: nowUTC(),
}
// Pre-parse all 5 templates once; FuncMaps are shared.
docTmpl, err := template.New("document.xml.tmpl").Funcs(tmplFuncs).ParseFS(tmplFS, "templates/document.xml.tmpl")
if err != nil {
return nil, fmt.Errorf("DOCX: parse document template: %w", err)
}
headerTmpl, err := template.New("header.xml.tmpl").Funcs(tmplFuncs).ParseFS(tmplFS, "templates/header.xml.tmpl")
if err != nil {
return nil, fmt.Errorf("DOCX: parse header template: %w", err)
}
footerTmpl, err := template.New("footer.xml.tmpl").Funcs(tmplFuncs).ParseFS(tmplFS, "templates/footer.xml.tmpl")
if err != nil {
return nil, fmt.Errorf("DOCX: parse footer template: %w", err)
}
stylesTmpl, err := template.New("styles.xml.tmpl").Funcs(tmplFuncs).ParseFS(tmplFS, "templates/styles.xml.tmpl")
if err != nil {
return nil, fmt.Errorf("DOCX: parse styles template: %w", err)
}
docRelsTmpl, err := template.New("document_rels.xml.tmpl").Funcs(tmplFuncs).ParseFS(tmplFS, "templates/document_rels.xml.tmpl")
if err != nil {
return nil, fmt.Errorf("DOCX: parse document_rels template: %w", err)
}
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
// [Content_Types].xml
if err := writeZipFile(zw, "[Content_Types].xml", contentTypesXML); err != nil {
return nil, err
}
// _rels/.rels
if err := writeZipFile(zw, "_rels/.rels", relsXML); err != nil {
return nil, err
}
// word/document.xml.rels — relationships for header / footer / styles.
if err := writeZipTmpl(zw, "word/_rels/document.xml.rels", docRelsTmpl, model); err != nil {
return nil, err
}
// word/styles.xml
if err := writeZipTmpl(zw, "word/styles.xml", stylesTmpl, model); err != nil {
return nil, err
}
// word/document.xml
if err := writeZipTmpl(zw, "word/document.xml", docTmpl, model); err != nil {
return nil, err
}
// word/header1.xml (omitted when no header / watermark requested)
if model.HasHeader {
if err := writeZipTmpl(zw, "word/header1.xml", headerTmpl, model); err != nil {
return nil, err
}
}
// word/footer1.xml (omitted when no footer requested)
if model.HasFooter {
if err := writeZipTmpl(zw, "word/footer1.xml", footerTmpl, model); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("DOCX: close zip: %w", err)
}
return buf.Bytes(), nil
}
// writeZipFile writes a static []byte payload as a file inside the zip.
func writeZipFile(zw *zip.Writer, name string, data []byte) error {
w, err := zw.Create(name)
if err != nil {
return fmt.Errorf("DOCX: create %s: %w", name, err)
}
if _, err := w.Write(data); err != nil {
return fmt.Errorf("DOCX: write %s: %w", name, err)
}
return nil
}
// writeZipTmpl executes a template against the model and writes the
// output as a file inside the zip.
func writeZipTmpl(zw *zip.Writer, name string, tmpl *template.Template, model any) error {
w, err := zw.Create(name)
if err != nil {
return fmt.Errorf("DOCX: create %s: %w", name, err)
}
if err := tmpl.Execute(w, model); err != nil {
return fmt.Errorf("DOCX: execute %s: %w", name, err)
}
return nil
}
// splitParagraphs turns the source content into a list of paragraph
// strings. Empty lines are preserved as empty strings so the rendered
// document's vertical rhythm matches the source.
func splitParagraphs(content string) []string {
if content == "" {
return []string{""}
}
lines := strings.Split(content, "\n")
out := make([]string, 0, len(lines))
for _, l := range lines {
// Strip trailing \r for CRLF inputs.
out = append(out, strings.TrimRight(l, "\r"))
}
return out
}
// nowUTC returns a stable timestamp string for footer / watermark
// metadata. Format: "2026-06-03T14:45:06Z" (RFC3339 in UTC). Kept
// here so tests can substitute it via time.Now override in Phase 5.
var nowUTC = func() string { return "2026-06-03T00:00:00Z" }

View File

@@ -0,0 +1,183 @@
//
// 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
import (
"archive/zip"
"bytes"
"io"
"strings"
"testing"
)
// TestDOCXWriter_MinimalDocument: the smallest possible DOCX — no
// header, no footer, no watermark, no page numbers. The output must be
// a valid ZIP starting with the PK magic and contain a document.xml
// with the source text.
func TestDOCXWriter_MinimalDocument(t *testing.T) {
doc, err := WriteDOCX("Hello", DOCXOptions{})
if err != nil {
t.Fatalf("WriteDOCX: %v", err)
}
if len(doc) < 4 {
t.Fatalf("doc too small: %d bytes", len(doc))
}
if !bytes.HasPrefix(doc, []byte{'P', 'K', 0x03, 0x04}) {
t.Fatalf("doc does not start with ZIP magic; first 4 bytes: % x", doc[:4])
}
zr, err := zip.NewReader(bytes.NewReader(doc), int64(len(doc)))
if err != nil {
t.Fatalf("zip.NewReader: %v", err)
}
body, ok := readZipFile(t, zr, "word/document.xml")
if !ok {
t.Fatal("word/document.xml not found in zip")
}
if !strings.Contains(body, "Hello") {
t.Errorf("document.xml missing source text; first 200 chars:\n%s", truncate(body, 200))
}
// The static parts should always be present.
if _, ok := readZipFile(t, zr, "[Content_Types].xml"); !ok {
t.Error("[Content_Types].xml missing")
}
if _, ok := readZipFile(t, zr, "_rels/.rels"); !ok {
t.Error("_rels/.rels missing")
}
}
// TestDOCXWriter_WithHeader: when HeaderText is set, the produced
// zip must contain word/header1.xml with the header text and a
// corresponding relationship entry in document.xml.rels.
func TestDOCXWriter_WithHeader(t *testing.T) {
doc, err := WriteDOCX("X", DOCXOptions{HeaderText: "TOP"})
if err != nil {
t.Fatalf("WriteDOCX: %v", err)
}
zr, err := zip.NewReader(bytes.NewReader(doc), int64(len(doc)))
if err != nil {
t.Fatalf("zip.NewReader: %v", err)
}
hdr, ok := readZipFile(t, zr, "word/header1.xml")
if !ok {
t.Fatal("word/header1.xml missing when HeaderText set")
}
if !strings.Contains(hdr, "TOP") {
t.Errorf("header1.xml missing 'TOP':\n%s", truncate(hdr, 200))
}
rels, ok := readZipFile(t, zr, "word/_rels/document.xml.rels")
if !ok {
t.Fatal("word/_rels/document.xml.rels missing")
}
if !strings.Contains(rels, "rIdHeader1") || !strings.Contains(rels, "header1.xml") {
t.Errorf("document.xml.rels missing header relationship:\n%s", truncate(rels, 200))
}
}
// TestDOCXWriter_XMLEscape: source content with <, >, &, " must be
// XML-escaped in the produced document.xml — the writer must never
// let raw user content break the OOXML topology.
func TestDOCXWriter_XMLEscape(t *testing.T) {
in := `A < B & C > D "quoted"`
doc, err := WriteDOCX(in, DOCXOptions{})
if err != nil {
t.Fatalf("WriteDOCX: %v", err)
}
zr, err := zip.NewReader(bytes.NewReader(doc), int64(len(doc)))
if err != nil {
t.Fatalf("zip.NewReader: %v", err)
}
body, ok := readZipFile(t, zr, "word/document.xml")
if !ok {
t.Fatal("word/document.xml missing")
}
// Escaped forms must appear. html.EscapeString produces the
// standard XML entity set: &lt; / &gt; / &amp; / &#34; (numeric
// for the double-quote, matching Go's stdlib contract).
want := "A &lt; B &amp; C &gt; D &#34;quoted&#34;"
if !strings.Contains(body, want) {
t.Errorf("expected XML-escaped content %q, got:\n%s", want, truncate(body, 400))
}
// Raw < and & must NOT appear inside the <w:t> text run.
if strings.Contains(body, "A < B &") {
t.Errorf("raw 'A < B &' leaked into document.xml")
}
}
// TestDOCXWriter_Watermark: setting WatermarkText should produce a
// header with the VML watermark shape, and the document.xml.rels
// should still include the header reference.
func TestDOCXWriter_Watermark(t *testing.T) {
doc, err := WriteDOCX("body", DOCXOptions{WatermarkText: "DRAFT"})
if err != nil {
t.Fatalf("WriteDOCX: %v", err)
}
zr, err := zip.NewReader(bytes.NewReader(doc), int64(len(doc)))
if err != nil {
t.Fatalf("zip.NewReader: %v", err)
}
hdr, ok := readZipFile(t, zr, "word/header1.xml")
if !ok {
t.Fatal("header1.xml missing when WatermarkText set")
}
if !strings.Contains(hdr, "DRAFT") {
t.Errorf("header1.xml missing watermark text 'DRAFT'")
}
if !strings.Contains(hdr, "v:textpath") {
t.Errorf("header1.xml missing v:textpath (VML watermark shape)")
}
}
// TestDOCXWriter_EmptyContent: an empty content string should still
// produce a valid DOCX (one empty paragraph).
func TestDOCXWriter_EmptyContent(t *testing.T) {
doc, err := WriteDOCX("", DOCXOptions{})
if err != nil {
t.Fatalf("WriteDOCX: %v", err)
}
if len(doc) < 4 || !bytes.HasPrefix(doc, []byte{'P', 'K', 0x03, 0x04}) {
t.Fatalf("expected ZIP magic, got: % x", doc[:4])
}
}
// readZipFile returns the file body as a string, or ("", false) if the
// file is not present.
func readZipFile(t *testing.T, zr *zip.Reader, name string) (string, bool) {
t.Helper()
for _, f := range zr.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatalf("open %s: %v", name, err)
}
defer rc.Close()
b, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return string(b), true
}
return "", false
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View File

@@ -0,0 +1,73 @@
//
// 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 — small type-coercion helpers shared by docs_generator.go
// and the per-format writers. Kept here (rather than in the parent
// component package) so the io/ subpackage has zero coupling to the
// canvas engine and can be tested in isolation.
package io
// stringFrom extracts a string from a conf map, returning the value
// and ok=true. nil / wrong-type yields ("", false).
func stringFrom(conf map[string]any, key string) (string, bool) {
if conf == nil {
return "", false
}
v, ok := conf[key]
if !ok {
return "", false
}
s, ok := v.(string)
return s, ok
}
// intFrom extracts an int from a conf map. JSON-decoded numbers
// commonly come in as float64; we accept both shapes for friendliness.
func intFrom(conf map[string]any, key string) (int, bool) {
if conf == nil {
return 0, false
}
v, ok := conf[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case int:
return n, true
case int32:
return int(n), true
case int64:
return int(n), true
case float32:
return int(n), true
case float64:
return int(n), true
}
return 0, false
}
// boolFrom extracts a bool from a conf map.
func boolFrom(conf map[string]any, key string) (bool, bool) {
if conf == nil {
return false, false
}
v, ok := conf[key]
if !ok {
return false, false
}
b, ok := v.(bool)
return b, ok
}

View File

@@ -0,0 +1,249 @@
//
// 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 — PDF writer (signintech/gopdf, plan §2.11.5.4).
//
// WritePDF renders the supplied content to a PDF using the MIT-licensed
// signintech/gopdf library. P4 ships in a **stub mode**: gopdf requires
// a TTF to be registered before any text is drawn, and we do not
// register one in P4. The writer probes via gopdf.SetFont; if the
// family is unknown, it surfaces ErrPDFFontNotConfigured so the
// orchestrator can return a clear deployment-time error. Production
// deployments register a TTF (e.g. Noto Sans CJK SC) at startup — the
// Phase 5 polish task will wire that into the boot sequence.
//
// When a TTF *is* registered, the writer emits a simple one-paragraph
// page per line of content, with a centered header and a centered
// footer carrying the page number / timestamp when requested. Visual
// fidelity (real watermark rotation, multi-column layout, etc.) is
// Phase 5 polish; the contract for P4 is the byte stream + a clear
// error path.
package io
import (
"errors"
"fmt"
"os"
"strings"
"time"
"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
}
// ErrPDFFontNotConfigured is returned when no TTF is registered.
// Callers should register a TTF via gopdf.SetFont before invoking
// WritePDF; Phase 5 will wire a default TTF into the boot path.
var ErrPDFFontNotConfigured = errors.New("PDF font not configured: register a TTF (e.g. Noto Sans CJK SC) via gopdf.SetFont before calling WritePDF")
// WritePDF renders the content to a PDF byte stream.
//
// Layout (P4):
//
// - A4 portrait, 36pt margins on all sides.
// - Body lines are drawn top-to-bottom, one per line of content.
// - Header is centered at the top of every page (when set).
// - Footer is centered at the bottom of every page and may include
// the footer text, a generation timestamp, and a page number.
// - Watermark is rendered as grey text near the page center; full
// rotation is Phase 5.
//
// When the requested font family is not registered, the function
// returns ErrPDFFontNotConfigured and does not write any output.
func WritePDF(content string, opts PDFOptions) ([]byte, error) {
if opts.FontSize <= 0 {
opts.FontSize = 12
}
if opts.FontFamily == "" {
opts.FontFamily = "Noto Sans CJK SC"
}
pdf := &gopdf.GoPdf{}
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
// Probe the font registry. gopdf returns an error like "font not
// found" when the family is not registered; we surface that as
// ErrPDFFontNotConfigured so callers can map it to a clear
// deployment message.
if err := pdf.SetFont(opts.FontFamily, "", opts.FontSize); err != nil {
if isFontNotFound(err) {
return nil, ErrPDFFontNotConfigured
}
return nil, fmt.Errorf("PDF: set font %q: %w", opts.FontFamily, err)
}
pdf.AddPage()
drawHeader(pdf, opts)
// Body — one Cell per line, manual y-cursor.
bodyX := 36.0
bodyY := 72.0
lineHeight := float64(opts.FontSize) * 1.5
pdf.SetX(bodyX)
pdf.SetY(bodyY)
for _, line := range splitLines(content) {
if line == "" {
// Preserve blank lines as vertical space.
bodyY += lineHeight
if bodyY > 760 {
drawFooter(pdf, opts)
pdf.AddPage()
drawHeader(pdf, opts)
bodyY = 72.0
}
pdf.SetX(bodyX)
pdf.SetY(bodyY)
continue
}
if bodyY > 760 {
drawFooter(pdf, opts)
pdf.AddPage()
drawHeader(pdf, opts)
bodyY = 72.0
}
pdf.SetX(bodyX)
pdf.SetY(bodyY)
if err := pdf.Cell(nil, line); err != nil {
return nil, fmt.Errorf("PDF: cell: %w", err)
}
bodyY += lineHeight
}
if opts.WatermarkText != "" {
drawWatermark(pdf, opts)
}
drawFooter(pdf, opts)
return writePDFToBytes(pdf)
}
// drawHeader emits the header text at the top of the current page.
// gopdf's API in v0.36.x doesn't expose a Header() callback; we draw
// at the top of every page after AddPage.
func drawHeader(pdf *gopdf.GoPdf, opts PDFOptions) {
if opts.HeaderText == "" {
return
}
_ = pdf.SetFont(opts.FontFamily, "", opts.FontSize-2)
pdf.SetX(36)
pdf.SetY(24)
_ = pdf.Cell(nil, opts.HeaderText)
// Restore body font.
_ = pdf.SetFont(opts.FontFamily, "", opts.FontSize)
}
// drawFooter emits the footer text plus optional timestamp / page
// number at the bottom of the current page.
func drawFooter(pdf *gopdf.GoPdf, opts PDFOptions) {
if opts.FooterText == "" && !opts.AddTimestamp && !opts.AddPageNumbers {
return
}
_ = pdf.SetFont(opts.FontFamily, "", opts.FontSize-2)
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 {
// gopdf v0.36 doesn't expose a page-number macro; emit a
// literal placeholder. Phase 5 will replace with a real
// {np} token if gopdf gains one.
parts = append(parts, "Page #")
}
_ = pdf.Cell(nil, strings.Join(parts, " | "))
// Restore body font.
_ = pdf.SetFont(opts.FontFamily, "", opts.FontSize)
}
// drawWatermark emits a centered grey watermark. Full rotation is
// not in the gopdf v0.36.x public surface; we use a light grey fill
// as a visual proxy.
func drawWatermark(pdf *gopdf.GoPdf, opts PDFOptions) {
if opts.WatermarkText == "" {
return
}
_ = pdf.SetFont(opts.FontFamily, "", 48)
pdf.SetTextColor(200, 200, 200)
pdf.SetX(120)
pdf.SetY(360)
_ = pdf.Cell(nil, opts.WatermarkText)
// Restore.
pdf.SetTextColor(0, 0, 0)
_ = pdf.SetFont(opts.FontFamily, "", opts.FontSize)
}
// writePDFToBytes serializes the gopdf output to a byte slice.
//
// gopdf's Write method requires an *os.File (it needs random access
// for the xref table), so we route through a TempFile. Phase 5 can
// lift this into a streaming implementation if needed.
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
}
// isFontNotFound reports whether the gopdf error indicates a missing
// TTF registration. We match the substrings that have been stable
// across recent gopdf versions.
func isFontNotFound(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "font") && (strings.Contains(s, "not") || strings.Contains(s, "no such") || strings.Contains(s, "undefined"))
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>
<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>
<Override PartName="/word/_rels/document.xml.rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
</Types>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<w:body>
{{range .Paragraphs}}<w:p><w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:szCs w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:t xml:space="preserve">{{xml .}}</w:t></w:r></w:p>
{{end}}<w:sectPr>
{{if .HasHeader}}<w:headerReference w:type="default" r:id="rIdHeader1"/>{{end}}
{{if .HasFooter}}<w:footerReference w:type="default" r:id="rIdFooter1"/>{{end}}
<w:pgSz w:w="12240" w:h="15840"/>
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>
<w:cols w:space="720"/>
<w:docGrid w:linePitch="360"/>
</w:sectPr>
</w:body>
</w:document>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
{{if .HasHeader}}<Relationship Id="rIdHeader1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>{{end}}
{{if .HasFooter}}<Relationship Id="rIdFooter1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>{{end}}
</Relationships>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<w:p><w:pPr><w:pStyle w:val="Footer"/><w:jc w:val="center"/></w:pPr>
{{if .FooterText}}<w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:t xml:space="preserve">{{xml .FooterText}}</w:t></w:r>{{if or .AddPageNumbers .AddTimestamp}}<w:r><w:t xml:space="preserve"> | </w:t></w:r>{{end}}{{end}}
{{if .AddPageNumbers}}<w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:t xml:space="preserve">Page </w:t></w:r>
<w:r><w:fldChar w:fldCharType="begin"/></w:r>
<w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
<w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:t>1</w:t></w:r>
<w:r><w:fldChar w:fldCharType="end"/></w:r>{{if .AddTimestamp}}<w:r><w:t xml:space="preserve"> | </w:t></w:r>{{end}}{{end}}
{{if .AddTimestamp}}<w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:t xml:space="preserve">{{xml .Timestamp}}</w:t></w:r>{{end}}
</w:p>
</w:ftr>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
{{if .WatermarkText}}<w:p><w:r><w:pict>
<v:shapetype id="_x0000_t136" coordsize="21600,21600" o:spt="136" adj="10800" path="m@7,l@8,m@5,21600l@6,21600e">
<v:formulas><v:f eqn="sum #0 0 10800"/><v:f eqn="prod #0 2 1"/><v:f eqn="sum 21600 0 @1"/><v:f eqn="sum 0 0 @2"/><v:f eqn="sum 21600 0 @3"/><v:f eqn="if @0 @3 0"/><v:f eqn="if @0 21600 @1"/><v:f eqn="if @0 0 @2"/><v:f eqn="if @0 @4 21600"/><v:f eqn="mid @5 @6"/><v:f eqn="mid @8 @5"/><v:f eqn="mid @7 @8"/><v:f eqn="mid @6 @7"/><v:f eqn="sum @6 0 @5"/></v:formulas>
<v:path o:extrusionok="f" gradientshapeok="t" o:connecttype="custom" o:connectlocs="@9,0;@10,10800;@11,21600;@12,10800" o:connectangles="270,180,90,0" textpathok="t"/>
<v:textpath on="t" fitshape="t"/>
<v:handles><v:h position="#0,bottomRight" xrange="6629,14971"/></v:handles>
<o:lock v:ext="edit" text="t" shapetype="t"/>
</v:shapetype>
<v:shape id="watermark" o:spid="_x0000_s1026" type="#_x0000_t136" style="position:absolute;margin-left:0;margin-top:0;width:500pt;height:90pt;rotation:315;z-index:-251658240;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" fillcolor="#d9d9d9" stroked="f">
<v:fill opacity=".5"/>
<v:textpath style="font-family:&quot;Calibri&quot;;font-size:1pt" string="{{xml .WatermarkText}}"/>
</v:shape>
</w:pict></w:r></w:p>{{end}}
{{if .HeaderText}}<w:p><w:pPr><w:pStyle w:val="Header"/><w:jc w:val="center"/></w:pPr><w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:t xml:space="preserve">{{xml .HeaderText}}</w:t></w:r></w:p>{{end}}
</w:hdr>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults>
<w:rPrDefault><w:rPr><w:rFonts w:ascii="{{.FontFamily}}" w:eastAsia="{{.FontFamily}}" w:hAnsi="{{.FontFamily}}" w:cs="Times New Roman"/><w:sz w:val="{{pt .FontSize}}"/><w:szCs w:val="{{pt .FontSize}}"/><w:lang w:val="en-US" w:eastAsia="zh-CN"/></w:rPr></w:rPrDefault>
<w:pPrDefault><w:pPr><w:spacing w:after="160" w:line="259" w:lineRule="auto"/></w:pPr></w:pPrDefault>
</w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
<w:style w:type="paragraph" w:styleId="Header"><w:name w:val="header"/><w:basedOn w:val="Normal"/><w:pPr><w:tabs><w:tab w:val="center" w:pos="4680"/><w:tab w:val="right" w:pos="9360"/></w:tabs><w:spacing w:after="0"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Footer"><w:name w:val="footer"/><w:basedOn w:val="Normal"/><w:pPr><w:tabs><w:tab w:val="center" w:pos="4680"/><w:tab w:val="right" w:pos="9360"/></w:tabs><w:spacing w:after="0"/></w:pPr></w:style>
</w:styles>