Skip to content

Commit 698ca28

Browse files
committed
improve markdown formatter GAS
1 parent d54edad commit 698ca28

4 files changed

Lines changed: 394 additions & 0 deletions

File tree

File renamed without changes.

MarkdownTools/Code.gs

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
/*
2+
BSD 2-Clause License
3+
4+
Copyright (c) 2026, Vlad Meșco
5+
6+
Redistribution and use in source and binary forms, with or without
7+
modification, are permitted provided that the following conditions are met:
8+
9+
1. Redistributions of source code must retain the above copyright notice, this
10+
list of conditions and the following disclaimer.
11+
12+
2. Redistributions in binary form must reproduce the above copyright notice,
13+
this list of conditions and the following disclaimer in the documentation
14+
and/or other materials provided with the distribution.
15+
16+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26+
*/
27+
28+
/**
29+
* onOpen hook to create the custom menu.
30+
*/
31+
function onOpen() {
32+
DocumentApp.getUi()
33+
.createMenu('Markdown Tools')
34+
.addItem('Format Selected Markdown', 'processSelectedMarkdown')
35+
.addToUi();
36+
}
37+
38+
/**
39+
* Main execution function for selected text.
40+
*/
41+
function processSelectedMarkdown() {
42+
const doc = DocumentApp.getActiveDocument();
43+
const selection = doc.getSelection();
44+
45+
if (!selection) {
46+
DocumentApp.getUi().alert('Please highlight the markdown text you want to process first.');
47+
return;
48+
}
49+
50+
const body = doc.getBody();
51+
let rawText = '';
52+
const rangeElements = selection.getRangeElements();
53+
let insertIndex = -1;
54+
const elementsToRemove = [];
55+
56+
// Phase 1: Extract selected text and find the insertion point
57+
for (let i = 0; i < rangeElements.length; i++) {
58+
const rangeEl = rangeElements[i];
59+
const el = rangeEl.getElement();
60+
61+
// Bubble up to find the direct child of the Body (e.g., Paragraph, ListItem)
62+
let topLevel = el;
63+
while (topLevel.getParent() && topLevel.getParent().getType() !== DocumentApp.ElementType.BODY_SECTION) {
64+
topLevel = topLevel.getParent();
65+
}
66+
67+
// Capture the index of the first selected block
68+
if (insertIndex === -1) {
69+
insertIndex = body.getChildIndex(topLevel);
70+
}
71+
72+
// Mark the block for deletion later
73+
if (!elementsToRemove.includes(topLevel)) {
74+
elementsToRemove.push(topLevel);
75+
}
76+
77+
// Extract the raw text
78+
if (el.editAsText) {
79+
const textStr = el.asText().getText();
80+
if (rangeEl.isPartial()) {
81+
rawText += textStr.substring(rangeEl.getStartOffset(), rangeEl.getEndOffsetInclusive() + 1);
82+
} else {
83+
rawText += textStr;
84+
}
85+
}
86+
87+
// Add a newline to separate blocks cleanly
88+
if (rangeEl.isPartial() || el.getType() === DocumentApp.ElementType.PARAGRAPH || el.getType() === DocumentApp.ElementType.LIST_ITEM) {
89+
rawText += '\n';
90+
}
91+
}
92+
93+
// Phase 2: Parse the raw text into structured blocks
94+
const { blocks, footnotes } = parseBlocks(rawText);
95+
96+
// Phase 3: Insert the parsed document blocks at the selection index
97+
insertDocumentBlocks(doc, body, insertIndex, blocks, footnotes);
98+
99+
// Phase 4: Remove the originally selected blocks
100+
elementsToRemove.forEach(el => {
101+
try {
102+
el.removeFromParent();
103+
} catch(e) {
104+
// Fallback if it's the last paragraph in the document (which cannot be removed)
105+
if (el.getType() === DocumentApp.ElementType.PARAGRAPH) {
106+
el.clear();
107+
}
108+
}
109+
});
110+
}
111+
112+
/**
113+
* Reads lines and categorizes them into block-level elements.
114+
*/
115+
function parseBlocks(rawText) {
116+
const lines = rawText.split('\n');
117+
const blocks = [];
118+
const footnotes = {};
119+
120+
let inCodeFence = false;
121+
let activeFence = '';
122+
let codeContent = [];
123+
124+
for (let i = 0; i < lines.length; i++) {
125+
const line = lines[i];
126+
127+
// 1. Code Fences
128+
const fenceMatch = line.trim().match(/^(`{3,})/);
129+
if (fenceMatch) {
130+
const matchedFence = fenceMatch[1];
131+
132+
if (!inCodeFence) {
133+
inCodeFence = true;
134+
activeFence = matchedFence;
135+
continue;
136+
} else if (inCodeFence && matchedFence.length >= activeFence.length) {
137+
blocks.push({ type: 'code', content: codeContent.join('\n') });
138+
inCodeFence = false;
139+
activeFence = '';
140+
codeContent = [];
141+
continue;
142+
}
143+
}
144+
145+
if (inCodeFence) {
146+
codeContent.push(line);
147+
continue;
148+
}
149+
150+
// 2. Footnote Definitions: [^id]: text
151+
const fnMatch = line.match(/^\[\^([^\]]+)\]:\s*(.*)/);
152+
if (fnMatch) {
153+
footnotes[fnMatch[1]] = fnMatch[2];
154+
continue;
155+
}
156+
157+
// 3. Headings
158+
const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
159+
if (headingMatch) {
160+
blocks.push({ type: 'heading', level: headingMatch[1].length, content: headingMatch[2] });
161+
continue;
162+
}
163+
164+
// 4. Tables
165+
{
166+
const prev = blocks[blocks.length - 1];
167+
if (line.trim().startsWith('|') && (line.trim().endsWith('|') || prev && prev.type === 'table')) {
168+
const rowData = line.split('|').slice(1, line.trim().endsWith('|') ? -1 : undefined).map(c => c.trim());
169+
if (rowData.every(c => /^[-:]+$/.test(c))) continue;
170+
171+
if (prev && prev.type === 'table') {
172+
prev.rows.push(rowData);
173+
} else {
174+
blocks.push({ type: 'table', rows: [rowData] });
175+
}
176+
continue;
177+
}
178+
}
179+
180+
// 5. Lists (Ordered and Unordered, with indentation detection)
181+
const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)/);
182+
if (listMatch) {
183+
const indent = listMatch[1].length;
184+
const isOrdered = /\d+\./.test(listMatch[2]);
185+
blocks.push({
186+
type: 'list',
187+
level: Math.floor(indent / 2),
188+
isOrdered: isOrdered,
189+
content: listMatch[3]
190+
});
191+
continue;
192+
}
193+
194+
// 6. Paragraphs
195+
if (line.trim() !== '') {
196+
blocks.push({ type: 'paragraph', content: line });
197+
} else {
198+
blocks.push({ type: 'paragraph', content: '' });
199+
}
200+
}
201+
202+
return { blocks, footnotes };
203+
}
204+
205+
/**
206+
* Reconstructs the document body by inserting parsed blocks at a specific index via a 2-pass engine.
207+
*/
208+
function insertDocumentBlocks(doc, body, startIndex, blocks, footnotes) {
209+
const elementsToStyle = [];
210+
let currentIndex = startIndex;
211+
212+
// PASS 1: STRUCTURE - Insert RAW elements at the specific index
213+
blocks.forEach(block => {
214+
let element = null;
215+
216+
if (block.type === 'heading') {
217+
const hEnum = DocumentApp.ParagraphHeading[`HEADING${block.level}`];
218+
element = body.insertParagraph(currentIndex, block.content);
219+
element.setHeading(hEnum);
220+
elementsToStyle.push({ el: element, block: block });
221+
currentIndex++;
222+
}
223+
else if (block.type === 'paragraph') {
224+
element = body.insertParagraph(currentIndex, block.content);
225+
elementsToStyle.push({ el: element, block: block });
226+
currentIndex++;
227+
}
228+
else if (block.type === 'code') {
229+
element = body.insertParagraph(currentIndex, block.content);
230+
elementsToStyle.push({ el: element, block: block });
231+
currentIndex++;
232+
}
233+
else if (block.type === 'table') {
234+
element = body.insertTable(currentIndex, block.rows);
235+
elementsToStyle.push({ el: element, block: block });
236+
currentIndex++;
237+
}
238+
else if (block.type === 'list') {
239+
element = body.insertListItem(currentIndex, block.content);
240+
elementsToStyle.push({ el: element, block: block });
241+
currentIndex++;
242+
}
243+
});
244+
245+
// PASS 2: PAINT AND BIND
246+
let currentListParent = null;
247+
248+
elementsToStyle.forEach(item => {
249+
const { el, block } = item;
250+
251+
// Break the list chain if we encounter a non-list element
252+
if (block.type !== 'list') {
253+
currentListParent = null;
254+
}
255+
256+
if (block.type === 'code') {
257+
el.setFontFamily('Courier New');
258+
el.setBackgroundColor('#f3f4f6');
259+
}
260+
else if (block.type === 'table') {
261+
for (let r = 0; r < el.getNumRows(); r++) {
262+
for (let c = 0; c < el.getRow(r).getNumCells(); c++) {
263+
processInlineStyles(doc, el.getRow(r).getCell(c).editAsText(), footnotes);
264+
}
265+
}
266+
}
267+
else {
268+
// Process inline styles FIRST (this mutates the string and breaks index 0)
269+
if (block.content !== '') {
270+
processInlineStyles(doc, el.editAsText(), footnotes);
271+
}
272+
273+
// NOW that the text string is final, apply list structure and glyphs safely
274+
if (block.type === 'list') {
275+
if (currentListParent) {
276+
el.setListId(currentListParent);
277+
} else {
278+
currentListParent = el;
279+
}
280+
281+
el.setNestingLevel(block.level);
282+
283+
const levelDepth = block.level % 3;
284+
if (block.isOrdered) {
285+
const orderedGlyphs = [
286+
DocumentApp.GlyphType.NUMBER,
287+
DocumentApp.GlyphType.LATIN_LOWER,
288+
DocumentApp.GlyphType.ROMAN_LOWER
289+
];
290+
el.setGlyphType(orderedGlyphs[levelDepth]);
291+
} else {
292+
const unorderedGlyphs = [
293+
DocumentApp.GlyphType.BULLET,
294+
DocumentApp.GlyphType.HOLLOW_BULLET,
295+
DocumentApp.GlyphType.SQUARE_BULLET
296+
];
297+
el.setGlyphType(unorderedGlyphs[levelDepth]);
298+
}
299+
}
300+
}
301+
});
302+
}
303+
304+
/**
305+
* Executes the inline formatting pipeline on a specific Text element.
306+
*/
307+
function processInlineStyles(doc, textElement, footnotes) {
308+
applyInlineStyle(textElement, '\\*\\*([^\\*]+)\\*\\*', 2, 2, (start, end) => textElement.setBold(start, end, true));
309+
applyInlineStyle(textElement, '\\*([^\\*]+)\\*', 1, 1, (start, end) => textElement.setItalic(start, end, true));
310+
applyInlineStyle(textElement, '<u>(.*?)<\\/u>', 3, 4, (start, end) => textElement.setUnderline(start, end, true));
311+
applyInlineStyle(textElement, '`([^`]+)`', 1, 1, (start, end) => {
312+
textElement.setFontFamily(start, end, 'Courier New');
313+
textElement.setBackgroundColor(start, end, '#f3f4f6');
314+
});
315+
processLinks(textElement);
316+
processFootnotes(doc, textElement, footnotes);
317+
}
318+
319+
/**
320+
* Generic inline styling helper for matched regex.
321+
*/
322+
function applyInlineStyle(textElement, regexStr, leftMarkerLen, rightMarkerLen, styleCallback) {
323+
let found = textElement.findText(regexStr);
324+
while (found) {
325+
let start = found.getStartOffset();
326+
let end = found.getEndOffsetInclusive();
327+
styleCallback(start + leftMarkerLen, end - rightMarkerLen);
328+
textElement.deleteText(end - rightMarkerLen + 1, end);
329+
textElement.deleteText(start, start + leftMarkerLen - 1);
330+
found = textElement.findText(regexStr);
331+
}
332+
}
333+
334+
/**
335+
* Extracts URL data and applies the link style.
336+
*/
337+
function processLinks(textElement) {
338+
const linkRegex = '\\[([^\\]]+)\\]\\(([^)]+)\\)';
339+
let found = textElement.findText(linkRegex);
340+
while (found) {
341+
let start = found.getStartOffset();
342+
let end = found.getEndOffsetInclusive();
343+
let fullText = textElement.getText().substring(start, end + 1);
344+
let match = fullText.match(/\[([^\]]+)\]\(([^)]+)\)/);
345+
if (match) {
346+
let display = match[1];
347+
let url = match[2];
348+
textElement.deleteText(start, end);
349+
textElement.insertText(start, display);
350+
textElement.setLinkUrl(start, start + display.length - 1, url);
351+
}
352+
found = textElement.findText(linkRegex);
353+
}
354+
}
355+
356+
/**
357+
* Replaces footnote markers with native Google Docs footnotes.
358+
*/
359+
function processFootnotes(doc, textElement, footnotesMap) {
360+
const fnRegex = '\\[\\^([^\\]]+)\\]';
361+
let found = textElement.findText(fnRegex);
362+
while (found) {
363+
let start = found.getStartOffset();
364+
let end = found.getEndOffsetInclusive();
365+
let fullText = textElement.getText().substring(start, end + 1);
366+
let match = fullText.match(/\[\^([^\]]+)\]/);
367+
if (match) {
368+
let id = match[1];
369+
let noteText = footnotesMap[id] || "Missing footnote definition";
370+
textElement.deleteText(start, end);
371+
try {
372+
let pos = doc.newPosition(textElement, start);
373+
let fn = doc.addFootnote(pos);
374+
fn.getFootnoteContents().getParagraphs()[0].setText(noteText);
375+
} catch(e) {
376+
textElement.insertText(start, ` [Footnote: ${noteText}]`);
377+
}
378+
}
379+
found = textElement.findText(fnRegex);
380+
}
381+
}
382+

MarkdownTools/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Google Apps Script Markdown tools
2+
=================================
3+
4+
This addon provides two operations:
5+
1. *Format Selected Markdown*: select some markdown in a google doc, and it will convert it to proper google docs formatting
6+
2. *Markdownify Selection*: select some formatted text, and the tool will do a best effort replacement with equivalent markdown
7+
8+
Supports rather vanilla markdown, including:
9+
- normal paragraphs, with inline **bold**, *italic*, `monospaced`, [links](example.com)
10+
- code fences
11+
- un/ordered lists, nested
12+
- tables

0 commit comments

Comments
 (0)