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
}