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 "<"/"&" 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 `

` and `` 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 children: a real // document's paragraphs can be nested inside a table cell // (///), 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 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 paragraph found // anywhere in document.xml, in document order: the concatenation of // every run's text within it, with rendered as a // literal tab, prefixed "- " when the paragraph carries a // (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 (OOXML requires section properties to be the // body's last child) — or immediately before 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 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 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 // if present, else right before . Returns -1 if // neither tag is found at all (not a recognizable Word document body). func bodyInsertionPoint(doc []byte) int { for _, tag := range []string{"= 0 { return i } } for _, tag := range []string{"", ""} { 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. "= 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 — // ..., the unprefixed ... 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[^>]*)?>)(.*?)()`) // 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 // 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 + ` ` const docxRootRels = xml.Header + ` ` const docxDocumentRels = xml.Header + ` ` const docxNumberingXML = xml.Header + ` ` const docxAppProps = xml.Header + `enkly` // officeCoreProps is shared verbatim by docx/xlsx/pptx — the schema is // package-level (docProps/core.xml), not format-specific. const officeCoreProps = xml.Header + `enkly agent`