1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Google Apps ScriptでMarkdownをGoogleスライドのテキストボックスに挿入する

Last updated at Posted at 2025-08-05

Google Apps Scriptを使って、Markdown記法を解釈し、Googleスライドのテキストボックスとして挿入します。Apps Scriptは太字、斜体、リスト、引用などのスタイルを適用するパーサー(構文解析器)として動作します。

Markdownからテキストボックスを作成するコード
コード.gs
/**
 * 指定されたTextRangeに、Markdownテキストを変換して適用します。
 *
 * @description
 * 対応するMarkdown記法:
 * - **太字**: `**text**`
 * - **斜体**: `*text*`
 * - **取り消し線**: `~~text~~`
 * - **リンク**: `[display text](url)`
 * - **箇条書きリスト**: `- item` (スペース2つでネスト可能)
 * - **番号付きリスト**: `1. item` (スペース2つでネスト可能)
 * - **引用**: `> text` (リストなど他のブロック要素を含むことが可能)
 *
 * @param {GoogleAppsScript.Slides.TextRange} textRange スタイルを適用する対象のTextRange。
 * @param {string} markdownText 変換するMarkdown形式の文字列。
 * @returns {GoogleAppsScript.Slides.TextRange} スタイル適用後のTextRange。
 */
function convertMarkdownToSlidesText(textRange, markdownText) {
  // --- パーサーの定義 ---
  const blockParsers = [
    {
      name: 'UNORDERED_LIST_ITEM',
      test: (line) => line.trim().startsWith('- ') || line.trim().startsWith('* '),
      clean: (line) => {
        const indentLevel = Math.floor((line.match(/^(\s*)/)?.[1].length || 0) / 2);
        return '\t'.repeat(indentLevel) + line.trim().substring(2);
      },
      applyStyle: (range) => {
        range.getListStyle().applyListPreset(SlidesApp.ListPreset.DISC_CIRCLE_SQUARE);
        console.log('  -> 箇条書きスタイルを適用しました。');
      }
    },
    {
      name: 'ORDERED_LIST_ITEM',
      test: (line) => /^\s*\d+\.\s/.test(line),
      clean: (line) => {
        const indentLevel = Math.floor((line.match(/^(\s*)/)?.[1].length || 0) / 2);
        return '\t'.repeat(indentLevel) + line.replace(/^\s*\d+\.\s/, '');
      },
      applyStyle: (range) => {
        range.getListStyle().applyListPreset(SlidesApp.ListPreset.DIGIT_NESTED);
        console.log('  -> 番号付き箇条書きスタイルを適用しました。');
      }
    },
    {
      name: 'QUOTE',
      test: (line) => line.trim().startsWith('> '),
      clean: (line) => {
        const innerContent = line.substring(line.indexOf('>') + 1).trim();
        const parser = blockParsers.find(p => p.test(innerContent)) || plainTextParser;
        return parser.clean(innerContent);
      },
      applyStyle: null
    }
  ];
  const plainTextParser = {
    name: 'PLAIN',
    test: (line) => line.trim().length > 0,
    clean: (line) => line,
    applyStyle: null
  };

  // --- 1. テキストの前処理と挿入 ---
  const originalLines = markdownText.split('\n');
  const cleanedLines = originalLines.map(line => {
    if (line.trim().length === 0) return '';
    const parser = blockParsers.find(p => p.test(line)) || plainTextParser;
    return parser.clean(line);
  });
  textRange.setText(cleanedLines.join('\n') + '\n');
  console.log('テキストを挿入しました。');

  // --- 2. スタイルの適用 ---
  applyBlockStyles_(textRange, originalLines, cleanedLines, blockParsers, plainTextParser);
  applyInlineStyles_(textRange);

  return textRange;
}

/**
 * ブロックレベルのMarkdown書式を適用します。(内部関数)
 * @param {GoogleAppsScript.Slides.TextRange} textRange 対象のTextRange。
 * @param {string[]} originalLines 元のMarkdownテキストの行配列。
 * @param {string[]} cleanedLines 整形後のテキストの行配列。
 * @param {object[]} blockParsers ブロックパーサーの配列。
 * @param {object} plainTextParser 標準テキスト用のパーサー。
 */
function applyBlockStyles_(textRange, originalLines, cleanedLines, blockParsers, plainTextParser) {
  console.log('--- ブロックレベルのスタイル適用開始 ---');
  let textSoFarLength = 0;
  for (let i = 0; i < originalLines.length;) {
    const currentLine = originalLines[i];
    if (currentLine.trim().length === 0) {
      textSoFarLength += 1; // 改行文字
      i++;
      continue;
    }

    const parser = blockParsers.find(p => p.test(currentLine)) || plainTextParser;
    const blockStartIndex = textSoFarLength;

    let j = i;
    while (j < originalLines.length && originalLines[j].trim().length > 0 && (blockParsers.find(p => p.test(originalLines[j])) || plainTextParser).name === parser.name) {
      j++;
    }

    const blockCleanedLines = cleanedLines.slice(i, j);
    const blockText = blockCleanedLines.join('\n');
    const blockEndIndex = blockStartIndex + blockText.length;

    console.log(`[ブロック処理] Type: ${parser.name}, Index: [${blockStartIndex} to ${blockEndIndex}]`);

    if (parser.name === 'QUOTE') {
      applyQuoteStyles_(textRange, originalLines.slice(i, j), cleanedLines.slice(i, j), blockStartIndex, blockParsers, plainTextParser);
    } else if (parser.applyStyle) {
      const fullTextLength = textRange.asString().length;
      const safeEndIndex = Math.min(blockEndIndex, fullTextLength > 0 ? fullTextLength - 1 : 0);
      if (blockStartIndex < safeEndIndex) {
        parser.applyStyle(textRange.getRange(blockStartIndex, safeEndIndex));
      }
    }

    textSoFarLength += blockText.length + 1; // ブロック後の改行文字
    i = j;
  }
}

/**
 * 引用ブロック内のスタイルを再帰的に適用します。(内部関数)
 * @param {GoogleAppsScript.Slides.TextRange} textRange
 * @param {string[]} quoteOriginalLines
 * @param {string[]} quoteCleanedLines
 * @param {number} blockStartIndex
 * @param {object[]} blockParsers
 * @param {object} plainTextParser
 */
function applyQuoteStyles_(textRange, quoteOriginalLines, quoteCleanedLines, blockStartIndex, blockParsers, plainTextParser) {
  console.log('  -> 引用ブロックを特別に処理中...');
  let charIndexInQuoteBlock = 0;
  for (let k = 0; k < quoteOriginalLines.length;) {
    const subOriginalLine = quoteOriginalLines[k];
    const subInnerContent = subOriginalLine.substring(subOriginalLine.indexOf('>') + 1).trim();
    const subParser = blockParsers.find(p => p.test(subInnerContent)) || plainTextParser;

    let m = k;
    const subBlockLines = [];
    while (m < quoteOriginalLines.length) {
      const currentSubOriginalLine = quoteOriginalLines[m];
      const currentSubInnerContent = currentSubOriginalLine.substring(currentSubOriginalLine.indexOf('>') + 1).trim();
      const currentSubParser = blockParsers.find(p => p.test(currentSubInnerContent)) || plainTextParser;
      if (currentSubParser.name !== subParser.name) {
        break;
      }
      subBlockLines.push(quoteCleanedLines[m]);
      m++;
    }

    const subBlockText = subBlockLines.join('\n');
    const subBlockStartIndex = blockStartIndex + charIndexInQuoteBlock;
    const subBlockEndIndex = subBlockStartIndex + subBlockText.length;

    if (subBlockText.length > 0) {
      const subBlockRange = textRange.getRange(subBlockStartIndex, subBlockEndIndex);
      subBlockRange.getParagraphStyle().setIndentStart(36);
      subBlockRange.getTextStyle().setItalic(true);
      if (subParser.applyStyle) {
        subParser.applyStyle(subBlockRange);
      }
    }

    charIndexInQuoteBlock += subBlockText.length + (m > k && m < quoteOriginalLines.length ? 1 : 0);
    k = m;
  }
}


/**
 * インラインレベルのMarkdown書式を適用します。(内部関数)
 * @param {GoogleAppsScript.Slides.TextRange} textRange 対象のTextRange。
 */
function applyInlineStyles_(textRange) {
  console.log('--- インラインスタイルの適用開始 ---');
  const inlineParsers = [
    {
      name: 'LINK',
      regex: /\[(.*?)\]\((.*?)\)/g,
      process: (range, match) => {
        const [fullMatchText, displayText, url] = match;
        const newRange = range.setText(displayText);
        // ★改善点: スタイルが混在していても安全に適用する
        const style = newRange.getTextStyle();
        if (style) {
          style.setLinkUrl(url);
        } else {
          newRange.getRuns().forEach(function(run) {
            const runStyle = run.getTextStyle();
            if (runStyle) runStyle.setLinkUrl(url);
          });
        }
        console.log(`  -> LINK: "${displayText}" にリンクを設定しました。`);
        return displayText.length - fullMatchText.length;
      }
    },
    {
      name: 'BOLD',
      regex: /\*\*(.*?)\*\*/g,
      process: (range, match) => {
        const [fullMatchText, capturedText] = match;
        const newRange = range.setText(capturedText);
        // ★改善点: スタイルが混在していても安全に適用する
        const style = newRange.getTextStyle();
        if (style) {
          style.setBold(true);
        } else {
          newRange.getRuns().forEach(function(run) {
            const runStyle = run.getTextStyle();
            if (runStyle) runStyle.setBold(true);
          });
        }
        console.log(`  -> BOLD: "${capturedText}" を太字にしました。`);
        return capturedText.length - fullMatchText.length;
      }
    },
    {
      name: 'STRIKETHROUGH',
      regex: /~~(.*?)~~/g,
      process: (range, match) => {
        const [fullMatchText, capturedText] = match;
        const newRange = range.setText(capturedText);
        // ★改善点: スタイルが混在していても安全に適用する
        const style = newRange.getTextStyle();
        if (style) {
          style.setStrikethrough(true);
        } else {
          newRange.getRuns().forEach(function(run) {
            const runStyle = run.getTextStyle();
            if (runStyle) runStyle.setStrikethrough(true);
          });
        }
        console.log(`  -> STRIKETHROUGH: "${capturedText}" に取り消し線を引きました。`);
        return capturedText.length - fullMatchText.length;
      }
    },
    {
      name: 'ITALIC',
      regex: /\*(.*?)\*/g,
      process: (range, match) => {
        const [fullMatchText, capturedText] = match;
        const newRange = range.setText(capturedText);
        // ★改善点: スタイルが混在していても安全に適用する
        const style = newRange.getTextStyle();
        if (style) {
          style.setItalic(true);
        } else {
          newRange.getRuns().forEach(function(run) {
            const runStyle = run.getTextStyle();
            if (runStyle) runStyle.setItalic(true);
          });
        }
        console.log(`  -> ITALIC: "${capturedText}" を斜体にしました。`);
        return capturedText.length - fullMatchText.length;
      }
    }
  ];

  inlineParsers.forEach(parser => {
    let text = textRange.asString();
    let match;
    // 正規表現に 'g' フラグが必須
    const regex = new RegExp(parser.regex, 'g');
    while ((match = regex.exec(text)) !== null) {
      const rangeToStyle = textRange.getRange(match.index, match.index + match[0].length);
      const lengthDifference = parser.process(rangeToStyle, match);

      // テキストが変更されたため、再度テキスト全体を取得し、
      // 検索インデックスを調整してループを継続する
      text = textRange.asString();
      regex.lastIndex = match.index + lengthDifference;
    }
  });
}

完成したスクリプトは非常に柔軟で、新しいMarkdownルールを簡単に追加できるように設計されています。

機能の概要

作成するスクリプトの中心となるのは、convertMarkdownToSlidesText(textRange, markdownText)という関数です。

この関数を呼び出すだけで、指定したスライド上のテキストボックス(textRange)に、Markdown形式のテキスト(markdownText)の内容が、書式を反映した状態(リッチテキスト)で挿入されます。

対応するMarkdown記法

このスクリプトは、以下の基本的なMarkdown記法に対応しています。

  • 太字: **text**
  • 斜体: *text*
  • 取り消し線: ~~text~~
  • リンク: [display text](url)
  • 箇条書きリスト: - item (スペース2つでネスト可能)
  • 番号付きリスト: 1. item (スペース2つでネスト可能)
  • 引用: > text (リストなど他の要素を含むことが可能)

動作の仕組み:2段階の解析(パーシング)

このスクリプトの核心は、テキストの解析を**「ブロックレベル」と「インラインレベル」**という2つの段階に分けている点です。

ブロックレベルの処理

まず、テキストを段落、リスト全体、引用といった大きな塊(ブロック)として捉えます。ここでは、行頭の記号(->など)を元に、インデントを付けたり、リストのマーカー(1.)を設定したりします。

インラインレベルの処理

次に、各ブロック内の文章に注目し、太字や斜体、リンクといった、文中に含まれる細かい書式(インライン要素)を処理します。

この2段階方式により、例えば「引用の中のリスト」のような複雑な入れ子構造にも正しく対応でき、メンテナンス性の高いコードを実現しています。

実装の詳細

フェーズ1: ブロックレベルのスタイル適用

まず、テキストを行単位でスキャンし、その行がどのブロック(段落、リスト、引用など)に属するかを判断します。この判断は、「パーサーオブジェクト」と呼ばれる、ルールの集まりを元に実施します。

例えば、箇条書きリスト用のパーサーオブジェクトは以下のようになっています。

  • ルールの名前: UNORDERED_LIST_ITEM
  • 判定条件 (test): 行の先頭が - で始まっているか?
  • 整形処理 (clean): - のようなMarkdown記号を取り除き、スライドに表示する純粋なテキストを準備する。
  • スタイル適用 (applyStyle): GoogleスライドのAPIを使い、箇条書きのスタイル(行頭文字「・」など)を適用する。

このようなルールを「番号付きリスト」や「引用」に対しても用意しておき、テキストの各行がどのルールに当てはまるかを判定して、適切なスタイルを適用していきます。

フェーズ2: インラインレベルのスタイル適用

ブロックレベルの処理が終わったテキスト全体に対して、文中の細かい書式を適用します。

こちらも先ほどと同様に、インライン用のパーサーオブジェクト(ルールの集まり)を使います。例えば、太字用のパーサーオブジェクトは以下の通りです。

  • ルールの名前: BOLD
  • 判定条件 (regex): **で囲まれたテキスト(\*\*(.*?)\*\*)に合致するか?
  • 整形&スタイル適用 (process): マッチした部分から**を取り除き、残ったテキストを太字にする。

処理の課題と解決策

インライン処理では一つ難しい点があります。それは、**太字**(6文字)を太字(2文字)に置き換えるように、テキストを変換すると文字数が変わり、後続の文字の位置がずれてしまうことです。

この問題を解決するため、正規表現を使いながら、テキストの先頭から一つずつマッチする箇所を探し、変換を適用するたびに、次の検索開始位置をずれた分だけ補正する、という戦略をとっています。これにより、文中に同じ書式が何度登場しても、正確にすべてを変換できます。

制限事項

このパーサーは、シンプルさと拡張性を重視しているため、いくつかの複雑なケースには対応していません。

  • リスト項目内の引用: 番号付きリストの項目内で引用を開始した場合、その引用は元のリストの続きとは見なされず、独立した新しいブロックとして扱われます。そのため、リストの番号がリセットされてしまいます。
    (動かない例)
    1. これはリストの1番目です。
    > 2. この行は「2.」ではなく、新しい引用ブロック内の「1.」として扱われます。
    3. この行は「3.」として扱われます。
    

まとめ

このスクリプトは、変換ルールを「パーサーオブジェクト」として一つずつ定義しているため、非常に見通しが良く、拡張しやすい構造になっています。もし将来、新しいMarkdown記法(例えばコードブロックなど)に対応したくなった場合も、新しいパーサーオブジェクトを追加するだけで機能拡張ができます。

参考情報

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?