Files
enkly-ai/pkg/officekit/zipkit.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

182 lines
6.4 KiB
Go

// Package officekit creates and edits real, native Office Open XML
// files — .docx (Word), .xlsx (Excel), .pptx (PowerPoint) — as plain
// Go structs and byte slices, with no dependency beyond the standard
// library (archive/zip + encoding/xml).
//
// # Why this package exists
//
// enkly's agent talks to a Qwen model through a vLLM OpenAI-compatible
// tool-calling API (pkg/vllmkit). Tool calls and tool results travel
// as structured JSON on that wire (an "arguments" string vLLM's
// tool-call parser extracts from the model's raw generation), not
// wrapped in the XML-ish chat-template delimiters an Ollama deployment
// of this same model would have used — so the specific "XML syntax
// error on line N" failure mode an earlier, Ollama-backed version of
// this service saw when a tool result contained raw angle-bracket
// markup no longer applies verbatim. vLLM's tool-call parser can still
// fail to extract a clean call from the model's output, surfaced
// client-side as vllmkit.IsToolCallParseError (see
// agentrun/agent.go's compactToolHistory doc comment for the retry
// strategy built on top of it) — a different failure mode, but the
// same underlying risk: anything that makes the model's raw output
// harder for its tool-call parser to read is worth avoiding.
//
// The structural fix lives here regardless of transport: the model
// NEVER authors or receives OOXML. It only ever calls a tool with
// plain structured arguments
// (a title, a list of paragraphs, a grid of cell values, a list of
// slide bullets) and gets back a short plain-text summary. This
// package does 100% of the actual XML generation and parsing,
// server-side, using Go's typed XML encoder/decoder (which always
// produces well-formed, correctly-escaped XML) — and the resulting
// bytes travel to/from the workspace service over the existing
// UploadFile/DownloadFile RPCs, which carry raw []byte, not the
// text-oriented ReadFile/WriteFile string fields. No proto changes,
// no binary-over-text encoding, and no XML ever touches the LLM.
package officekit
import (
"archive/zip"
"bytes"
"fmt"
"io"
)
// zipPart is one file inside an OOXML package (which is just a zip
// archive with a prescribed internal layout).
type zipPart struct {
Name string
Body string
}
// buildZip packages parts into an OOXML container. Office is picky
// about [Content_Types].xml being present; beyond that, part order
// doesn't matter to any reader in practice, but we write it first
// anyway since that's the convention most real Office-authored files
// follow.
func buildZip(parts []zipPart) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, p := range parts {
w, err := zw.Create(p.Name)
if err != nil {
return nil, fmt.Errorf("officekit: create %s: %w", p.Name, err)
}
if _, err := w.Write([]byte(p.Body)); err != nil {
return nil, fmt.Errorf("officekit: write %s: %w", p.Name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("officekit: close zip: %w", err)
}
return buf.Bytes(), nil
}
// readZipPart extracts one named part's raw bytes from an OOXML
// package, or returns ok=false if it isn't present.
func readZipPart(data []byte, name string) ([]byte, bool, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, false, fmt.Errorf("officekit: not a valid Office file (not a zip archive): %w", err)
}
for _, f := range zr.File {
if f.Name == name {
rc, err := f.Open()
if err != nil {
return nil, false, fmt.Errorf("officekit: open %s: %w", name, err)
}
defer rc.Close()
var buf bytes.Buffer
if _, err := buf.ReadFrom(rc); err != nil {
return nil, false, fmt.Errorf("officekit: read %s: %w", name, err)
}
return buf.Bytes(), true, nil
}
}
return nil, false, nil
}
// addZipParts returns a copy of an OOXML package with every existing
// part copied through unchanged, plus the given new parts appended
// (used by AppendPptxSlide to add a slideN.xml/slideN.xml.rels pair
// without disturbing anything already in the archive).
func addZipParts(data []byte, newParts []zipPart) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("officekit: not a valid Office file (not a zip archive): %w", err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, f := range zr.File {
w, err := zw.Create(f.Name)
if err != nil {
return nil, fmt.Errorf("officekit: create %s: %w", f.Name, err)
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("officekit: open %s: %w", f.Name, err)
}
_, copyErr := io.Copy(w, rc)
rc.Close()
if copyErr != nil {
return nil, fmt.Errorf("officekit: copy %s: %w", f.Name, copyErr)
}
}
for _, p := range newParts {
w, err := zw.Create(p.Name)
if err != nil {
return nil, fmt.Errorf("officekit: create %s: %w", p.Name, err)
}
if _, err := w.Write([]byte(p.Body)); err != nil {
return nil, fmt.Errorf("officekit: write %s: %w", p.Name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("officekit: close zip: %w", err)
}
return buf.Bytes(), nil
}
// replaceZipPart returns a copy of an OOXML package with one part's
// body replaced (used by the edit_* operations: download, patch one
// XML part, re-upload). Every other part is copied through unchanged.
func replaceZipPart(data []byte, name string, newBody []byte) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("officekit: not a valid Office file (not a zip archive): %w", err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
replaced := false
for _, f := range zr.File {
w, err := zw.Create(f.Name)
if err != nil {
return nil, fmt.Errorf("officekit: create %s: %w", f.Name, err)
}
if f.Name == name {
replaced = true
if _, err := w.Write(newBody); err != nil {
return nil, fmt.Errorf("officekit: write %s: %w", f.Name, err)
}
continue
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("officekit: open %s: %w", f.Name, err)
}
_, copyErr := io.Copy(w, rc)
rc.Close()
if copyErr != nil {
return nil, fmt.Errorf("officekit: copy %s: %w", f.Name, copyErr)
}
}
if !replaced {
return nil, fmt.Errorf("officekit: part %s not found in archive", name)
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("officekit: close zip: %w", err)
}
return buf.Bytes(), nil
}