Files
apex-public/internal/docx/parser.go
T
2026-07-06 11:05:50 -04:00

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
}