package docfill import ( "archive/zip" "database/sql" "encoding/json" "fmt" "io" "os" "path/filepath" "regexp" "sort" "strconv" "strings" _ "modernc.org/sqlite" "github.com/xuri/excelize/v2" ) type config struct { Vars map[string]string `json:"vars"` Data dataConfig `json:"data"` Filter filterConfig `json:"filter"` 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 filterConfig struct { Equals map[string]string `json:"equals"` Expect string `json:"expect"` } 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 Logf func(format string, args ...any) } 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) { logf(opts, "load config path=%s", configPath) 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) logf(opts, "read data type=%s path=%s", cfg.Data.Type, cfg.Data.Path) rows, err := readRowsWithVars(cfg.Data, vars) if err != nil { return RunResult{}, err } if len(rows) == 0 { return RunResult{}, appError(CodeDataEmpty, "data returned no rows"). withHint("Check the data source, sheet/header row, SQL where clause, or --var values."). withContext("data", cfg.Data.Path) } logf(opts, "read data type=%s path=%s rows=%d", cfg.Data.Type, cfg.Data.Path, len(rows)) sourceRowCount := len(rows) rows, err = filterRows(cfg.Filter, rows, vars) if err != nil { return RunResult{}, err } if hasFilter(cfg.Filter) { logf(opts, "filter rows=%d matched=%d expect=%s", sourceRowCount, len(rows), filterExpect(cfg.Filter)) } 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: %w", i+1, appError(CodeTemplateUnresolved, "unresolved placeholder in output path"). withHint("Check output.path placeholders and available data fields or --var values."). withContext("output", outputPath)) } if !cfg.Output.Overwrite && fileExists(outputPath) { return RunResult{}, fmt.Errorf("render row %d: %w", i+1, appError(CodeOutputExists, "output already exists"). withHint("Change output.path or set output.overwrite to true."). withContext("output", outputPath)) } logf(opts, "render row=%d output=%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 logf(opts RunOptions, format string, args ...any) { if opts.Logf != nil { opts.Logf(format, args...) } } func loadConfig(path string) (config, error) { if path == "" { return config{}, appError(CodeConfigValidate, "config path is required"). withHint("Pass -c task.json or --config task.json.") } data, err := os.ReadFile(path) if err != nil { return config{}, appError(CodeConfigRead, "read config"). withHint("Check that the config file exists and is readable."). withContext("config", path). withCause(err) } var cfg config if err := json.Unmarshal(data, &cfg); err != nil { return config{}, appError(CodeConfigParse, "parse config json"). withHint("Fix task.json syntax and make sure it is valid JSON."). withContext("config", path). withCause(err) } resolveConfigPaths(&cfg, filepath.Dir(absPath(path))) return cfg, nil } func validateConfig(cfg config) error { if cfg.Data.Type == "" { return appError(CodeConfigValidate, "data.type is required"). withHint("Set data.type to excel or sqlite.") } if cfg.Data.Path == "" { return appError(CodeConfigValidate, "data.path is required"). withHint("Set data.path to an .xlsx or .db file.") } if cfg.Template.Path == "" { return appError(CodeConfigValidate, "template.path is required"). withHint("Set template.path to a .docx or .xlsx file.") } if cfg.Output.Path == "" { return appError(CodeConfigValidate, "output.path is required"). withHint("Set output.path to the generated file pattern.") } if cfg.Data.HeaderRow < 0 { return appError(CodeConfigValidate, "data.header_row must not be negative"). withHint("Use a 1-based header_row value, or omit it for 1.") } if err := validateFilterConfig(cfg.Filter); err != nil { return err } 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, appError(CodeConfigValidate, fmt.Sprintf("unsupported data.type %q", cfg.Type)). withHint("Set data.type to excel or sqlite."). withContext("data.type", cfg.Type) } } func readExcelRows(cfg dataConfig) ([]map[string]string, error) { f, err := excelize.OpenFile(cfg.Path) if err != nil { return nil, appError(CodeDataExcel, "open excel data"). withHint("Check that data.path points to a readable .xlsx file."). withContext("data", cfg.Path). withCause(err) } defer f.Close() sheet := cfg.Sheet if sheet == "" { sheet = f.GetSheetName(0) } if sheet == "" { return nil, appError(CodeDataExcel, "excel data has no sheets"). withHint("Check the Excel data file."). withContext("data", cfg.Path) } rawRows, err := f.GetRows(sheet) if err != nil { return nil, appError(CodeDataExcel, fmt.Sprintf("read excel sheet %q", sheet)). withHint("Check data.sheet and the workbook contents."). withContext("data", cfg.Path). withContext("sheet", sheet). withCause(err) } if len(rawRows) == 0 { return nil, appError(CodeDataExcel, "excel data has no header row"). withHint("Add a header row or set data.header_row to the correct row."). withContext("data", cfg.Path). withContext("sheet", sheet) } headerRow := cfg.HeaderRow if headerRow == 0 { headerRow = 1 } if headerRow > len(rawRows) { return nil, appError(CodeDataExcel, fmt.Sprintf("excel header_row %d is outside sheet %q", headerRow, sheet)). withHint("Use a header_row value within the sheet row count."). withContext("data", cfg.Path). withContext("sheet", 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, appError(CodeConfigValidate, "data.query is required for sqlite"). withHint("Set data.query to the SQL select statement.") } db, err := sql.Open("sqlite", cfg.Path) if err != nil { return nil, appError(CodeDataSQLite, "open sqlite data"). withHint("Check that data.path points to a readable SQLite database."). withContext("data", cfg.Path). withCause(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, appError(CodeDataSQLite, "query sqlite data"). withHint("Check data.query, data.params, and --var values."). withContext("data", cfg.Path). withCause(err) } defer result.Close() columns, err := result.Columns() if err != nil { return nil, appError(CodeDataSQLite, "read sqlite columns"). withContext("data", cfg.Path). withCause(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, appError(CodeDataSQLite, "scan sqlite row"). withContext("data", cfg.Path). withCause(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, appError(CodeDataSQLite, "iterate sqlite rows"). withContext("data", cfg.Path). withCause(err) } return rows, nil } func validateFilterConfig(cfg filterConfig) error { expect := strings.ToLower(strings.TrimSpace(cfg.Expect)) if expect != "" && expect != "one" && expect != "many" { return appError(CodeConfigValidate, fmt.Sprintf("unsupported filter.expect %q", cfg.Expect)). withHint("Set filter.expect to one or many."). withContext("filter.expect", cfg.Expect) } if expect != "" && len(cfg.Equals) == 0 { return appError(CodeConfigValidate, "filter.equals is required when filter.expect is set"). withHint("Add at least one filter.equals condition or remove filter.expect.") } for field := range cfg.Equals { if strings.TrimSpace(field) == "" { return appError(CodeConfigValidate, "filter.equals field name must not be empty"). withHint("Use a data column name as each filter.equals key.") } } return nil } func filterRows(cfg filterConfig, rows []map[string]string, vars map[string]string) ([]map[string]string, error) { if !hasFilter(cfg) { return rows, nil } conditions, err := filterConditions(cfg, vars) if err != nil { return nil, err } if len(rows) > 0 { if err := validateFilterFields(rows[0], conditions); err != nil { return nil, err } } matches := make([]map[string]string, 0, len(rows)) for _, row := range rows { if rowMatchesFilter(row, conditions) { matches = append(matches, row) } } expect := filterExpect(cfg) if len(matches) == 0 { return nil, appError(CodeDataFilterNoMatch, "filter matched no rows"). withHint("Check filter.equals, --var values, or source table contents."). withContext("filter", filterSummary(conditions)). withContext("expect", expect) } if expect == "one" && len(matches) != 1 { return nil, appError(CodeDataFilterMultiMatch, fmt.Sprintf("filter matched %d rows but expected one", len(matches))). withHint("Add another filter condition or clean duplicate rows in the source table."). withContext("filter", filterSummary(conditions)). withContext("matches", strconv.Itoa(len(matches))) } return matches, nil } func hasFilter(cfg filterConfig) bool { return len(cfg.Equals) > 0 } func filterExpect(cfg filterConfig) string { expect := strings.ToLower(strings.TrimSpace(cfg.Expect)) if expect == "" { return "one" } return expect } func filterConditions(cfg filterConfig, vars map[string]string) (map[string]string, error) { conditions := make(map[string]string, len(cfg.Equals)) for field, pattern := range cfg.Equals { field = strings.TrimSpace(field) context := map[string]string{"filter": field} if err := ensurePlaceholdersAvailableWithCode( fmt.Sprintf("filter.equals[%s]", field), pattern, vars, CodeDataFilterValue, "Provide the missing filter value with vars or --var.", context, ); err != nil { return nil, err } value := replacePlaceholders(pattern, vars) if hasPlaceholder(value) { return nil, appError(CodeDataFilterValue, fmt.Sprintf("unresolved placeholder in filter.equals[%s]", field)). withHint("Provide the missing filter value with vars or --var."). withContext("filter", field) } conditions[field] = strings.TrimSpace(value) } return conditions, nil } func validateFilterFields(row map[string]string, conditions map[string]string) error { for field := range conditions { if _, ok := row[field]; !ok { return appError(CodeDataFilterField, fmt.Sprintf("filter field %q is not in data", field)). withHint("Check the data header/query columns or adjust filter.equals."). withContext("field", field) } } return nil } func rowMatchesFilter(row map[string]string, conditions map[string]string) bool { for field, want := range conditions { if strings.TrimSpace(row[field]) != want { return false } } return true } func filterSummary(conditions map[string]string) string { keys := make([]string, 0, len(conditions)) for key := range conditions { keys = append(keys, key) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, key := range keys { parts = append(parts, key+"="+conditions[key]) } return strings.Join(parts, ",") } 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 appError(CodeOutputWrite, "create output directory"). withHint("Check output.path directory permissions."). withContext("output", outputPath). withCause(err) } switch strings.ToLower(filepath.Ext(templatePath)) { case ".xlsx": return renderXLSX(templatePath, outputPath, row) case ".docx": return renderDOCX(templatePath, outputPath, row) default: return appError(CodeConfigValidate, fmt.Sprintf("unsupported template extension %q", filepath.Ext(templatePath))). withHint("Use a .docx or .xlsx template."). withContext("template", templatePath) } } func renderXLSX(templatePath, outputPath string, row map[string]string) error { f, err := excelize.OpenFile(templatePath) if err != nil { return appError(CodeTemplateOpen, "open xlsx template"). withHint("Check that template.path points to a readable .xlsx file."). withContext("template", templatePath). withCause(err) } defer f.Close() for _, sheet := range f.GetSheetList() { rows, err := f.GetRows(sheet) if err != nil { return appError(CodeTemplateOpen, fmt.Sprintf("read template sheet %q", sheet)). withContext("template", templatePath). withContext("sheet", sheet). withCause(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 appError(CodeTemplateUnresolved, fmt.Sprintf("unresolved placeholder in %s!%s", sheet, cell)). withHint("Check source data values and template placeholders."). withContext("template", templatePath). withContext("cell", sheet+"!"+cell) } if err := f.SetCellValue(sheet, cell, rendered); err != nil { return appError(CodeOutputWrite, fmt.Sprintf("set cell %s!%s", sheet, cell)). withContext("output", outputPath). withCause(err) } } } } if err := f.SaveAs(outputPath); err != nil { return appError(CodeOutputWrite, "save xlsx output"). withHint("Check output.path and file permissions."). withContext("output", outputPath). withCause(err) } return nil } func renderDOCX(templatePath, outputPath string, row map[string]string) error { reader, err := zip.OpenReader(templatePath) if err != nil { return appError(CodeTemplateOpen, "open docx template"). withHint("Check that template.path points to a readable .docx file."). withContext("template", templatePath). withCause(err) } defer reader.Close() output, err := os.Create(outputPath) if err != nil { return appError(CodeOutputWrite, "create docx output"). withHint("Check output.path and file permissions."). withContext("output", outputPath). withCause(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 appError(CodeOutputWrite, "close docx output"). withContext("output", outputPath). withCause(err) } if err := output.Close(); err != nil { return appError(CodeOutputWrite, "close docx file"). withContext("output", outputPath). withCause(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 appError(CodeOutputWrite, fmt.Sprintf("create docx part %s", source.Name)). withCause(err) } if source.FileInfo().IsDir() { return nil } src, err := source.Open() if err != nil { return appError(CodeTemplateOpen, fmt.Sprintf("open docx part %s", source.Name)). withCause(err) } defer src.Close() data, err := io.ReadAll(src) if err != nil { return appError(CodeTemplateOpen, fmt.Sprintf("read docx part %s", source.Name)). withCause(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 appError(CodeTemplateUnresolved, fmt.Sprintf("unresolved placeholder in docx part %s", source.Name)). withHint("Check source data values and template placeholders.") } data = []byte(rendered) } if _, err := dest.Write(data); err != nil { return appError(CodeOutputWrite, fmt.Sprintf("write docx part %s", source.Name)). withCause(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 := ensurePlaceholdersAvailableWithCode( fmt.Sprintf("sqlite params[%d]", i), param, vars, CodeDataSQLite, "Provide the missing SQLite parameter with vars or --var.", nil, ); err != nil { return nil, err } rendered := replacePlaceholders(param, vars) if hasPlaceholder(rendered) { return nil, appError(CodeDataSQLite, fmt.Sprintf("unresolved placeholder in sqlite params[%d]", i)). withHint("Provide the missing SQLite parameter with vars or --var.") } 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 appError(CodeTemplateFieldMissing, fmt.Sprintf("missing template field %q", field)). withHint(fmt.Sprintf("Add column %q to the data source or pass --var %s=...", field, field)). withContext("field", field). withContext("template", templatePath) } } 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, appError(CodeTemplateOpen, "open xlsx template"). withHint("Check that template.path points to a readable .xlsx file."). withContext("template", templatePath). withCause(err) } defer f.Close() fields := make(map[string]struct{}) for _, sheet := range f.GetSheetList() { rows, err := f.GetRows(sheet) if err != nil { return nil, appError(CodeTemplateOpen, fmt.Sprintf("read template sheet %q", sheet)). withContext("template", templatePath). withContext("sheet", sheet). withCause(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, appError(CodeTemplateOpen, "open docx template"). withHint("Check that template.path points to a readable .docx file."). withContext("template", templatePath). withCause(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, appError(CodeTemplateOpen, fmt.Sprintf("open docx part %s", file.Name)). withContext("template", templatePath). withCause(err) } data, readErr := io.ReadAll(rc) closeErr := rc.Close() if readErr != nil { return nil, appError(CodeTemplateOpen, fmt.Sprintf("read docx part %s", file.Name)). withContext("template", templatePath). withCause(readErr) } if closeErr != nil { return nil, appError(CodeTemplateOpen, fmt.Sprintf("close docx part %s", file.Name)). withContext("template", templatePath). withCause(closeErr) } xml := string(data) addPlaceholders(fields, xml) addDocxTextPlaceholders(fields, xml) } return fields, nil } func ensurePlaceholdersAvailable(label, text string, row map[string]string) error { return ensurePlaceholdersAvailableWithCode( label, text, row, CodeTemplateFieldMissing, "Add the missing field to the data source or pass it with --var.", nil, ) } func ensurePlaceholdersAvailableWithCode(label, text string, row map[string]string, code ErrorCode, hint string, context map[string]string) error { fields := make(map[string]struct{}) addPlaceholders(fields, text) for field := range fields { if _, ok := row[field]; !ok { err := appError(code, fmt.Sprintf("missing %s field %q", label, field)). withHint(hint). withContext("field", field) for key, value := range context { err.withContext(key, value) } return err } } 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 "", appError(CodeTemplateUnresolved, "unresolved placeholder in docx text"). withHint("Check source data values and template placeholders.") } 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 }