package docfill import ( "archive/zip" "database/sql" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "regexp" "strconv" "strings" _ "modernc.org/sqlite" "github.com/xuri/excelize/v2" ) type config struct { Vars map[string]string `json:"vars"` Data dataConfig `json:"data"` Template templateConfig `json:"template"` Output outputConfig `json:"output"` } type dataConfig struct { Type string `json:"type"` Path string `json:"path"` Sheet string `json:"sheet"` Query string `json:"query"` Params []string `json:"params"` HeaderRow int `json:"header_row"` } type templateConfig struct { Path string `json:"path"` } type outputConfig struct { Path string `json:"path"` Overwrite bool `json:"overwrite"` } type RunOptions struct { Vars map[string]string } type RunResult struct { Files []string } var ( placeholderPattern = regexp.MustCompile(`\{\{([^{}<>]+)\}\}`) docxParagraphPattern = regexp.MustCompile(`(?s)]*>.*?`) docxTextNodePattern = regexp.MustCompile(`(?s)]*>(.*?)`) docxGeneratedXMLTypes = map[string]struct{}{ ".xml": {}, } ) // Run executes one configured fill task. func Run(configPath string) error { _, err := RunWithOptions(configPath, RunOptions{}) return err } // RunWithOptions executes one configured fill task and returns generated files. func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) { cfg, err := loadConfig(configPath) if err != nil { return RunResult{}, err } if err := validateConfig(cfg); err != nil { return RunResult{}, err } vars := mergeVars(cfg.Vars, opts.Vars) rows, err := readRowsWithVars(cfg.Data, vars) if err != nil { return RunResult{}, err } if len(rows) == 0 { return RunResult{}, errors.New("data returned no rows") } result := RunResult{Files: make([]string, 0, len(rows))} for i, row := range rows { context := renderContext(vars, row, i+1) if err := ensurePlaceholdersAvailable("output path", cfg.Output.Path, context); err != nil { return RunResult{}, fmt.Errorf("render row %d: %w", i+1, err) } outputPath := replacePathPlaceholders(cfg.Output.Path, context) if hasPlaceholder(outputPath) { return RunResult{}, fmt.Errorf("render row %d: unresolved placeholder in output path %q", i+1, outputPath) } if !cfg.Output.Overwrite && fileExists(outputPath) { return RunResult{}, fmt.Errorf("render row %d: output already exists: %s", i+1, outputPath) } if err := renderTemplate(cfg.Template.Path, outputPath, context); err != nil { return RunResult{}, fmt.Errorf("render row %d: %w", i+1, err) } result.Files = append(result.Files, outputPath) } return result, nil } func loadConfig(path string) (config, error) { if path == "" { return config{}, errors.New("config path is required") } data, err := os.ReadFile(path) if err != nil { return config{}, fmt.Errorf("read config: %w", err) } var cfg config if err := json.Unmarshal(data, &cfg); err != nil { return config{}, fmt.Errorf("parse config json: %w", err) } resolveConfigPaths(&cfg, filepath.Dir(absPath(path))) return cfg, nil } func validateConfig(cfg config) error { if cfg.Data.Type == "" { return errors.New("data.type is required") } if cfg.Data.Path == "" { return errors.New("data.path is required") } if cfg.Template.Path == "" { return errors.New("template.path is required") } if cfg.Output.Path == "" { return errors.New("output.path is required") } if cfg.Data.HeaderRow < 0 { return errors.New("data.header_row must not be negative") } return nil } func readRows(cfg dataConfig) ([]map[string]string, error) { return readRowsWithVars(cfg, nil) } func readRowsWithVars(cfg dataConfig, vars map[string]string) ([]map[string]string, error) { switch strings.ToLower(cfg.Type) { case "excel", "xlsx": return readExcelRows(cfg) case "sqlite": return readSQLiteRows(cfg, vars) default: return nil, fmt.Errorf("unsupported data.type %q", cfg.Type) } } func readExcelRows(cfg dataConfig) ([]map[string]string, error) { f, err := excelize.OpenFile(cfg.Path) if err != nil { return nil, fmt.Errorf("open excel data: %w", err) } defer f.Close() sheet := cfg.Sheet if sheet == "" { sheet = f.GetSheetName(0) } if sheet == "" { return nil, errors.New("excel data has no sheets") } rawRows, err := f.GetRows(sheet) if err != nil { return nil, fmt.Errorf("read excel sheet %q: %w", sheet, err) } if len(rawRows) == 0 { return nil, errors.New("excel data has no header row") } headerRow := cfg.HeaderRow if headerRow == 0 { headerRow = 1 } if headerRow > len(rawRows) { return nil, fmt.Errorf("excel header_row %d is outside sheet %q", headerRow, sheet) } headers := make([]string, len(rawRows[headerRow-1])) for i, header := range rawRows[headerRow-1] { headers[i] = strings.TrimSpace(header) } var rows []map[string]string for _, raw := range rawRows[headerRow:] { if rowIsEmpty(raw) { continue } row := make(map[string]string) for i, header := range headers { if header == "" { continue } if i < len(raw) { row[header] = raw[i] } else { row[header] = "" } } rows = append(rows, row) } return rows, nil } func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string, error) { if cfg.Query == "" { return nil, errors.New("data.query is required for sqlite") } db, err := sql.Open("sqlite", cfg.Path) if err != nil { return nil, fmt.Errorf("open sqlite data: %w", err) } defer db.Close() args, err := sqliteParams(cfg.Params, vars) if err != nil { return nil, err } result, err := db.Query(cfg.Query, args...) if err != nil { return nil, fmt.Errorf("query sqlite data: %w", err) } defer result.Close() columns, err := result.Columns() if err != nil { return nil, fmt.Errorf("read sqlite columns: %w", err) } var rows []map[string]string for result.Next() { values := make([]any, len(columns)) scanTargets := make([]any, len(columns)) for i := range values { scanTargets[i] = &values[i] } if err := result.Scan(scanTargets...); err != nil { return nil, fmt.Errorf("scan sqlite row: %w", err) } row := make(map[string]string) for i, column := range columns { row[column] = stringify(values[i]) } rows = append(rows, row) } if err := result.Err(); err != nil { return nil, fmt.Errorf("iterate sqlite rows: %w", err) } return rows, nil } func renderTemplate(templatePath, outputPath string, row map[string]string) error { if err := validateTemplateFields(templatePath, row); err != nil { return err } if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { return fmt.Errorf("create output directory: %w", err) } switch strings.ToLower(filepath.Ext(templatePath)) { case ".xlsx": return renderXLSX(templatePath, outputPath, row) case ".docx": return renderDOCX(templatePath, outputPath, row) default: return fmt.Errorf("unsupported template extension %q", filepath.Ext(templatePath)) } } func renderXLSX(templatePath, outputPath string, row map[string]string) error { f, err := excelize.OpenFile(templatePath) if err != nil { return fmt.Errorf("open xlsx template: %w", err) } defer f.Close() for _, sheet := range f.GetSheetList() { rows, err := f.GetRows(sheet) if err != nil { return fmt.Errorf("read template sheet %q: %w", sheet, err) } for r, values := range rows { for c, value := range values { if !strings.Contains(value, "{{") { continue } cell, err := excelize.CoordinatesToCellName(c+1, r+1) if err != nil { return fmt.Errorf("cell name: %w", err) } rendered := replacePlaceholders(value, row) if hasPlaceholder(rendered) { return fmt.Errorf("unresolved placeholder in %s!%s", sheet, cell) } if err := f.SetCellValue(sheet, cell, rendered); err != nil { return fmt.Errorf("set cell %s!%s: %w", sheet, cell, err) } } } } if err := f.SaveAs(outputPath); err != nil { return fmt.Errorf("save xlsx output: %w", err) } return nil } func renderDOCX(templatePath, outputPath string, row map[string]string) error { reader, err := zip.OpenReader(templatePath) if err != nil { return fmt.Errorf("open docx template: %w", err) } defer reader.Close() output, err := os.Create(outputPath) if err != nil { return fmt.Errorf("create docx output: %w", err) } writer := zip.NewWriter(output) for _, source := range reader.File { if err := copyDocxPart(writer, source, row); err != nil { _ = writer.Close() _ = output.Close() return err } } if err := writer.Close(); err != nil { _ = output.Close() return fmt.Errorf("close docx output: %w", err) } if err := output.Close(); err != nil { return fmt.Errorf("close docx file: %w", err) } return nil } func copyDocxPart(writer *zip.Writer, source *zip.File, row map[string]string) error { header := source.FileHeader header.Name = source.Name header.Method = source.Method dest, err := writer.CreateHeader(&header) if err != nil { return fmt.Errorf("create docx part %s: %w", source.Name, err) } if source.FileInfo().IsDir() { return nil } src, err := source.Open() if err != nil { return fmt.Errorf("open docx part %s: %w", source.Name, err) } defer src.Close() data, err := io.ReadAll(src) if err != nil { return fmt.Errorf("read docx part %s: %w", source.Name, err) } if isDocxXMLPart(source.Name) { rendered, err := renderDocxXML(string(data), row) if err != nil { return fmt.Errorf("render docx part %s: %w", source.Name, err) } if docxHasPlaceholder(rendered) { return fmt.Errorf("unresolved placeholder in docx part %s", source.Name) } data = []byte(rendered) } if _, err := dest.Write(data); err != nil { return fmt.Errorf("write docx part %s: %w", source.Name, err) } return nil } func replacePlaceholders(text string, row map[string]string) string { return replacePlaceholdersWith(text, row, func(value string) string { return value }) } func replaceDocxPlaceholders(text string, row map[string]string) string { return replacePlaceholdersWith(text, row, escapeXMLText) } func replacePathPlaceholders(text string, row map[string]string) string { return replacePlaceholdersWith(text, row, sanitizePathValue) } func replacePlaceholdersWith(text string, row map[string]string, transform func(string) string) string { return placeholderPattern.ReplaceAllStringFunc(text, func(match string) string { key := placeholderKey(match) value, ok := row[key] if !ok { return match } return transform(value) }) } func rowIsEmpty(row []string) bool { for _, cell := range row { if strings.TrimSpace(cell) != "" { return false } } return true } func stringify(value any) string { switch v := value.(type) { case nil: return "" case []byte: return string(v) case string: return v default: return fmt.Sprint(v) } } func mergeVars(configVars, optionVars map[string]string) map[string]string { merged := make(map[string]string) for key, value := range configVars { merged[key] = value } for key, value := range optionVars { merged[key] = value } return merged } func renderContext(vars, row map[string]string, rowNumber int) map[string]string { context := make(map[string]string) for key, value := range vars { context[key] = value } for key, value := range row { context[key] = value } context["_row"] = strconv.Itoa(rowNumber) return context } func sqliteParams(params []string, vars map[string]string) ([]any, error) { args := make([]any, 0, len(params)) for i, param := range params { if err := ensurePlaceholdersAvailable(fmt.Sprintf("sqlite params[%d]", i), param, vars); err != nil { return nil, err } rendered := replacePlaceholders(param, vars) if hasPlaceholder(rendered) { return nil, fmt.Errorf("unresolved placeholder in sqlite params[%d]", i) } args = append(args, rendered) } return args, nil } func validateTemplateFields(templatePath string, row map[string]string) error { fields, err := templatePlaceholders(templatePath) if err != nil { return err } for field := range fields { if _, ok := row[field]; !ok { return fmt.Errorf("missing template field %q", field) } } return nil } func templatePlaceholders(templatePath string) (map[string]struct{}, error) { switch strings.ToLower(filepath.Ext(templatePath)) { case ".xlsx": return xlsxPlaceholders(templatePath) case ".docx": return docxPlaceholders(templatePath) default: return nil, fmt.Errorf("unsupported template extension %q", filepath.Ext(templatePath)) } } func xlsxPlaceholders(templatePath string) (map[string]struct{}, error) { f, err := excelize.OpenFile(templatePath) if err != nil { return nil, fmt.Errorf("open xlsx template: %w", err) } defer f.Close() fields := make(map[string]struct{}) for _, sheet := range f.GetSheetList() { rows, err := f.GetRows(sheet) if err != nil { return nil, fmt.Errorf("read template sheet %q: %w", sheet, err) } for _, row := range rows { for _, value := range row { addPlaceholders(fields, value) } } } return fields, nil } func docxPlaceholders(templatePath string) (map[string]struct{}, error) { reader, err := zip.OpenReader(templatePath) if err != nil { return nil, fmt.Errorf("open docx template: %w", err) } defer reader.Close() fields := make(map[string]struct{}) for _, file := range reader.File { if !strings.HasSuffix(strings.ToLower(file.Name), ".xml") { continue } rc, err := file.Open() if err != nil { return nil, fmt.Errorf("open docx part %s: %w", file.Name, err) } data, readErr := io.ReadAll(rc) closeErr := rc.Close() if readErr != nil { return nil, fmt.Errorf("read docx part %s: %w", file.Name, readErr) } if closeErr != nil { return nil, fmt.Errorf("close docx part %s: %w", file.Name, closeErr) } xml := string(data) addPlaceholders(fields, xml) addDocxTextPlaceholders(fields, xml) } return fields, nil } func ensurePlaceholdersAvailable(label, text string, row map[string]string) error { fields := make(map[string]struct{}) addPlaceholders(fields, text) for field := range fields { if _, ok := row[field]; !ok { return fmt.Errorf("missing %s field %q", label, field) } } return nil } func addPlaceholders(fields map[string]struct{}, text string) { matches := placeholderPattern.FindAllStringSubmatch(text, -1) for _, match := range matches { if len(match) < 2 { continue } key := strings.TrimSpace(match[1]) if key != "" { fields[key] = struct{}{} } } } func placeholderKey(match string) string { matches := placeholderPattern.FindStringSubmatch(match) if len(matches) < 2 { return "" } return strings.TrimSpace(matches[1]) } func hasPlaceholder(text string) bool { return placeholderPattern.MatchString(text) } func renderDocxXML(xml string, row map[string]string) (string, error) { rendered := replaceDocxPlaceholders(xml, row) return renderDocxSplitTextPlaceholders(rendered, row) } func renderDocxSplitTextPlaceholders(xml string, row map[string]string) (string, error) { matches := docxParagraphPattern.FindAllStringIndex(xml, -1) if len(matches) == 0 { return renderDocxTextGroup(xml, row) } var out strings.Builder cursor := 0 for _, match := range matches { out.WriteString(xml[cursor:match[0]]) rendered, err := renderDocxTextGroup(xml[match[0]:match[1]], row) if err != nil { return "", err } out.WriteString(rendered) cursor = match[1] } out.WriteString(xml[cursor:]) return out.String(), nil } func renderDocxTextGroup(xml string, row map[string]string) (string, error) { textMatches := docxTextNodePattern.FindAllStringSubmatchIndex(xml, -1) if len(textMatches) == 0 { return xml, nil } joined := joinedDocxText(xml, textMatches) if !hasPlaceholder(joined) { return xml, nil } if err := ensurePlaceholdersAvailable("docx text", joined, row); err != nil { return "", err } rendered := replaceDocxPlaceholders(joined, row) if hasPlaceholder(rendered) { return "", errors.New("unresolved placeholder in docx text") } var out strings.Builder cursor := 0 for i, match := range textMatches { out.WriteString(xml[cursor:match[2]]) if i == 0 { out.WriteString(rendered) } out.WriteString(xml[match[3]:match[1]]) cursor = match[1] } out.WriteString(xml[cursor:]) return out.String(), nil } func joinedDocxText(xml string, matches [][]int) string { var joined strings.Builder for _, match := range matches { joined.WriteString(xml[match[2]:match[3]]) } return joined.String() } func addDocxTextPlaceholders(fields map[string]struct{}, xml string) { matches := docxParagraphPattern.FindAllStringIndex(xml, -1) if len(matches) == 0 { addPlaceholders(fields, joinedDocxText(xml, docxTextNodePattern.FindAllStringSubmatchIndex(xml, -1))) return } for _, match := range matches { paragraph := xml[match[0]:match[1]] textMatches := docxTextNodePattern.FindAllStringSubmatchIndex(paragraph, -1) addPlaceholders(fields, joinedDocxText(paragraph, textMatches)) } } func docxHasPlaceholder(xml string) bool { if hasPlaceholder(xml) { return true } fields := make(map[string]struct{}) addDocxTextPlaceholders(fields, xml) return len(fields) > 0 } func escapeXMLText(value string) string { replacer := strings.NewReplacer( "&", "&", "<", "<", ">", ">", ) return replacer.Replace(value) } func isDocxXMLPart(name string) bool { _, ok := docxGeneratedXMLTypes[strings.ToLower(filepath.Ext(name))] return ok } func sanitizePathValue(value string) string { replacer := strings.NewReplacer( "/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", `"`, "_", "<", "_", ">", "_", "|", "_", ) return strings.TrimSpace(replacer.Replace(value)) } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } func resolveConfigPaths(cfg *config, baseDir string) { cfg.Data.Path = resolvePath(baseDir, cfg.Data.Path) cfg.Template.Path = resolvePath(baseDir, cfg.Template.Path) cfg.Output.Path = resolvePath(baseDir, cfg.Output.Path) } func resolvePath(baseDir, path string) string { if path == "" || filepath.IsAbs(path) { return path } return filepath.Clean(filepath.Join(baseDir, path)) } func absPath(path string) string { abs, err := filepath.Abs(path) if err != nil { return path } return abs }