Google Docs “Smart Cleanup” — One Click to Fix Spacing, Quotes, Dashes, and Hidden Characters
https://github.com/lsvekis/Google-Docs-Smart-Clean-Up-app-One-Click-fix-with-Apps-Script
If you work in Google Docs all day (course outlines, policies, SOPs, lesson plans, client docs), you’ve seen it:
- random double spaces
- weird “smart quotes”
- em-dashes vs hyphens used inconsistently
- invisible zero-width characters that break copy/paste
- “empty lines” that aren’t really empty
This post gives you a commonly needed Docs solution: a one-click “Smart Cleanup” menu built with Google Apps Script that normalizes a document’s text formatting safely.
You’ll get:
- ✅ a working menu UI
- ✅ fully functional source code
- ✅ deep explanation + how to customize
- ✅ a test seeder so you can verify results fast
What This Script Fixes (In Plain English)
This cleanup tool focuses on safe, high-value fixes that don’t restructure your document:
Text normalization
- converts smart quotes → straight quotes
- converts en/em dashes → regular hyphen
-(optional) - removes zero-width characters (common copy/paste issue)
- replaces NBSP (non-breaking spaces) with normal spaces
- collapses multiple spaces into one
- removes spaces before punctuation:
"word ."→"word." - ensures a space after punctuation where missing:
"Hi,world"→"Hi, world" - normalizes ellipses:
"...."→"..." - collapses repeated punctuation:
"!!!"→"!"
Blank line cleanup
- collapses multiple empty paragraphs into one
- avoids Google Docs errors (Docs requires at least one paragraph)
Install & Run (2 minutes)
- Open a Google Doc
- Go to Extensions → Apps Script
- Create a file named
Code.gs - Paste the code below
- Reload the Google Doc
- Use the menu: Doc Tools → Smart Cleanup
✅ Full Working Code (Copy/Paste)
/***************************************
* Google Docs Smart Cleanup Toolkit
* - Safe text normalization + spacing cleanup
* - Removes extra blank lines
* - Adds a menu for one-click usage
***************************************/
function onOpen() {
DocumentApp.getUi()
.createMenu("Doc Tools")
.addItem("Smart Cleanup (Safe)", "smartCleanup")
.addSeparator()
.addItem("Seed Messy Test Content", "seedMessyTestContent")
.addToUi();
}
/**
* Runs the full "safe cleanup" pipeline on the active doc.
*/
function smartCleanup() {
const doc = DocumentApp.getActiveDocument();
removeExtraEmptyLines_(doc);
removeZeroWidthChars_(doc);
normalizeNbsp_(doc);
convertTabsToSpaces_(doc);
trimParagraphEdges_(doc);
collapseMultipleSpaces_(doc);
removeSpacesBeforePunctuation_(doc);
ensureSpaceAfterPunctuation_(doc);
normalizeEllipses_(doc);
collapseRepeatedPunctuation_(doc);
normalizeSmartQuotes_(doc);
normalizeDashes_(doc); // optional behavior (see notes below)
DocumentApp.getUi().alert("✅ Smart Cleanup complete!");
}
/**
* Adds intentionally messy content to the active doc so you can test cleanup quickly.
* This avoids DocumentApp.create() permission issues by using the open doc.
*/
function seedMessyTestContent() {
const doc = DocumentApp.getActiveDocument();
const body = doc.getBody();
safeClearBody_(body);
body.appendParagraph(" This is a test paragraph with extra spaces . ");
body.appendParagraph("MissingSpaceAfterPunctuation.Hello,world;again:now");
body.appendParagraph("Smart quotes: “Hello” and ‘World’ — plus an – en dash.");
body.appendParagraph("Zero width:\u200Bhere\u200Dthere\uFEFFdone");
body.appendParagraph("NBSP:\u00A0\u00A0Hello\u00A0World");
body.appendParagraph("Tabs:\t\tOne\tTwo\t\tThree");
body.appendParagraph("Ellipses.... and more......");
body.appendParagraph("Repeated punctuation!!! ??? .... ,, ..");
body.appendParagraph("");
body.appendParagraph(" ");
body.appendParagraph("");
body.appendParagraph('Quotes spacing:"Hello" , "World" .');
DocumentApp.getUi().alert("✅ Seeded messy test content. Now run Doc Tools → Smart Cleanup (Safe).");
}
/* =========================================================
* Core iteration helpers
* ========================================================= */
/**
* Iterate paragraphs safely.
* (We only modify paragraph text in this toolkit to keep it safe.)
*/
function forEachParagraph_(doc, cb) {
const body = doc.getBody();
const total = body.getNumChildren();
for (let i = 0; i < total; i++) {
const el = body.getChild(i);
if (el.getType() !== DocumentApp.ElementType.PARAGRAPH) continue;
cb(el.asParagraph(), i, body);
}
}
/**
* Apply a text transform to every paragraph.
* IMPORTANT: never set empty string; use a single space if the transform empties it.
*/
function normalizeParagraphText_(doc, transformFn) {
forEachParagraph_(doc, (p) => {
const textObj = p.editAsText();
const original = textObj.getText() || "";
const updated = transformFn(original);
if (updated !== original) {
textObj.setText(updated === "" ? " " : updated);
}
});
}
/**
* Google Docs requires at least 1 paragraph in the body.
* This clears the doc safely by keeping an anchor paragraph at index 0.
*/
function safeClearBody_(body) {
// Ensure there is at least one paragraph
if (body.getNumChildren() === 0) {
body.appendParagraph(" ");
} else if (body.getChild(0).getType() !== DocumentApp.ElementType.PARAGRAPH) {
body.insertParagraph(0, " ");
}
// Make sure anchor is non-empty
body.getChild(0).asParagraph().setText(" ");
// Remove everything after the anchor
while (body.getNumChildren() > 1) {
body.removeChild(body.getChild(1));
}
}
/* =========================================================
* Cleanup functions (safe)
* ========================================================= */
/**
* Removes extra empty lines by collapsing multiple consecutive empty paragraphs into one.
* Works backwards to avoid index shifts.
*/
function removeExtraEmptyLines_(doc) {
const body = doc.getBody();
let previousWasEmpty = false;
for (let i = body.getNumChildren() - 1; i >= 0; i--) {
const el = body.getChild(i);
if (el.getType() !== DocumentApp.ElementType.PARAGRAPH) {
previousWasEmpty = false;
continue;
}
const p = el.asParagraph();
const isEmpty = (p.getText() || "").trim() === "";
if (isEmpty) {
if (previousWasEmpty) {
safeRemoveParagraph_(body, p);
}
previousWasEmpty = true;
} else {
previousWasEmpty = false;
}
}
}
/**
* Safely remove a paragraph without triggering:
* "Can't remove the last paragraph in a document section."
*/
function safeRemoveParagraph_(body, paragraph) {
if (body.getNumChildren() === 1) {
paragraph.setText(" ");
return;
}
body.removeChild(paragraph);
}
/**
* Remove zero-width characters often introduced via copy/paste.
*/
function removeZeroWidthChars_(doc) {
normalizeParagraphText_(doc, (t) => t.replace(/[\u200B-\u200D\uFEFF]/g, ""));
}
/**
* Convert non-breaking spaces to regular spaces.
*/
function normalizeNbsp_(doc) {
normalizeParagraphText_(doc, (t) => t.replace(/\u00A0/g, " "));
}
/**
* Replace tabs with a single space.
*/
function convertTabsToSpaces_(doc) {
normalizeParagraphText_(doc, (t) => t.replace(/\t+/g, " "));
}
/**
* Trim leading and trailing spaces on each paragraph.
*/
function trimParagraphEdges_(doc) {
normalizeParagraphText_(doc, (t) => t.replace(/^[ \t]+|[ \t]+$/g, ""));
}
/**
* Collapse 2+ spaces into one.
*/
function collapseMultipleSpaces_(doc) {
normalizeParagraphText_(doc, (t) => t.replace(/[ ]{2,}/g, " "));
}
/**
* Remove spaces before punctuation:
* "hello ." -> "hello."
*/
function removeSpacesBeforePunctuation_(doc) {
normalizeParagraphText_(doc, (t) => t.replace(/\s+([.,!?;:])/g, "$1"));
}
/**
* Ensure space after punctuation when a letter follows:
* "Hi,world" -> "Hi, world"
*/
function ensureSpaceAfterPunctuation_(doc) {
normalizeParagraphText_(doc, (t) => t.replace(/([.,!?;:])([A-Za-z])/g, "$1 $2"));
}
/**
* Normalize long ellipses:
* "...." or "......" -> "..."
*/
function normalizeEllipses_(doc) {
normalizeParagraphText_(doc, (t) => t.replace(/\.{4,}/g, "..."));
}
/**
* Collapse repeated punctuation:
* "!!!" -> "!"
* "???" -> "?"
* ",," -> ","
*/
function collapseRepeatedPunctuation_(doc) {
normalizeParagraphText_(doc, (t) => t.replace(/([!?.,])\1{1,}/g, "$1"));
}
/**
* Convert smart quotes to straight quotes for consistency:
* “ ” -> "
* ‘ ’ -> '
*/
function normalizeSmartQuotes_(doc) {
normalizeParagraphText_(doc, (t) =>
t.replace(/[“”]/g, '"').replace(/[‘’]/g, "'")
);
}
/**
* Normalize dash types:
* em dash — and en dash – -> hyphen -
*
* NOTE: This changes typography. If you prefer to keep em dashes,
* comment this function out of smartCleanup().
*/
function normalizeDashes_(doc) {
normalizeParagraphText_(doc, (t) => t.replace(/[—–]/g, "-"));
}
Deep Explanation (How It Works)
1) Why we iterate paragraphs
Google Docs content is a tree of elements (paragraphs, list items, tables, images, etc). For a safe starter toolkit, we:
- only touch
PARAGRAPHelements - only modify text with
editAsText().setText()
This avoids the “oops I broke your structure” problem.
2) The most important helper: normalizeParagraphText_
This function applies a transformation to every paragraph:
normalizeParagraphText_(doc, (t) => t.replace(...))
Key safety rule:
- Google Docs can throw “Cannot insert an empty text element.”
- So if a transform results in an empty string, we set it to
" "(a single space).
3) Removing empty lines safely
Docs requires at least one paragraph in the body, so we use:
- a backwards loop (so removal doesn’t shift indexes)
safeRemoveParagraph_()which never removes the last paragraph
This prevents:
- “Can’t remove the last paragraph in a document section.”
4) Why the “Seed Messy Test Content” matters
Testing is hard if you don’t have predictable content.
So the script includes:
seedMessyTestContent()to fill your current doc with known messy lines- then you run cleanup and compare
This also avoids permission errors from DocumentApp.create().
Customizing It (Make Your Own Version)
Here are common custom upgrades readers usually add:
A) Skip headings
if (p.getHeading() !== DocumentApp.ParagraphHeading.NORMAL) return;
B) Skip paragraphs that look like code
const text = p.getText() || "";
if (/^\s{2,}\S+/.test(text) || text.includes("{") || text.includes("=>")) return;
C) Only clean selected text
(advanced) Use doc.getSelection() and only modify selected range elements.