Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

557
pkg/officekit/docx.go Normal file
View File

@@ -0,0 +1,557 @@
package officekit
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"regexp"
"strconv"
"strings"
)
// ParaStyle is the small, Claude-Code-skill-inspired vocabulary of
// paragraph styles the model is offered — deliberately not "any OOXML
// style name", so the model can never need to type XML to format
// something.
type ParaStyle string
const (
StyleNormal ParaStyle = "normal"
StyleHeading1 ParaStyle = "heading1"
StyleHeading2 ParaStyle = "heading2"
StyleHeading3 ParaStyle = "heading3"
StyleBullet ParaStyle = "bullet"
)
// Paragraph is one structured paragraph of a Word document, as the
// model supplies it via the create_document/edit_document tool
// arguments — plain fields, never markup.
type Paragraph struct {
Text string
Style ParaStyle
Bold bool
Italic bool
Underline bool
}
const docxDocumentPart = "word/document.xml"
// --- word/document.xml, built with encoding/xml so every run of text
// is correctly escaped no matter what characters it contains (a stray
// "<" or "&" in a paragraph's text becomes "&lt;"/"&amp;" the same way
// Go's XML encoder would escape it anywhere else — it can never end up
// as literal, parseable-looking markup). ---
// wDocument's XMLName tag uses Go's "namespace-URI local-name" form
// (space-separated, not a colon prefix — see the package doc comment
// in zipkit.go's header and this file's note above BuildDocx for why
// colon-prefixed tags like "w:document" don't round-trip). Marshal
// emits this as the default namespace (xmlns="...") on the root
// element; every child struct below uses a plain, unprefixed local
// name with no namespace component, which both (a) is valid XML —
// unprefixed descendants simply inherit the nearest enclosing default
// namespace — and (b) unmarshals back correctly, because an
// unnamespaced field tag matches an element by local name only,
// regardless of which namespace it actually resolved to.
type wDocument struct {
XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main document"`
Body wBody `xml:"body"`
}
type wBody struct {
Paragraphs []wParagraph `xml:"p"`
SectPr wSectPr `xml:"sectPr"`
}
type wSectPr struct {
PgSz wPgSz `xml:"pgSz"`
}
type wPgSz struct {
W int `xml:"w,attr"`
H int `xml:"h,attr"`
}
type wParagraph struct {
PPr *wPPr `xml:"pPr,omitempty"`
Run wRun `xml:"r"`
}
type wPPr struct {
NumPr *wNumPr `xml:"numPr,omitempty"`
}
type wNumPr struct {
Ilvl wVal `xml:"ilvl"`
NumId wVal `xml:"numId"`
}
type wVal struct {
Val int `xml:"val,attr"`
}
type wRun struct {
RPr *wRPr `xml:"rPr,omitempty"`
Text wText `xml:"t"`
}
// wRPr's field order follows OOXML's CT_RPr schema sequence (b, i,
// sz, u, ...) — Go's xml.Marshal emits struct fields in declaration
// order, and while real-world readers (Word included) tend to be
// lenient about run-property order, writing it correctly costs
// nothing and keeps generated files schema-valid, not just
// Word-openable.
type wRPr struct {
Bold *struct{} `xml:"b,omitempty"`
Italic *struct{} `xml:"i,omitempty"`
Sz *wSz `xml:"sz,omitempty"`
Underline *wU `xml:"u,omitempty"`
}
type wSz struct {
Val int `xml:"val,attr"`
}
type wU struct {
Val string `xml:"val,attr"`
}
type wText struct {
Space string `xml:"xml:space,attr"`
Value string `xml:",chardata"`
}
// wParagraphFragment is the same content as wParagraph, but with its
// own explicit WordprocessingML namespace declaration on XMLName.
// It exists only for AppendDocxParagraph, which marshals ONE paragraph
// standalone and splices its bytes directly into an existing
// document.xml rather than re-marshaling the whole document (see that
// function's doc comment for why). A spliced fragment has no parent
// element to inherit a namespace from if the target document uses
// Word's own "w:"-prefixed convention instead of this package's
// default-namespace one — so this type redeclares the namespace
// locally on the fragment's own root element. XML lets any element do
// that regardless of its surrounding document's prefix convention, and
// every OOXML-consuming application (Word included) resolves element
// identity by namespace URI + local name, not by literal prefix text,
// so `<p xmlns="...wordprocessingml.../main">` and `<w:p>` are the same
// element as far as Word is concerned. wParagraph itself deliberately
// keeps no such tag, so BuildDocx's normal output — where every
// paragraph already inherits the root's default namespace — doesn't
// pay for a redundant xmlns on every single paragraph.
type wParagraphFragment struct {
XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main p"`
PPr *wPPr `xml:"pPr,omitempty"`
Run wRun `xml:"r"`
}
func newParagraphFragment(p Paragraph) wParagraphFragment {
wp := toWParagraph(p)
return wParagraphFragment{PPr: wp.PPr, Run: wp.Run}
}
// runProps maps the model's small formatting vocabulary (bold, italic,
// underline, plus the size/boldness implied by a heading style) to a
// run's rPr, or nil if the run needs no rPr at all (the common case:
// a plain, unstyled normal/bullet paragraph).
func runProps(p Paragraph) *wRPr {
bold, italic, underline := p.Bold, p.Italic, p.Underline
var sz *wSz
switch p.Style {
case StyleHeading1:
bold = true
sz = &wSz{Val: 32} // 16pt
case StyleHeading2:
bold = true
sz = &wSz{Val: 28} // 14pt
case StyleHeading3:
bold = true
sz = &wSz{Val: 24} // 12pt
}
if !bold && !italic && !underline && sz == nil {
return nil
}
rpr := &wRPr{Sz: sz}
if bold {
rpr.Bold = &struct{}{}
}
if italic {
rpr.Italic = &struct{}{}
}
if underline {
rpr.Underline = &wU{Val: "single"}
}
return rpr
}
func toWParagraph(p Paragraph) wParagraph {
wp := wParagraph{
Run: wRun{
RPr: runProps(p),
Text: wText{Space: "preserve", Value: p.Text},
},
}
if p.Style == StyleBullet {
wp.PPr = &wPPr{NumPr: &wNumPr{Ilvl: wVal{Val: 0}, NumId: wVal{Val: 1}}}
}
return wp
}
// BuildDocx renders paragraphs into a complete, valid .docx file.
func BuildDocx(paragraphs []Paragraph) ([]byte, error) {
body := wBody{SectPr: wSectPr{PgSz: wPgSz{W: 12240, H: 15840}}}
for _, p := range paragraphs {
body.Paragraphs = append(body.Paragraphs, toWParagraph(p))
}
doc := wDocument{Body: body}
docXML, err := xml.Marshal(doc)
if err != nil {
return nil, fmt.Errorf("officekit: marshal document.xml: %w", err)
}
// A minimal numbering part is included (word/numbering.xml) only so
// StyleBullet's w:numId="1" resolves to something rather than being
// a dangling reference; every bullet paragraph shares the same
// single bullet-list definition.
parts := []zipPart{
{Name: "[Content_Types].xml", Body: docxContentTypes},
{Name: "_rels/.rels", Body: docxRootRels},
{Name: "word/_rels/document.xml.rels", Body: docxDocumentRels},
{Name: docxDocumentPart, Body: xml.Header + string(docXML)},
{Name: "word/numbering.xml", Body: docxNumberingXML},
{Name: "docProps/core.xml", Body: officeCoreProps},
{Name: "docProps/app.xml", Body: docxAppProps},
}
return buildZip(parts)
}
// ReadDocxText extracts the plain text of a .docx file, one line per
// paragraph (bullets prefixed with "- "), for the read_document tool.
// This is what the model actually sees — never the underlying XML.
//
// This walks document.xml's raw token stream rather than unmarshaling
// into a struct that only models direct <w:body> children: a real
// document's paragraphs can be nested inside a table cell
// (<w:tbl>/<w:tr>/<w:tc>/<w:p>), and a struct-based Unmarshal into
// wBody.Paragraphs would silently skip every one of those, making a
// user-uploaded document with tables look emptier than it is. The
// token walk picks up every <w:p> wherever it occurs, in document
// order; it still doesn't reconstruct a table's row/column grid (see
// the docx skill's documented limitation) — each cell's paragraphs
// simply appear as their own lines.
func ReadDocxText(data []byte) (string, error) {
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
lines, err := walkParagraphText(raw)
if err != nil {
return "", fmt.Errorf("officekit: parse document.xml: %w", err)
}
return strings.Join(lines, "\n"), nil
}
// walkParagraphText returns one text line per <w:p> paragraph found
// anywhere in document.xml, in document order: the concatenation of
// every <w:t> run's text within it, with <w:tab/> rendered as a
// literal tab, prefixed "- " when the paragraph carries a <w:numPr>
// (list) property. Matches by local element name only (see the
// package-level namespace note on wDocument), so it works the same
// whether the source uses Word's "w:" prefix or this package's own
// default-namespace convention.
func walkParagraphText(raw []byte) ([]string, error) {
dec := xml.NewDecoder(bytes.NewReader(raw))
var (
lines []string
cur strings.Builder
inPara bool
inText bool
isBullet bool
)
flushPara := func() {
text := cur.String()
if isBullet {
text = "- " + text
}
lines = append(lines, text)
cur.Reset()
inPara, isBullet = false, false
}
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
switch t := tok.(type) {
case xml.StartElement:
switch t.Name.Local {
case "p":
inPara = true
case "t":
inText = true
case "tab":
if inPara {
cur.WriteByte('\t')
}
case "numPr":
if inPara {
isBullet = true
}
}
case xml.EndElement:
switch t.Name.Local {
case "p":
if inPara {
flushPara()
}
case "t":
inText = false
}
case xml.CharData:
if inPara && inText {
cur.Write(t)
}
}
}
return lines, nil
}
// AppendDocxParagraph adds one paragraph as the new last content
// element of the document body and returns the new file bytes.
//
// This splices the new paragraph's marshaled XML directly into
// document.xml's existing raw bytes, immediately before the body's
// trailing <w:sectPr> (OOXML requires section properties to be the
// body's last child) — or immediately before </w:body> if there's no
// sectPr at all — instead of the unmarshal-modify-marshal round trip
// this used to share with ReplaceDocxText. That round trip only ever
// modeled direct <w:body> children (paragraphs and the trailing
// sectPr), so re-marshaling it back would silently drop everything
// else a real document has: tables, images, headers, multiple runs
// per paragraph, tracked changes. Splicing means every byte of the
// original document this package doesn't model survives untouched.
func AppendDocxParagraph(data []byte, p Paragraph) ([]byte, error) {
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
paraXML, err := xml.Marshal(newParagraphFragment(p))
if err != nil {
return nil, fmt.Errorf("officekit: marshal paragraph: %w", err)
}
at := bodyInsertionPoint(raw)
if at < 0 {
return nil, fmt.Errorf("officekit: %s has no <w:body> to append to (not a valid .docx)", docxDocumentPart)
}
newDoc := make([]byte, 0, len(raw)+len(paraXML))
newDoc = append(newDoc, raw[:at]...)
newDoc = append(newDoc, paraXML...)
newDoc = append(newDoc, raw[at:]...)
return replaceZipPart(data, docxDocumentPart, newDoc)
}
// bodyInsertionPoint returns the byte offset in document.xml where new
// body content should be spliced in: right before the body's trailing
// <w:sectPr> if present, else right before </w:body>. Returns -1 if
// neither tag is found at all (not a recognizable Word document body).
func bodyInsertionPoint(doc []byte) int {
for _, tag := range []string{"<w:sectPr", "<sectPr"} {
if i := lastTagIndex(doc, tag); i >= 0 {
return i
}
}
for _, tag := range []string{"</w:body>", "</body>"} {
if i := bytes.Index(doc, []byte(tag)); i >= 0 {
return i
}
}
return -1
}
// lastTagIndex finds the last byte offset of an opening tag whose name
// is exactly tag (e.g. "<w:sectPr"), rejecting a longer tag name that
// merely starts with the same bytes — such as the tracked-changes
// element "<w:sectPrChange", which would otherwise look like a match.
func lastTagIndex(doc []byte, tag string) int {
needle := []byte(tag)
end := len(doc)
for {
i := bytes.LastIndex(doc[:end], needle)
if i < 0 {
return -1
}
after := i + len(needle)
if after >= len(doc) {
return i
}
switch doc[after] {
case ' ', '\t', '\n', '\r', '>', '/':
return i
}
end = i // false positive (e.g. sectPrChange); keep searching earlier
}
}
// wTextElementRe matches one WordprocessingML text run's element —
// <w:t>...</w:t>, the unprefixed <t>...</t> this package's own output
// uses, or any other single-letter-prefixed variant — capturing the
// opening tag, the raw (still XML-escaped) inner text, and the closing
// tag separately, so ReplaceDocxText only ever rewrites the inner text
// and leaves the tag itself, and everything outside it, byte-for-byte
// untouched. The boundary check after the local name (next character
// must be whitespace, "/", or ">") excludes longer names that merely
// start with "t", such as "tab", "tbl", or "tc".
var wTextElementRe = regexp.MustCompile(`(?s)(<(?:[A-Za-z][\w.-]*:)?t(?:\s[^>]*)?>)(.*?)(</(?:[A-Za-z][\w.-]*:)?t>)`)
// ReplaceDocxText replaces every exact, literal occurrence of find
// with replace inside the document's text runs — including runs
// nested inside a table cell, which the old paragraph-struct-based
// approach never saw at all — and returns the new file bytes plus how
// many text runs were changed. It rewrites only the matched <w:t>
// elements' inner text in document.xml's raw bytes; every other part
// of the document (formatting, tables, images, headers) passes through
// completely untouched.
//
// Because Word often splits a single sentence across more than one run
// (spell-check and revision tracking both do this), a phrase that
// spans a run boundary may not be found even though it reads as one
// phrase in Word — read_document first, and prefer a shorter, more
// literal match string when a longer one doesn't seem to apply.
func ReplaceDocxText(data []byte, find, replace string) ([]byte, int, error) {
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return nil, 0, err
}
if !ok {
return nil, 0, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
changed := 0
newDoc := wTextElementRe.ReplaceAllFunc(raw, func(match []byte) []byte {
groups := wTextElementRe.FindSubmatch(match)
open, inner, closeTag := groups[1], groups[2], groups[3]
decoded := xmlUnescapeText(inner)
if !strings.Contains(decoded, find) {
return match
}
changed++
replaced := strings.ReplaceAll(decoded, find, replace)
out := make([]byte, 0, len(open)+len(replaced)+len(closeTag))
out = append(out, open...)
out = append(out, []byte(xmlEscapeText(replaced))...)
out = append(out, closeTag...)
return out
})
if changed == 0 {
return nil, 0, fmt.Errorf("officekit: %q not found in any text run", find)
}
out, err := replaceZipPart(data, docxDocumentPart, newDoc)
return out, changed, err
}
// xmlUnescapeText reverses XML's five predefined entities plus numeric
// character references (&#NNN; / &#xHH;) — the inverse of
// xmlEscapeText (pptx.go) — so ReplaceDocxText can match and replace
// against the plain text the model actually passed, not its
// XML-escaped form. An unknown or malformed entity is left verbatim
// rather than erroring: this only ever feeds a literal-substring
// comparison, so there's nothing to fail loudly about.
func xmlUnescapeText(b []byte) string {
s := string(b)
if !strings.ContainsRune(s, '&') {
return s
}
var out strings.Builder
for {
i := strings.IndexByte(s, '&')
if i < 0 {
out.WriteString(s)
break
}
out.WriteString(s[:i])
rest := s[i+1:]
semi := strings.IndexByte(rest, ';')
if semi < 0 {
out.WriteByte('&')
s = rest
continue
}
entity, tail := rest[:semi], rest[semi+1:]
switch {
case entity == "amp":
out.WriteByte('&')
case entity == "lt":
out.WriteByte('<')
case entity == "gt":
out.WriteByte('>')
case entity == "apos":
out.WriteByte('\'')
case entity == "quot":
out.WriteByte('"')
case strings.HasPrefix(entity, "#x"), strings.HasPrefix(entity, "#X"):
if n, err := strconv.ParseInt(entity[2:], 16, 32); err == nil {
out.WriteRune(rune(n))
} else {
out.WriteByte('&')
out.WriteString(entity)
out.WriteByte(';')
}
case strings.HasPrefix(entity, "#"):
if n, err := strconv.ParseInt(entity[1:], 10, 32); err == nil {
out.WriteRune(rune(n))
} else {
out.WriteByte('&')
out.WriteString(entity)
out.WriteByte(';')
}
default:
out.WriteByte('&')
out.WriteString(entity)
out.WriteByte(';')
}
s = tail
}
return out.String()
}
const docxContentTypes = xml.Header + `<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/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
</Types>`
const docxRootRels = xml.Header + `<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"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>`
const docxDocumentRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>
</Relationships>`
const docxNumberingXML = xml.Header + `<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:abstractNum w:abstractNumId="0">
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val=""/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl>
</w:abstractNum>
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
</w:numbering>`
const docxAppProps = xml.Header + `<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>enkly</Application></Properties>`
// officeCoreProps is shared verbatim by docx/xlsx/pptx — the schema is
// package-level (docProps/core.xml), not format-specific.
const officeCoreProps = xml.Header + `<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:creator>enkly agent</dc:creator></cp:coreProperties>`

128
pkg/officekit/markdown.go Normal file
View File

@@ -0,0 +1,128 @@
// markdown.go adds Markdown → the same structured []Paragraph
// vocabulary create_document and BuildDocx already use, so an
// existing Markdown document (a README, a chat reply, something an
// upload already contains) can become a real .docx without the model
// ever hand-translating headings and bullets into paragraph arguments
// itself. It reuses the gomarkdown parser already vendored for
// chat-message rendering (pkg/renderkit) rather than hand-rolling a
// second Markdown parser — one less thing in this codebase that can
// disagree about what counts as a heading or a list item.
//
// Scope: block-level structure only — headings, paragraphs, and
// bullet/numbered lists (numbered lists render as bullets, since
// Paragraph has no numbered-list style of its own yet). Fenced or
// indented code blocks come through as plain paragraphs, since
// Paragraph has no monospace style either. Inline formatting
// (bold/italic spans within a paragraph, links, inline code) is
// flattened to plain text — Paragraph only supports whole-paragraph
// bold/italic/underline (see docx.go), the same limit
// create_document's tool description already states. Tables, raw HTML
// blocks, footnotes, and anything else without a Paragraph-vocabulary
// equivalent are silently dropped rather than guessed at.
package officekit
import (
"strings"
"github.com/gomarkdown/markdown/ast"
mdparser "github.com/gomarkdown/markdown/parser"
)
// MarkdownToParagraphs parses a Markdown document into the paragraph
// vocabulary BuildDocx/AppendDocxParagraph already understand, so a
// caller can go straight from Markdown source to a .docx via
// BuildDocx(MarkdownToParagraphs(src)).
func MarkdownToParagraphs(src string) []Paragraph {
p := mdparser.NewWithExtensions(mdparser.CommonExtensions)
doc := p.Parse([]byte(src))
var out []Paragraph
for _, block := range childNodes(doc) {
out = append(out, blockToParagraphs(block)...)
}
return out
}
func blockToParagraphs(n ast.Node) []Paragraph {
switch v := n.(type) {
case *ast.Heading:
text := leafText(v)
if text == "" {
return nil
}
style := StyleHeading3
switch {
case v.Level <= 1:
style = StyleHeading1
case v.Level == 2:
style = StyleHeading2
}
return []Paragraph{{Text: text, Style: style}}
case *ast.Paragraph:
text := leafText(v)
if text == "" {
return nil
}
return []Paragraph{{Text: text, Style: StyleNormal}}
case *ast.List:
var out []Paragraph
for _, item := range childNodes(v) {
text := leafText(item)
if text == "" {
continue
}
out = append(out, Paragraph{Text: text, Style: StyleBullet})
}
return out
case *ast.CodeBlock:
text := strings.TrimRight(string(v.Literal), "\n")
if text == "" {
return nil
}
return []Paragraph{{Text: text, Style: StyleNormal}}
case *ast.BlockQuote:
var out []Paragraph
for _, c := range childNodes(v) {
out = append(out, blockToParagraphs(c)...)
}
return out
default:
// HorizontalRule, Table, HTMLBlock, and anything else this
// package has no Paragraph-vocabulary equivalent for.
return nil
}
}
// childNodes returns n's direct block-level children via gomarkdown's
// Container/Leaf node split: a Leaf node (Text, Code, ...) has none.
func childNodes(n ast.Node) []ast.Node {
if c := n.AsContainer(); c != nil {
return c.Children
}
return nil
}
// leafText concatenates every Text/Code leaf found under n, depth
// first — the inline-formatting-flattening step described in this
// file's package doc.
func leafText(n ast.Node) string {
var sb strings.Builder
collectText(n, &sb)
return strings.TrimSpace(sb.String())
}
func collectText(n ast.Node, sb *strings.Builder) {
switch v := n.(type) {
case *ast.Text:
sb.Write(v.Literal)
return
case *ast.Code:
sb.Write(v.Literal)
return
case *ast.Hardbreak:
sb.WriteByte(' ')
return
}
for _, c := range childNodes(n) {
collectText(c, sb)
}
}

View File

@@ -0,0 +1,74 @@
package officekit
import "testing"
func TestMarkdownToParagraphsStructure(t *testing.T) {
src := "# Title\n\nAn intro paragraph.\n\n## Section\n\n- First item\n- Second item\n"
paras := MarkdownToParagraphs(src)
want := []Paragraph{
{Text: "Title", Style: StyleHeading1},
{Text: "An intro paragraph.", Style: StyleNormal},
{Text: "Section", Style: StyleHeading2},
{Text: "First item", Style: StyleBullet},
{Text: "Second item", Style: StyleBullet},
}
if len(paras) != len(want) {
t.Fatalf("got %d paragraphs, want %d: %+v", len(paras), len(want), paras)
}
for i := range want {
if paras[i].Text != want[i].Text || paras[i].Style != want[i].Style {
t.Errorf("paragraph %d: got %+v, want %+v", i, paras[i], want[i])
}
}
}
func TestMarkdownToParagraphsFlattensInlineFormatting(t *testing.T) {
paras := MarkdownToParagraphs("This has **bold** and *italic* and `code` text.")
if len(paras) != 1 {
t.Fatalf("got %d paragraphs, want 1: %+v", len(paras), paras)
}
want := "This has bold and italic and code text."
if paras[0].Text != want {
t.Errorf("got %q, want %q", paras[0].Text, want)
}
}
func TestMarkdownToParagraphsFeedsBuildDocx(t *testing.T) {
paras := MarkdownToParagraphs("# Report\n\nBody text here.\n")
data, err := BuildDocx(paras)
if err != nil {
t.Fatalf("BuildDocx(MarkdownToParagraphs(...)): %v", err)
}
text, err := ReadDocxText(data)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
for _, want := range []string{"Report", "Body text here."} {
if !containsLine(text, want) {
t.Errorf("expected read-back text to contain line %q, got:\n%s", want, text)
}
}
}
func containsLine(text, line string) bool {
for _, l := range splitLines(text) {
if l == line {
return true
}
}
return false
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
lines = append(lines, s[start:])
return lines
}

View File

@@ -0,0 +1,274 @@
package officekit
import (
"encoding/xml"
"fmt"
"strings"
"testing"
)
// minimalWordDoc builds a tiny, realistic .docx — using Word's own
// "w:"-prefixed convention, not this package's unprefixed
// default-namespace one — containing one body paragraph and one table
// with a single cell's worth of text. This exercises the append/replace
// operations against content this package doesn't itself model (tables)
// without needing a real binary fixture file on disk.
func minimalWordDoc(t *testing.T) []byte {
t.Helper()
documentXML := xml.Header + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
`<w:body>` +
`<w:p><w:r><w:t>Intro paragraph.</w:t></w:r></w:p>` +
`<w:tbl><w:tr><w:tc><w:p><w:r><w:t>cell one text</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
`<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>` +
`</w:body></w:document>`
data, err := buildZip([]zipPart{
{Name: "[Content_Types].xml", Body: docxContentTypes},
{Name: "_rels/.rels", Body: docxRootRels},
{Name: docxDocumentPart, Body: documentXML},
})
if err != nil {
t.Fatalf("buildZip: %v", err)
}
return data
}
func TestBuildDocxReadRoundTrip(t *testing.T) {
data, err := BuildDocx([]Paragraph{
{Text: "Title", Style: StyleHeading1},
{Text: "First item", Style: StyleBullet},
{Text: "Second item", Style: StyleBullet},
{Text: "Styled body text", Bold: true, Italic: true, Underline: true},
})
if err != nil {
t.Fatalf("BuildDocx: %v", err)
}
text, err := ReadDocxText(data)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
want := []string{"Title", "- First item", "- Second item", "Styled body text"}
got := strings.Split(text, "\n")
if len(got) != len(want) {
t.Fatalf("expected %d lines, got %d: %q", len(want), len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("line %d: got %q, want %q", i, got[i], want[i])
}
}
}
func TestDocxRunFormattingXML(t *testing.T) {
data, err := BuildDocx([]Paragraph{{Text: "x", Bold: true, Italic: true, Underline: true}})
if err != nil {
t.Fatalf("BuildDocx: %v", err)
}
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil || !ok {
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
}
body := string(raw)
for _, want := range []string{"<b></b>", "<i></i>", `<u val="single">`} {
if !strings.Contains(body, want) {
t.Errorf("expected document.xml to contain %s, got:\n%s", want, body)
}
}
}
func TestAppendDocxParagraphPreservesTable(t *testing.T) {
data := minimalWordDoc(t)
out, err := AppendDocxParagraph(data, Paragraph{Text: "Appended line."})
if err != nil {
t.Fatalf("AppendDocxParagraph: %v", err)
}
text, err := ReadDocxText(out)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
for _, want := range []string{"Intro paragraph.", "cell one text", "Appended line."} {
if !strings.Contains(text, want) {
t.Errorf("expected read-back text to contain %q, got:\n%s", want, text)
}
}
// The new paragraph must land before the trailing sectPr, not after
// it — content after a body's final sectPr is invalid OOXML.
raw, ok, err := readZipPart(out, docxDocumentPart)
if err != nil || !ok {
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
}
body := string(raw)
idxAppended := strings.Index(body, "Appended line.")
idxSectPr := strings.Index(body, "<w:sectPr")
if idxAppended < 0 || idxSectPr < 0 || idxAppended > idxSectPr {
t.Errorf("expected appended paragraph before <w:sectPr>, got appended@%d sectPr@%d:\n%s", idxAppended, idxSectPr, body)
}
}
func TestReplaceDocxTextInTable(t *testing.T) {
data := minimalWordDoc(t)
out, n, err := ReplaceDocxText(data, "cell one text", "updated cell text")
if err != nil {
t.Fatalf("ReplaceDocxText: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 text run changed, got %d", n)
}
text, err := ReadDocxText(out)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
if strings.Contains(text, "cell one text") {
t.Errorf("old text still present after replace: %s", text)
}
if !strings.Contains(text, "updated cell text") {
t.Errorf("expected replaced text, got: %s", text)
}
if !strings.Contains(text, "Intro paragraph.") {
t.Errorf("unrelated paragraph lost: %s", text)
}
}
func TestReplaceDocxTextNotFound(t *testing.T) {
data := minimalWordDoc(t)
if _, _, err := ReplaceDocxText(data, "nope, not in this document", "x"); err == nil {
t.Fatal("expected an error for text that isn't present")
}
}
func TestSetXlsxCellsFormulaAndOrdering(t *testing.T) {
data, err := BuildXlsx([]Sheet{{Name: "Sheet1", Rows: [][]string{{"Label", "Value"}}}})
if err != nil {
t.Fatalf("BuildXlsx: %v", err)
}
// Written deliberately out of row order, including a formula, to
// exercise the sort-before-marshal fix.
out, err := SetXlsxCells(data, "", map[string]string{
"A5": "fifth",
"A2": "second",
"B2": "=A2",
"A1": "first",
})
if err != nil {
t.Fatalf("SetXlsxCells: %v", err)
}
rows, _, err := ReadXlsxCells(out, "")
if err != nil {
t.Fatalf("ReadXlsxCells: %v", err)
}
// Three sparse rows exist: 1 (from BuildXlsx + overwritten A1), 2, 5.
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d: %v", len(rows), rows)
}
if rows[0][0] != "first" {
t.Errorf("row 1 col A: got %q, want %q", rows[0][0], "first")
}
if rows[1][0] != "second" {
t.Errorf("row 2 col A: got %q, want %q", rows[1][0], "second")
}
if len(rows[1]) < 2 || rows[1][1] != "=A2" {
t.Errorf("row 2 col B: got %v, want formula \"=A2\"", rows[1])
}
if rows[2][0] != "fifth" {
t.Errorf("row 5 col A: got %q, want %q", rows[2][0], "fifth")
}
// A real reader (Excel included) cares about the underlying
// element order, not just what this package's own reader derives —
// confirm the raw XML itself is in ascending row/column order.
raw, ok, err := readZipPart(out, xlsxSheetPart(1))
if err != nil || !ok {
t.Fatalf("readZipPart sheet1: ok=%v err=%v", ok, err)
}
body := string(raw)
idxA1 := strings.Index(body, `r="A1"`)
idxA2 := strings.Index(body, `r="A2"`)
idxB2 := strings.Index(body, `r="B2"`)
idxA5 := strings.Index(body, `r="A5"`)
if idxA1 < 0 || idxA2 < 0 || idxB2 < 0 || idxA5 < 0 {
t.Fatalf("expected every written cell ref present in raw XML:\n%s", body)
}
if !(idxA1 < idxA2 && idxA2 < idxB2 && idxB2 < idxA5) {
t.Errorf("expected ascending order A1 < A2 < B2 < A5 in raw XML, got offsets %d,%d,%d,%d:\n%s",
idxA1, idxA2, idxB2, idxA5, body)
}
}
// TestSetXlsxCellsManyNewRowsNoDataLoss is a regression test for a bug
// where SetXlsxCells indexed newly-created rows by a *pointer* taken
// at append time. Go's append reallocates a slice's backing array
// once its capacity is exceeded, which silently strands any
// previously-taken pointer into the old array — a write applied
// through it returns no error but never reaches the file. That bug
// only showed up when (a) more than one cell landed in the same
// brand-new row and (b) a slice reallocation happened to fall between
// those two writes, so it passed or failed depending on Go's
// (deliberately randomized) map iteration order. This test forces
// many reallocations (far more new rows than any small initial slice
// capacity) and writes two cells into every one of them, so a
// reintroduction of the pointer bug fails deterministically rather
// than intermittently.
func TestSetXlsxCellsManyNewRowsNoDataLoss(t *testing.T) {
data, err := BuildXlsx([]Sheet{{Name: "Sheet1", Rows: [][]string{{"seed"}}}})
if err != nil {
t.Fatalf("BuildXlsx: %v", err)
}
const numRows = 200
writes := make(map[string]string, numRows*2)
for r := 2; r <= numRows+1; r++ {
writes[fmt.Sprintf("A%d", r)] = fmt.Sprintf("label-%d", r)
writes[fmt.Sprintf("B%d", r)] = fmt.Sprintf("=A%d", r)
}
out, err := SetXlsxCells(data, "", writes)
if err != nil {
t.Fatalf("SetXlsxCells: %v", err)
}
rows, _, err := ReadXlsxCells(out, "")
if err != nil {
t.Fatalf("ReadXlsxCells: %v", err)
}
if len(rows) != numRows+1 { // +1 for the seeded row 1
t.Fatalf("expected %d rows, got %d", numRows+1, len(rows))
}
for r := 2; r <= numRows+1; r++ {
row := rows[r-1]
if len(row) != 2 {
t.Fatalf("row %d: expected 2 cells, got %d: %v", r, len(row), row)
}
wantA := fmt.Sprintf("label-%d", r)
wantB := fmt.Sprintf("=A%d", r)
if row[0] != wantA {
t.Errorf("row %d col A: got %q, want %q", r, row[0], wantA)
}
if row[1] != wantB {
t.Errorf("row %d col B: got %q, want %q", r, row[1], wantB)
}
}
}
func TestBuildPptxReadRoundTrip(t *testing.T) {
data, err := BuildPptx([]Slide{
{Title: "Welcome"},
{Title: "Agenda", Bullets: []string{"Point one", "Point two & three"}},
})
if err != nil {
t.Fatalf("BuildPptx: %v", err)
}
slides, err := ReadPptxSlides(data)
if err != nil {
t.Fatalf("ReadPptxSlides: %v", err)
}
if len(slides) != 2 {
t.Fatalf("expected 2 slides, got %d", len(slides))
}
if slides[0].Title != "Welcome" {
t.Errorf("slide 1 title: got %q", slides[0].Title)
}
if slides[1].Title != "Agenda" || len(slides[1].Bullets) != 2 || slides[1].Bullets[1] != "Point two & three" {
t.Errorf("slide 2: got %+v", slides[1])
}
}

206
pkg/officekit/pdf.go Normal file
View File

@@ -0,0 +1,206 @@
// pdf.go adds best-effort PDF text extraction: read_pdf's only job is
// "give me back the words", not full-fidelity layout, so this
// deliberately stays a few hundred lines of pure stdlib (no PDF
// library, no cgo, nothing to go get in an offline deployment) rather
// than a complete PDF renderer. It walks the file's raw bytes for
// `stream ... endstream` blocks, inflates the ones declared
// FlateDecode (the overwhelming majority of PDFs produced by real
// tools — Word, LibreOffice, browsers, Pandoc, Pages — all compress
// their content streams this way), and scans the decompressed
// PostScript-like content for the handful of text-showing operators
// (Tj, TJ, ', ") that actually place characters on the page.
// Everything else in the file — fonts, images, vector graphics,
// annotations, page geometry — is ignored.
//
// What this can't do, and won't pretend to: scanned/image-only PDFs
// (no embedded text at all — that needs OCR, out of scope here),
// PDFs whose content streams use a filter other than FlateDecode
// (rare in practice), and any PDF that encodes its text with a custom
// font encoding and no usable mapping back to plain characters — those
// come out as empty or as mangled text, and ReadPdfText says so rather
// than silently returning garbage.
package officekit
import (
"bytes"
"compress/zlib"
"fmt"
"io"
"regexp"
"strconv"
"strings"
)
// pdfStreamRe finds every `stream\r?\n ... \r?\nendstream` block in
// the file, capturing the body separately from the "stream" keyword
// that precedes it — ReadPdfText looks backward from the keyword for
// the object dictionary's /Filter entry rather than actually parsing
// PDF's object/dictionary syntax.
var pdfStreamRe = regexp.MustCompile(`(?s)stream\r?\n(.*?)\r?\nendstream`)
// pdfDictLookback is how far back from a `stream` keyword ReadPdfText
// looks for that stream object's dictionary (and in particular its
// /Filter entry) — generous enough for any realistic content-stream
// dictionary (/Length, /Filter, /Length1, a handful of other keys)
// without risking bleeding into the previous object.
const pdfDictLookback = 600
// ReadPdfText extracts a PDF's plain text content, best-effort. See
// the package doc above for exactly what it does and doesn't handle.
// Returns an error only when the input isn't recognizable as a PDF,
// or no extractable text was found anywhere in it — a PDF that fails
// to extract from some streams but not others still returns whatever
// text it did recover from the rest.
func ReadPdfText(data []byte) (string, error) {
head := data
if len(head) > 1024 {
head = head[:1024]
}
if !bytes.Contains(head, []byte("%PDF-")) {
return "", fmt.Errorf("officekit: not a PDF file (missing %%PDF- header)")
}
var out strings.Builder
for _, loc := range pdfStreamRe.FindAllSubmatchIndex(data, -1) {
streamStart, bodyStart, bodyEnd := loc[0], loc[2], loc[3]
dictStart := streamStart - pdfDictLookback
if dictStart < 0 {
dictStart = 0
}
dict := data[dictStart:streamStart]
if !bytes.Contains(dict, []byte("FlateDecode")) {
continue // not a stream this package knows how to decompress
}
inflated, err := inflateZlib(data[bodyStart:bodyEnd])
if err != nil {
continue // FlateDecode-tagged but not plain DEFLATE (e.g. an embedded font program); skip it
}
if text := extractShownText(inflated); text != "" {
if out.Len() > 0 {
out.WriteString("\n\n")
}
out.WriteString(text)
}
}
result := strings.TrimSpace(out.String())
if result == "" {
return "", fmt.Errorf("officekit: no extractable text found (the PDF may be scanned/image-only, or use an encoding this reader doesn't support)")
}
return result, nil
}
func inflateZlib(b []byte) ([]byte, error) {
r, err := zlib.NewReader(bytes.NewReader(b))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// pdfTextOpRe matches one text-showing operator call together with
// its operand(s): one or more PDF "literal strings" — parenthesized,
// with their own backslash-escape syntax decoded by
// pdfUnescapeLiteral — followed by Tj, TJ, ' or ". A TJ call's operand
// is actually an array, e.g. `[(Hel)-20(lo)] TJ`, which is why this
// matches "one or more" consecutive literals rather than exactly one.
var pdfTextOpRe = regexp.MustCompile(`(?s)((?:\((?:\\.|[^()\\])*\)[^()]*?)+)(Tj|TJ|'|")`)
// pdfLiteralRe pulls each individual (...) literal string out of one
// operator call's operand span.
var pdfLiteralRe = regexp.MustCompile(`(?s)\((?:\\.|[^()\\])*\)`)
// pdfNewlineOpRe matches the content-stream operators that move the
// text cursor to a new line (Td, TD, T*) — used only as a heuristic
// for where extractShownText inserts a newline into the recovered
// text, since the stream itself carries no notion of "paragraph" or
// "line" beyond cursor movement.
var pdfNewlineOpRe = regexp.MustCompile(`\bT[dD*]\b`)
// extractShownText walks one decompressed content stream in document
// order and concatenates the string(s) passed to each text-showing
// operator call, inserting a newline wherever a line-positioning
// operator (Td/TD/T*) appeared since the previous call and a plain
// space otherwise — an approximation of the stream's original line
// breaks, not a layout engine. A single TJ call's several literals
// (PDF splits one word across more than one literal to carry kerning
// adjustments, e.g. `[(Hel)-20(lo)] TJ`) are joined with nothing
// between them, since the gap is a spacing nudge, not a real word
// break — any actual space in the shown text already appears as a
// literal " " character the way it does in a Tj call.
func extractShownText(stream []byte) string {
var sb strings.Builder
pos := 0
pendingNewline := false
for pos < len(stream) {
loc := pdfTextOpRe.FindSubmatchIndex(stream[pos:])
if loc == nil {
break
}
matchStart, matchEnd := pos+loc[0], pos+loc[1]
if pdfNewlineOpRe.Match(stream[pos:matchStart]) {
pendingNewline = true
}
operand := stream[pos+loc[2] : pos+loc[3]]
var call strings.Builder
for _, lit := range pdfLiteralRe.FindAll(operand, -1) {
call.WriteString(pdfUnescapeLiteral(lit[1 : len(lit)-1]))
}
if text := call.String(); text != "" {
if sb.Len() > 0 {
if pendingNewline {
sb.WriteByte('\n')
} else {
sb.WriteByte(' ')
}
}
pendingNewline = false
sb.WriteString(text)
}
pos = matchEnd
}
return strings.TrimSpace(sb.String())
}
// pdfUnescapeLiteral decodes a PDF literal string's backslash escapes
// (\n \r \t \( \) \\ and octal \ddd) and treats every other byte as
// raw Latin-1 — correct for the common case (a simple, non-custom
// font encoding) and the best this package can do without parsing the
// PDF's font /Encoding and /ToUnicode tables.
func pdfUnescapeLiteral(b []byte) string {
var sb strings.Builder
for i := 0; i < len(b); i++ {
c := b[i]
if c != '\\' || i == len(b)-1 {
sb.WriteByte(c)
continue
}
i++
switch n := b[i]; {
case n == 'n':
sb.WriteByte('\n')
case n == 'r':
sb.WriteByte('\r')
case n == 't':
sb.WriteByte('\t')
case n == 'b' || n == 'f':
// backspace/form-feed: not meaningful in extracted text
case n == '(' || n == ')' || n == '\\':
sb.WriteByte(n)
case n == '\n':
// backslash-newline is a line continuation, not a literal char
case n >= '0' && n <= '7':
j := i
for j < len(b) && j < i+3 && b[j] >= '0' && b[j] <= '7' {
j++
}
if v, err := strconv.ParseInt(string(b[i:j]), 8, 32); err == nil {
sb.WriteByte(byte(v))
}
i = j - 1
default:
sb.WriteByte(n)
}
}
return sb.String()
}

90
pkg/officekit/pdf_test.go Normal file
View File

@@ -0,0 +1,90 @@
package officekit
import (
"bytes"
"compress/zlib"
"strings"
"testing"
)
// buildMinimalPdf wraps one FlateDecode-compressed content stream in
// just enough surrounding PDF syntax for ReadPdfText's regex-based
// scanner to find it — a %PDF- header and one `N 0 obj << ... /Filter
// /FlateDecode >> stream ... endstream endobj` object. Not a complete,
// schema-valid PDF (no xref table, no page tree), but ReadPdfText
// never looks at either of those, so this is a faithful enough fixture
// for exercising it without needing a real binary PDF checked into
// the repo.
func buildMinimalPdf(t *testing.T, content string) []byte {
t.Helper()
var compressed bytes.Buffer
zw := zlib.NewWriter(&compressed)
if _, err := zw.Write([]byte(content)); err != nil {
t.Fatalf("zlib write: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("zlib close: %v", err)
}
var buf bytes.Buffer
buf.WriteString("%PDF-1.4\n")
buf.WriteString("4 0 obj\n<< /Length 999 /Filter /FlateDecode >>\nstream\n")
buf.Write(compressed.Bytes())
buf.WriteString("\nendstream\nendobj\n%%EOF")
return buf.Bytes()
}
func TestReadPdfTextBasic(t *testing.T) {
data := buildMinimalPdf(t, "BT /F1 12 Tf 72 712 Td (Hello World) Tj ET")
text, err := ReadPdfText(data)
if err != nil {
t.Fatalf("ReadPdfText: %v", err)
}
if text != "Hello World" {
t.Errorf("got %q, want %q", text, "Hello World")
}
}
func TestReadPdfTextLineBreaksAndEscapes(t *testing.T) {
content := "BT /F1 12 Tf 72 712 Td (Line one \\(escaped\\)) Tj\n72 700 Td (Line two) Tj ET"
data := buildMinimalPdf(t, content)
text, err := ReadPdfText(data)
if err != nil {
t.Fatalf("ReadPdfText: %v", err)
}
if !strings.Contains(text, "Line one (escaped)") {
t.Errorf("expected unescaped parens in %q", text)
}
if !strings.Contains(text, "Line two") {
t.Errorf("expected second Td'd line's text in %q", text)
}
if !strings.Contains(text, "\n") {
t.Errorf("expected a newline between Td-separated text runs, got %q", text)
}
}
func TestReadPdfTextTJArrayKerning(t *testing.T) {
data := buildMinimalPdf(t, "BT /F1 12 Tf 72 712 Td [(Hel)-20(lo)20( world)] TJ ET")
text, err := ReadPdfText(data)
if err != nil {
t.Fatalf("ReadPdfText: %v", err)
}
if text != "Hello world" {
t.Errorf("got %q, want %q (TJ kerning numbers must not become spaces)", text, "Hello world")
}
}
func TestReadPdfTextRejectsNonPdf(t *testing.T) {
if _, err := ReadPdfText([]byte("just some ordinary bytes, not a PDF")); err == nil {
t.Error("expected an error for input with no %PDF- header")
}
}
func TestReadPdfTextNoShownTextOperators(t *testing.T) {
// A validly-structured FlateDecode stream (so the inflate step
// succeeds) with no Tj/TJ/'/" calls at all — e.g. a pure image draw.
data := buildMinimalPdf(t, "q 100 0 0 100 0 0 cm /Im0 Do Q")
if _, err := ReadPdfText(data); err == nil {
t.Error("expected an error when the stream has no text-showing operators")
}
}

376
pkg/officekit/pptx.go Normal file
View File

@@ -0,0 +1,376 @@
package officekit
import (
"bytes"
"encoding/xml"
"fmt"
"strings"
)
// Slide is one slide's content, as the model supplies it to
// create_presentation/edit_presentation: a title and a flat list of
// bullet lines. Deliberately this simple — no layouts, no inherited
// placeholders, no raw DrawingML — so the model never authors
// PresentationML either; every slide is rendered as two independent,
// absolutely-positioned text boxes (title, body) that don't need to
// resolve against the slide layout/master's placeholder scheme.
type Slide struct {
Title string
Bullets []string
}
// --- read side: typed XML for parsing a slide's shape tree back out
// (ReadPptxSlides). Every struct tag below is a plain, unprefixed
// local name with no namespace component — PresentationML genuinely
// needs several simultaneous namespaces (p: for presentation elements,
// a: for drawing elements), so unlike docx's single-namespace case we
// can't lean on one default namespace to cover everything. Instead we
// rely on a different, equally standard encoding/xml behavior: a field
// tag with no namespace component matches an element by local name
// only, regardless of which namespace it actually resolved to. So
// "cSld" matches <p:cSld> and "xfrm" matches <a:xfrm> alike, without
// our Go types needing to know or care which prefix/namespace a given
// element belongs to. (See the longer version of this note on
// wDocument in docx.go — same underlying mechanism, applied here to
// the harder, multi-namespace case.) ---
type pSld struct {
XMLName xml.Name `xml:"sld"`
CSld pCSld `xml:"cSld"`
}
type pCSld struct {
SpTree pSpTree `xml:"spTree"`
}
type pSpTree struct {
Sp []pSp `xml:"sp"`
}
type pCNvPr struct {
Id int `xml:"id,attr"`
Name string `xml:"name,attr"`
}
type pSp struct {
NvSpPr pNvSpPr `xml:"nvSpPr"`
TxBody pTxBody `xml:"txBody"`
}
type pNvSpPr struct {
CNvPr pCNvPr `xml:"cNvPr"`
}
type pTxBody struct {
Paras []pTxPara `xml:"p"`
}
type pTxPara struct {
Run pTxRun `xml:"r"`
}
type pTxRun struct {
Text string `xml:"t"`
}
// --- write side: deliberately NOT built with xml.Marshal. PresentationML's
// several simultaneous namespaces mean a struct-tag-driven Marshal would
// need Go to auto-assign namespace prefixes, which is both unpredictable
// and avoidable here — we fully control this template, so we write the
// real p:/a: prefixes directly and escape every piece of user-supplied
// text through encoding/xml's own escaper (xmlEscapeText), which is the
// same function Marshal itself uses internally for chardata. That keeps
// the "well-formed, correctly-escaped, never touched by the model"
// property from the package doc comment without fighting Go's namespace
// prefix allocation. ---
// xmlEscapeText XML-escapes s for use as element chardata (title and
// bullet text — the only user-supplied strings that end up in a slide).
func xmlEscapeText(s string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(s))
return buf.String()
}
const titleShapeTemplate = `<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Title"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="457200" y="274638"/><a:ext cx="8229600" cy="1143000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="3200" b="1"/><a:t>%s</a:t></a:r></a:p></p:txBody>
</p:sp>`
const bodyParaTemplate = `<a:p><a:pPr><a:buChar char="&#8226;"/></a:pPr><a:r><a:rPr lang="en-US" sz="2000"/><a:t>%s</a:t></a:r></a:p>`
const bodyShapeTemplate = `<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Body"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="457200" y="1600200"/><a:ext cx="8229600" cy="4525963"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/>%s</p:txBody>
</p:sp>`
func titleShape(id int, text string) string {
return fmt.Sprintf(titleShapeTemplate, id, xmlEscapeText(text))
}
func bodyShape(id int, bullets []string) string {
var paras strings.Builder
for _, b := range bullets {
fmt.Fprintf(&paras, bodyParaTemplate, xmlEscapeText(b))
}
return fmt.Sprintf(bodyShapeTemplate, id, paras.String())
}
func slideXML(s Slide) ([]byte, error) {
var shapes strings.Builder
if s.Title != "" {
shapes.WriteString(titleShape(2, s.Title))
}
if len(s.Bullets) > 0 {
shapes.WriteString(bodyShape(3, s.Bullets))
}
out := xml.Header + `<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>` + shapes.String() + `</p:spTree></p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>`
return []byte(out), nil
}
func slidePath(n int) string { return fmt.Sprintf("ppt/slides/slide%d.xml", n) }
func slideRelsPath(n int) string { return fmt.Sprintf("ppt/slides/_rels/slide%d.xml.rels", n) }
const slideRelsBody = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
</Relationships>`
// BuildPptx renders slides into a complete, valid .pptx file.
func BuildPptx(slides []Slide) ([]byte, error) {
if len(slides) == 0 {
slides = []Slide{{}}
}
parts := []zipPart{
{Name: "[Content_Types].xml", Body: pptxContentTypes(len(slides))},
{Name: "_rels/.rels", Body: pptxRootRels},
{Name: "ppt/presentation.xml", Body: pptxPresentationXML(len(slides))},
{Name: "ppt/_rels/presentation.xml.rels", Body: pptxPresentationRels(len(slides))},
{Name: "ppt/slideMasters/slideMaster1.xml", Body: pptxSlideMaster},
{Name: "ppt/slideMasters/_rels/slideMaster1.xml.rels", Body: pptxSlideMasterRels},
{Name: "ppt/slideLayouts/slideLayout1.xml", Body: pptxSlideLayout},
{Name: "ppt/slideLayouts/_rels/slideLayout1.xml.rels", Body: pptxSlideLayoutRels},
{Name: "ppt/theme/theme1.xml", Body: pptxTheme},
{Name: "docProps/core.xml", Body: officeCoreProps},
}
for i, s := range slides {
n := i + 1
sx, err := slideXML(s)
if err != nil {
return nil, err
}
parts = append(parts,
zipPart{Name: slidePath(n), Body: string(sx)},
zipPart{Name: slideRelsPath(n), Body: slideRelsBody},
)
}
return buildZip(parts)
}
// ReadPptxSlides returns every slide's title + bullets, in order, for
// the read_presentation tool. It re-derives slide order and count from
// ppt/presentation.xml's sldIdLst / presentation.xml.rels rather than
// assuming a fixed count, so a file this package wrote (or edited)
// round-trips exactly.
func ReadPptxSlides(data []byte) ([]Slide, error) {
n, err := countSlides(data)
if err != nil {
return nil, err
}
var out []Slide
for i := 1; i <= n; i++ {
raw, ok, err := readZipPart(data, slidePath(i))
if err != nil {
return nil, err
}
if !ok {
continue
}
var sld pSld
if err := xml.Unmarshal(raw, &sld); err != nil {
return nil, fmt.Errorf("officekit: parse %s: %w", slidePath(i), err)
}
s := Slide{}
for _, sp := range sld.CSld.SpTree.Sp {
switch sp.NvSpPr.CNvPr.Name {
case "Title":
if len(sp.TxBody.Paras) > 0 {
s.Title = sp.TxBody.Paras[0].Run.Text
}
case "Body":
for _, p := range sp.TxBody.Paras {
s.Bullets = append(s.Bullets, p.Run.Text)
}
}
}
out = append(out, s)
}
return out, nil
}
// countSlides counts how many ppt/slides/slideN.xml parts a .pptx
// actually contains by probing sequentially (simplest reliable
// approach given this package writes slides as a dense 1..n sequence
// and never deletes from the middle).
func countSlides(data []byte) (int, error) {
n := 0
for {
_, ok, err := readZipPart(data, slidePath(n+1))
if err != nil {
return 0, err
}
if !ok {
break
}
n++
}
return n, nil
}
// AppendPptxSlide adds one new slide at the end and returns the new
// file bytes, keeping [Content_Types].xml, presentation.xml and
// presentation.xml.rels in sync with the new slide count.
func AppendPptxSlide(data []byte, s Slide) ([]byte, error) {
n, err := countSlides(data)
if err != nil {
return nil, err
}
newN := n + 1
sx, err := slideXML(s)
if err != nil {
return nil, err
}
out, err := addZipParts(data, []zipPart{
{Name: slidePath(newN), Body: string(sx)},
{Name: slideRelsPath(newN), Body: slideRelsBody},
})
if err != nil {
return nil, err
}
out, err = replaceZipPart(out, "[Content_Types].xml", []byte(pptxContentTypes(newN)))
if err != nil {
return nil, err
}
out, err = replaceZipPart(out, "ppt/presentation.xml", []byte(pptxPresentationXML(newN)))
if err != nil {
return nil, err
}
return replaceZipPart(out, "ppt/_rels/presentation.xml.rels", []byte(pptxPresentationRels(newN)))
}
// UpdatePptxSlide replaces one existing slide's content (1-based
// index) in place.
func UpdatePptxSlide(data []byte, index int, s Slide) ([]byte, error) {
n, err := countSlides(data)
if err != nil {
return nil, err
}
if index < 1 || index > n {
return nil, fmt.Errorf("officekit: slide %d out of range (presentation has %d slide(s))", index, n)
}
sx, err := slideXML(s)
if err != nil {
return nil, err
}
return replaceZipPart(data, slidePath(index), []byte(sx))
}
func pptxContentTypes(n int) string {
var overrides strings.Builder
for i := 1; i <= n; i++ {
fmt.Fprintf(&overrides, `<Override PartName="/%s" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`, slidePath(i))
}
return xml.Header + `<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="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
` + overrides.String() + `</Types>`
}
func pptxPresentationXML(n int) string {
var ids strings.Builder
for i := 1; i <= n; i++ {
fmt.Fprintf(&ids, `<p:sldId id="%d" r:id="rId%d"/>`, 255+i, i+1)
}
return xml.Header + `<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
<p:sldIdLst>` + ids.String() + `</p:sldIdLst>
<p:sldSz cx="9144000" cy="6858000"/>
<p:notesSz cx="6858000" cy="9144000"/>
</p:presentation>`
}
func pptxPresentationRels(n int) string {
var rels strings.Builder
for i := 1; i <= n; i++ {
fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide%d.xml"/>`, i+1, i)
}
return xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
` + rels.String() + `</Relationships>`
}
const pptxRootRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
</Relationships>`
const pptxSlideMasterRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
</Relationships>`
const pptxSlideLayoutRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
</Relationships>`
const pptxSlideMaster = xml.Header + `<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>
<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>
<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>
<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
</p:sldMaster>`
const pptxSlideLayout = xml.Header + `<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1">
<p:cSld name="Blank">
<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sldLayout>`
const pptxTheme = xml.Header + `<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="enkly">
<a:themeElements>
<a:clrScheme name="enkly">
<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
<a:dk2><a:srgbClr val="44546A"/></a:dk2>
<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
<a:accent1><a:srgbClr val="4472C4"/></a:accent1>
<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
<a:accent4><a:srgbClr val="FFC000"/></a:accent4>
<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>
<a:accent6><a:srgbClr val="70AD47"/></a:accent6>
<a:hlink><a:srgbClr val="0563C1"/></a:hlink>
<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
</a:clrScheme>
<a:fontScheme name="enkly">
<a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>
<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>
</a:fontScheme>
<a:fmtScheme name="enkly">
<a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst>
<a:lnStyleLst><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst>
<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>
<a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst>
</a:fmtScheme>
</a:themeElements>
</a:theme>`

302
pkg/officekit/redline.go Normal file
View File

@@ -0,0 +1,302 @@
// redline.go adds lightweight "track changes" (redlining) support on
// top of the plain append/replace operations in docx.go: the same
// kind of edit — change this document's text — but recorded as a
// Word-reviewable <w:ins>/<w:del> pair instead of a silent in-place
// rewrite, plus accept/reject to resolve those changes afterward.
// Word, LibreOffice and Google Docs (on import) all read and display
// these the same way: Review > Track Changes.
//
// Scope, deliberately: a tracked replace marks the WHOLE run that
// contained a match as changed (old text struck through, new text
// inserted next to it) rather than computing a minimal character-level
// diff within the run — the same "whole unit, not a sub-span"
// tradeoff ReplaceDocxText already makes implicitly by working at the
// <w:t> level (see its doc comment in docx.go). A run in a real
// document is usually already one coherent span of text in one
// formatting (Word splits runs on formatting changes, not mid-word),
// so in practice this reads as a natural redline rather than an
// oversized strikethrough.
package officekit
import (
"bytes"
"encoding/xml"
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
// wRunRe matches one complete <w:r>...</w:r> run — its opening tag,
// any attributes, its full inner content, and its closing tag — so a
// tracked replace can pull the run's formatting (its leading <w:rPr>,
// if any) out and copy it into both halves of the del/ins pair it
// produces. Safe as a non-nested regex because OOXML's schema never
// allows a <w:r> to contain another <w:r> (a run is the schema's
// smallest text-formatting unit, not a container for other runs).
var wRunRe = regexp.MustCompile(`(?s)<((?:[A-Za-z][\w.-]*:)?r)(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?r>`)
// wRPrLeadingRe pulls a run's leading <w:rPr>...</w:rPr> block (if
// any) off the front of its inner content.
var wRPrLeadingRe = regexp.MustCompile(`(?s)^\s*(<(?:[A-Za-z][\w.-]*:)?rPr>.*?</(?:[A-Za-z][\w.-]*:)?rPr>)`)
// wInsBlockRe / wDelBlockRe match one complete <w:ins>/<w:del> block,
// capturing its opening tag's attributes and its inner content
// separately — AcceptAllDocxChanges, RejectAllDocxChanges and
// ReadDocxRevisions all work by matching one or the other and either
// keeping, dropping, or reporting on what's inside.
var wInsBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?ins(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?ins>`)
var wDelBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?del(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?del>`)
// wDelTextElementRe matches one <w:delText>...</w:delText> element,
// the deletion-tracking equivalent of <w:t> used only inside <w:del>
// runs — RejectAllDocxChanges turns these back into ordinary <w:t>
// elements so the restored (un-deleted) text renders normally again.
var wDelTextElementRe = regexp.MustCompile(`(?s)(<(?:[A-Za-z][\w.-]*:)?delText(?:\s[^>]*)?>)(.*?)(</(?:[A-Za-z][\w.-]*:)?delText>)`)
// changeAuthorRe pulls the author="..." (optionally namespace-
// prefixed) attribute value out of a <w:ins>/<w:del> opening tag's
// captured attribute string.
var changeAuthorRe = regexp.MustCompile(`(?:^|\s)(?:[\w.-]+:)?author="([^"]*)"`)
// revisionIDRe finds every existing id="N" attribute in a
// document.xml, so new tracked changes can continue numbering from
// the highest ID already present rather than risking a collision with
// revisions from an earlier edit (an uploaded document, or an earlier
// tool call in the same session).
var revisionIDRe = regexp.MustCompile(`id="(\d+)"`)
func nextRevisionIDFrom(doc []byte) int {
max := 0
for _, m := range revisionIDRe.FindAllSubmatch(doc, -1) {
if n, err := strconv.Atoi(string(m[1])); err == nil && n > max {
max = n
}
}
return max + 1
}
// revisionDate is w:date's expected format (ISO 8601, UTC, second
// precision) — the same convention Word itself writes.
func revisionDate() string {
return time.Now().UTC().Format("2006-01-02T15:04:05Z")
}
func xmlEscapeAttrVal(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "<", "&lt;")
return s
}
// ReplaceDocxTextTracked is ReplaceDocxText's tracked-changes sibling:
// instead of silently rewriting matching text runs in place, it turns
// each matching run into a <w:del>/<w:ins> pair recording who changed
// it and when, exactly as if a human reviewer had made the edit with
// Track Changes switched on in Word. See this file's package doc for
// the whole-run-not-sub-span scope this operates at. author defaults
// to "enkly agent" if empty.
func ReplaceDocxTextTracked(data []byte, find, replace, author string) ([]byte, int, error) {
if author == "" {
author = "enkly agent"
}
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return nil, 0, err
}
if !ok {
return nil, 0, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
nextID := nextRevisionIDFrom(raw)
date := revisionDate()
changed := 0
newDoc := wRunRe.ReplaceAllFunc(raw, func(match []byte) []byte {
g := wRunRe.FindSubmatch(match)
inner := g[3]
tm := wTextElementRe.FindSubmatch(inner)
if tm == nil {
return match // a tab, break, or field run — no <w:t> to compare against
}
oldText := xmlUnescapeText(tm[2])
if !strings.Contains(oldText, find) {
return match
}
newText := strings.ReplaceAll(oldText, find, replace)
changed++
rPr := ""
if m := wRPrLeadingRe.FindSubmatch(inner); m != nil {
rPr = string(m[1])
}
delID, insID := nextID, nextID+1
nextID += 2
var sb strings.Builder
fmt.Fprintf(&sb, `<del id="%d" author="%s" date="%s"><r>%s<delText xml:space="preserve">%s</delText></r></del>`,
delID, xmlEscapeAttrVal(author), date, rPr, xmlEscapeText(oldText))
fmt.Fprintf(&sb, `<ins id="%d" author="%s" date="%s"><r>%s<t xml:space="preserve">%s</t></r></ins>`,
insID, xmlEscapeAttrVal(author), date, rPr, xmlEscapeText(newText))
return []byte(sb.String())
})
if changed == 0 {
return nil, 0, fmt.Errorf("officekit: %q not found in any text run", find)
}
out, err := replaceZipPart(data, docxDocumentPart, newDoc)
return out, changed, err
}
// wIns is a <w:ins> element's content: the tracked-insertion wrapper
// AppendDocxParagraphTracked puts around a brand-new paragraph's run,
// mirroring ReplaceDocxTextTracked's own <ins id author date><r>...
// shape above but built with encoding/xml (like the rest of a freshly
// generated paragraph fragment) rather than string formatting, since
// here — unlike a splice into arbitrary existing XML — the whole
// fragment is this package's own output.
type wIns struct {
ID int `xml:"id,attr"`
Author string `xml:"author,attr"`
Date string `xml:"date,attr"`
Run wRun `xml:"r"`
}
// wInsParagraphFragment is wParagraphFragment's (docx.go)
// tracked-insert counterpart: same paragraph content, but with its
// run wrapped in <w:ins> so a reviewer sees the whole new paragraph as
// a pending insertion rather than it silently becoming part of the
// document.
type wInsParagraphFragment struct {
XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main p"`
PPr *wPPr `xml:"pPr,omitempty"`
Ins wIns `xml:"ins"`
}
// AppendDocxParagraphTracked is AppendDocxParagraph's tracked-changes
// sibling: the new paragraph is spliced in exactly the same way (see
// AppendDocxParagraph's doc comment in docx.go), but its run is
// wrapped in <w:ins>, so it shows up in Word as a pending insertion
// (Review > Track Changes) instead of silently becoming part of the
// document. author defaults to "enkly agent" if empty.
func AppendDocxParagraphTracked(data []byte, p Paragraph, author string) ([]byte, error) {
if author == "" {
author = "enkly agent"
}
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
wp := toWParagraph(p)
frag := wInsParagraphFragment{
PPr: wp.PPr,
Ins: wIns{ID: nextRevisionIDFrom(raw), Author: author, Date: revisionDate(), Run: wp.Run},
}
paraXML, err := xml.Marshal(frag)
if err != nil {
return nil, fmt.Errorf("officekit: marshal tracked paragraph: %w", err)
}
at := bodyInsertionPoint(raw)
if at < 0 {
return nil, fmt.Errorf("officekit: %s has no <w:body> to append to (not a valid .docx)", docxDocumentPart)
}
newDoc := make([]byte, 0, len(raw)+len(paraXML))
newDoc = append(newDoc, raw[:at]...)
newDoc = append(newDoc, paraXML...)
newDoc = append(newDoc, raw[at:]...)
return replaceZipPart(data, docxDocumentPart, newDoc)
}
// AcceptAllDocxChanges resolves every tracked change in the document
// the way Word's "Accept All Changes" does: deleted text (<w:del>) is
// removed for good, and inserted text (<w:ins>) becomes ordinary,
// no-longer-marked content.
func AcceptAllDocxChanges(data []byte) ([]byte, error) {
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
newDoc := wDelBlockRe.ReplaceAll(raw, nil)
newDoc = wInsBlockRe.ReplaceAllFunc(newDoc, func(m []byte) []byte {
return wInsBlockRe.FindSubmatch(m)[2]
})
return replaceZipPart(data, docxDocumentPart, newDoc)
}
// RejectAllDocxChanges resolves every tracked change the way Word's
// "Reject All Changes" does: inserted text (<w:ins>) is discarded, and
// deleted text (<w:del>) is restored as ordinary content.
func RejectAllDocxChanges(data []byte) ([]byte, error) {
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
newDoc := wInsBlockRe.ReplaceAll(raw, nil)
newDoc = wDelBlockRe.ReplaceAllFunc(newDoc, func(m []byte) []byte {
inner := wDelBlockRe.FindSubmatch(m)[2]
// delText -> t, so the restored text renders as normal content
// rather than staying tagged as a (now orphaned) deletion.
return wDelTextElementRe.ReplaceAllFunc(inner, func(tm []byte) []byte {
g := wDelTextElementRe.FindSubmatch(tm)
open := bytes.Replace(g[1], []byte("delText"), []byte("t"), 1)
closeTag := bytes.Replace(g[3], []byte("delText"), []byte("t"), 1)
out := make([]byte, 0, len(open)+len(g[2])+len(closeTag))
out = append(out, open...)
out = append(out, g[2]...)
out = append(out, closeTag...)
return out
})
})
return replaceZipPart(data, docxDocumentPart, newDoc)
}
// wChangeBlockRe matches one complete <w:ins>...</w:ins> or
// <w:del>...</w:del> block, capturing which kind it is, its opening
// tag's attributes, and its inner content — ReadDocxRevisions' single
// pass over the document.
var wChangeBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?(ins|del)(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?(?:ins|del)>`)
// ReadDocxRevisions lists every tracked change (insertion or deletion)
// currently in the document — author and the affected text, in
// document order — so a caller can review what Track Changes has
// recorded before deciding whether to accept or reject it. Returns
// ("", nil) if the document has no tracked changes at all.
func ReadDocxRevisions(data []byte) (string, error) {
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
matches := wChangeBlockRe.FindAllSubmatch(raw, -1)
if len(matches) == 0 {
return "", nil
}
var sb strings.Builder
for i, m := range matches {
kind, attrs, inner := string(m[1]), m[2], m[3]
author := "unknown"
if am := changeAuthorRe.FindSubmatch(attrs); am != nil {
author = string(am[1])
}
label, textRe := "inserted", wTextElementRe
if kind == "del" {
label, textRe = "deleted", wDelTextElementRe
}
var text strings.Builder
for _, tm := range textRe.FindAllSubmatch(inner, -1) {
text.WriteString(xmlUnescapeText(tm[2]))
}
fmt.Fprintf(&sb, "%d. %s by %s: %q\n", i+1, label, author, text.String())
}
return strings.TrimRight(sb.String(), "\n"), nil
}

View File

@@ -0,0 +1,114 @@
package officekit
import (
"strings"
"testing"
)
func TestReplaceDocxTextTrackedRoundTrip(t *testing.T) {
data := minimalWordDoc(t)
out, n, err := ReplaceDocxTextTracked(data, "cell one text", "updated cell text", "Reviewer A")
if err != nil {
t.Fatalf("ReplaceDocxTextTracked: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 run changed, got %d", n)
}
// Plain read-back should show the NEW text (deleted text is review
// chrome, not live content) and still see the untouched paragraph.
text, err := ReadDocxText(out)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
if !strings.Contains(text, "updated cell text") {
t.Errorf("expected new text in read-back, got: %s", text)
}
if !strings.Contains(text, "Intro paragraph.") {
t.Errorf("unrelated paragraph lost: %s", text)
}
revisions, err := ReadDocxRevisions(out)
if err != nil {
t.Fatalf("ReadDocxRevisions: %v", err)
}
for _, want := range []string{"Reviewer A", "cell one text", "updated cell text"} {
if !strings.Contains(revisions, want) {
t.Errorf("expected revisions summary to contain %q, got:\n%s", want, revisions)
}
}
raw, ok, err := readZipPart(out, docxDocumentPart)
if err != nil || !ok {
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
}
body := string(raw)
if !strings.Contains(body, "<del ") || !strings.Contains(body, "<ins ") {
t.Errorf("expected <del>/<ins> elements in document.xml, got:\n%s", body)
}
}
func TestAppendDocxParagraphTrackedThenAccept(t *testing.T) {
data := minimalWordDoc(t)
out, err := AppendDocxParagraphTracked(data, Paragraph{Text: "Pending new line."}, "")
if err != nil {
t.Fatalf("AppendDocxParagraphTracked: %v", err)
}
revisions, err := ReadDocxRevisions(out)
if err != nil {
t.Fatalf("ReadDocxRevisions: %v", err)
}
if !strings.Contains(revisions, "enkly agent") || !strings.Contains(revisions, "Pending new line.") {
t.Errorf("expected default author + inserted text in revisions, got:\n%s", revisions)
}
accepted, err := AcceptAllDocxChanges(out)
if err != nil {
t.Fatalf("AcceptAllDocxChanges: %v", err)
}
text, err := ReadDocxText(accepted)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
if !strings.Contains(text, "Pending new line.") {
t.Errorf("expected accepted insertion to survive as plain text, got: %s", text)
}
if noRev, err := ReadDocxRevisions(accepted); err != nil || noRev != "" {
t.Errorf("expected no revisions left after accept, got %q (err=%v)", noRev, err)
}
}
func TestReplaceDocxTextTrackedThenReject(t *testing.T) {
data := minimalWordDoc(t)
out, _, err := ReplaceDocxTextTracked(data, "Intro paragraph.", "Rewritten intro.", "Reviewer B")
if err != nil {
t.Fatalf("ReplaceDocxTextTracked: %v", err)
}
rejected, err := RejectAllDocxChanges(out)
if err != nil {
t.Fatalf("RejectAllDocxChanges: %v", err)
}
text, err := ReadDocxText(rejected)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
if !strings.Contains(text, "Intro paragraph.") {
t.Errorf("expected original text restored after reject, got: %s", text)
}
if strings.Contains(text, "Rewritten intro.") {
t.Errorf("rejected insertion should not survive, got: %s", text)
}
if noRev, err := ReadDocxRevisions(rejected); err != nil || noRev != "" {
t.Errorf("expected no revisions left after reject, got %q (err=%v)", noRev, err)
}
}
func TestReadDocxRevisionsEmptyWhenNoChanges(t *testing.T) {
revisions, err := ReadDocxRevisions(minimalWordDoc(t))
if err != nil {
t.Fatalf("ReadDocxRevisions: %v", err)
}
if revisions != "" {
t.Errorf("expected no revisions in a plain document, got: %s", revisions)
}
}

505
pkg/officekit/xlsx.go Normal file
View File

@@ -0,0 +1,505 @@
package officekit
import (
"encoding/xml"
"fmt"
"sort"
"strconv"
"strings"
)
// Sheet is one worksheet's data, as the model supplies it to
// create_spreadsheet: a name and a grid of string cell values (numbers
// are written as plain decimal strings; anything that parses as a
// number is stored as a numeric cell so Excel treats it as one).
type Sheet struct {
Name string
Rows [][]string
}
const xlsxWorkbookPart = "xl/workbook.xml"
const xlsxWorkbookRelsPart = "xl/_rels/workbook.xml.rels"
// xlsxSheetPart returns the conventional worksheet part path for the
// Nth (1-based) sheet BuildXlsx writes — sheet1.xml, sheet2.xml, ...
func xlsxSheetPart(n int) string { return fmt.Sprintf("xl/worksheets/sheet%d.xml", n) }
// --- typed XML for the parts BuildXlsx/edit operations actually
// generate from user-supplied content (worksheet XML, plus the small
// workbook/rels indirection needed to support more than one sheet);
// every other part is static boilerplate with no user content. ---
type sWorksheet struct {
XMLName xml.Name `xml:"worksheet"`
Xmlns string `xml:"xmlns,attr"`
SheetData sSheetData `xml:"sheetData"`
}
type sSheetData struct {
Rows []sRow `xml:"row"`
}
type sRow struct {
R int `xml:"r,attr"`
Cells []sCell `xml:"c"`
}
// sCell's field order (after the r/t attrs) follows OOXML's CT_Cell
// schema sequence — f (formula), v (value), is (inline string) — so a
// formula cell with a cached value (f + v both set, as real Excel
// always writes them) round-trips as a schema-valid element.
type sCell struct {
R string `xml:"r,attr"`
Type string `xml:"t,attr,omitempty"` // "inlineStr" for text; omitted (numeric/formula) otherwise
Formula *string `xml:"f,omitempty"` // formula cells ("=SUM(...)" minus the leading "=")
Value *string `xml:"v,omitempty"` // numeric cells, and a formula's last-known/cached result
Is *sIs `xml:"is,omitempty"` // inline-string cells
}
type sIs struct {
T string `xml:"t"`
}
// sWorkbook is the minimal shape of xl/workbook.xml this package
// cares about: just enough to resolve a sheet name/1-based index to
// its relationship id, so ReadXlsxCells/SetXlsxCells can find the
// right worksheet part even in a workbook with more than one sheet
// (including ones not authored by BuildXlsx, e.g. a user upload).
type sWorkbook struct {
XMLName xml.Name `xml:"workbook"`
Sheets []sWbName `xml:"sheets>sheet"`
}
type sWbName struct {
Name string `xml:"name,attr"`
RID string `xml:"id,attr"` // r:id — encoding/xml matches the local name regardless of the "r" namespace prefix.
}
// sRelationships is the minimal shape of a .rels part this package
// needs: a relationship id -> target path.
type sRelationships struct {
XMLName xml.Name `xml:"Relationships"`
Rel []sRelationship `xml:"Relationship"`
}
type sRelationship struct {
ID string `xml:"Id,attr"`
Target string `xml:"Target,attr"`
}
// colLetter converts a 0-based column index to its spreadsheet letter
// (0 -> A, 25 -> Z, 26 -> AA, ...).
func colLetter(i int) string {
s := ""
for {
s = string(rune('A'+(i%26))) + s
i = i/26 - 1
if i < 0 {
break
}
}
return s
}
func isNumeric(s string) bool {
if s == "" {
return false
}
_, err := strconv.ParseFloat(s, 64)
return err == nil
}
func rowsToSheetXML(rows [][]string) ([]byte, error) {
sd := sSheetData{}
for ri, row := range rows {
r := sRow{R: ri + 1}
for ci, val := range row {
cell := sCell{R: colLetter(ci) + strconv.Itoa(ri+1)}
setCellValue(&cell, val)
r.Cells = append(r.Cells, cell)
}
sd.Rows = append(sd.Rows, r)
}
ws := sWorksheet{Xmlns: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", SheetData: sd}
body, err := xml.Marshal(ws)
if err != nil {
return nil, fmt.Errorf("officekit: marshal worksheet xml: %w", err)
}
return []byte(xml.Header + string(body)), nil
}
// BuildXlsx renders one or more sheets into a complete, valid .xlsx
// workbook — every sheet given is written as its own worksheet part
// and listed in xl/workbook.xml in order, each with a matching
// relationship in xl/_rels/workbook.xml.rels. An unnamed sheet gets
// the conventional "SheetN" name.
func BuildXlsx(sheets []Sheet) ([]byte, error) {
if len(sheets) == 0 {
sheets = []Sheet{{Name: "Sheet1"}}
}
parts := []zipPart{
{Name: "_rels/.rels", Body: xlsxRootRels},
{Name: "xl/styles.xml", Body: xlsxStyles},
{Name: "docProps/core.xml", Body: officeCoreProps},
}
var sheetEntries, relEntries, overrides strings.Builder
for i, sh := range sheets {
idx := i + 1
name := sh.Name
if name == "" {
name = fmt.Sprintf("Sheet%d", idx)
}
sheetXML, err := rowsToSheetXML(sh.Rows)
if err != nil {
return nil, err
}
parts = append(parts, zipPart{Name: xlsxSheetPart(idx), Body: string(sheetXML)})
fmt.Fprintf(&sheetEntries, `<sheet name="%s" sheetId="%d" r:id="rId%d"/>`, xmlEscapeAttr(name), idx, idx)
fmt.Fprintf(&relEntries, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet%d.xml"/>`, idx, idx)
fmt.Fprintf(&overrides, `<Override PartName="/xl/worksheets/sheet%d.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`, idx)
}
// The styles relationship id just needs to be distinct from every
// sheet's rId above; one past the last sheet always is.
fmt.Fprintf(&relEntries, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`, len(sheets)+1)
workbookXML := xml.Header + `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>` +
sheetEntries.String() + `</sheets></workbook>`
workbookRels := xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
relEntries.String() + `</Relationships>`
contentTypes := xml.Header + `<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="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
overrides.String() +
`<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>` +
`<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>`
parts = append(parts,
zipPart{Name: "[Content_Types].xml", Body: contentTypes},
zipPart{Name: xlsxWorkbookPart, Body: workbookXML},
zipPart{Name: xlsxWorkbookRelsPart, Body: workbookRels},
)
return buildZip(parts)
}
// xmlEscapeAttr escapes the handful of characters that matter inside
// an XML attribute value. Used only for the places we interpolate
// user text into a hand-written template string (sheet names in
// workbook.xml) rather than through encoding/xml.
func xmlEscapeAttr(s string) string {
r := strings.NewReplacer(`&`, "&amp;", `<`, "&lt;", `>`, "&gt;", `"`, "&quot;")
return r.Replace(s)
}
// ReadXlsxSheetNames returns every sheet name in the workbook, in
// order — used by read_spreadsheet to tell the model what else is in
// a multi-sheet file it only asked to read one sheet of.
func ReadXlsxSheetNames(data []byte) ([]string, error) {
wb, err := readXlsxWorkbook(data)
if err != nil {
return nil, err
}
names := make([]string, len(wb.Sheets))
for i, s := range wb.Sheets {
names[i] = s.Name
}
return names, nil
}
func readXlsxWorkbook(data []byte) (*sWorkbook, error) {
raw, ok, err := readZipPart(data, xlsxWorkbookPart)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("officekit: %s not found (not a valid .xlsx)", xlsxWorkbookPart)
}
var wb sWorkbook
if err := xml.Unmarshal(raw, &wb); err != nil {
return nil, fmt.Errorf("officekit: parse workbook xml: %w", err)
}
return &wb, nil
}
// resolveXlsxSheetPart maps a sheet reference (empty for the first
// sheet, a 1-based index as a string, or a sheet name) to that sheet's
// actual worksheet part path, by reading xl/workbook.xml and
// xl/_rels/workbook.xml.rels — not by assuming sheet1.xml is sheet
// one, since that isn't guaranteed for a file this package didn't
// write itself (a user-uploaded workbook whose sheets were reordered
// or renumbered in Excel). Falls back to the sheet1.xml convention
// only if the workbook's own bookkeeping can't be parsed at all.
func resolveXlsxSheetPart(data []byte, sheetRef string) (partPath, sheetName string, err error) {
wb, err := readXlsxWorkbook(data)
if err != nil || len(wb.Sheets) == 0 {
return xlsxSheetPart(1), "", nil
}
var target *sWbName
switch {
case sheetRef == "":
target = &wb.Sheets[0]
default:
if n, convErr := strconv.Atoi(sheetRef); convErr == nil {
if n < 1 || n > len(wb.Sheets) {
return "", "", fmt.Errorf("officekit: sheet %d out of range (workbook has %d sheet(s))", n, len(wb.Sheets))
}
target = &wb.Sheets[n-1]
} else {
for i := range wb.Sheets {
if strings.EqualFold(wb.Sheets[i].Name, sheetRef) {
target = &wb.Sheets[i]
break
}
}
if target == nil {
return "", "", fmt.Errorf("officekit: no sheet named %q", sheetRef)
}
}
}
relsRaw, ok, err := readZipPart(data, xlsxWorkbookRelsPart)
if err != nil || !ok {
return xlsxSheetPart(1), target.Name, nil
}
var rels sRelationships
if err := xml.Unmarshal(relsRaw, &rels); err != nil {
return xlsxSheetPart(1), target.Name, nil
}
for _, r := range rels.Rel {
if r.ID == target.RID {
t := strings.TrimPrefix(r.Target, "/")
if !strings.HasPrefix(t, "xl/") {
t = "xl/" + t
}
return t, target.Name, nil
}
}
return xlsxSheetPart(1), target.Name, nil
}
// ReadXlsxCells returns one worksheet's cells as a row-major grid of
// display strings, for the read_spreadsheet tool. sheet selects which
// one: empty for the first sheet, a 1-based index ("2"), or a sheet
// name (case-insensitive); the resolved name is returned alongside so
// callers can report it.
func ReadXlsxCells(data []byte, sheet string) ([][]string, string, error) {
partPath, sheetName, err := resolveXlsxSheetPart(data, sheet)
if err != nil {
return nil, "", err
}
raw, ok, err := readZipPart(data, partPath)
if err != nil {
return nil, "", err
}
if !ok {
return nil, "", fmt.Errorf("officekit: %s not found (not a valid .xlsx)", partPath)
}
var ws sWorksheet
if err := xml.Unmarshal(raw, &ws); err != nil {
return nil, "", fmt.Errorf("officekit: parse worksheet xml: %w", err)
}
var out [][]string
for _, row := range ws.SheetData.Rows {
var line []string
for _, c := range row.Cells {
switch {
case c.Is != nil:
line = append(line, c.Is.T)
case c.Value != nil:
// A real Excel-authored formula cell always caches its
// last-computed value alongside the formula itself; show
// that computed result rather than the formula text.
line = append(line, *c.Value)
case c.Formula != nil:
// A formula cell this package just wrote has no cached
// value (nothing here evaluates formulas) — show the
// formula itself rather than a misleading blank.
line = append(line, "="+*c.Formula)
default:
line = append(line, "")
}
}
out = append(out, line)
}
return out, sheetName, nil
}
// SetXlsxCells downloads-parses-modifies-reuploads: applies a sparse
// set of cell writes (keyed by "A1"-style reference) to one worksheet
// (selected the same way as ReadXlsxCells) and returns the new file
// bytes. Cells outside the current grid extend it; existing rows/cells
// are preserved otherwise.
func SetXlsxCells(data []byte, sheet string, writes map[string]string) ([]byte, error) {
partPath, _, err := resolveXlsxSheetPart(data, sheet)
if err != nil {
return nil, err
}
raw, ok, err := readZipPart(data, partPath)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("officekit: %s not found (not a valid .xlsx)", partPath)
}
var ws sWorksheet
if err := xml.Unmarshal(raw, &ws); err != nil {
return nil, fmt.Errorf("officekit: parse worksheet xml: %w", err)
}
// rowByNum maps a row number to its INDEX in ws.SheetData.Rows, not
// a pointer into it. A pointer taken here (&ws.SheetData.Rows[i])
// is only valid for the backing array that exists at the moment
// it's taken; the very next `append` below that grows the slice
// past its capacity allocates a new backing array and copies into
// it, silently stranding any earlier-taken pointer on the old,
// now-orphaned one. Writes applied through a stranded pointer
// still "succeed" from the caller's point of view — no error, no
// panic — but vanish, because they land on memory nothing else
// reads. An index has no such failure mode: ws.SheetData.Rows[idx]
// always re-resolves against whatever backing array the slice
// header currently points to.
rowByNum := map[int]int{}
for i := range ws.SheetData.Rows {
rowByNum[ws.SheetData.Rows[i].R] = i
}
// Writes are applied in ascending (row, column) order rather than
// map iteration order (which Go deliberately randomizes). This
// doesn't affect correctness — sortSheetData below restores final
// order regardless — but it keeps behavior deterministic and easy
// to reason about/debug when a batch of cell writes is logged or
// replayed.
refs := make([]string, 0, len(writes))
for ref := range writes {
refs = append(refs, ref)
}
sort.Slice(refs, func(a, b int) bool {
ra, ca, _ := parseCellRef(refs[a])
rb, cb, _ := parseCellRef(refs[b])
if ra != rb {
return ra < rb
}
return ca < cb
})
for _, ref := range refs {
val := writes[ref]
rowNum, _, err := parseCellRef(ref)
if err != nil {
return nil, err
}
idx, ok := rowByNum[rowNum]
if !ok {
ws.SheetData.Rows = append(ws.SheetData.Rows, sRow{R: rowNum})
idx = len(ws.SheetData.Rows) - 1
rowByNum[rowNum] = idx
}
row := &ws.SheetData.Rows[idx]
replaced := false
for i := range row.Cells {
if row.Cells[i].R == ref {
setCellValue(&row.Cells[i], val)
replaced = true
break
}
}
if !replaced {
c := sCell{R: ref}
setCellValue(&c, val)
row.Cells = append(row.Cells, c)
}
}
// Excel is strict about row/cell order within xl/worksheets/sheetN.xml
// (rows ascending by r, cells within a row ascending by column) —
// writes above can append a row/cell out of order (e.g. writing "A1"
// after "A5" already existed), which a lenient reader shrugs off but
// Excel itself can flag as needing "repair". Restore ascending order
// before marshaling so a sparse, arbitrarily-ordered set of writes
// always produces a schema-valid worksheet.
sortSheetData(&ws.SheetData)
body, err := xml.Marshal(ws)
if err != nil {
return nil, fmt.Errorf("officekit: marshal worksheet xml: %w", err)
}
return replaceZipPart(data, partPath, []byte(xml.Header+string(body)))
}
// sortSheetData reorders rows ascending by row number and, within each
// row, cells ascending by column — the order SetXlsxCells' sparse,
// map-driven writes don't otherwise guarantee.
func sortSheetData(sd *sSheetData) {
sort.Slice(sd.Rows, func(i, j int) bool { return sd.Rows[i].R < sd.Rows[j].R })
for i := range sd.Rows {
cells := sd.Rows[i].Cells
sort.Slice(cells, func(a, b int) bool { return cellColumn(cells[a].R) < cellColumn(cells[b].R) })
}
}
// cellColumn returns a cell reference's 0-based column index (e.g.
// "B12" -> 1), or 0 if ref doesn't parse as one — used only to order
// cells within a row for output, so a malformed ref sorts first rather
// than failing a write this deep into the process.
func cellColumn(ref string) int {
_, col, err := parseCellRef(ref)
if err != nil {
return 0
}
return col
}
// setCellValue classifies val and sets exactly the sCell fields that
// representation needs, clearing the others: a leading "=" (with at
// least one character after it) stores a formula, same convention
// Excel itself uses to distinguish "=SUM(A1:A2)" from the literal text
// "=SUM(A1:A2)"; otherwise a value that parses as a plain number is
// numeric, and everything else is inline text.
func setCellValue(c *sCell, val string) {
switch {
case strings.HasPrefix(val, "=") && len(val) > 1:
f := val[1:]
c.Formula, c.Value, c.Is, c.Type = &f, nil, nil, ""
case isNumeric(val):
v := val
c.Formula, c.Value, c.Is, c.Type = nil, &v, nil, ""
default:
c.Formula, c.Value = nil, nil
c.Is, c.Type = &sIs{T: val}, "inlineStr"
}
}
// parseCellRef splits "B12" into (row=12, col=1). Only used to locate
// a row bucket for SetXlsxCells; the column stays embedded in the "r"
// attribute string as-is.
func parseCellRef(ref string) (row, col int, err error) {
i := 0
for i < len(ref) && ref[i] >= 'A' && ref[i] <= 'Z' {
col = col*26 + int(ref[i]-'A'+1)
i++
}
if i == 0 || i == len(ref) {
return 0, 0, fmt.Errorf("officekit: invalid cell reference %q (expected e.g. \"A1\")", ref)
}
row, err = strconv.Atoi(ref[i:])
if err != nil || row < 1 {
return 0, 0, fmt.Errorf("officekit: invalid cell reference %q (expected e.g. \"A1\")", ref)
}
return row, col - 1, nil
}
const xlsxRootRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
</Relationships>`
const xlsxStyles = xml.Header + `<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
</styleSheet>`

181
pkg/officekit/zipkit.go Normal file
View File

@@ -0,0 +1,181 @@
// 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
}