Google Apps Scriptを使って、Markdown記法を解釈し、Googleスライドのテキストボックスとして挿入します。Apps Scriptは太字、斜体、リスト、引用などのスタイルを適用するパーサー(構文解析器)として動作します。
Markdownからテキストボックスを作成するコード
/**
* 指定された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記法(例えばコードブロックなど)に対応したくなった場合も、新しいパーサーオブジェクトを追加するだけで機能拡張ができます。
参考情報