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{"", "