This commit is contained in:
557
pkg/officekit/docx.go
Normal file
557
pkg/officekit/docx.go
Normal 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 "<"/"&" 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
128
pkg/officekit/markdown.go
Normal 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)
|
||||
}
|
||||
}
|
||||
74
pkg/officekit/markdown_test.go
Normal file
74
pkg/officekit/markdown_test.go
Normal 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
|
||||
}
|
||||
274
pkg/officekit/officekit_test.go
Normal file
274
pkg/officekit/officekit_test.go
Normal 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
206
pkg/officekit/pdf.go
Normal 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
90
pkg/officekit/pdf_test.go
Normal 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
376
pkg/officekit/pptx.go
Normal 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="•"/></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(¶s, 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
302
pkg/officekit/redline.go
Normal 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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
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
|
||||
}
|
||||
114
pkg/officekit/redline_test.go
Normal file
114
pkg/officekit/redline_test.go
Normal 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
505
pkg/officekit/xlsx.go
Normal 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(`&`, "&", `<`, "<", `>`, ">", `"`, """)
|
||||
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
181
pkg/officekit/zipkit.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user