Skip to content

Commit ddca19d

Browse files
feat(slides): add object-scoped text editing primitives
1 parent c5d081c commit ddca19d

2 files changed

Lines changed: 78 additions & 10 deletions

File tree

internal/cmd/slides_replace_text.go

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -183,15 +183,19 @@ func buildSlidesObjectReplaceTextRequests(pres *slides.Presentation, objectID, f
183183
return nil, 0, usage("object-scoped replace-text currently supports shape text objects only")
184184
}
185185

186-
matches := slidesTextMatches(text, find, matchCase)
186+
matches := slidesTextMatches(text.content, find, matchCase)
187187
if len(matches) == 0 {
188188
return nil, 0, usage("no matching text found in object")
189189
}
190190

191191
requests := make([]*slides.Request, 0, len(matches)*2)
192192
for i := len(matches) - 1; i >= 0; i-- {
193193
match := matches[i]
194-
textRange, err := slidesFixedTextRange(match.start, match.end)
194+
start, end, err := text.slidesRange(match.start, match.end)
195+
if err != nil {
196+
return nil, 0, err
197+
}
198+
textRange, err := slidesFixedTextRange(start, end)
195199
if err != nil {
196200
return nil, 0, err
197201
}
@@ -205,7 +209,7 @@ func buildSlidesObjectReplaceTextRequests(pres *slides.Presentation, objectID, f
205209
requests = append(requests, &slides.Request{
206210
InsertText: &slides.InsertTextRequest{
207211
ObjectId: objectID,
208-
InsertionIndex: match.start,
212+
InsertionIndex: start,
209213
Text: replacement,
210214
},
211215
})
@@ -214,9 +218,37 @@ func buildSlidesObjectReplaceTextRequests(pres *slides.Presentation, objectID, f
214218
return requests, len(matches), nil
215219
}
216220

217-
func slidesShapeTextByObjectID(pres *slides.Presentation, objectID string) (string, bool, bool) {
221+
type slidesIndexedText struct {
222+
content string
223+
codeUnitIndexes []int64
224+
}
225+
226+
func (t slidesIndexedText) slidesRange(start, end int64) (int64, int64, error) {
227+
if start < 0 || end <= start || end > int64(len(t.codeUnitIndexes)) {
228+
return 0, 0, usage("matched text range is outside the object's Slides text indexes")
229+
}
230+
return t.codeUnitIndexes[start], t.codeUnitIndexes[end-1] + 1, nil
231+
}
232+
233+
func (t *slidesIndexedText) appendTextRun(startIndex int64, content string) {
234+
var b strings.Builder
235+
b.WriteString(t.content)
236+
b.WriteString(content)
237+
t.content = b.String()
238+
239+
index := startIndex
240+
for _, r := range content {
241+
units := utf16CodeUnits(string(r))
242+
for i := int64(0); i < units; i++ {
243+
t.codeUnitIndexes = append(t.codeUnitIndexes, index+i)
244+
}
245+
index += units
246+
}
247+
}
248+
249+
func slidesShapeTextByObjectID(pres *slides.Presentation, objectID string) (slidesIndexedText, bool, bool) {
218250
if pres == nil {
219-
return "", false, false
251+
return slidesIndexedText{}, false, false
220252
}
221253
for _, page := range pres.Slides {
222254
if page == nil {
@@ -227,18 +259,18 @@ func slidesShapeTextByObjectID(pres *slides.Presentation, objectID string) (stri
227259
continue
228260
}
229261
if el.Shape == nil || el.Shape.Text == nil {
230-
return "", true, false
262+
return slidesIndexedText{}, true, false
231263
}
232-
var b strings.Builder
264+
var text slidesIndexedText
233265
for _, textElement := range el.Shape.Text.TextElements {
234266
if textElement != nil && textElement.TextRun != nil {
235-
b.WriteString(textElement.TextRun.Content)
267+
text.appendTextRun(textElement.StartIndex, textElement.TextRun.Content)
236268
}
237269
}
238-
return b.String(), true, true
270+
return text, true, true
239271
}
240272
}
241-
return "", false, false
273+
return slidesIndexedText{}, false, false
242274
}
243275

244276
type slidesTextMatch struct {

internal/cmd/slides_replace_text_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,42 @@ func TestSlidesReplaceText_ObjectScopedShapeText(t *testing.T) {
149149
}
150150
}
151151

152+
func TestSlidesReplaceText_ObjectScopedPreservesTextElementIndexes(t *testing.T) {
153+
pres := &slides.Presentation{
154+
Slides: []*slides.Page{{
155+
PageElements: []*slides.PageElement{{
156+
ObjectId: "shape_1",
157+
Shape: &slides.Shape{Text: &slides.TextContent{
158+
TextElements: []*slides.TextElement{
159+
{StartIndex: 4, EndIndex: 10, TextRun: &slides.TextRun{Content: "prefix"}},
160+
{StartIndex: 20, EndIndex: 23, TextRun: &slides.TextRun{Content: "old"}},
161+
},
162+
}},
163+
}},
164+
}},
165+
}
166+
167+
requests, replaced, err := buildSlidesObjectReplaceTextRequests(pres, "shape_1", "old", "fresh", false)
168+
if err != nil {
169+
t.Fatalf("build requests: %v", err)
170+
}
171+
if replaced != 1 {
172+
t.Fatalf("replaced = %d, want 1", replaced)
173+
}
174+
if len(requests) != 2 {
175+
t.Fatalf("expected delete+insert request, got %d: %+v", len(requests), requests)
176+
}
177+
del := requests[0].DeleteText
178+
if del == nil || del.TextRange == nil ||
179+
*del.TextRange.StartIndex != 20 || *del.TextRange.EndIndex != 23 {
180+
t.Fatalf("delete should use Slides text element indexes 20:23, got %#v", del)
181+
}
182+
ins := requests[1].InsertText
183+
if ins == nil || ins.InsertionIndex != 20 || ins.Text != "fresh" {
184+
t.Fatalf("insert should use Slides text element index 20, got %#v", ins)
185+
}
186+
}
187+
152188
func TestSlidesReplaceText_ObjectAndPageMutuallyExclusive(t *testing.T) {
153189
flags := &RootFlags{Account: "a@b.com"}
154190
ctx := withSlidesTestServiceFactory(

0 commit comments

Comments
 (0)