148 lines
3.3 KiB
Go
148 lines
3.3 KiB
Go
package docx
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
MaxDocxSize = 50 * 1024 * 1024 // 50MB
|
|
)
|
|
|
|
// DocumentParser handles DOCX file parsing
|
|
type DocumentParser struct {
|
|
filePath string
|
|
}
|
|
|
|
// NewDocumentParser creates a new DOCX parser
|
|
func NewDocumentParser(filePath string) *DocumentParser {
|
|
return &DocumentParser{filePath: filePath}
|
|
}
|
|
|
|
// ParseDOCX extracts text from a DOCX file
|
|
// Returns (markdown text, extracted images as bytes, error)
|
|
func (p *DocumentParser) ParseDOCX() (string, map[string][]byte, error) {
|
|
// Validate file size
|
|
fileInfo, err := os.Stat(p.filePath)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to stat file: %w", err)
|
|
}
|
|
|
|
if fileInfo.Size() > MaxDocxSize {
|
|
return "", nil, fmt.Errorf("file exceeds 50MB limit: %d bytes", fileInfo.Size())
|
|
}
|
|
|
|
// Validate ZIP magic number
|
|
f, err := os.Open(p.filePath)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to open file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
magicBytes := make([]byte, 4)
|
|
_, err = f.Read(magicBytes)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to read magic bytes: %w", err)
|
|
}
|
|
|
|
if string(magicBytes) != "PK\x03\x04" {
|
|
return "", nil, fmt.Errorf("not a valid DOCX file (invalid ZIP header)")
|
|
}
|
|
|
|
// Open as ZIP
|
|
f.Seek(0, 0)
|
|
reader, err := zip.NewReader(f, fileInfo.Size())
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to open ZIP: %w", err)
|
|
}
|
|
|
|
// Extract document.xml
|
|
var docText string
|
|
images := make(map[string][]byte)
|
|
|
|
for _, file := range reader.File {
|
|
if file.Name == "word/document.xml" {
|
|
rc, err := file.Open()
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to open document.xml: %w", err)
|
|
}
|
|
defer rc.Close()
|
|
|
|
bytes, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to read document.xml: %w", err)
|
|
}
|
|
|
|
docText, err = p.extractTextFromXML(bytes)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to extract text: %w", err)
|
|
}
|
|
}
|
|
|
|
// Extract images from media/
|
|
if strings.HasPrefix(file.Name, "word/media/") {
|
|
rc, err := file.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
defer rc.Close()
|
|
|
|
imageBytes, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
fileName := filepath.Base(file.Name)
|
|
images[fileName] = imageBytes
|
|
}
|
|
}
|
|
|
|
return docText, images, nil
|
|
}
|
|
|
|
// extractTextFromXML parses word/document.xml and extracts plain text
|
|
// Unsafe XML entity resolution is disabled by default in Go's xml.Decoder
|
|
func (p *DocumentParser) extractTextFromXML(data []byte) (string, error) {
|
|
var doc struct {
|
|
XMLName xml.Name
|
|
Body struct {
|
|
Paragraphs []struct {
|
|
Text string `xml:"w|t"`
|
|
Runs []struct {
|
|
Text string `xml:"w|t"`
|
|
} `xml:"w|r"`
|
|
} `xml:"w|p"`
|
|
} `xml:"w|body"`
|
|
}
|
|
|
|
decoder := xml.NewDecoder(strings.NewReader(string(data)))
|
|
// XXE protection: Go's xml.Decoder is safe by default (no entity resolution)
|
|
decoder.Strict = false
|
|
|
|
err := decoder.Decode(&doc)
|
|
if err != nil && err != io.EOF {
|
|
return "", fmt.Errorf("failed to decode XML: %w", err)
|
|
}
|
|
|
|
// Extract text from paragraphs
|
|
var result []string
|
|
for _, p := range doc.Body.Paragraphs {
|
|
var text []string
|
|
for _, run := range p.Runs {
|
|
if run.Text != "" {
|
|
text = append(text, run.Text)
|
|
}
|
|
}
|
|
if len(text) > 0 {
|
|
result = append(result, strings.Join(text, ""))
|
|
}
|
|
}
|
|
|
|
return strings.Join(result, "\n"), nil
|
|
}
|