gogcli/internal/cmd/docs_sed_brace_format.go

482 lines
13 KiB
Go

// Package cmd provides CLI commands for Google Docs operations.
package cmd
import (
"strconv"
"strings"
"google.golang.org/api/docs/v1"
)
// buildBraceTextStyleRequests converts a braceExpr to UpdateTextStyle requests.
// It handles boolean flags (bold, italic, etc.), value flags (color, font, size),
// negation, and reset.
func buildBraceTextStyleRequests(be *braceExpr, start, end int64) []*docs.Request {
if be == nil {
return nil
}
// Handle reset: explicit {0} OR implicit (default unless {!0}).
// Every brace expression implicitly resets all formatting first,
// making output deterministic regardless of inherited doc styles.
// Use {!0} to opt into additive mode (preserve existing styles).
if be.Reset || !be.NoReset {
return buildResetTextStyleRequests(be, start, end)
}
style := &docs.TextStyle{}
var fields []string
// Boolean flags
if be.Bold != nil {
style.Bold = *be.Bold
fields = append(fields, "bold")
}
if be.Italic != nil {
style.Italic = *be.Italic
fields = append(fields, "italic")
}
if be.Underline != nil {
style.Underline = *be.Underline
fields = append(fields, "underline")
}
if be.Strike != nil {
style.Strikethrough = *be.Strike
fields = append(fields, "strikethrough")
}
if be.SmallCaps != nil {
style.SmallCaps = *be.SmallCaps
fields = append(fields, "smallCaps")
}
if be.Sup != nil && *be.Sup {
style.BaselineOffset = "SUPERSCRIPT"
fields = append(fields, "baselineOffset")
}
if be.Sub != nil && *be.Sub {
style.BaselineOffset = "SUBSCRIPT"
fields = append(fields, "baselineOffset")
}
// Reset baseline if both sup and sub are explicitly false
if be.Sup != nil && !*be.Sup && be.Sub != nil && !*be.Sub {
style.BaselineOffset = "NONE"
fields = append(fields, "baselineOffset")
}
// Code flag: monospace font + grey background
if be.Code != nil && *be.Code {
style.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: "Courier New"}
style.BackgroundColor = greyColor(codeBackgroundGrey)
fields = append(fields, "weightedFontFamily", "backgroundColor")
}
// Value flags
if be.Font != "" {
style.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: be.Font}
fields = append(fields, "weightedFontFamily")
}
if be.Size > 0 {
style.FontSize = &docs.Dimension{Magnitude: be.Size, Unit: "PT"}
fields = append(fields, "fontSize")
}
if be.Color != "" {
if r, g, b, ok := parseHexColor(be.Color); ok {
style.ForegroundColor = &docs.OptionalColor{
Color: &docs.Color{RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b}},
}
fields = append(fields, "foregroundColor")
}
}
if be.Bg != "" {
if r, g, b, ok := parseHexColor(be.Bg); ok {
style.BackgroundColor = &docs.OptionalColor{
Color: &docs.Color{RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b}},
}
fields = append(fields, "backgroundColor")
}
}
if be.URL != "" {
// Handle bookmark links (#name) vs regular URLs
if strings.HasPrefix(be.URL, "#") {
style.Link = &docs.Link{BookmarkId: be.URL[1:]}
} else {
style.Link = &docs.Link{Url: be.URL}
}
fields = append(fields, "link")
}
if len(fields) == 0 {
return nil
}
return []*docs.Request{
{
UpdateTextStyle: &docs.UpdateTextStyleRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end},
TextStyle: style,
Fields: strings.Join(fields, ","),
},
},
}
}
// resetFieldsStr is the pre-joined field mask for resetting all text formatting.
// Package-level to avoid per-call allocation and string joining.
var resetFieldsStr = strings.Join([]string{
"bold", "italic", "underline", "strikethrough", "smallCaps",
"baselineOffset", "foregroundColor", "backgroundColor",
"fontSize", "weightedFontFamily", "link",
}, ",")
// buildResetTextStyleRequests builds requests to clear all formatting, then apply any
// additional flags specified after the reset (e.g., {0 b} = reset then bold).
func buildResetTextStyleRequests(be *braceExpr, start, end int64) []*docs.Request {
requests := []*docs.Request{
{
UpdateTextStyle: &docs.UpdateTextStyleRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end},
TextStyle: &docs.TextStyle{}, // Empty style = reset
Fields: resetFieldsStr,
},
},
}
// Now apply any flags that were set alongside the reset
// Create a copy without Reset flag and with NoReset to avoid recursion
postReset := *be
postReset.Reset = false
postReset.NoReset = true
if braceExprHasTextFormat(&postReset) {
requests = append(requests, buildBraceTextStyleRequests(&postReset, start, end)...)
}
return requests
}
// braceExprHasTextFormat returns true if the braceExpr has any text-level formatting.
func braceExprHasTextFormat(be *braceExpr) bool {
if be == nil {
return false
}
return be.Bold != nil || be.Italic != nil || be.Underline != nil ||
be.Strike != nil || be.Code != nil || be.Sup != nil ||
be.Sub != nil || be.SmallCaps != nil || be.Color != "" ||
be.Bg != "" || be.Font != "" || be.Size > 0 || be.URL != ""
}
// buildBraceParagraphStyleRequests converts a braceExpr to paragraph-level requests.
// Handles headings, alignment, indent, leading, spacing, bullets.
func buildBraceParagraphStyleRequests(be *braceExpr, start, end int64) []*docs.Request {
if be == nil {
return nil
}
var requests []*docs.Request
// Build paragraph style
paraStyle := &docs.ParagraphStyle{}
var paraFields []string
// Heading
if be.Heading != "" {
namedStyle := resolveHeading(be.Heading)
paraStyle.NamedStyleType = namedStyle
paraFields = append(paraFields, "namedStyleType")
}
// Alignment
if be.Align != "" {
paraStyle.Alignment = resolveAlign(be.Align)
paraFields = append(paraFields, "alignment")
}
// Indent level (converted to indentStart in points)
if be.Indent >= 0 {
// Each indent level = 36pt (standard Google Docs indent)
indentPt := float64(be.Indent) * indentPointsPerLevel
paraStyle.IndentStart = &docs.Dimension{Magnitude: indentPt, Unit: "PT"}
paraFields = append(paraFields, "indentStart")
}
// Line height / leading
if be.Leading > 0 {
paraStyle.LineSpacing = be.Leading * 100 // 1.5 → 150
paraFields = append(paraFields, "lineSpacing")
}
// Paragraph spacing (above/below)
if be.SpacingSet {
if be.SpacingAbove > 0 || be.SpacingBelow > 0 {
paraStyle.SpaceAbove = &docs.Dimension{Magnitude: be.SpacingAbove, Unit: "PT"}
paraStyle.SpaceBelow = &docs.Dimension{Magnitude: be.SpacingBelow, Unit: "PT"}
paraFields = append(paraFields, "spaceAbove", "spaceBelow")
} else {
// Reset spacing to zero
paraStyle.SpaceAbove = &docs.Dimension{Magnitude: 0, Unit: "PT"}
paraStyle.SpaceBelow = &docs.Dimension{Magnitude: 0, Unit: "PT"}
paraFields = append(paraFields, "spaceAbove", "spaceBelow")
}
}
if len(paraFields) > 0 {
requests = append(requests, &docs.Request{
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
Range: &docs.Range{StartIndex: start, EndIndex: end},
ParagraphStyle: paraStyle,
Fields: strings.Join(paraFields, ","),
},
})
}
return requests
}
// buildBraceInlineRequests handles inline scoping — multiple styled spans within one replacement.
// Each span has its own start/end positions and formatting.
func buildBraceInlineRequests(spans []*braceSpan, baseIndex int64) []*docs.Request {
if len(spans) == 0 {
return nil
}
var requests []*docs.Request
for _, span := range spans {
if span.IsGlobal || span.Expr == nil {
continue // Global spans handled separately
}
start := baseIndex + int64(span.Start)
end := baseIndex + int64(span.End)
if end <= start {
continue
}
requests = append(requests, buildBraceTextStyleRequests(span.Expr, start, end)...)
}
return requests
}
// buildBraceBreakRequests handles break flags (+, +=p, +=c, +=s).
// Returns requests to insert horizontal rules, page/column/section breaks.
func buildBraceBreakRequests(be *braceExpr, insertIdx int64) []*docs.Request {
if be == nil || !be.HasBreak {
return nil
}
var requests []*docs.Request
switch be.Break {
case "p": // Page break
requests = append(requests, &docs.Request{
InsertPageBreak: &docs.InsertPageBreakRequest{
Location: &docs.Location{Index: insertIdx},
},
})
case "c": // Column break
// Column break is just a special character insert
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: insertIdx},
Text: "\v", // Vertical tab = column break in Docs
},
})
case "s": // Section break
requests = append(requests, &docs.Request{
InsertSectionBreak: &docs.InsertSectionBreakRequest{
Location: &docs.Location{Index: insertIdx},
SectionType: "NEXT_PAGE",
},
})
default: // "" = horizontal rule
// Insert newline with bottom border
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: insertIdx},
Text: "\n",
},
})
requests = append(requests, buildHruleBorderRequest(insertIdx, insertIdx+1))
}
return requests
}
// braceExprToFormats converts a braceExpr to the existing format string slice
// for backward compatibility with existing code paths.
func braceExprToFormats(be *braceExpr) []string {
if be == nil {
return nil
}
var formats []string
// Boolean flags
if be.Bold != nil && *be.Bold {
formats = append(formats, "bold")
}
if be.Italic != nil && *be.Italic {
formats = append(formats, "italic")
}
if be.Underline != nil && *be.Underline {
formats = append(formats, "underline")
}
if be.Strike != nil && *be.Strike {
formats = append(formats, "strikethrough")
}
if be.Code != nil && *be.Code {
formats = append(formats, "code")
}
if be.Sup != nil && *be.Sup {
formats = append(formats, "superscript")
}
if be.Sub != nil && *be.Sub {
formats = append(formats, "subscript")
}
if be.SmallCaps != nil && *be.SmallCaps {
formats = append(formats, "smallcaps")
}
// Value flags
if be.Font != "" {
formats = append(formats, "font:"+be.Font)
}
if be.Size > 0 {
formats = append(formats, "size:"+formatFloat(be.Size))
}
if be.Color != "" {
formats = append(formats, "color:"+be.Color)
}
if be.Bg != "" {
formats = append(formats, "bg:"+be.Bg)
}
if be.URL != "" {
formats = append(formats, "link:"+be.URL)
}
// Heading
if be.Heading != "" {
level := be.Heading
switch level {
case "t":
formats = append(formats, "title")
case "s":
formats = append(formats, "subtitle")
case "0":
formats = append(formats, "normal")
default:
formats = append(formats, "heading"+level)
}
}
// Alignment
if be.Align != "" {
formats = append(formats, "align:"+be.Align)
}
return formats
}
// formatFloat formats a float64 without unnecessary trailing zeros.
func formatFloat(f float64) string {
// Check if it's a whole number
if f == float64(int64(f)) {
return strconv.FormatInt(int64(f), 10)
}
// Format with precision, trim trailing zeros
s := strconv.FormatFloat(f, 'f', -1, 64)
return s
}
// hasBraceTextFormat checks if braceExpr has formatting that requires text styling.
func hasBraceTextFormat(be *braceExpr) bool {
return braceExprHasTextFormat(be)
}
// hasBraceParagraphFormat checks if braceExpr has formatting that requires paragraph styling.
func hasBraceParagraphFormat(be *braceExpr) bool {
if be == nil {
return false
}
return be.Heading != "" || be.Align != "" || be.Indent >= 0 ||
be.Leading > 0 || be.SpacingSet
}
// mergeBraceSpans merges multiple braceSpans into a single braceExpr for global formatting.
// Only global spans (those that apply to the entire match) are merged; non-global spans
// represent inline-scoped formatting (e.g., {b=Warning}) and are handled separately
// by buildBraceInlineRequests, which applies them at their specific positions.
func mergeBraceSpans(spans []*braceSpan) *braceExpr {
merged := &braceExpr{Indent: indentNotSet}
for _, span := range spans {
if span.IsGlobal && span.Expr != nil {
// Copy global flags to merged
if span.Expr.Bold != nil {
merged.Bold = span.Expr.Bold
}
if span.Expr.Italic != nil {
merged.Italic = span.Expr.Italic
}
if span.Expr.Underline != nil {
merged.Underline = span.Expr.Underline
}
if span.Expr.Strike != nil {
merged.Strike = span.Expr.Strike
}
if span.Expr.Code != nil {
merged.Code = span.Expr.Code
}
if span.Expr.Sup != nil {
merged.Sup = span.Expr.Sup
}
if span.Expr.Sub != nil {
merged.Sub = span.Expr.Sub
}
if span.Expr.SmallCaps != nil {
merged.SmallCaps = span.Expr.SmallCaps
}
if span.Expr.Color != "" {
merged.Color = span.Expr.Color
}
if span.Expr.Bg != "" {
merged.Bg = span.Expr.Bg
}
if span.Expr.Font != "" {
merged.Font = span.Expr.Font
}
if span.Expr.Size > 0 {
merged.Size = span.Expr.Size
}
if span.Expr.URL != "" {
merged.URL = span.Expr.URL
}
if span.Expr.Heading != "" {
merged.Heading = span.Expr.Heading
}
if span.Expr.Align != "" {
merged.Align = span.Expr.Align
}
if span.Expr.Leading > 0 {
merged.Leading = span.Expr.Leading
}
if span.Expr.SpacingSet {
merged.SpacingSet = true
merged.SpacingAbove = span.Expr.SpacingAbove
merged.SpacingBelow = span.Expr.SpacingBelow
}
if span.Expr.Indent >= 0 {
merged.Indent = span.Expr.Indent
}
if span.Expr.Reset {
merged.Reset = true
}
if span.Expr.HasBreak {
merged.HasBreak = true
merged.Break = span.Expr.Break
}
}
}
return merged
}
// (Legacy attrsTobraceExpr removed — sedAttrs no longer exists)