mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-15 13:14:28 +08:00
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:
244
internal/agent/component/io/docx_writer.go
Normal file
244
internal/agent/component/io/docx_writer.go
Normal 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" }
|
||||
183
internal/agent/component/io/docx_writer_test.go
Normal file
183
internal/agent/component/io/docx_writer_test.go
Normal 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: < / > / & / " (numeric
|
||||
// for the double-quote, matching Go's stdlib contract).
|
||||
want := "A < B & C > D "quoted""
|
||||
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] + "..."
|
||||
}
|
||||
73
internal/agent/component/io/helpers.go
Normal file
73
internal/agent/component/io/helpers.go
Normal 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
|
||||
}
|
||||
249
internal/agent/component/io/pdf_writer.go
Normal file
249
internal/agent/component/io/pdf_writer.go
Normal 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"))
|
||||
}
|
||||
10
internal/agent/component/io/templates/content_types.xml
Normal file
10
internal/agent/component/io/templates/content_types.xml
Normal 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>
|
||||
14
internal/agent/component/io/templates/document.xml.tmpl
Normal file
14
internal/agent/component/io/templates/document.xml.tmpl
Normal 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>
|
||||
@@ -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>
|
||||
13
internal/agent/component/io/templates/footer.xml.tmpl
Normal file
13
internal/agent/component/io/templates/footer.xml.tmpl
Normal 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>
|
||||
17
internal/agent/component/io/templates/header.xml.tmpl
Normal file
17
internal/agent/component/io/templates/header.xml.tmpl
Normal 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:"Calibri";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>
|
||||
4
internal/agent/component/io/templates/rels.xml
Normal file
4
internal/agent/component/io/templates/rels.xml
Normal 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>
|
||||
10
internal/agent/component/io/templates/styles.xml.tmpl
Normal file
10
internal/agent/component/io/templates/styles.xml.tmpl
Normal 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>
|
||||
Reference in New Issue
Block a user