package htmlform import ( "bytes" "fmt" "regexp" "sort" "strings" "github.com/yuin/goldmark" "pdf-wizard/internal/shortcode" ) var ( // Legacy [FN] marker regex legacyMarkerRe = regexp.MustCompile(`\[F(\d+)\]`) // Surrounding parentheses patterns stripBeforeRe = regexp.MustCompile(`\([\s\*]*\[F(\d+)\][\s\*]*\)`) stripAfterRe = regexp.MustCompile(`\[F(\d+)\][\s\*]*\([\s\*]*\)`) // Placeholder regex — \x01FIELD_N\x01 placeHolderRe = regexp.MustCompile(`\x01FIELD_(\d+)\x01`) ) // Field represents a form field for rendering (populated from shortcodes). type Field struct { Idx int // PDF field position (from shortcode idx) Type string // text, email, tel, number, url, date, textarea, button, radio Label string // display label GroupName string // group name (for grouped behavior) GroupType string // inferred: multiline, checkbox_group, radio_group } // DefaultFieldTypes maps PDF field positions to their original types. type DefaultFieldTypes map[int]string // Render converts markdown with [field] shortcodes to HTML with form inputs. // defaultTypes maps field index → original PDF type as fallback when shortcode type is unspecified. func Render(markdown string, defaultTypes DefaultFieldTypes) string { // Parse all field shortcodes from markdown scFields := shortcode.GetFieldsWithDefaults(markdown, defaultTypes) if len(scFields) == 0 { // No fields, just convert markdown to HTML return convertToHTML(markdown) } // Build field map by index fieldsByIdx := make(map[int]*shortcode.Field) for _, f := range scFields { fieldsByIdx[f.Idx] = f } // Strip legacy parentheses around [FN] markers markdown = stripBeforeRe.ReplaceAllString(markdown, "[F$1]") markdown = stripAfterRe.ReplaceAllString(markdown, "[F$1]") // Replace all shortcode and legacy markers with unique placeholders markdown = replaceWithPlaceholders(markdown, fieldsByIdx) // Convert markdown to HTML htmlStr := convertToHTML(markdown) // Replace placeholders with form inputs htmlFields := buildHTMLFields(scFields) groupMap := buildGroupMap(htmlFields) htmlStr = replacePlaceholders(htmlStr, htmlFields, groupMap) return htmlStr } // replaceWithPlaceholders replaces shortcode markers and legacy [FN] markers // with unique \x01FIELD_N\x01 placeholders. func replaceWithPlaceholders(markdown string, fieldsByIdx map[int]*shortcode.Field) string { // Collect all marker positions (shortcodes and legacy markers) type marker struct { start int end int idx int } var markers []marker // Find all [field ...] shortcodes for _, match := range shortcode.ShortcodesRe.FindAllStringSubmatchIndex(markdown, -1) { full := markdown[match[0]:match[1]] tag := markdown[match[2]:match[3]] if tag != "field" { continue } attrs := shortcode.ParseAttrs(full) idxStr, ok := attrs["idx"] if !ok { continue } var idx int fmt.Sscanf(idxStr, "%d", &idx) if _, exists := fieldsByIdx[idx]; exists { markers = append(markers, marker{start: match[0], end: match[1], idx: idx}) } } // Find all legacy [FN] markers for _, match := range legacyMarkerRe.FindAllStringSubmatchIndex(markdown, -1) { idxStr := markdown[match[2]:match[3]] var idx int fmt.Sscanf(idxStr, "%d", &idx) markers = append(markers, marker{start: match[0], end: match[1], idx: idx}) } // Sort markers by position descending (replace from end to start) sort.Slice(markers, func(i, j int) bool { return markers[i].start > markers[j].start }) // Replace each marker with placeholder for _, m := range markers { placeholder := fmt.Sprintf("\x01FIELD_%d\x01", m.idx) markdown = markdown[:m.start] + placeholder + markdown[m.end:] } return markdown } func buildHTMLFields(scFields []*shortcode.Field) []Field { fields := make([]Field, 0, len(scFields)) for _, sf := range scFields { fields = append(fields, Field{ Idx: sf.Idx, Type: sf.Type, Label: sf.Label, GroupName: sf.Group, }) } return fields } func buildGroupMap(fields []Field) map[string][]Field { groupMap := make(map[string][]Field) for _, f := range fields { if f.GroupName != "" { groupMap[f.GroupName] = append(groupMap[f.GroupName], f) } } // Infer group type from member types for _, members := range groupMap { gt := inferGroupTypeFromMembers(members) for i := range members { members[i].GroupType = gt } } return groupMap } func inferGroupTypeFromMembers(members []Field) string { if len(members) < 2 { return "" } hasText := false hasButton := false hasRadio := false allSame := true firstType := "" for _, m := range members { switch m.Type { case "text", "email", "tel", "number", "url", "date", "textarea": t := "text" if firstType == "" { firstType = t } else if firstType != t { allSame = false } hasText = true case "button": if firstType == "" { firstType = "button" } else if firstType != "button" { allSame = false } hasButton = true case "radio": if firstType == "" { firstType = "radio" } else if firstType != "radio" { allSame = false } hasRadio = true default: allSame = false } } if hasText && allSame { return "multiline" } if hasButton && allSame { return "checkbox_group" } if hasRadio && allSame { return "radio_group" } return "" } func replacePlaceholders(htmlStr string, fields []Field, groupMap map[string][]Field) string { // Build skip set and group maps fieldMap := make(map[int]Field) skipFields := make(map[int]bool) checkboxGroupMap := make(map[int]string) // idx -> group name radioGroupMap := make(map[int]string) for _, f := range fields { fieldMap[f.Idx] = f } for gName, members := range groupMap { sortFieldsByIdx(members) switch members[0].GroupType { case "multiline": for _, m := range members[1:] { skipFields[m.Idx] = true } first := fieldMap[members[0].Idx] first.Type = "textarea" fieldMap[members[0].Idx] = first case "checkbox_group": for _, m := range members { checkboxGroupMap[m.Idx] = gName } case "radio_group": for _, m := range members { radioGroupMap[m.Idx] = gName } } } // Replace placeholders with inputs return placeHolderRe.ReplaceAllStringFunc(htmlStr, func(match string) string { idxStr := placeHolderRe.FindStringSubmatch(match)[1] var idx int fmt.Sscanf(idxStr, "%d", &idx) if skipFields[idx] { return "" } field, ok := fieldMap[idx] if !ok { return "" } label := htmlEscapeAttr(field.Label) switch field.Type { case "text", "email", "tel", "number", "url", "date": return renderTextInput(idx, field.Type, label) case "textarea": return renderTextarea(idx, label) case "button": extra := checkboxGroupAttr(idx, checkboxGroupMap) return renderCheckbox(idx, label, extra) case "radio": extra := radioGroupAttr(idx, radioGroupMap) return renderCheckbox(idx, label, extra) } return "" }) } func renderTextInput(idx int, typ, label string) string { if label != "" { return fmt.Sprintf( `
`, idx, label, typ, idx, idx, idx, ) } return fmt.Sprintf( `
`, typ, idx, idx, idx, ) } func renderTextarea(idx int, label string) string { if label != "" { return fmt.Sprintf( `
`, idx, label, idx, idx, idx, ) } return fmt.Sprintf( `
`, idx, idx, idx, ) } func renderCheckbox(idx int, label, extra string) string { if label != "" { return fmt.Sprintf( `
`, idx, idx, idx, extra, idx, label, ) } return fmt.Sprintf( `
`, idx, idx, idx, extra, ) } func checkboxGroupAttr(idx int, m map[int]string) string { if name, ok := m[idx]; ok { return fmt.Sprintf(` data-checkbox-group="%s"`, name) } return "" } func radioGroupAttr(idx int, m map[int]string) string { if name, ok := m[idx]; ok { return fmt.Sprintf(` data-radio-group="%s"`, name) } return "" } func sortFieldsByIdx(fields []Field) { sort.Slice(fields, func(i, j int) bool { return fields[i].Idx < fields[j].Idx }) } func convertToHTML(md string) string { var buf bytes.Buffer goldmark.Convert([]byte(md), &buf) return buf.String() } func htmlEscapeAttr(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, `"`, """) s = strings.ReplaceAll(s, "'", "'") return s }