From d4d78550e0dc82a39ffbf8ef114060e514eab617 Mon Sep 17 00:00:00 2001 From: mehdi maghsoodnia Date: Thu, 26 Mar 2026 12:07:57 +0000 Subject: [PATCH] feat(docs): add rich formatting support (font, color, alignment) Add `docs format` subcommand and formatting flags to `docs write` for applying rich text formatting via the Google Docs API batchUpdate. New `docs format` command: - Target text by content match: --match "text" (first occurrence) - Format all occurrences: --match-all - Apply to entire document when no --match given - Reuses findTextInDoc/findTextMatches for text search Formatting flags (shared by `docs write` and `docs format`): - --font-family: font family (e.g. Arial, Georgia) - --font-size: font size in points - --text-color: text color as hex (#RRGGBB) - --bg-color: background highlight color as hex - --bold, --italic, --underline, --strikethrough - --alignment: left|center|right|justified - --line-spacing: line spacing percentage (e.g. 150 = 1.5x) Implementation: - FormattingOpts struct and buildFormattingRequests() in docs_formatter.go - Builds UpdateTextStyle and UpdateParagraphStyle requests with proper field masks, including ForceSendFields for false boolean values - Reuses existing parseHexColor from docs_sed_helpers.go via wrapper Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cmd/docs.go | 1 + internal/cmd/docs_edit.go | 168 +++++++++++++++++++++++++++++++++ internal/cmd/docs_formatter.go | 151 +++++++++++++++++++++++++++++ 3 files changed, 320 insertions(+) diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 97c93e62..b3a0e991 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -32,6 +32,7 @@ type DocsCmd struct { Update DocsUpdateCmd `cmd:"" name:"update" help:"Insert text at a specific index in a Google Doc"` Edit DocsEditCmd `cmd:"" name:"edit" help:"Find and replace text in a Google Doc"` Sed DocsSedCmd `cmd:"" name:"sed" help:"Regex find/replace (sed-style: s/pattern/replacement/g)"` + Format DocsFormatCmd `cmd:"" name:"format" aliases:"fmt" help:"Apply formatting (font, color, alignment) to text in a Google Doc"` Clear DocsClearCmd `cmd:"" name:"clear" help:"Clear all content from a Google Doc"` Structure DocsStructureCmd `cmd:"" name:"structure" aliases:"struct" help:"Show document structure with numbered paragraphs"` } diff --git a/internal/cmd/docs_edit.go b/internal/cmd/docs_edit.go index 2d7ad2b6..632fb0de 100644 --- a/internal/cmd/docs_edit.go +++ b/internal/cmd/docs_edit.go @@ -20,6 +20,16 @@ type DocsWriteCmd struct { Append bool `name:"append" help:"Append instead of replacing the document body"` Pageless bool `name:"pageless" help:"Set document to pageless mode"` TabID string `name:"tab-id" help:"Target a specific tab by ID (see docs list-tabs)"` + + // Formatting flags (applied after content write) + FontFamily string `name:"font-family" help:"Font family (e.g. Arial, Georgia, Times New Roman)"` + FontSize float64 `name:"font-size" help:"Font size in points (e.g. 12, 14, 16)"` + TextColor string `name:"text-color" help:"Text color as hex (#RRGGBB)"` + BgColor string `name:"bg-color" help:"Background highlight color as hex (#RRGGBB)"` + Alignment string `name:"alignment" help:"Paragraph alignment: left|center|right|justified"` + Underline bool `name:"underline" help:"Apply underline to written text"` + Strikethrough bool `name:"strikethrough" help:"Apply strikethrough to written text"` + LineSpacing float64 `name:"line-spacing" help:"Line spacing percentage (e.g. 150 = 1.5x)"` } func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { @@ -85,6 +95,25 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF } } + // Apply formatting if any flags set + fmtOpts := c.formattingOpts() + if fmtOpts.hasAny() { + docEnd, endErr := docsTargetEndIndex(ctx, svc, id, c.TabID) + if endErr != nil { + return fmt.Errorf("re-fetch document for formatting: %w", endErr) + } + fmtEnd := docEnd - 1 + if fmtEnd > 1 { + fmtReqs := buildFormattingRequests(1, fmtEnd, fmtOpts) + if len(fmtReqs) > 0 { + _, err = svc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{Requests: fmtReqs}).Context(ctx).Do() + if err != nil { + return fmt.Errorf("apply formatting: %w", err) + } + } + } + } + if outfmt.IsJSON(ctx) { payload := map[string]any{ "documentId": resp.DocumentId, @@ -114,6 +143,23 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF return nil } +func (c *DocsWriteCmd) formattingOpts() FormattingOpts { + opts := FormattingOpts{ + FontFamily: c.FontFamily, FontSize: c.FontSize, + TextColor: c.TextColor, BgColor: c.BgColor, + Alignment: c.Alignment, LineSpacing: c.LineSpacing, + } + if c.Underline { + v := true + opts.Underline = &v + } + if c.Strikethrough { + v := true + opts.Strikethrough = &v + } + return opts +} + type DocsUpdateCmd struct { DocID string `arg:"" name:"docId" help:"Doc ID"` Text string `name:"text" help:"Text to insert"` @@ -543,3 +589,125 @@ func (c *DocsFindReplaceCmd) resolveReplaceText() (string, error) { } return string(data), nil } + +// DocsFormatCmd applies formatting to existing text in a Google Doc. +type DocsFormatCmd struct { + DocID string `arg:"" name:"docId" help:"Doc ID"` + Match string `name:"match" help:"Text to find and format (first occurrence unless --match-all)"` + All bool `name:"match-all" help:"Format all occurrences of --match text"` + + FontFamily string `name:"font-family" help:"Font family (e.g. Arial, Georgia)"` + FontSize float64 `name:"font-size" help:"Font size in points"` + TextColor string `name:"text-color" help:"Text color as hex (#RRGGBB)"` + BgColor string `name:"bg-color" help:"Background highlight color as hex (#RRGGBB)"` + Bold bool `name:"bold" help:"Apply bold"` + Italic bool `name:"italic" help:"Apply italic"` + Underline bool `name:"underline" help:"Apply underline"` + Strikethrough bool `name:"strikethrough" help:"Apply strikethrough"` + Alignment string `name:"alignment" help:"Paragraph alignment: left|center|right|justified"` + LineSpacing float64 `name:"line-spacing" help:"Line spacing percentage (e.g. 150 = 1.5x)"` +} + +func (c *DocsFormatCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + id := strings.TrimSpace(c.DocID) + if id == "" { + return usage("empty docId") + } + + opts := c.formattingOpts() + if !opts.hasAny() { + return usage("at least one formatting flag is required") + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + + doc, err := svc.Documents.Get(id).Context(ctx).Do() + if err != nil { + if isDocsNotFound(err) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id) + } + return err + } + + type fmtRange struct{ start, end int64 } + var ranges []fmtRange + + if c.Match != "" { + if c.All { + matches := findTextMatches(doc, c.Match, true) + for _, m := range matches { + ranges = append(ranges, fmtRange{m.startIndex, m.endIndex}) + } + } else { + start, end, total := findTextInDoc(doc, c.Match, true) + if total == 0 { + return fmt.Errorf("text %q not found in document", c.Match) + } + ranges = append(ranges, fmtRange{start, end}) + } + if len(ranges) == 0 { + return fmt.Errorf("text %q not found in document", c.Match) + } + } else { + // Apply to entire document + if doc.Body != nil && len(doc.Body.Content) > 0 { + last := doc.Body.Content[len(doc.Body.Content)-1] + if last != nil && last.EndIndex > 2 { + ranges = append(ranges, fmtRange{1, last.EndIndex - 1}) + } + } + if len(ranges) == 0 { + return fmt.Errorf("document is empty") + } + } + + var allReqs []*docs.Request + for _, r := range ranges { + allReqs = append(allReqs, buildFormattingRequests(r.start, r.end, opts)...) + } + + _, err = svc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{Requests: allReqs}).Context(ctx).Do() + if err != nil { + return fmt.Errorf("apply formatting: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "success": true, + "documentId": id, + "ranges": len(ranges), + }) + } + + u.Out().Printf("Formatted %d range(s) in document %s", len(ranges), id) + return nil +} + +func (c *DocsFormatCmd) formattingOpts() FormattingOpts { + opts := FormattingOpts{ + FontFamily: c.FontFamily, FontSize: c.FontSize, + TextColor: c.TextColor, BgColor: c.BgColor, + Alignment: c.Alignment, LineSpacing: c.LineSpacing, + } + if c.Bold { + v := true + opts.Bold = &v + } + if c.Italic { + v := true + opts.Italic = &v + } + if c.Underline { + v := true + opts.Underline = &v + } + if c.Strikethrough { + v := true + opts.Strikethrough = &v + } + return opts +} diff --git a/internal/cmd/docs_formatter.go b/internal/cmd/docs_formatter.go index 079cc4b6..608c8e7f 100644 --- a/internal/cmd/docs_formatter.go +++ b/internal/cmd/docs_formatter.go @@ -7,6 +7,157 @@ import ( "google.golang.org/api/docs/v1" ) +// FormattingOpts holds explicit formatting options for range-based formatting. +type FormattingOpts struct { + FontFamily string + FontSize float64 + TextColor string // hex #RRGGBB + BgColor string // hex #RRGGBB + Bold *bool // nil = unset + Italic *bool + Underline *bool + Strikethrough *bool + Alignment string // left|center|right|justified + LineSpacing float64 // percentage, e.g. 150 = 1.5x + SpaceAbove float64 // points + SpaceBelow float64 // points +} + +// hasAny returns true if any formatting option is set. +func (o FormattingOpts) hasAny() bool { + return o.FontFamily != "" || o.FontSize != 0 || + o.TextColor != "" || o.BgColor != "" || + o.Bold != nil || o.Italic != nil || + o.Underline != nil || o.Strikethrough != nil || + o.Alignment != "" || o.LineSpacing != 0 || + o.SpaceAbove != 0 || o.SpaceBelow != 0 +} + +// hexToOptionalColor converts a hex color string to a Google Docs OptionalColor. +// Reuses the existing parseHexColor from docs_sed_helpers.go. +func hexToOptionalColor(hex string) *docs.OptionalColor { + r, g, b, ok := parseHexColor(hex) + if !ok { + return nil + } + return &docs.OptionalColor{ + Color: &docs.Color{ + RgbColor: &docs.RgbColor{Red: r, Green: g, Blue: b}, + }, + } +} + +// buildFormattingRequests builds UpdateTextStyle and UpdateParagraphStyle requests +// for the given range and formatting options. Only set fields are included. +func buildFormattingRequests(startIndex, endIndex int64, opts FormattingOpts) []*docs.Request { + var reqs []*docs.Request + + textStyle := &docs.TextStyle{} + var textFields []string + + if opts.FontFamily != "" { + textStyle.WeightedFontFamily = &docs.WeightedFontFamily{FontFamily: opts.FontFamily, Weight: 400} + textFields = append(textFields, "weightedFontFamily") + } + if opts.FontSize != 0 { + textStyle.FontSize = &docs.Dimension{Magnitude: opts.FontSize, Unit: "PT"} + textFields = append(textFields, "fontSize") + } + if opts.TextColor != "" { + if c := hexToOptionalColor(opts.TextColor); c != nil { + textStyle.ForegroundColor = c + textFields = append(textFields, "foregroundColor") + } + } + if opts.BgColor != "" { + if c := hexToOptionalColor(opts.BgColor); c != nil { + textStyle.BackgroundColor = c + textFields = append(textFields, "backgroundColor") + } + } + if opts.Bold != nil { + textStyle.Bold = *opts.Bold + if !*opts.Bold { + textStyle.ForceSendFields = append(textStyle.ForceSendFields, "Bold") + } + textFields = append(textFields, "bold") + } + if opts.Italic != nil { + textStyle.Italic = *opts.Italic + if !*opts.Italic { + textStyle.ForceSendFields = append(textStyle.ForceSendFields, "Italic") + } + textFields = append(textFields, "italic") + } + if opts.Underline != nil { + textStyle.Underline = *opts.Underline + if !*opts.Underline { + textStyle.ForceSendFields = append(textStyle.ForceSendFields, "Underline") + } + textFields = append(textFields, "underline") + } + if opts.Strikethrough != nil { + textStyle.Strikethrough = *opts.Strikethrough + if !*opts.Strikethrough { + textStyle.ForceSendFields = append(textStyle.ForceSendFields, "Strikethrough") + } + textFields = append(textFields, "strikethrough") + } + + if len(textFields) > 0 { + reqs = append(reqs, &docs.Request{ + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Range: &docs.Range{StartIndex: startIndex, EndIndex: endIndex}, + TextStyle: textStyle, + Fields: strings.Join(textFields, ","), + }, + }) + } + + paraStyle := &docs.ParagraphStyle{} + var paraFields []string + + if opts.Alignment != "" { + switch strings.ToLower(opts.Alignment) { + case "left": + paraStyle.Alignment = "START" + case "center": + paraStyle.Alignment = "CENTER" + case "right": + paraStyle.Alignment = "END" + case "justified": + paraStyle.Alignment = "JUSTIFIED" + } + if paraStyle.Alignment != "" { + paraFields = append(paraFields, "alignment") + } + } + if opts.LineSpacing != 0 { + paraStyle.LineSpacing = opts.LineSpacing + paraFields = append(paraFields, "lineSpacing") + } + if opts.SpaceAbove != 0 { + paraStyle.SpaceAbove = &docs.Dimension{Magnitude: opts.SpaceAbove, Unit: "PT"} + paraFields = append(paraFields, "spaceAbove") + } + if opts.SpaceBelow != 0 { + paraStyle.SpaceBelow = &docs.Dimension{Magnitude: opts.SpaceBelow, Unit: "PT"} + paraFields = append(paraFields, "spaceBelow") + } + + if len(paraFields) > 0 { + reqs = append(reqs, &docs.Request{ + UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{ + Range: &docs.Range{StartIndex: startIndex, EndIndex: endIndex}, + ParagraphStyle: paraStyle, + Fields: strings.Join(paraFields, ","), + }, + }) + } + + return reqs +} + // Debug flag for markdown formatter var debugMarkdown = false