// 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 }