Skip to content

Commit ddaf2de

Browse files
committed
fixes
1 parent 698ca28 commit ddaf2de

4 files changed

Lines changed: 285 additions & 6 deletions

File tree

FormattingFromMarkdownWhole/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
Deprecated in favour of a more granular [version](../MarkdownTools/README.md) with roundtripping.
2+
3+
---
4+
15
While I know you can render Markdown to some Rich Text variant and paste that into Gdocs and it will look right...
26

37
...why not write a GAS?

MarkdownTools/Code.gs

Lines changed: 260 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ function onOpen() {
3232
DocumentApp.getUi()
3333
.createMenu('Markdown Tools')
3434
.addItem('Format Selected Markdown', 'processSelectedMarkdown')
35+
.addItem('Markdownify Formatted Selection', 'markdownifySelectedText')
3536
.addToUi();
3637
}
3738

@@ -109,11 +110,265 @@ function processSelectedMarkdown() {
109110
});
110111
}
111112

113+
/**
114+
* REVERSE ENGINE: Converts selected Google Docs elements into Markdown
115+
*/
116+
function markdownifySelectedText() {
117+
const doc = DocumentApp.getActiveDocument();
118+
const selection = doc.getSelection();
119+
120+
if (!selection) {
121+
DocumentApp.getUi().alert('Please highlight the text you want to Markdownify.');
122+
return;
123+
}
124+
125+
const body = doc.getBody();
126+
const rangeElements = selection.getRangeElements();
127+
let insertIndex = -1;
128+
const elementsToProcess = [];
129+
130+
// Phase 1: Identify full blocks involved in the selection
131+
const processedIndices = new Set(); // Track unique element indices
132+
133+
for (let i = 0; i < rangeElements.length; i++) {
134+
const rangeEl = rangeElements[i];
135+
let topLevel = rangeEl.getElement();
136+
137+
// Bubble up to find the direct child of the Body
138+
while (topLevel.getParent() && topLevel.getParent().getType() !== DocumentApp.ElementType.BODY_SECTION) {
139+
topLevel = topLevel.getParent();
140+
}
141+
142+
const childIndex = body.getChildIndex(topLevel);
143+
144+
if (insertIndex === -1) {
145+
insertIndex = childIndex;
146+
}
147+
148+
// Deduplicate using the child index instead of the object reference
149+
if (!processedIndices.has(childIndex)) {
150+
console.log("Adding element to process:", topLevel.getType().toString(), "with text:", topLevel.getText ? topLevel.getText().substr(0, 40) : "[no text]");
151+
152+
processedIndices.add(childIndex);
153+
elementsToProcess.push(topLevel);
154+
}
155+
}
156+
157+
// Phase 2: Traverse elements and generate Markdown strings
158+
let mdLines = [];
159+
let footnotes = [];
160+
let inCodeFence = false;
161+
162+
elementsToProcess.forEach(el => {
163+
const type = el.getType();
164+
console.log("Processing element type:", type.toString(), "with text:", el.getText ? el.getText().substr(0, 40) : "[no text]");
165+
166+
// Handle Code Fences
167+
if (type === DocumentApp.ElementType.PARAGRAPH && isMonospaceBlock(el)) {
168+
if (!inCodeFence) {
169+
mdLines.push('```');
170+
inCodeFence = true;
171+
}
172+
mdLines.push(el.getText()); // Raw text, no inline markdown processing
173+
return;
174+
} else {
175+
if (inCodeFence) {
176+
mdLines.push('```');
177+
inCodeFence = false;
178+
}
179+
}
180+
181+
// Process different block types
182+
if (type === DocumentApp.ElementType.PARAGRAPH) {
183+
const headingMap = {
184+
[DocumentApp.ParagraphHeading.HEADING1]: '# ',
185+
[DocumentApp.ParagraphHeading.HEADING2]: '## ',
186+
[DocumentApp.ParagraphHeading.HEADING3]: '### ',
187+
[DocumentApp.ParagraphHeading.HEADING4]: '#### ',
188+
[DocumentApp.ParagraphHeading.HEADING5]: '##### ',
189+
[DocumentApp.ParagraphHeading.HEADING6]: '###### '
190+
};
191+
const prefix = headingMap[el.getHeading()] || '';
192+
const text = extractMarkdownFromContainer(el, footnotes);
193+
if (text.trim() !== '' || prefix !== '') {
194+
mdLines.push(prefix + text);
195+
} else {
196+
mdLines.push('');
197+
}
198+
}
199+
else if (type === DocumentApp.ElementType.LIST_ITEM) {
200+
const indent = ' '.repeat(el.getNestingLevel());
201+
const orderedGlyphs = [
202+
DocumentApp.GlyphType.NUMBER,
203+
DocumentApp.GlyphType.LATIN_LOWER,
204+
DocumentApp.GlyphType.LATIN_UPPER,
205+
DocumentApp.GlyphType.ROMAN_LOWER,
206+
DocumentApp.GlyphType.ROMAN_UPPER
207+
];
208+
const unorderedChars = [
209+
'-',
210+
'+',
211+
'*'
212+
];
213+
const isOrdered = orderedGlyphs.includes(el.getGlyphType());
214+
const prefix = isOrdered ? '1. ' : `${unorderedChars[el.getNestingLevel() % unorderedChars.length]} `;
215+
mdLines.push(indent + prefix + extractMarkdownFromContainer(el, footnotes));
216+
}
217+
else if (type === DocumentApp.ElementType.TABLE) {
218+
console.log("Processing table with", el.getNumRows(), "rows");
219+
for (let r = 0; r < el.getNumRows(); r++) {
220+
const row = el.getRow(r);
221+
console.log("Processing row", r, "with", row.getNumCells(), "cells");
222+
let rowData = [];
223+
for (let c = 0; c < row.getNumCells(); c++) {
224+
console.log("Processing cell", c);
225+
let cell = row.getCell(c);
226+
let cellMdLines = [];
227+
for (let p = 0; p < cell.getNumChildren(); p++) {
228+
cellMdLines.push(extractMarkdownFromContainer(cell.getChild(p), footnotes));
229+
}
230+
console.log("Add row", r, "cell", c, "content:", cellMdLines.join(' | '));
231+
// Flatten multi-paragraph cells to `<br>` for markdown tables
232+
rowData.push(cellMdLines.join('<br>'));
233+
}
234+
mdLines.push('| ' + rowData.join(' | ') + ' |');
235+
236+
// Add table header separator
237+
if (r === 0) {
238+
mdLines.push('|' + rowData.map(() => '---').join('|') + '|');
239+
}
240+
}
241+
}
242+
});
243+
244+
if (inCodeFence) mdLines.push('```');
245+
246+
// Append Footnotes
247+
if (footnotes.length > 0) {
248+
mdLines.push('');
249+
footnotes.forEach((note, index) => {
250+
mdLines.push(`[^${index + 1}]: ${note}`);
251+
});
252+
}
253+
254+
// Phase 3: Insert raw text and remove original objects
255+
const finalMarkdown = mdLines.join('\n');
256+
body.insertParagraph(insertIndex, finalMarkdown);
257+
258+
elementsToProcess.forEach(el => {
259+
try {
260+
el.removeFromParent();
261+
} catch(e) {
262+
if (el.getType() === DocumentApp.ElementType.PARAGRAPH) {
263+
el.clear();
264+
}
265+
}
266+
});
267+
}
268+
269+
/**
270+
* Extracts inner contents and transforms inline Google Docs styles to Markdown
271+
*/
272+
function extractMarkdownFromContainer(container, footnotes) {
273+
let mdText = '';
274+
// Check if standard container with children
275+
if (typeof container.getNumChildren !== 'function') return container.getText();
276+
277+
for (let i = 0; i < container.getNumChildren(); i++) {
278+
const child = container.getChild(i);
279+
const type = child.getType();
280+
281+
if (type === DocumentApp.ElementType.TEXT) {
282+
mdText += processTextRun(child);
283+
} else if (type === DocumentApp.ElementType.FOOTNOTE) {
284+
const fnContents = child.getFootnoteContents().getText().trim();
285+
footnotes.push(fnContents);
286+
mdText += `[^${footnotes.length}]`;
287+
} else {
288+
// Best effort for unhandled inline items (like equations)
289+
if (child.getText) mdText += child.getText();
290+
}
291+
}
292+
return mdText;
293+
}
294+
295+
/**
296+
* Helper to extract and format attributes in an inline Text Element
297+
*/
298+
function processTextRun(textEl) {
299+
const rawText = textEl.getText();
300+
if (!rawText) return '';
301+
302+
const indices = textEl.getTextAttributeIndices();
303+
let mdStr = '';
304+
305+
for (let i = 0; i < indices.length; i++) {
306+
const start = indices[i];
307+
const end = i + 1 < indices.length ? indices[i + 1] - 1 : rawText.length - 1;
308+
let seg = rawText.substring(start, end + 1);
309+
310+
// Pull out whitespace to prevent markdown syntax breaking, e.g., "** bold **"
311+
const leadingSpace = seg.match(/^\s*/)[0];
312+
const trailingSpace = seg.match(/\s*$/)[0];
313+
let coreSeg = seg.trim();
314+
315+
if (!coreSeg) {
316+
mdStr += seg;
317+
continue;
318+
}
319+
320+
const font = textEl.getFontFamily(start);
321+
const isCode = ['Courier New', 'Consolas', 'Monaco', 'monospace'].includes(font);
322+
const isBold = textEl.isBold(start);
323+
const isItalic = textEl.isItalic(start);
324+
const isUnderline = textEl.isUnderline(start);
325+
const linkUrl = textEl.getLinkUrl(start);
326+
327+
// Apply inline syntax (inside out)
328+
if (isCode) coreSeg = `\`${coreSeg}\``;
329+
if (isItalic) coreSeg = `*${coreSeg}*`;
330+
if (isBold) coreSeg = `**${coreSeg}**`;
331+
if (isUnderline) coreSeg = `<u>${coreSeg}</u>`;
332+
if (linkUrl) coreSeg = `[${coreSeg}](${linkUrl})`;
333+
334+
mdStr += leadingSpace + coreSeg + trailingSpace;
335+
}
336+
337+
return mdStr;
338+
}
339+
340+
/**
341+
* Helper: Detects if an entire paragraph is uniformly styled as monospace.
342+
*/
343+
function isMonospaceBlock(container) {
344+
if (container.getType() !== DocumentApp.ElementType.PARAGRAPH) return false;
345+
if (container.getNumChildren() === 0) return false;
346+
347+
let hasText = false;
348+
for (let i = 0; i < container.getNumChildren(); i++) {
349+
const child = container.getChild(i);
350+
if (child.getType() === DocumentApp.ElementType.TEXT) {
351+
hasText = true;
352+
const font = child.getFontFamily(0);
353+
if (!['Courier New', 'Consolas', 'Monaco', 'monospace'].includes(font)) {
354+
return false;
355+
}
356+
} else if (child.getType() !== DocumentApp.ElementType.FOOTNOTE) {
357+
return false; // Inline images, etc., break the "pure block" assumption
358+
}
359+
}
360+
return hasText;
361+
}
362+
363+
// ---------------------------------------------------------
364+
// ORIGINAL PARSING FUNCTIONS BELOW
365+
// ---------------------------------------------------------
366+
112367
/**
113368
* Reads lines and categorizes them into block-level elements.
114369
*/
115370
function parseBlocks(rawText) {
116-
const lines = rawText.split('\n');
371+
const lines = rawText.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n');
117372
const blocks = [];
118373
const footnotes = {};
119374

@@ -164,13 +419,16 @@ function parseBlocks(rawText) {
164419
// 4. Tables
165420
{
166421
const prev = blocks[blocks.length - 1];
167-
if (line.trim().startsWith('|') && (line.trim().endsWith('|') || prev && prev.type === 'table')) {
422+
if (line.trim().startsWith('|') && (line.trim().endsWith('|') || (prev && prev.type === 'table'))) {
168423
const rowData = line.split('|').slice(1, line.trim().endsWith('|') ? -1 : undefined).map(c => c.trim());
169424
if (rowData.every(c => /^[-:]+$/.test(c))) continue;
170425

171426
if (prev && prev.type === 'table') {
427+
console.log("Appending row to existing table:", rowData);
172428
prev.rows.push(rowData);
173429
} else {
430+
console.log("starting table", rowData);
431+
console.log("line:", `{{${line}}}`, line.split('').map(c => c.charCodeAt(0)));
174432
blocks.push({ type: 'table', rows: [rowData] });
175433
}
176434
continue;
@@ -379,4 +637,3 @@ function processFootnotes(doc, textElement, footnotesMap) {
379637
found = textElement.findText(fnRegex);
380638
}
381639
}
382-

MarkdownTools/README.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
11
Google Apps Script Markdown tools
22
=================================
33

4+
![screenshot of the Markdown Tools menu](./screenshot.png)
5+
46
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+
1. *Format Selected Markdown*: select some markdown in a google doc, and it will convert it to proper google docs formatting to the best of its abilities
8+
2. *Markdownify Formatted Selection*: select some formatted text, and the tool will do a best effort replacement with equivalent markdown
79

8-
Supports rather vanilla markdown, including:
10+
Supports vanilla markdown, including:
911
- normal paragraphs, with inline **bold**, *italic*, `monospaced`, [links](example.com)
1012
- code fences
1113
- un/ordered lists, nested
1214
- tables
15+
16+
The one obvious missing feature would be image blocks. Those get left alone.
17+
18+
Installation
19+
------------
20+
21+
VBA macros style:
22+
1. Click Extensions -> Apps Script
23+
2. Copy the code from [Code.gs](./Code.gs) and paste it in the Apps Script editor for Code.gs
24+
3. Hit save / CTRL+S
25+
4. Reload the Doc
26+
5. There is now a "Markdown Tools" menu.
27+
28+
At some point there will be a scareware screen about allowing "Unknown project" access to your Google Drive. The Unknown project is the thing you pasted, that is the project.
29+
30+
Alternatively, you can save this into a Google Apps Script on your drive and create a Test deployment targeting a google doc.

MarkdownTools/screenshot.png

29.3 KB
Loading

0 commit comments

Comments
 (0)