Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- function onOpen() {
- DocumentApp.getUi()
- .createMenu('Chapter Tools')
- .addItem('Auto Format Chapters', 'autoFormatChapters')
- .addToUi();
- }
- function autoFormatChapters() {
- const ui = DocumentApp.getUi();
- const doc = DocumentApp.getActiveDocument();
- const body = doc.getBody();
- const paragraphs = body.getParagraphs();
- // Regex for a valid chapter line
- const chapterRegex = /^\s*(chapter)\s+([0-9]+(?:\.[0-9]+)?)\s*([:–—-])\s*(.+?)\s*$/i;
- const dividerCharsRegex = /[:–—-]/g;
- // Regex for text message format: Name: message
- const textMessageRegex = /^(\S+):\s/;
- const errors = [];
- const warnings = [];
- /** Info for each valid chapter line we find */
- const chapterLines = []; // { paragraph, originalText, detectedNumber, title, index }
- const chapterIndices = new Set(); // Track which paragraph indices are valid chapters
- let lastChapterNum = -Infinity; // for non-decreasing check
- // -------- FIRST PASS: VALIDATION ONLY (NO MUTATIONS) --------
- for (let i = 0; i < paragraphs.length; i++) {
- const p = paragraphs[i];
- const text = p.getText();
- const trimmed = text.trim();
- if (!trimmed) continue; // skip empty lines
- // Check for any line starting with "CHAPTER" (case-insensitive)
- const startsWithChapter = trimmed.toUpperCase().startsWith('CHAPTER');
- const match = chapterRegex.exec(text);
- if (match) {
- // This is a candidate chapter line
- const chapterWord = match[1]; // "chapter"
- const numStr = match[2]; // original number
- const divider = match[3]; // :, -, –, —
- const title = match[4]; // rest of line
- // 1) Ensure exactly ONE divider in the whole line
- const dividerMatches = text.match(dividerCharsRegex) || [];
- if (dividerMatches.length !== 1) {
- errors.push(
- `Line ${i + 1}: chapter line has ${dividerMatches.length} divider characters (expected exactly 1): "${text}"`
- );
- continue;
- }
- // 2) Parse chapter number as float
- const chNum = parseFloat(numStr);
- if (!isFinite(chNum)) {
- errors.push(`Line ${i + 1}: invalid chapter number "${numStr}" in line: "${text}"`);
- continue;
- }
- // 3) Range limitation
- if (!(chNum > 0.0 && chNum < 1000000.0)) {
- errors.push(
- `Line ${i + 1}: chapter number ${chNum} out of allowed range (0.0 < n < 1000000.0): "${text}"`
- );
- continue;
- }
- // 4) Non-decreasing order check
- if (chNum + 1e-9 < lastChapterNum) {
- errors.push(
- `Line ${i + 1}: chapter number ${chNum} is less than previous chapter number ${lastChapterNum}: "${text}"`
- );
- continue;
- }
- if (chNum > lastChapterNum) {
- lastChapterNum = chNum;
- }
- // 5) Title must be non-empty after trim
- if (!title.trim()) {
- errors.push(`Line ${i + 1}: chapter line has no title after divider: "${text}"`);
- continue;
- }
- chapterLines.push({
- index: i,
- paragraph: p,
- originalText: text,
- detectedNumber: chNum,
- title: title.trim()
- });
- chapterIndices.add(i);
- } else if (startsWithChapter) {
- // A line starts with "CHAPTER" but did NOT match our strict pattern
- errors.push(
- `Line ${i + 1}: starts with "CHAPTER" but is not a valid chapter heading (check formatting): "${text}"`
- );
- }
- // Any Heading 1 that isn't a valid chapter line -> warning
- if (p.getHeading() === DocumentApp.ParagraphHeading.HEADING1 && !match) {
- warnings.push(
- `Line ${i + 1}: Heading 1 paragraph that is not a valid chapter heading (will be removed): "${text}"`
- );
- }
- }
- // If any errors, show ONE dialog and abort without changes
- if (errors.length > 0) {
- const msg =
- 'Auto Format Chapters aborted due to errors.\n\n' +
- errors.join('\n') +
- '\n\nPlease fix these issues and run again. No changes were applied.';
- ui.alert('Auto Format Chapters – Errors Found', msg, ui.ButtonSet.OK);
- return;
- }
- // -------- SECOND PASS: APPLY CHANGES --------
- // 1) Set whole document font to Verdana 12, no exceptions
- const text = body.editAsText();
- if (text) {
- const len = text.getText().length;
- if (len > 0) {
- text.setFontFamily(0, len - 1, 'Verdana');
- text.setFontSize(0, len - 1, 12);
- }
- }
- // 2) Handle all Heading 1 formatting: apply ONLY to valid chapters, remove from others
- for (let i = 0; i < paragraphs.length; i++) {
- const p = paragraphs[i];
- if (chapterIndices.has(i)) {
- // This is a valid chapter - ensure it has Heading 1
- p.setHeading(DocumentApp.ParagraphHeading.HEADING1);
- } else if (p.getHeading() === DocumentApp.ParagraphHeading.HEADING1) {
- // This has Heading 1 but is NOT a valid chapter - remove it
- p.setHeading(DocumentApp.ParagraphHeading.NORMAL);
- }
- }
- // 3) Renumber chapters sequentially: Chapter 1: Title, Chapter 2: Title, ...
- let chapterCounter = 0;
- for (let i = 0; i < chapterLines.length; i++) {
- const info = chapterLines[i];
- const p = info.paragraph;
- const newNum = ++chapterCounter;
- const newText = `Chapter ${newNum}: ${info.title}`;
- // Replace text
- p.setText(newText);
- }
- // 4) Split paragraphs containing soft line breaks (\n or \r) into real paragraphs
- for (let i = paragraphs.length - 1; i >= 0; i--) {
- const p = paragraphs[i];
- let text = p.getText();
- if (text.indexOf('\n') === -1 && text.indexOf('\r') === -1) continue; // no soft breaks → nothing to do
- // Split on line breaks and trim each resulting line
- const parts = text.replace(/\r/g, '\n').split('\n').map(s => s.trim());
- let insertIndex = i + 1; // where to insert new paragraphs
- // Handle the first part
- if (parts[0] !== "") {
- p.setText(parts[0]);
- } else {
- // First part is empty - try to delete original paragraph
- try {
- body.removeChild(p);
- insertIndex = i; // We deleted it, so insert at i, not i+1
- } catch (e) {
- // Can't delete - leave it with empty/original text
- }
- }
- // Insert subsequent parts as new paragraphs
- for (let j = parts.length - 1; j >= 1; j--) {
- if (parts[j] !== "") {
- const newP = body.insertParagraph(insertIndex, parts[j]);
- newP.setHeading(p.getHeading());
- newP.setAttributes(p.getAttributes());
- }
- }
- }
- // 5) Trim whitespace & collapse repeated spaces for all non-chapter paragraphs
- for (let i = paragraphs.length - 1; i >= 0; i--) {
- const p = paragraphs[i];
- let text = p.getText();
- const original = text;
- // Trim leading/trailing whitespace
- text = text.trim();
- if (text !== original && text !== "") {
- p.setText(text);
- }
- }
- // -------- THIRD PASS: WARNINGS AND TEXT MESSAGE FORMATTING --------
- // Get fresh paragraph list after modifications
- const updatedParagraphs = body.getParagraphs();
- let prevParagraphBold = false;
- let prevParagraphItalic = false;
- let prevIndex = -1;
- for (let i = 0; i < updatedParagraphs.length; i++) {
- const p = updatedParagraphs[i];
- const text = p.getText();
- const trimmed = text.trim();
- if (!trimmed) {
- prevParagraphBold = false;
- prevParagraphItalic = false;
- prevIndex = -1;
- continue;
- }
- // Check if entire paragraph is bold or italic
- // OPTIMIZATION: Sample only 5 positions instead of checking every character
- const editText = p.editAsText();
- const len = text.length;
- let entirelyBold = true;
- let entirelyItalic = true;
- if (len > 0) {
- // Sample at most 5 positions: start, 25%, 50%, 75%, end
- const samplePositions = [
- 0,
- Math.floor(len * 0.25),
- Math.floor(len * 0.5),
- Math.floor(len * 0.75),
- len - 1
- ];
- // Remove duplicates for short paragraphs
- const uniquePositions = [...new Set(samplePositions)];
- for (let pos of uniquePositions) {
- if (!editText.isBold(pos)) entirelyBold = false;
- if (!editText.isItalic(pos)) entirelyItalic = false;
- if (!entirelyBold && !entirelyItalic) break; // Early exit
- }
- } else {
- entirelyBold = false;
- entirelyItalic = false;
- }
- // Check for consecutive bold paragraphs
- if (entirelyBold && prevParagraphBold) {
- const prevText = updatedParagraphs[prevIndex].getText();
- warnings.push(
- `Lines ${prevIndex + 1} and ${i + 1}: consecutive paragraphs are entirely bold (possible copy-paste error)\n` +
- ` "${prevText.substring(0, 50)}${prevText.length > 50 ? '...' : ''}"\n` +
- ` "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`
- );
- }
- // Check for consecutive italic paragraphs
- if (entirelyItalic && prevParagraphItalic) {
- const prevText = updatedParagraphs[prevIndex].getText();
- warnings.push(
- `Lines ${prevIndex + 1} and ${i + 1}: consecutive paragraphs are entirely italics (possible copy-paste error)\n` +
- ` "${prevText.substring(0, 50)}${prevText.length > 50 ? '...' : ''}"\n` +
- ` "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`
- );
- }
- prevParagraphBold = entirelyBold;
- prevParagraphItalic = entirelyItalic;
- prevIndex = i;
- // Check for text message format and apply bold to label
- const msgMatch = textMessageRegex.exec(text);
- if (msgMatch) {
- const label = msgMatch[1] + ':'; // e.g., "Natalie:"
- const labelLen = label.length;
- // Check if label is already bold (check first and last char only)
- const labelIsBold = editText.isBold(0) && (labelLen === 1 || editText.isBold(labelLen - 1));
- if (!labelIsBold) {
- // Apply bold to the label
- editText.setBold(0, labelLen - 1, true);
- }
- }
- }
- // Show warnings or success message
- if (warnings.length > 0) {
- const msg =
- 'Auto Format Chapters completed with warnings.\n\n' +
- warnings.join('\n');
- ui.alert('Auto Format Chapters – Warnings', msg, ui.ButtonSet.OK);
- } else {
- ui.alert('Auto Format Chapters', 'Completed successfully with no warnings.', ui.ButtonSet.OK);
- }
- }
Advertisement
Add Comment
Please, Sign In to add comment