@@ -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 */
115370function 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-
0 commit comments