Files
2026-08-11 10:40:10 +01:00

254 lines
6.1 KiB
Go

package parsing
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"strconv"
"strings"
"github.com/andydixon/relata/internal/domain"
)
// DOCXParser extracts text from Office Open XML word documents.
//
// A DOCX is a zip. Only the single known part `word/document.xml` is read, by
// exact name — the archive is never walked and no entry path is ever joined
// with a filesystem path, so zip-slip does not apply. Decompressed size is
// capped so a compression bomb cannot exhaust memory.
type DOCXParser struct{}
// Name identifies the parser.
func (DOCXParser) Name() string { return "docx" }
// Handles reports whether p can parse mime.
func (DOCXParser) Handles(mime string) bool { return isDOCX(mime) }
// maxDOCXPartBytes caps the decompressed size of word/document.xml.
const maxDOCXPartBytes = 64 << 20
// Parse reads paragraph runs from the main document part, mapping Word's
// heading styles onto structural headings.
func (DOCXParser) Parse(in Input) (*Document, error) {
zr, err := zip.NewReader(bytes.NewReader(in.Data), int64(len(in.Data)))
if err != nil {
return nil, fmt.Errorf("open docx container: %w", err)
}
var part *zip.File
for _, f := range zr.File {
if f.Name == "word/document.xml" {
part = f
break
}
}
if part == nil {
return nil, fmt.Errorf("docx is missing word/document.xml")
}
if part.UncompressedSize64 > maxDOCXPartBytes {
return nil, fmt.Errorf("docx main part is too large: %d bytes", part.UncompressedSize64)
}
rc, err := part.Open()
if err != nil {
return nil, fmt.Errorf("open docx main part: %w", err)
}
defer rc.Close()
// LimitReader guards against a lying uncompressed-size header.
raw, err := io.ReadAll(io.LimitReader(rc, maxDOCXPartBytes+1))
if err != nil {
return nil, fmt.Errorf("read docx main part: %w", err)
}
if int64(len(raw)) > maxDOCXPartBytes {
return nil, fmt.Errorf("docx main part exceeded the %d byte limit when decompressed", int64(maxDOCXPartBytes))
}
paras, warns, err := parseDocumentXML(raw)
if err != nil {
return nil, err
}
doc := &Document{Warnings: warns}
var sb strings.Builder
var sectionPath []string
for _, p := range paras {
text := strings.TrimRight(p.text, " \t")
if strings.TrimSpace(text) == "" {
continue
}
if sb.Len() > 0 {
sb.WriteString("\n\n")
}
start := sb.Len()
sb.WriteString(text)
kind := BlockParagraph
level := 0
switch {
case p.headingLevel > 0:
kind, level = BlockHeading, p.headingLevel
if level-1 < len(sectionPath) {
sectionPath = sectionPath[:level-1]
}
for len(sectionPath) < level-1 {
sectionPath = append(sectionPath, "")
}
sectionPath = append(sectionPath, text)
case p.isList:
kind = BlockList
}
doc.Blocks = append(doc.Blocks, Block{
Kind: kind,
Text: text,
Start: start,
End: start + len(text),
Level: level,
Section: strings.Join(sectionPath, " > "),
})
}
doc.Text = sb.String()
if len(doc.Blocks) == 0 {
doc.Warnings = append(doc.Warnings, warn(domain.WarnLowExtractableText,
"No paragraph text was found in the Word document. If it contains only images, Relata does not perform OCR in this version.", ""))
}
return doc, nil
}
type docxPara struct {
text string
headingLevel int
isList bool
}
// parseDocumentXML streams the WordprocessingML, concatenating text runs into
// paragraphs. Streaming (rather than unmarshalling the whole tree) keeps memory
// bounded on large documents.
func parseDocumentXML(raw []byte) ([]docxPara, []domain.ParseWarning, error) {
dec := xml.NewDecoder(bytes.NewReader(raw))
// Reject external entity references outright: this input is untrusted and
// there is no legitimate reason for a DOCX part to pull in outside content.
dec.Strict = false
dec.Entity = map[string]string{}
dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
// Only UTF-8 is expected; anything else is passed through as bytes
// rather than resolved via an external charset table.
return input, nil
}
var (
paras []docxPara
warns []domain.ParseWarning
cur strings.Builder
inPara bool
inText bool
inStyle bool
curStyle string
isList bool
depth int
)
flush := func() {
if !inPara {
return
}
paras = append(paras, docxPara{
text: cur.String(),
headingLevel: headingLevelFromStyle(curStyle),
isList: isList,
})
cur.Reset()
curStyle = ""
isList = false
inPara = false
}
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
// Salvage what was parsed so far rather than losing the document.
warns = append(warns, warn(domain.WarnParseFailed,
"The Word document's XML ended unexpectedly; text extracted up to that point is indexed.",
truncate(err.Error(), 200)))
break
}
switch t := tok.(type) {
case xml.StartElement:
depth++
if depth > 1000 {
return paras, warns, fmt.Errorf("docx xml nesting is implausibly deep")
}
switch t.Name.Local {
case "p":
flush()
inPara = true
case "t":
inText = true
case "pStyle":
inStyle = true
for _, a := range t.Attr {
if a.Name.Local == "val" {
curStyle = a.Value
}
}
case "numPr":
isList = true
case "tab":
if inPara {
cur.WriteByte('\t')
}
case "br", "cr":
if inPara {
cur.WriteByte('\n')
}
}
case xml.CharData:
if inText && inPara {
cur.Write(t)
}
case xml.EndElement:
depth--
switch t.Name.Local {
case "p":
flush()
case "t":
inText = false
case "pStyle":
inStyle = false
}
}
_ = inStyle
}
flush()
return paras, warns, nil
}
// headingLevelFromStyle maps Word style identifiers ("Heading1", "heading 2",
// "Title") onto a 1..6 structural level, returning 0 for body text.
func headingLevelFromStyle(style string) int {
s := strings.ToLower(strings.ReplaceAll(style, " ", ""))
switch s {
case "title":
return 1
case "subtitle":
return 2
}
if !strings.HasPrefix(s, "heading") {
return 0
}
n, err := strconv.Atoi(strings.TrimPrefix(s, "heading"))
if err != nil || n < 1 || n > 6 {
return 0
}
return n
}