427 lines
9.9 KiB
Go
427 lines
9.9 KiB
Go
package parser
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"resume-platform/pkg/utils"
|
|
"strings"
|
|
)
|
|
|
|
type Parser interface {
|
|
Parse(fileName string, reader io.Reader) (string, error)
|
|
}
|
|
|
|
func NewParser(fileName string) (Parser, error) {
|
|
ext := strings.ToLower(filepath.Ext(fileName))
|
|
switch ext {
|
|
case ".pdf":
|
|
return &PDFParser{}, nil
|
|
case ".docx":
|
|
return &DOCXParser{}, nil
|
|
case ".xlsx":
|
|
return &ExcelParser{}, nil
|
|
case ".md", ".markdown":
|
|
return &MarkdownParser{}, nil
|
|
case ".txt":
|
|
return &TextParser{}, nil
|
|
default:
|
|
return &TextParser{}, nil
|
|
}
|
|
}
|
|
|
|
func ParseFile(fileName string, reader io.Reader) (string, error) {
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
parser, err := NewParser(fileName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
text, err := parser.Parse(fileName, bytes.NewReader(data))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if text == "" && len(data) > 0 {
|
|
text = extractTextFromBinary(data)
|
|
}
|
|
|
|
return text, nil
|
|
}
|
|
|
|
func extractTextFromBinary(data []byte) string {
|
|
content := string(data)
|
|
var builder strings.Builder
|
|
|
|
for _, r := range content {
|
|
if r >= 0x20 && r <= 0x7E || r == '\n' || r == '\r' || r == '\t' ||
|
|
(r >= 0x4E00 && r <= 0x9FFF) || (r >= 0x3000 && r <= 0x303F) ||
|
|
(r >= 0xFF00 && r <= 0xFFEF) {
|
|
builder.WriteRune(r)
|
|
} else if r > 0 {
|
|
builder.WriteRune(' ')
|
|
}
|
|
}
|
|
|
|
return cleanText(builder.String())
|
|
}
|
|
|
|
type TextParser struct{}
|
|
|
|
func (p *TextParser) Parse(fileName string, reader io.Reader) (string, error) {
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return cleanText(string(data)), nil
|
|
}
|
|
|
|
type MarkdownParser struct{}
|
|
|
|
func (p *MarkdownParser) Parse(fileName string, reader io.Reader) (string, error) {
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return cleanText(string(data)), nil
|
|
}
|
|
|
|
type PDFParser struct{}
|
|
|
|
func (p *PDFParser) Parse(fileName string, reader io.Reader) (string, error) {
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
content := string(data)
|
|
if !strings.HasPrefix(content, "%PDF-") {
|
|
return "", fmt.Errorf("not a valid PDF file")
|
|
}
|
|
|
|
text := extractTextFromPDF(content)
|
|
return cleanText(text), nil
|
|
}
|
|
|
|
func extractTextFromPDF(content string) string {
|
|
var builder strings.Builder
|
|
inTextBlock := false
|
|
inStream := false
|
|
lines := strings.Split(content, "\n")
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
if strings.Contains(line, "/Length") && !inStream {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(line, "stream") {
|
|
inStream = true
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(line, "endstream") {
|
|
inStream = false
|
|
continue
|
|
}
|
|
|
|
if strings.Contains(line, "BT") {
|
|
inTextBlock = true
|
|
continue
|
|
}
|
|
|
|
if strings.Contains(line, "ET") {
|
|
inTextBlock = false
|
|
continue
|
|
}
|
|
|
|
if inTextBlock && !inStream {
|
|
if strings.HasPrefix(line, "(") && strings.HasSuffix(line, ")") {
|
|
text := line[1 : len(line)-1]
|
|
text = strings.ReplaceAll(text, "\\n", "\n")
|
|
text = strings.ReplaceAll(text, "\\r", "\r")
|
|
text = strings.ReplaceAll(text, "\\t", "\t")
|
|
text = strings.ReplaceAll(text, "\\(", "(")
|
|
text = strings.ReplaceAll(text, "\\)", ")")
|
|
text = strings.ReplaceAll(text, "\\/", "/")
|
|
builder.WriteString(text)
|
|
builder.WriteString(" ")
|
|
} else if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
|
content := line[1 : len(line)-1]
|
|
parts := strings.Fields(content)
|
|
for _, part := range parts {
|
|
if strings.HasPrefix(part, "(") && strings.HasSuffix(part, ")") {
|
|
text := part[1 : len(part)-1]
|
|
text = strings.ReplaceAll(text, "\\n", "\n")
|
|
text = strings.ReplaceAll(text, "\\r", "\r")
|
|
text = strings.ReplaceAll(text, "\\t", "\t")
|
|
builder.WriteString(text)
|
|
builder.WriteString(" ")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return strings.TrimSpace(builder.String())
|
|
}
|
|
|
|
type DOCXParser struct{}
|
|
|
|
func (p *DOCXParser) Parse(fileName string, reader io.Reader) (string, error) {
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
fmt.Printf("DEBUG DOCXParser: file size: %d bytes\n", len(data))
|
|
|
|
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to open DOCX as zip: %w", err)
|
|
}
|
|
|
|
fmt.Printf("DEBUG DOCXParser: zip contains %d files\n", len(zipReader.File))
|
|
|
|
var text strings.Builder
|
|
foundDocument := false
|
|
|
|
for _, file := range zipReader.File {
|
|
fmt.Printf("DEBUG DOCXParser: zip file: %s (size: %d)\n", file.Name, file.UncompressedSize64)
|
|
|
|
if file.Name == "word/document.xml" {
|
|
foundDocument = true
|
|
f, err := file.Open()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to open document.xml: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
xmlData, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read document.xml: %w", err)
|
|
}
|
|
|
|
fmt.Printf("DEBUG DOCXParser: document.xml size: %d bytes\n", len(xmlData))
|
|
|
|
parsedText := parseDOCXXML(string(xmlData))
|
|
fmt.Printf("DEBUG DOCXParser: parsed text from document.xml: %d chars\n", len(parsedText))
|
|
|
|
text.WriteString(parsedText)
|
|
} else if strings.HasPrefix(file.Name, "word/header") && strings.HasSuffix(file.Name, ".xml") {
|
|
f, err := file.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
defer f.Close()
|
|
xmlData, _ := io.ReadAll(f)
|
|
text.WriteString(parseDOCXXML(string(xmlData)))
|
|
text.WriteString("\n")
|
|
} else if strings.HasPrefix(file.Name, "word/footer") && strings.HasSuffix(file.Name, ".xml") {
|
|
f, err := file.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
defer f.Close()
|
|
xmlData, _ := io.ReadAll(f)
|
|
text.WriteString(parseDOCXXML(string(xmlData)))
|
|
text.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
if !foundDocument {
|
|
fmt.Println("WARN DOCXParser: document.xml not found in zip")
|
|
return cleanText(extractTextFromBinary(data)), nil
|
|
}
|
|
|
|
result := cleanText(text.String())
|
|
fmt.Printf("DEBUG DOCXParser: final result length: %d chars\n", len(result))
|
|
|
|
if len(result) > 0 {
|
|
fmt.Printf("DEBUG DOCXParser: first 500 chars:\n%s\n", result[:utils.Min(len(result), 500)])
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func parseDOCXXML(xmlContent string) string {
|
|
fmt.Printf("DEBUG parseDOCXXML: input length: %d chars\n", len(xmlContent))
|
|
|
|
xmlContent = removeNamespacePrefixes(xmlContent)
|
|
|
|
allTexts := extractTextContent(xmlContent)
|
|
|
|
var builder strings.Builder
|
|
for _, text := range allTexts {
|
|
text = strings.TrimSpace(text)
|
|
if text != "" {
|
|
builder.WriteString(text)
|
|
builder.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
result := cleanText(builder.String())
|
|
fmt.Printf("DEBUG parseDOCXXML: extracted %d text elements, result length: %d\n", len(allTexts), len(result))
|
|
|
|
if len(result) > 0 {
|
|
fmt.Printf("DEBUG parseDOCXXML: first 500 chars:\n%s\n", result[:utils.Min(len(result), 500)])
|
|
}
|
|
|
|
if result == "" {
|
|
fmt.Println("WARN: Structured parsing returned empty, using fallback")
|
|
return cleanText(extractPlainTextFromXML(xmlContent))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func extractTextContent(xmlContent string) []string {
|
|
var texts []string
|
|
|
|
inTag := false
|
|
currentText := ""
|
|
|
|
for _, r := range xmlContent {
|
|
if r == '<' {
|
|
inTag = true
|
|
if currentText != "" {
|
|
texts = append(texts, currentText)
|
|
currentText = ""
|
|
}
|
|
continue
|
|
}
|
|
if r == '>' {
|
|
inTag = false
|
|
continue
|
|
}
|
|
if !inTag {
|
|
currentText += string(r)
|
|
}
|
|
}
|
|
|
|
if currentText != "" {
|
|
texts = append(texts, currentText)
|
|
}
|
|
|
|
return texts
|
|
}
|
|
|
|
func removeNamespacePrefixes(xmlContent string) string {
|
|
prefixes := []string{"w:", "r:", "wp:", "wpc:", "wps:", "wpg:", "wpi:", "wne:", "mc:", "o:", "v:", "w10:", "wpc:", "wpg:", "wps:", "a:", "m:"}
|
|
for _, prefix := range prefixes {
|
|
xmlContent = strings.ReplaceAll(xmlContent, "<"+prefix, "<")
|
|
xmlContent = strings.ReplaceAll(xmlContent, "</"+prefix, "</")
|
|
xmlContent = strings.ReplaceAll(xmlContent, " "+prefix, " ")
|
|
}
|
|
return xmlContent
|
|
}
|
|
|
|
func extractPlainTextFromXML(xmlContent string) string {
|
|
var builder strings.Builder
|
|
inTag := false
|
|
|
|
for _, r := range xmlContent {
|
|
if r == '<' {
|
|
inTag = true
|
|
continue
|
|
}
|
|
if r == '>' {
|
|
inTag = false
|
|
continue
|
|
}
|
|
if !inTag {
|
|
builder.WriteRune(r)
|
|
}
|
|
}
|
|
|
|
return builder.String()
|
|
}
|
|
|
|
type ExcelParser struct{}
|
|
|
|
func (p *ExcelParser) Parse(fileName string, reader io.Reader) (string, error) {
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to open XLSX as zip: %w", err)
|
|
}
|
|
|
|
var text strings.Builder
|
|
for _, file := range zipReader.File {
|
|
if strings.HasPrefix(file.Name, "xl/worksheets/") && strings.HasSuffix(file.Name, ".xml") {
|
|
sheetName := strings.TrimPrefix(file.Name, "xl/worksheets/sheet")
|
|
sheetName = strings.TrimSuffix(sheetName, ".xml")
|
|
text.WriteString("===" + sheetName + "===\n\n")
|
|
|
|
f, err := file.Open()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to open worksheet: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
xmlData, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read worksheet: %w", err)
|
|
}
|
|
|
|
text.WriteString(parseXLSXXML(string(xmlData)))
|
|
text.WriteString("\n\n")
|
|
}
|
|
}
|
|
|
|
return cleanText(text.String()), nil
|
|
}
|
|
|
|
func parseXLSXXML(xmlContent string) string {
|
|
type Cell struct {
|
|
Text string `xml:",chardata"`
|
|
}
|
|
type Row struct {
|
|
Cells []Cell `xml:"c"`
|
|
}
|
|
type SheetData struct {
|
|
Rows []Row `xml:"row"`
|
|
}
|
|
type Worksheet struct {
|
|
SheetData SheetData `xml:"sheetData"`
|
|
}
|
|
|
|
var ws Worksheet
|
|
err := xml.Unmarshal([]byte(xmlContent), &ws)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
var builder strings.Builder
|
|
for _, row := range ws.SheetData.Rows {
|
|
var cells []string
|
|
for _, cell := range row.Cells {
|
|
text := strings.TrimSpace(cell.Text)
|
|
if text != "" {
|
|
cells = append(cells, text)
|
|
}
|
|
}
|
|
if len(cells) > 0 {
|
|
builder.WriteString(strings.Join(cells, "\t"))
|
|
builder.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return strings.TrimSpace(builder.String())
|
|
}
|
|
|
|
func cleanText(text string) string {
|
|
return utils.CleanText(text)
|
|
} |