Google Docs Smart Clean Up app One Click fix with Apps Script

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)

  1. Open a Google Doc
  2. Go to Extensions → Apps Script
  3. Create a file named Code.gs
  4. Paste the code below
  5. Reload the Google Doc
  6. 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 PARAGRAPH elements
  • 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.