diff --git a/Param.go b/Param.go index b6605b0..7e08e9a 100644 --- a/Param.go +++ b/Param.go @@ -52,7 +52,7 @@ type Param struct { } // NewParam .. -func NewParam(key interface{}) *Param { +func NewParam(key any) *Param { p := &Param{ Key: fmt.Sprintf("%v", key), } @@ -84,7 +84,7 @@ func (p *Param) replaceIn(buf []byte) []byte { } // SetValue - any value to string -func (p *Param) SetValue(val interface{}) { +func (p *Param) SetValue(val any) { switch v := val.(type) { case string: p.Value = v @@ -239,7 +239,7 @@ func (p *Param) RunTrigger(xnode *xmlNode) { n := xnode.closestUp(ntypes) if n == nil || n.isDeleted { - // color.Red("EMPTY parent of %v", xnode.Tag()) + // aurora.Red("EMPTY parent of %v", xnode.Tag()) return } @@ -253,7 +253,7 @@ func (p *Param) RunTrigger(xnode *xmlNode) { for _, wpNode := range n.parent.Nodes { isitem, listid := wpNode.IsListItem() if !isitem || listid != listID { - // color.Red("--- %s [%s]", wpNode, wpNode.AllContents()) + // aurora.Red("--- %s [%s]", wpNode, wpNode.AllContents()) continue } if p.Trigger.Command == TriggerCommandRemove { @@ -266,7 +266,7 @@ func (p *Param) RunTrigger(xnode *xmlNode) { // Simple cases if p.Trigger.Command == TriggerCommandRemove { - // n.printTree("TRIGGER: " + p.Trigger.String() + " " + p.Trigger.Command) + // fmt.Printf("Trigger: [%s] [%s]\t Command=[%s]\n", aurora.Blue(p.AbsoluteKey), aurora.Magenta(p.Trigger.String()), aurora.BgMagenta(p.Trigger.Command)) n.Nodes = nil n.delete() return diff --git a/ParamList.go b/ParamList.go index 07357f7..3f21e9f 100644 --- a/ParamList.go +++ b/ParamList.go @@ -10,11 +10,26 @@ import ( // ParamList .. type ParamList []*Param -// StructParams - load params from given any struct +// Get method returns the value associated with the given name +func (plist ParamList) Get(key string) any { + for _, p := range plist { + if p.Key == key { + return p.Value + } + } + return nil +} + +// Len .. +func (p ParamList) Len() int { + return len(p) +} + +// AnyToParams - load params from given any struct // 1) Convert struct to JSON -// 2) Now convert JSON to map[string]interface{} +// 2) Now convert JSON to map[string]any // 3) Clear params from nil -func StructParams(v interface{}) ParamList { +func AnyToParams(v any) ParamList { // to JSON output buf, _ := json.MarshalIndent(v, "", "\t") return JSONToParams(buf) @@ -23,7 +38,7 @@ func StructParams(v interface{}) ParamList { // JSONToParams - load params from JSON func JSONToParams(buf []byte) ParamList { // to map - m := map[string]interface{}{} + m := map[string]any{} if err := json.Unmarshal(buf, &m); err != nil { log.Printf("JSONToParams: %s", err) return nil @@ -37,17 +52,17 @@ func JSONToParams(buf []byte) ParamList { return params } -// walk map[string]interface{} and collect valid params -func mapToParams(m map[string]interface{}) ParamList { +// walk map[string]any and collect valid params +func mapToParams(m map[string]any) ParamList { var params ParamList for mKey, mVal := range m { p := NewParam(mKey) switch v := mVal.(type) { - case map[string]interface{}: + case map[string]any: p.Type = StructParam p.Params = mapToParams(v) - case []interface{}: + case []any: p.Type = SliceParam p.Params = sliceToParams(v) default: @@ -66,7 +81,7 @@ func mapToParams(m map[string]interface{}) ParamList { } // sliceToParams - slice of unknown - simple slice or complex -func sliceToParams(arr []interface{}) ParamList { +func sliceToParams(arr []any) ParamList { var params ParamList for i, val := range arr { @@ -75,7 +90,7 @@ func sliceToParams(arr []interface{}) ParamList { p := NewParam(i + 1) switch v := val.(type) { - case map[string]interface{}: + case map[string]any: p.Type = StructParam p.Params = mapToParams(v) default: @@ -93,7 +108,7 @@ func sliceToParams(arr []interface{}) ParamList { } // StructToParams - walk struct and collect valid params -func StructToParams(paramStruct interface{}) ParamList { +func StructToParams(paramStruct any) ParamList { var params ParamList var keys reflect.Type var vals reflect.Value @@ -158,12 +173,12 @@ func reflectStructToParams(p *Param, val reflect.Value) { } p.Type = ImageParam p.SetValue(imgVal) - } else { - p.Type = StructParam - p.Params = StructToParams(val) + return } - return + p.Type = StructParam + p.Params = StructToParams(val) + } // reflectSliceToParams - map slice of reflect to params @@ -198,8 +213,6 @@ func reflectSliceToParams(p *Param, val reflect.Value) { p.Params = append(p.Params, itemParam) } - - return } // Parse row content to param list @@ -207,9 +220,11 @@ func rowParams(row []byte) ParamList { // extract from raw contents re := regexp.MustCompile(ParamPattern) matches := re.FindAllSubmatch(row, -1) + if matches == nil || matches[0] == nil { return nil } + var list []*Param for _, match := range matches { p := NewParam(string(match[2])) diff --git a/Template.go b/Template.go index d0a5cb0..02faeb5 100644 --- a/Template.go +++ b/Template.go @@ -3,7 +3,6 @@ package docxplate import ( "archive/zip" "bytes" - "encoding/xml" "fmt" "io" "log" @@ -126,51 +125,6 @@ func OpenTemplateWithURL(docurl string) (tpl *Template, err error) { return } -// Convert given bytes to struct of xml nodes -func (t *Template) bytesToXMLStruct(buf []byte) *xmlNode { - // Do not strip "Hello World!"" -func (t *Template) Params(v interface{}) { +func (t *Template) Params(v any) { // t.params = collectParams("", v) switch val := v.(type) { case map[string]interface{}: @@ -193,7 +147,8 @@ func (t *Template) Params(v interface{}) { if reflect.ValueOf(v).Kind() == reflect.Struct { t.params = StructToParams(val) } else { - t.params = StructParams(val) + // any other type try to convert + t.params = AnyToParams(val) } } @@ -205,9 +160,9 @@ func (t *Template) Params(v interface{}) { xnode := t.fileToXMLStruct(f.Name) - // Enchance some markup (removed when building XML in the end) + // Enhance some markup (removed when building XML in the end) // so easier to find some element - t.enchanceMarkup(xnode) + t.enhanceMarkup(xnode) // While formating docx sometimes same style node is split to // multiple same style nodes and different content @@ -226,15 +181,9 @@ func (t *Template) Params(v interface{}) { // otherwise they are left t.triggerMissingParams(xnode) - // xnode.Walk(func(n *xmlNode) { - // if is, _ := n.IsListItem(); is { - // n.Walk(func(wt *xmlNode) { - // if wt.Tag() == "w-t" { - // color.Yellow("%s", wt) - // } - // }) - // } - // }) + // After all done with placeholders, modify contents + // - new lines to docx new lines + t.enhanceContent(xnode) // Save []bytes t.modified[f.Name] = structToXMLBytes(xnode) @@ -242,314 +191,6 @@ func (t *Template) Params(v interface{}) { } } -// Collect and trigger placeholders with trigger but unset in `t.params` -// Placeholders with trigger `:empty` must be triggered -// otherwise they are left -func (t *Template) triggerMissingParams(xnode *xmlNode) { - if t.params == nil { - return - } - - var triggerParams ParamList - - xnode.Walk(func(n *xmlNode) { - if !n.isRowElement() || !n.HaveParams() { - return - } - - p := NewParamFromRaw(n.AllContents()) - if p != nil && p.Trigger != nil { - triggerParams = append(triggerParams, p) - } - }) - - if triggerParams == nil { - return - } - - // make sure not to "tint" original t.params - _params := t.params - t.params = triggerParams - - // do stuff only with filtered params - t.replaceSingleParams(xnode, true) - - // back to original - t.params = _params -} - -type placeholderType int8 - -const ( - singlePlaceholder placeholderType = iota - inlinePlaceholder - rowPlaceholder -) - -type placeholder struct { - Type placeholderType - Placeholders []string - Separator string -} - -// Expand complex placeholders -func (t *Template) expandPlaceholders(xnode *xmlNode) { - t.params.Walk(func(p *Param) { - if p.Type != SliceParam { - return - } - - prefixes := []string{ - p.PlaceholderPrefix(), - p.ToCompact(p.PlaceholderPrefix()), - } - - var max int - for _, prefix := range prefixes { - xnode.Walk(func(nrow *xmlNode) { - if nrow.isNew { - return - } - if !nrow.isRowElement() { - return - } - if !nrow.AnyChildContains([]byte(prefix)) { - return - } - - contents := nrow.AllContents() - rowParams := rowParams(contents) - rowPlaceholders := make(map[string]*placeholder) - // Collect placeholder that for expansion - for _, rowParam := range rowParams { - var placeholderType placeholderType - if len(rowParam.Separator) > 0 { - placeholderType = inlinePlaceholder - } else { - placeholderType = rowPlaceholder - } - - var trigger string - if rowParam.Trigger != nil { - trigger = " " + rowParam.Trigger.String() - } - - var isMatch bool - var index = -1 - currentLevel := p.Level - placeholders := make([]string, 0, len(p.Params)) - p.WalkFunc(func(p *Param) { - if p.Level == currentLevel+1 { - index++ - } - if rowParam.AbsoluteKey == p.CompactKey { - isMatch = true - placeholders = append(placeholders, "{{"+p.AbsoluteKey+trigger+"}}") - } - }) - - if isMatch { - rowPlaceholders[rowParam.RowPlaceholder] = &placeholder{ - Type: placeholderType, - Placeholders: placeholders, - Separator: strings.TrimLeft(rowParam.Separator, " "), - } - - if max < len(placeholders) { - max = len(placeholders) - } - } - } - // Expand placeholder exactly - nnews := make([]*xmlNode, max, max) - for oldPlaceholder, newPlaceholder := range rowPlaceholders { - switch newPlaceholder.Type { - case inlinePlaceholder: - nrow.Walk(func(n *xmlNode) { - if !inSlice(n.XMLName.Local, []string{"w-t"}) || len(n.Content) == 0 { - return - } - n.Content = bytes.ReplaceAll(n.Content, []byte(oldPlaceholder), []byte(strings.Join(newPlaceholder.Placeholders, newPlaceholder.Separator))) - }) - case rowPlaceholder: - defer func() { - nrow.delete() - }() - for i, placeholder := range newPlaceholder.Placeholders { - if nnews[i] == nil { - nnews[i] = nrow.cloneAndAppend() - } - nnews[i].Walk(func(n *xmlNode) { - if !inSlice(n.XMLName.Local, []string{"w-t"}) || len(n.Content) == 0 { - return - } - n.Content = bytes.ReplaceAll(n.Content, []byte(oldPlaceholder), []byte(placeholder)) - }) - } - } - } - }) - } - }) - - // Cloned nodes are marked as new by default. - // After expanding mark as old so next operations doesn't ignore them - xnode.Walk(func(n *xmlNode) { - n.isNew = false - }) -} - -// Replace single params by type -func (t *Template) replaceSingleParams(xnode *xmlNode, triggerParamOnly bool) { - xnode.Walk(func(n *xmlNode) { - if n == nil || n.isDeleted { - return - } - - // node params - t.params.Walk(func(p *Param) { - for i, attr := range n.Attrs { - if strings.Contains(attr.Value, "{{") { - n.Attrs[i].Value = string(p.replaceIn([]byte(attr.Value))) - } - } - }) - - // node contentt - if bytes.Contains(n.Content, []byte("{{")) { - // Try to replace on node that contains possible placeholder - t.params.Walk(func(p *Param) { - // Only string and image param to replace - if p.Type != StringParam && p.Type != ImageParam { - return - } - // Prefix check - if !n.ContentHasPrefix(p.PlaceholderPrefix()) { - return - } - // Trigger: does placeholder have trigger - if p.Trigger = p.extractTriggerFrom(n.Content); p.Trigger != nil { - defer func() { - p.RunTrigger(n) - }() - } - if triggerParamOnly { - return - } - // Repalce by type - switch p.Type { - case StringParam: - t.replaceTextParam(n, p) - case ImageParam: - t.replaceImageParams(n, p) - } - }) - } - }) -} - -// wrapper for simple param replace func -func (t *Template) replaceTextParam(xnode *xmlNode, param *Param) { - xnode.Content = param.replaceIn(xnode.Content) -} - -// Image placeholder replace -func (t *Template) replaceImageParams(xnode *xmlNode, param *Param) { - // Sometime the placeholder is in the before or middle of the text, but node is appended in the last. - // So, we have to split the text and image into different nodes to achieve cross-display. - contentSlice := bytes.Split(xnode.Content, []byte(param.Placeholder())) - for i, content := range contentSlice { - // text node - if len(content) != 0 { - contentNode := &xmlNode{ - XMLName: xml.Name{Space: "", Local: "w-t"}, - Content: content, - parent: xnode.parent, - isNew: true, - } - xnode.parent.Nodes = append(xnode.parent.Nodes, contentNode) - } - // image node - if len(contentSlice)-i > 1 { - imgNode := t.bytesToXMLStruct([]byte(param.Value)) - imgNode.parent = xnode.parent - xnode.parent.Nodes = append(xnode.parent.Nodes, imgNode) - } - } - // Empty the content before deleting to prevent reprocessing when params walk - xnode.Content = []byte("") - xnode.delete() -} - -// Enchance some markup (removed when building XML in the end) -// so easier to find some element -func (t *Template) enchanceMarkup(xnode *xmlNode) { - - // List items - add list item node `w-p` attributes - // so it's recognized as listitem - xnode.Walk(func(n *xmlNode) { - if n.Tag() != "w-p" { - return - } - - isListItem, listID := n.IsListItem() - if !isListItem { - return - } - - // n.XMLName.Local = "w-item" - n.Attrs = append(n.Attrs, xml.Attr{ - Name: xml.Name{Local: "list-id"}, - Value: listID, - }) - - }) -} - -// This func is fixing broken placeholders by merging "w-t" nodes. -// "w-p" (Record) can hold multiple "w-r". And "w-r" holts "w-t" node -// - -// If these nodes not fixed than params replace can not be done as -// replacer process nodes one by one -func (t *Template) fixBrokenPlaceholders(xnode *xmlNode) { - xnode.Walk(func(xnode *xmlNode) { - if !xnode.isRowElement() { - // broken placeholders are in row elements - return - } - - if !xnode.HaveParams() { - // whole text doesn't hold any params - return - } - - var isBrokenLeftPlaceholder bool - var isBrokenRightPlaceholder bool - contents := xnode.AllContents() - xnode.Walk(func(xnode *xmlNode) { - if xnode.Content == nil || len(xnode.Content) == 0 { - return - } - // Match right }} to sub or delete - if isBrokenLeftPlaceholder { - isBrokenRightPlaceholder = t.matchBrokenRightPlaceholder(string(xnode.Content)) - if isBrokenRightPlaceholder { - xnode.Content = xnode.Content[bytes.Index(xnode.Content, []byte("}}"))+2:] - } else { - xnode.delete() - return - } - } - // Match left {{ to fix broken - isBrokenLeftPlaceholder = t.matchBrokenLeftPlaceholder(string(xnode.Content)) - if isBrokenLeftPlaceholder { - xnode.Content = append(xnode.Content, contents[bytes.Index(contents, xnode.Content)+len(xnode.Content):bytes.Index(contents, []byte("}}"))+2]...) - } - contents = contents[bytes.Index(contents, xnode.Content)+len(xnode.Content):] - }) - }) -} - // Bytes - create docx archive but return only bytes of it // do not save it anywhere func (t *Template) Bytes() ([]byte, error) { @@ -635,44 +276,6 @@ func (t *Template) Placeholders() []string { return arr } -// Match left part placeholder `{{` -func (t *Template) matchBrokenLeftPlaceholder(content string) bool { - stack := make([]string, 0) - - for i, char := range content { - if i > 0 { - if char == '{' && content[i-1] == '{' { - stack = append(stack, "{{") - } else if char == '}' && content[i-1] == '}' && len(stack) > 0 { - stack = stack[:len(stack)-1] - } - } - } - - return len(stack) > 0 -} - -// Match right placeholder part `}}` -func (t *Template) matchBrokenRightPlaceholder(content string) bool { - stack := make([]string, 0) - - for i, char := range content { - if i > 0 { - if char == '{' && content[i-1] == '{' { - stack = append(stack, "{{") - } else if char == '}' && content[i-1] == '}' { - if len(stack) > 0 { - stack = stack[:len(stack)-1] - } else { - return true - } - } - } - } - - return false -} - // Plaintext - return as plaintext func (t *Template) Plaintext() string { diff --git a/Template.helpers.go b/Template.helpers.go new file mode 100644 index 0000000..e929201 --- /dev/null +++ b/Template.helpers.go @@ -0,0 +1,121 @@ +package docxplate + +import ( + "bytes" + "encoding/xml" + "log" +) + +// Convert given bytes to struct of xml nodes +func (t *Template) bytesToXMLStruct(buf []byte) *xmlNode { + // Do not strip 1 { + imgNode := t.bytesToXMLStruct([]byte(param.Value)) + imgNode.parent = xnode.parent + xnode.parent.Nodes = append(xnode.parent.Nodes, imgNode) + } + } + // Empty the content before deleting to prevent reprocessing when params walk + xnode.Content = []byte("") + xnode.delete() +} + +// Check for broken placeholders +func (t *Template) matchBrokenPlaceholder(content string, isLeft bool) bool { + stack := 0 + + for i := 1; i < len(content); i++ { + if content[i] == '{' && content[i-1] == '{' { + stack++ + i++ // Skip next character + continue + } + if content[i] == '}' && content[i-1] == '}' { + if stack > 0 { + stack-- + i++ // Skip next character + continue + } + + if !isLeft { + return true // Broken right placeholder + } + } + } + + return isLeft && stack > 0 // Broken left placeholder +} + +// Match left part placeholder `{{` +func (t *Template) matchBrokenLeftPlaceholder(content string) bool { + return t.matchBrokenPlaceholder(content, true) +} + +// Match right placeholder part `}}` +func (t *Template) matchBrokenRightPlaceholder(content string) bool { + return t.matchBrokenPlaceholder(content, false) +} diff --git a/Template.stage.funcs.go b/Template.stage.funcs.go new file mode 100644 index 0000000..d4d78da --- /dev/null +++ b/Template.stage.funcs.go @@ -0,0 +1,310 @@ +package docxplate + +import ( + "bytes" + "encoding/xml" + "strings" +) + +// Collect and trigger placeholders with trigger but unset in `t.params` +// Placeholders with trigger `:empty` must be triggered +// otherwise they are left +func (t *Template) triggerMissingParams(xnode *xmlNode) { + if t.params == nil { + return + } + + var triggerParams ParamList + + xnode.Walk(func(n *xmlNode) { + if !n.isRowElement() || !n.HaveParams() { + return + } + + p := NewParamFromRaw(n.AllContents()) + if p != nil && p.Trigger != nil { + triggerParams = append(triggerParams, p) + } + }) + + if triggerParams == nil { + return + } + + // make sure not to "tint" original t.params + _params := t.params + t.params = triggerParams + + // do stuff only with filtered params + t.replaceSingleParams(xnode, true) + + // back to original + t.params = _params +} + +// Expand complex placeholders +func (t *Template) expandPlaceholders(xnode *xmlNode) { + t.params.Walk(func(p *Param) { + if p.Type != SliceParam { + return + } + + prefixes := []string{ + p.PlaceholderPrefix(), + p.ToCompact(p.PlaceholderPrefix()), + } + + var max int + for _, prefix := range prefixes { + xnode.Walk(func(nrow *xmlNode) { + if nrow.isNew { + return + } + if !nrow.isRowElement() { + return + } + if !nrow.AnyChildContains([]byte(prefix)) { + return + } + + contents := nrow.AllContents() + rowParams := rowParams(contents) + rowPlaceholders := make(map[string]*placeholder) + // Collect placeholder that for expansion + for _, rowParam := range rowParams { + var placeholderType placeholderType + if len(rowParam.Separator) > 0 { + placeholderType = inlinePlaceholder + } else { + placeholderType = rowPlaceholder + } + + var trigger string + if rowParam.Trigger != nil { + trigger = " " + rowParam.Trigger.String() + } + + var isMatch bool + var index = -1 + currentLevel := p.Level + placeholders := make([]string, 0, len(p.Params)) + p.WalkFunc(func(p *Param) { + if p.Level == currentLevel+1 { + index++ + } + if rowParam.AbsoluteKey == p.CompactKey { + isMatch = true + placeholders = append(placeholders, "{{"+p.AbsoluteKey+trigger+"}}") + } + }) + + if isMatch { + rowPlaceholders[rowParam.RowPlaceholder] = &placeholder{ + Type: placeholderType, + Placeholders: placeholders, + Separator: strings.TrimLeft(rowParam.Separator, " "), + } + + if max < len(placeholders) { + max = len(placeholders) + } + } + } + // Expand placeholder exactly + nnews := make([]*xmlNode, max, max) + for oldPlaceholder, newPlaceholder := range rowPlaceholders { + switch newPlaceholder.Type { + case inlinePlaceholder: + nrow.Walk(func(n *xmlNode) { + if !inSlice(n.XMLName.Local, []string{"w-t"}) || len(n.Content) == 0 { + return + } + n.Content = bytes.ReplaceAll(n.Content, []byte(oldPlaceholder), []byte(strings.Join(newPlaceholder.Placeholders, newPlaceholder.Separator))) + }) + case rowPlaceholder: + defer func() { + nrow.delete() + }() + for i, placeholder := range newPlaceholder.Placeholders { + if nnews[i] == nil { + nnews[i] = nrow.cloneAndAppend() + } + nnews[i].Walk(func(n *xmlNode) { + if !inSlice(n.XMLName.Local, []string{"w-t"}) || len(n.Content) == 0 { + return + } + n.Content = bytes.ReplaceAll(n.Content, []byte(oldPlaceholder), []byte(placeholder)) + }) + } + } + } + }) + } + }) + + // Cloned nodes are marked as new by default. + // After expanding mark as old so next operations doesn't ignore them + xnode.Walk(func(n *xmlNode) { + n.isNew = false + }) +} + +// Replace single params by type +func (t *Template) replaceSingleParams(xnode *xmlNode, triggerParamOnly bool) { + xnode.Walk(func(n *xmlNode) { + if n == nil || n.isDeleted { + return + } + + // node params + t.params.Walk(func(p *Param) { + for i, attr := range n.Attrs { + if strings.Contains(attr.Value, "{{") { + n.Attrs[i].Value = string(p.replaceIn([]byte(attr.Value))) + } + } + }) + + // node contentt + if bytes.Contains(n.Content, []byte("{{")) { + // Try to replace on node that contains possible placeholder + t.params.Walk(func(p *Param) { + // Only string and image param to replace + if p.Type != StringParam && p.Type != ImageParam { + return + } + // Prefix check + if !n.ContentHasPrefix(p.PlaceholderPrefix()) { + return + } + // Trigger: does placeholder have trigger + if p.Trigger = p.extractTriggerFrom(n.Content); p.Trigger != nil { + defer func() { + p.RunTrigger(n) + }() + } + if triggerParamOnly { + return + } + // Repalce by type + switch p.Type { + case StringParam: + t.replaceTextParam(n, p) + case ImageParam: + t.replaceImageParams(n, p) + } + }) + } + }) +} + +// Enhance some markup (removed when building XML in the end) +// so easier to find some element +func (t *Template) enhanceMarkup(xnode *xmlNode) { + + // List items - add list item node `w-p` attributes + // so it's recognized as listitem + xnode.Walk(func(n *xmlNode) { + if n.Tag() != "w-p" { + return + } + + isListItem, listID := n.IsListItem() + if !isListItem { + return + } + + // n.XMLName.Local = "w-item" + n.Attrs = append(n.Attrs, xml.Attr{ + Name: xml.Name{Local: "list-id"}, + Value: listID, + }) + + }) +} + +// new line variable for reuse +var nl = []byte("\n") + +// Enhance content +func (t *Template) enhanceContent(xnode *xmlNode) { + + // New lines from text as docx new lines + xnode.Walk(func(n *xmlNode) { + if !n.isSingle() { + return + } + + if !bytes.Contains(n.Content, nl) { + return + } + + nrow := n.closestUp([]string{"w-p"}) + // log.Printf("NEW LINE: %s..%s [%q] %d new lines", aurora.Cyan(nrow.Tag()), aurora.Blue(n.Tag()), aurora.Yellow(n.Content), bytes.Count(n.Content, nl)) + + parts := bytes.Split(n.Content, nl) + for i, buf := range parts { + // clone the original node to preserve styles and append the cloned node + nlast := nrow.cloneAndAppend() + + // first and last node can hold other text node prefixes, skip + if i >= 1 && i <= len(parts) { + nlast.Walk(func(n2 *xmlNode) { + if n2.isSingle() && len(n2.Content) > 0 && !bytes.Contains(n.Content, n2.Content) { + // delete all other text nodes because we need the same text node + n2.delete() + } + }) + } + nlast.ReplaceInContents(n.Content, buf) + // nlast.printTree("NROW") + } + + // delete the original node after cloning and adjusting (otherwise it shows at the end) + nrow.delete() + + }) +} + +// This func is fixing broken placeholders by merging "w-t" nodes. +// "w-p" (Record) can hold multiple "w-r". And "w-r" holts "w-t" node +// - +// If these nodes not fixed than params replace can not be done as +// replacer process nodes one by one +func (t *Template) fixBrokenPlaceholders(xnode *xmlNode) { + + xnode.Walk(func(nrow *xmlNode) { + if !nrow.isRowElement() { + return + } + + var brokenNode *xmlNode + nrow.Walk(func(n *xmlNode) { + // broken node state? merge next nodes + + if !n.isSingle() && len(n.AllContents()) > 0 { + // fmt.Printf("\t RESET -- %s->%s [%s]\n", n.parent.Tag(), aurora.Blue(n.Tag()), aurora.Red(n.AllContents())) + brokenNode = nil + return + } + + if brokenNode != nil { + // fmt.Printf("OK [%s] + [%s]\n", aurora.Green(brokenNode.AllContents()), aurora.Green(n.AllContents())) + brokenNode.Content = append(brokenNode.Content, n.AllContents()...) + // aurora.Magenta("[%s] %v -- %v -- %v -- %v", brokenNode.Content, brokenNode.Tag(), brokenNode.parent.Tag(), brokenNode.parent.parent.Tag(), brokenNode.parent.parent.parent.Tag()) + n.Nodes = nil + n.delete() + return + } + + if t.matchBrokenLeftPlaceholder(string(n.Content)) { + // nrow.printTree("BROKEN") + brokenNode = n + return + } + + brokenNode = nil + + }) + }) +} diff --git a/go.mod b/go.mod index 6b65bcd..ac29722 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,4 @@ module github.com/bobiverse/docxplate go 1.22.4 -require github.com/fatih/color v1.17.0 - -require ( - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.21.0 // indirect -) +require github.com/logrusorgru/aurora/v4 v4.0.0 diff --git a/go.sum b/go.sum index b303f8e..0ea1602 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,10 @@ -github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/logrusorgru/aurora/v4 v4.0.0 h1:sRjfPpun/63iADiSvGGjgA1cAYegEWMPCJdUpJYn9JA= +github.com/logrusorgru/aurora/v4 v4.0.0/go.mod h1:lP0iIa2nrnT/qoFXcOZSrZQpJ1o6n2CUf/hyHi2Q4ZQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helpers.go b/helpers.go index 8d955c3..49cfbca 100644 --- a/helpers.go +++ b/helpers.go @@ -35,7 +35,7 @@ func readerBytes(rdr io.ReadCloser) []byte { } // Encode struct to xml code string -func structToXMLBytes(v interface{}) []byte { +func structToXMLBytes(v any) []byte { // buf, err := xml.MarshalIndent(v, "", " ") buf, err := xml.Marshal(v) if err != nil { diff --git a/placeholder.go b/placeholder.go new file mode 100644 index 0000000..912266a --- /dev/null +++ b/placeholder.go @@ -0,0 +1,15 @@ +package docxplate + +type placeholderType int8 + +const ( + singlePlaceholder placeholderType = iota + inlinePlaceholder + rowPlaceholder +) + +type placeholder struct { + Type placeholderType + Placeholders []string + Separator string +} diff --git a/t.docx_test.go b/t.docx_test.go index 9257c86..2b7c6e5 100644 --- a/t.docx_test.go +++ b/t.docx_test.go @@ -16,6 +16,7 @@ type User struct { Age int Nicknames []string Friends []*User + Motto string BrokenStylePlaceholder string TriggerRemove string ImageLocal *docxplate.Image @@ -128,10 +129,11 @@ func TestPlaceholders(t *testing.T) { } filenames := []string{ - "user.template.docx", "tables.docx", "lists.docx", "header-footer.docx", + "user.template-no-images.docx", + "user.template-with-images.docx", } for _, fname := range filenames { @@ -195,12 +197,12 @@ func TestPlaceholders(t *testing.T) { } if !strings.Contains(plaintext, u.Name) { - t.Fatalf("[%s] User[%s] friends Name must be found: \n\n%s", fname, u.Name, tdoc.Plaintext()) + t.Fatalf("[%s][%s] User[%s] friends Name must be found: \n\n%s", inType, fname, u.Name, tdoc.Plaintext()) } years := fmt.Sprintf("%d y/o", u.Age) if !strings.Contains(plaintext, years) { - t.Fatalf("[%s] User[%s] friends Age[%d] must be found: \n\n%s", fname, u.Name, u.Age, tdoc.Plaintext()) + t.Fatalf("[%s][%s] User[%s] friends Age[%d] must be found: \n\n%s", inType, fname, u.Name, u.Age, tdoc.Plaintext()) } } @@ -235,19 +237,20 @@ func TestPlaceholders(t *testing.T) { func TestDepthStructToParams(t *testing.T) { var user = User{ - Name: "Alice", - Age: 27, + Name: "Alice", + Age: 27, + Motto: "Always stay humble.", Friends: []*User{ - {Name: "Bob", Age: 28, Friends: []*User{ - {Name: "Cecilia", Age: 29}, - {Name: "Sun", Age: 999}, - {Name: "Tony", Age: 999}, + {Name: "Bob", Age: 28, Motto: "Be\nbrave,\nbe\nbold.", Friends: []*User{ + {Name: "Cecilia", Age: 29, Motto: "Chase\nyour\ndreams."}, + {Name: "Sun", Age: 999, Motto: ""}, + {Name: "Tony", Age: 999, Motto: ""}, }}, - {Name: "Den", Age: 30, Friends: []*User{ - {Name: "Ben", Age: 999}, - {Name: "Edgar", Age: 31}, - {Name: "Jouny", Age: 999}, - {Name: "Carrzy", Age: 999}, + {Name: "Den", Age: 30, Motto: "Don't give up.", Friends: []*User{ + {Name: "Ben", Age: 999, Motto: "Be brave, be bold as twice as Bob."}, + {Name: "Edgar", Age: 31, Motto: "Embrace the moment."}, + {Name: "Jouny", Age: 999, Motto: ""}, + {Name: "Carrzy", Age: 999, Motto: "Chase your dreamzzz"}, }}, }, } diff --git a/t.docxissues_test.go b/t.docxissues_test.go new file mode 100644 index 0000000..060516e --- /dev/null +++ b/t.docxissues_test.go @@ -0,0 +1,24 @@ +package docxplate_test + +import ( + "testing" + + "github.com/bobiverse/docxplate" +) + +func TestIssues(t *testing.T) { + + filenames := []string{ + "issue-31.docx", + } + + for _, fname := range filenames { + tdoc, _ := docxplate.OpenTemplate("test-data/" + fname) + tdoc.Params(map[string]any{"ISSUE": 31}) + if err := tdoc.ExportDocx("test-data/~test-" + fname); err != nil { + t.Fatalf("[%s] ExportDocx: %s", fname, err) + } + + // success: just needs to be parsed without errors + } +} diff --git a/t.params_test.go b/t.params_test.go new file mode 100644 index 0000000..3238735 --- /dev/null +++ b/t.params_test.go @@ -0,0 +1,57 @@ +package docxplate + +import ( + "fmt" + "testing" +) + +func TestAnyToParamsMapStringString(t *testing.T) { + + inParams := map[string]string{ + "Name": "Alice", + "Greeting": "Hi!", + } + + outParams := AnyToParams(inParams) + if len(inParams) != outParams.Len() { + t.Fatalf("param count don't match. Expected %d, found %d", len(inParams), outParams.Len()) + } + + // check if all params exists + for k, _ := range inParams { + if p := outParams.Get(k); p == nil { + t.Fatalf("param `%s` not found", k) + } + + } + +} + +func TestAnyToParamsMapStringAny(t *testing.T) { + + type dummyTestType int + + inParams := map[string]any{ + "Name": "Bob", + "Age": uint(28), + "FavColor": dummyTestType(0xF00), + } + + outParams := AnyToParams(inParams) + if len(inParams) != outParams.Len() { + t.Fatalf("param count don't match. Expected %d, found %d", len(inParams), outParams.Len()) + } + + // check if all params exists + for k, v := range inParams { + val := outParams.Get(k) + if val == nil { + t.Fatalf("param `%s` not found", k) + } + if fmt.Sprintf("%v", v) != fmt.Sprintf("%v", val) { + t.Fatalf("param `%s` value not equal: [%v]!=[%v]", k, v, val) + } + + } + +} diff --git a/test-data/depth.docx b/test-data/depth.docx index a512d0c..041a31a 100644 Binary files a/test-data/depth.docx and b/test-data/depth.docx differ diff --git a/test-data/issue-31.docx b/test-data/issue-31.docx new file mode 100644 index 0000000..837bb59 Binary files /dev/null and b/test-data/issue-31.docx differ diff --git a/test-data/user.template-no-images.docx b/test-data/user.template-no-images.docx new file mode 100644 index 0000000..d1de754 Binary files /dev/null and b/test-data/user.template-no-images.docx differ diff --git a/test-data/user.template.docx b/test-data/user.template-with-images.docx similarity index 100% rename from test-data/user.template.docx rename to test-data/user.template-with-images.docx diff --git a/version.txt b/version.txt index c7cd5b2..e7f45a8 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v1.2.4 +v1.3 diff --git a/xml.node.go b/xml.node.go index 6ef5d00..a8af602 100644 --- a/xml.node.go +++ b/xml.node.go @@ -5,9 +5,10 @@ import ( "encoding/xml" "fmt" "regexp" + "slices" "strings" - "github.com/fatih/color" + "github.com/logrusorgru/aurora/v4" ) // NodeSingleTypes - NB! sequence is important @@ -34,8 +35,8 @@ type xmlNode struct { } func (xnode xmlNode) ContentHasPrefix(str string) bool { - splitContent :=bytes.Split(xnode.Content, []byte(str)) - if len(splitContent) == 1{ + splitContent := bytes.Split(xnode.Content, []byte(str)) + if len(splitContent) == 1 { return false } contentSuffix := splitContent[1] @@ -128,6 +129,12 @@ func (xnode *xmlNode) isRowElement() bool { return false } +// Single type +// w-t, w-r +func (xnode *xmlNode) isSingle() bool { + return slices.Contains[[]string](NodeSingleTypes, xnode.XMLName.Local) +} + // HaveParams - does node contents contains any param func (xnode *xmlNode) HaveParams() bool { buf := xnode.AllContents() @@ -246,6 +253,7 @@ func (xnode *xmlNode) delete() { if index != -1 { xnode.parent.Nodes[index] = nil } + xnode.Nodes = nil xnode.isDeleted = true } @@ -259,15 +267,15 @@ func (xnode *xmlNode) closestUp(nodeTypes []string) *xmlNode { continue } - // color.Magenta("[%s] == [%s]", xnode.parent.Tag(), ntype) + // aurora.Magenta("[%s] == [%s]", xnode.parent.Tag(), ntype) if xnode.parent.Tag() == ntype { - // color.Green("found parent: [%s] == [%s]", xnode.parent.Tag(), ntype) + // aurora.Green("found parent: [%s] == [%s]", xnode.parent.Tag(), ntype) return xnode.parent } for _, n := range xnode.parent.Nodes { if n.Tag() == ntype { - // color.Green("found parent: [%s] == [%s]", n.Tag(), ntype) + // aurora.Green("found parent: [%s] == [%s]", n.Tag(), ntype) return n } @@ -284,6 +292,7 @@ func (xnode *xmlNode) closestUp(nodeTypes []string) *xmlNode { // ReplaceInContents - replace plain text contents with something func (xnode *xmlNode) ReplaceInContents(old, new []byte) []byte { xnode.Walk(func(n *xmlNode) { + n.Content = bytes.ReplaceAll(n.Content, old, new) }) return xnode.AllContents() @@ -303,7 +312,7 @@ func (xnode *xmlNode) Tag() string { func (xnode *xmlNode) String() string { s := fmt.Sprintf("#%d: ", xnode.index()) if xnode.isDeleted { - s += color.RedString(" !!DELETED!! ") + s += aurora.Red(" !!DELETED!! ").String() } s += fmt.Sprintf("-- %p -- ", xnode) @@ -321,69 +330,68 @@ func (xnode *xmlNode) String() string { return s } -// -//// Print tree of node and down -// func (xnode *xmlNode) printTree(label string) { -// fmt.Printf("[ %s ]", label) -// fmt.Println("|" + strings.Repeat("-", 80)) -// -// if xnode == nil { -// color.Red("Empty node.") -// return -// } -// fmt.Printf("|%s |%p| %s\n", xnode.XMLName.Local, xnode, xnode.Content) -// -// xnode.WalkTree(0, func(depth int, n *xmlNode) { -// s := "|" -// s += strings.Repeat(" ", depth*4) -// -// // tag -// s += fmt.Sprintf("%-10s", n.XMLName.Local) -// if xnode.isNew { -// s = color.CyanString(s) -// } -// if xnode.isDeleted { -// s = color.HiRedString(s) -// } -// -// // pointers -// s += fmt.Sprintf("|%p|", n) -// sptr := fmt.Sprintf("|%p| ", n.parent) -// if n.parent == nil { -// sptr = color.HiRedString(sptr) -// } -// s += sptr -// -// if isListItem, listID := n.IsListItem(); isListItem { -// s += color.HiBlueString(" (List:%s) ", listID) -// } -// -// if bytes.TrimSpace(n.Content) != nil { -// s += color.YellowString("[%s]", n.Content) -// } else if n.haveParam { -// s += color.HiMagentaString("<< empty param value >>") -// } -// -// // s += color.CyanString(" -- %s", n.StylesString()) -// -// fmt.Println(s) -// }) -// -// fmt.Println("|" + strings.Repeat("-", 80)) -//} -// -// func (xnode *xmlNode) attrID() string { -// if xnode == nil { -// return "" -// } -// -// for _, attr := range xnode.Attrs { -// if attr.Name.Local == "id" { -// return attr.Value -// } -// } -// return "" -//} +// Print tree of node and down +func (xnode *xmlNode) printTree(label string) { + fmt.Printf("[ %s ]", label) + fmt.Println("|" + strings.Repeat("-", 80)) + + if xnode == nil { + aurora.Red("Empty node.") + return + } + fmt.Printf("|%s |%p| %s\n", xnode.XMLName.Local, xnode, xnode.Content) + + xnode.WalkTree(0, func(depth int, n *xmlNode) { + s := "|" + s += strings.Repeat(" ", depth*4) + + // tag + s += fmt.Sprintf("%-10s", n.XMLName.Local) + if xnode.isNew { + s = aurora.Cyan(s).String() + } + if xnode.isDeleted { + s = aurora.Red(s).String() + } + + // pointers + s += fmt.Sprintf("|%p|", n) + sptr := fmt.Sprintf("|%p| ", n.parent) + if n.parent == nil { + sptr = aurora.Red(sptr).String() + } + s += sptr + + if isListItem, listID := n.IsListItem(); isListItem { + s += fmt.Sprintf(" (List:%s) ", aurora.Blue(listID)) + } + + if bytes.TrimSpace(n.Content) != nil { + s += fmt.Sprintf("[%s]", aurora.Yellow(n.Content)) + } else if n.HaveParams() { + s += aurora.Magenta("<< empty param value >>").String() + } + + // s += aurora.Cyan(" -- %s", n.StylesString()) + + fmt.Println(s) + }) + + fmt.Println("|" + strings.Repeat("-", 80)) +} + +func (xnode *xmlNode) attrID() string { + if xnode == nil { + return "" + } + + for _, attr := range xnode.Attrs { + if attr.Name.Local == "id" { + return attr.Value + } + } + return "" +} // ^ > w-p > w-pPr > w-numPr > w-numId func (xnode *xmlNode) nodeBySelector(selector string) *xmlNode { @@ -395,19 +403,19 @@ func (xnode *xmlNode) nodeBySelector(selector string) *xmlNode { for _, n := range xnode.Nodes { if n.Tag() == tag { if len(tags[i:]) == 1 { - // color.HiGreen("FOUND: %s", tag) + // aurora.HiGreen("FOUND: %s", tag) return n } selector = strings.Join(tags[i:], ">") - // color.Green("NEXT: %s", selector) + // aurora.Green("NEXT: %s", selector) return n.nodeBySelector(selector) } } } - // color.Red("Selector not found: [%s]", selector) + // aurora.Red("Selector not found: [%s]", selector) return nil }