Summary
gog docs find-replace --first (and the markdown / all-occurrences path that goes through the same matcher) computes wrong document indices when the target paragraph contains a non-text inline element before/within the match — a person smart chip, inline image, footnote reference, equation, etc. The delete+insert lands one (or more) positions off, corrupting the document: it drops or doubles a couple of characters near, but not at, the match site. The error compounds with each successive call.
gog docs format --match shares the same buggy offset path and is affected in the same way (it styles the wrong range).
Plain paragraphs — including those with multi-byte characters like em-dashes or smart quotes, or with multiple style runs (bold/inline-code) — are not affected, because those runs stay index-contiguous. The trigger is specifically a non-TextRun inline element occupying index space inside the paragraph.
Version
gog version
v0.25.0-9-g10bb60d8 (10bb60d871f4 2026-06-12T09:44:12Z)
Reproducer (standalone, copy-paste)
# 1. Create a throwaway doc
DOC=$(gog docs create "fr-repro" -j | python3 -c "import json,sys;print(json.load(sys.stdin)['file']['id'])")
# 2. One paragraph with an em-dash and an inline code span
printf 'A paragraph with rule-based comments — use inline code and bold here when so.\n' > /tmp/repro.md
gog docs write "$DOC" --replace --markdown --tab=t.0 --file /tmp/repro.md
# 3. Turn "rule-based comments" into a person smart chip.
# This puts a non-TextRun inline element in the paragraph, before "inline code".
gog docs insert-person "$DOC" [email protected] --at "rule-based comments" --tab=t.0
gog docs read "$DOC"
# A paragraph with — use inline code and bold here when so.
# 4. find-replace a substring that comes AFTER the chip
gog docs find-replace "$DOC" "inline code" "XXXX" --first --tab=t.0
gog docs read "$DOC"
Expected: A paragraph with [chip] — use XXXX and bold here when so.
Actual (corrupted, off by one):
A paragraph with — useXXXXe and bold here when so.
The replacement was applied one index too early: it ate the space + inline cod, left a stray trailing e, and inserted XXXX before the space. Repeated find-replace calls on the same paragraph keep shifting and mangle further — matching the originally observed "rule-based comments" → "rule-basecomments", "if so," → "if soso," drift.
gog docs format --match "inline code" --underline --tab=t.0 on the same paragraph underlines 109..120 instead of the true 110..121 — same one-off, so it styles the trailing space and drops the final character.
Root cause
findTextInParagraph in internal/cmd/docs_mutation.go builds the paragraph's comparable text by concatenating only TextRun content, skipping every non-TextRun element, and then anchors all offsets on the first run's StartIndex plus the accumulated concatenated-text length:
// internal/cmd/docs_mutation.go
for _, pe := range para.Elements {
if pe.TextRun == nil {
continue // <-- non-text element skipped, but its index width is lost
}
if first {
paraStart = pe.StartIndex // <-- only the FIRST run's start is used as the anchor
first = false
}
paraText.WriteString(pe.TextRun.Content)
}
...
matchStart := paraStart + utf16Len(text[:absIdx]) // assumes runs are index-contiguous from paraStart
matchEnd := matchStart + utf16Len(searchText)
This is correct only when all runs are contiguous and there are no non-TextRun elements before the match. A person chip / inline object / footnote reference occupies index space in the document but contributes nothing to paraText, so every match after it is shifted by that element's index width. utf16Len itself is correct — the bug is the offset model, not UTF-16 conversion (which is why em-dashes and smart quotes alone don't trigger it).
The sibling --at / find-range resolver already does this correctly — it tracks each run's own StartIndex:
// internal/cmd/docs_find_range.go (appendDocsComparableParagraphText)
for _, pe := range para.Elements {
if pe == nil || pe.TextRun == nil {
(*segment)++
continue
}
runOffset := int64(0)
for _, r := range pe.TextRun.Content {
startIndex := pe.StartIndex + runOffset // anchors on THIS run's start
endIndex := startIndex + utf16RuneLen(r)
runOffset = endIndex - pe.StartIndex
...
}
}
Fix: make findTextInParagraph / findTextMatches anchor each match on the containing run's actual StartIndex (as find-range does), instead of firstRunStart + concatenatedTextLen. Ideally consolidate both matchers onto the find-range text-unit walker so there is a single, correct implementation.
Two further nits in the same function:
matchEnd := matchStart + utf16Len(searchText) uses the original-case searchText; with --match-case=false the matched substring can differ in length from the search term for some Unicode case foldings. Use the actual matched slice length.
- The matcher can't match across a run boundary that a non-text element splits, which the
find-range walker handles via its segment model.
Affected vs. safe (confirmed empirically against a chip-bearing paragraph)
| Command |
Status |
gog docs find-replace --first |
Broken — corrupts (off by the skipped element's index width) |
gog docs find-replace (markdown / all-occurrences path) |
Broken — same matcher (findTextMatches) |
gog docs format --match / --match-all |
Broken — styles the wrong range (same matcher) |
gog docs edit <find> <replace> |
Safe — server-side ReplaceAllText, no client offset math |
gog docs find-range |
Safe — uses the per-run StartIndex walker |
gog docs insert --at |
Safe — uses the --at anchor resolver |
gog docs delete --at |
Safe — uses the --at anchor resolver |
gog docs find-replace (plain, all-occurrences, no --first) routes through the Docs API ReplaceAllText and is also safe; only the paths that compute client-side indices via findTextMatches/findTextInParagraph are affected.
Summary
gog docs find-replace --first(and the markdown / all-occurrences path that goes through the same matcher) computes wrong document indices when the target paragraph contains a non-text inline element before/within the match — a person smart chip, inline image, footnote reference, equation, etc. The delete+insert lands one (or more) positions off, corrupting the document: it drops or doubles a couple of characters near, but not at, the match site. The error compounds with each successive call.gog docs format --matchshares the same buggy offset path and is affected in the same way (it styles the wrong range).Plain paragraphs — including those with multi-byte characters like em-dashes or smart quotes, or with multiple style runs (bold/inline-code) — are not affected, because those runs stay index-contiguous. The trigger is specifically a non-
TextRuninline element occupying index space inside the paragraph.Version
Reproducer (standalone, copy-paste)
Expected:
A paragraph with [chip] — use XXXX and bold here when so.Actual (corrupted, off by one):
The replacement was applied one index too early: it ate the space +
inline cod, left a stray trailinge, and insertedXXXXbefore the space. Repeatedfind-replacecalls on the same paragraph keep shifting and mangle further — matching the originally observed"rule-based comments" → "rule-basecomments","if so," → "if soso,"drift.gog docs format --match "inline code" --underline --tab=t.0on the same paragraph underlines109..120instead of the true110..121— same one-off, so it styles the trailing space and drops the final character.Root cause
findTextInParagraphininternal/cmd/docs_mutation.gobuilds the paragraph's comparable text by concatenating onlyTextRuncontent, skipping every non-TextRunelement, and then anchors all offsets on the first run'sStartIndexplus the accumulated concatenated-text length:This is correct only when all runs are contiguous and there are no non-
TextRunelements before the match. A person chip / inline object / footnote reference occupies index space in the document but contributes nothing toparaText, so every match after it is shifted by that element's index width.utf16Lenitself is correct — the bug is the offset model, not UTF-16 conversion (which is why em-dashes and smart quotes alone don't trigger it).The sibling
--at/find-rangeresolver already does this correctly — it tracks each run's ownStartIndex:Fix: make
findTextInParagraph/findTextMatchesanchor each match on the containing run's actualStartIndex(asfind-rangedoes), instead offirstRunStart + concatenatedTextLen. Ideally consolidate both matchers onto thefind-rangetext-unit walker so there is a single, correct implementation.Two further nits in the same function:
matchEnd := matchStart + utf16Len(searchText)uses the original-casesearchText; with--match-case=falsethe matched substring can differ in length from the search term for some Unicode case foldings. Use the actual matched slice length.find-rangewalker handles via its segment model.Affected vs. safe (confirmed empirically against a chip-bearing paragraph)
gog docs find-replace --firstgog docs find-replace(markdown / all-occurrences path)findTextMatches)gog docs format --match/--match-allgog docs edit <find> <replace>ReplaceAllText, no client offset mathgog docs find-rangeStartIndexwalkergog docs insert --at--atanchor resolvergog docs delete --at--atanchor resolvergog docs find-replace(plain, all-occurrences, no--first) routes through the Docs APIReplaceAllTextand is also safe; only the paths that compute client-side indices viafindTextMatches/findTextInParagraphare affected.