Spaces:
Sleeping
Sleeping
| // src/markdown-transformer/markdownToDocsRequests.ts | |
| import { docs_v1 } from 'googleapis'; | |
| import MarkdownIt from 'markdown-it'; | |
| import type Token from 'markdown-it/lib/token.mjs'; | |
| import { | |
| buildUpdateTextStyleRequest, | |
| buildUpdateParagraphStyleRequest, | |
| } from '../googleDocsApiHelpers.js'; | |
| import { MarkdownConversionError } from '../types.js'; | |
| // --- Markdown-it Setup --- | |
| function createParser(): MarkdownIt { | |
| return new MarkdownIt({ | |
| html: false, | |
| linkify: true, | |
| typographer: false, | |
| breaks: false, | |
| xhtmlOut: false, | |
| }); | |
| } | |
| function getLinkHref(token: Token): string | null { | |
| if (token.type !== 'link_open') return null; | |
| const hrefAttr = token.attrs?.find((attr: [string, string]) => attr[0] === 'href'); | |
| return hrefAttr ? hrefAttr[1] : null; | |
| } | |
| function getHeadingLevel(token: Token): number | null { | |
| if (!token.type.startsWith('heading_')) return null; | |
| const match = token.tag.match(/h(\d)/); | |
| return match ? parseInt(match[1], 10) : null; | |
| } | |
| // --- Internal Types --- | |
| interface TextRange { | |
| startIndex: number; | |
| endIndex: number; | |
| formatting: FormattingState; | |
| } | |
| interface FormattingState { | |
| bold?: boolean; | |
| italic?: boolean; | |
| strikethrough?: boolean; | |
| link?: string; | |
| code?: boolean; | |
| } | |
| interface ParagraphRange { | |
| startIndex: number; | |
| endIndex: number; | |
| namedStyleType?: string; | |
| } | |
| interface ListState { | |
| type: 'bullet' | 'ordered'; | |
| level: number; | |
| } | |
| interface PendingListItem { | |
| startIndex: number; | |
| endIndex?: number; | |
| nestingLevel: number; | |
| bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN' | 'BULLET_DISC_CIRCLE_SQUARE' | 'BULLET_CHECKBOX'; | |
| taskPrefixProcessed: boolean; | |
| } | |
| interface CodeBlockRange { | |
| tableStartIndex: number; | |
| textStartIndex: number; | |
| textEndIndex: number; | |
| language?: string; | |
| } | |
| interface TableCellData { | |
| text: string; | |
| isHeader: boolean; | |
| textRanges: { startIndex: number; endIndex: number; formatting: FormattingState }[]; | |
| } | |
| interface TableState { | |
| rows: TableCellData[][]; | |
| currentRow: TableCellData[]; | |
| inHeader: boolean; | |
| currentCell: TableCellData | null; | |
| } | |
| export interface ConversionOptions { | |
| /** Treat the first H1 (`# ...`) as a Google Docs TITLE instead of HEADING_1. Default false. */ | |
| firstHeadingAsTitle?: boolean; | |
| } | |
| interface ConversionContext { | |
| currentIndex: number; | |
| insertRequests: docs_v1.Schema$Request[]; | |
| formatRequests: docs_v1.Schema$Request[]; | |
| textRanges: TextRange[]; | |
| formattingStack: FormattingState[]; | |
| listStack: ListState[]; | |
| paragraphRanges: ParagraphRange[]; | |
| normalParagraphRanges: { startIndex: number; endIndex: number }[]; | |
| listSpacingRanges: { startIndex: number; endIndex: number }[]; | |
| pendingListItems: PendingListItem[]; | |
| openListItemStack: number[]; | |
| hrRanges: { startIndex: number; endIndex: number }[]; | |
| codeBlockRanges: CodeBlockRange[]; | |
| tableState?: TableState; | |
| inTableCell: boolean; | |
| tabId?: string; | |
| currentParagraphStart?: number; | |
| currentHeadingLevel?: number; | |
| /** When firstHeadingAsTitle is on, tracks whether the title H1 has been consumed. */ | |
| titleConsumed: boolean; | |
| firstHeadingAsTitle: boolean; | |
| } | |
| const CODE_FONT_FAMILY = 'Roboto Mono'; | |
| const CODE_TEXT_HEX = '#188038'; | |
| const CODE_BACKGROUND_HEX = '#F1F3F4'; | |
| // --- Code Block (table-based) Constants --- | |
| // Google Docs "Code Block" building block is a styled 1x1 table. | |
| // These constants define the visual style for programmatically created code blocks. | |
| const CODE_BLOCK_BG_RGB = { red: 0.937, green: 0.945, blue: 0.953 }; // #EFF1F3 | |
| const CODE_BLOCK_BORDER_RGB = { red: 0.855, green: 0.863, blue: 0.878 }; // #DADCE0 | |
| // IMPORTANT: The Google Docs API always inserts a newline character ("\n") BEFORE | |
| // the table when processing an insertTable request. So calling insertTable at index T | |
| // produces the following document structure: | |
| // | |
| // T → paragraph break ("\n") — auto-inserted by the API | |
| // T + 1 → table.startIndex (the actual table element) | |
| // T + 2 → tableRow.startIndex | |
| // T + 3 → tableCell.startIndex | |
| // T + 4 → paragraph.startIndex ← cell content (text insertion point) | |
| // T + 6 → table.endIndex | |
| // | |
| // Therefore: | |
| // CELL_CONTENT_OFFSET = 4 (from insertTable target T to cell content at T+4) | |
| // EMPTY_1x1_TABLE_SIZE = 6 (total positions: 1 newline + 5 table structure) | |
| // Actual table start for updateTableCellStyle = T + 1 (NOT T) | |
| // | |
| // Verified empirically via documents.get on a real document with a 1x1 table. | |
| const CELL_CONTENT_OFFSET = 4; | |
| const EMPTY_1x1_TABLE_SIZE = 6; | |
| // --- Main Conversion Function --- | |
| /** | |
| * Converts a markdown string to an array of Google Docs API batch update requests. | |
| * | |
| * This is an internal function -- callers should use `insertMarkdown()` from | |
| * the barrel export instead. | |
| * | |
| * @param markdown - The markdown content to convert | |
| * @param startIndex - The document index where content should be inserted (1-based) | |
| * @param tabId - Optional tab ID for multi-tab documents | |
| * @param options - Optional conversion options (e.g. firstHeadingAsTitle) | |
| * @returns Array of Google Docs API requests (insertions first, then formatting) | |
| */ | |
| export function convertMarkdownToRequests( | |
| markdown: string, | |
| startIndex: number = 1, | |
| tabId?: string, | |
| options?: ConversionOptions | |
| ): docs_v1.Schema$Request[] { | |
| if (!markdown || markdown.trim().length === 0) { | |
| return []; | |
| } | |
| const parser = createParser(); | |
| const tokens = parser.parse(markdown, {}); | |
| const context: ConversionContext = { | |
| currentIndex: startIndex, | |
| insertRequests: [], | |
| formatRequests: [], | |
| textRanges: [], | |
| formattingStack: [], | |
| listStack: [], | |
| paragraphRanges: [], | |
| normalParagraphRanges: [], | |
| listSpacingRanges: [], | |
| pendingListItems: [], | |
| openListItemStack: [], | |
| hrRanges: [], | |
| codeBlockRanges: [], | |
| tableState: undefined, | |
| inTableCell: false, | |
| tabId, | |
| titleConsumed: false, | |
| firstHeadingAsTitle: options?.firstHeadingAsTitle ?? false, | |
| }; | |
| try { | |
| for (const token of tokens) { | |
| processToken(token, context); | |
| } | |
| finalizeFormatting(context); | |
| return [...context.insertRequests, ...context.formatRequests]; | |
| } catch (error) { | |
| if (error instanceof MarkdownConversionError) { | |
| throw error; | |
| } | |
| throw new MarkdownConversionError( | |
| `Failed to convert markdown: ${error instanceof Error ? error.message : 'Unknown error'}` | |
| ); | |
| } | |
| } | |
| // --- Token Processing --- | |
| function processToken(token: Token, context: ConversionContext): void { | |
| switch (token.type) { | |
| // Headings | |
| case 'heading_open': | |
| handleHeadingOpen(token, context); | |
| break; | |
| case 'heading_close': | |
| handleHeadingClose(context); | |
| break; | |
| // Paragraphs | |
| case 'paragraph_open': | |
| handleParagraphOpen(context); | |
| break; | |
| case 'paragraph_close': | |
| handleParagraphClose(context); | |
| break; | |
| // Text content | |
| case 'text': | |
| handleTextToken(token, context); | |
| break; | |
| case 'code_inline': | |
| handleCodeInlineToken(token, context); | |
| break; | |
| // Inline formatting | |
| case 'strong_open': | |
| context.formattingStack.push({ bold: true }); | |
| break; | |
| case 'strong_close': | |
| popFormatting(context, 'bold'); | |
| break; | |
| case 'em_open': | |
| context.formattingStack.push({ italic: true }); | |
| break; | |
| case 'em_close': | |
| popFormatting(context, 'italic'); | |
| break; | |
| case 's_open': | |
| context.formattingStack.push({ strikethrough: true }); | |
| break; | |
| case 's_close': | |
| popFormatting(context, 'strikethrough'); | |
| break; | |
| // Links | |
| case 'link_open': { | |
| const href = getLinkHref(token); | |
| if (href) { | |
| context.formattingStack.push({ link: href }); | |
| } | |
| break; | |
| } | |
| case 'link_close': | |
| popFormatting(context, 'link'); | |
| break; | |
| // Lists | |
| case 'bullet_list_open': | |
| context.listStack.push({ type: 'bullet', level: context.listStack.length }); | |
| break; | |
| case 'bullet_list_close': | |
| handleListClose(context); | |
| break; | |
| case 'ordered_list_open': | |
| context.listStack.push({ type: 'ordered', level: context.listStack.length }); | |
| break; | |
| case 'ordered_list_close': | |
| handleListClose(context); | |
| break; | |
| case 'list_item_open': | |
| handleListItemOpen(context); | |
| break; | |
| case 'list_item_close': | |
| handleListItemClose(context); | |
| break; | |
| // Breaks | |
| case 'softbreak': | |
| if (context.inTableCell && context.tableState?.currentCell) { | |
| context.tableState.currentCell.text += ' '; | |
| } else { | |
| insertText(' ', context); | |
| } | |
| break; | |
| case 'hardbreak': | |
| if (context.inTableCell && context.tableState?.currentCell) { | |
| context.tableState.currentCell.text += '\n'; | |
| } else { | |
| insertText('\n', context); | |
| } | |
| break; | |
| // Inline container | |
| case 'inline': | |
| if (token.children) { | |
| for (const child of token.children) { | |
| processToken(child, context); | |
| } | |
| } | |
| break; | |
| // Tables | |
| case 'table_open': | |
| context.tableState = { rows: [], currentRow: [], inHeader: false, currentCell: null }; | |
| break; | |
| case 'thead_open': | |
| if (context.tableState) context.tableState.inHeader = true; | |
| break; | |
| case 'thead_close': | |
| if (context.tableState) context.tableState.inHeader = false; | |
| break; | |
| case 'tbody_open': | |
| case 'tbody_close': | |
| break; | |
| case 'tr_open': | |
| if (context.tableState) context.tableState.currentRow = []; | |
| break; | |
| case 'tr_close': | |
| if (context.tableState && context.tableState.currentRow.length > 0) { | |
| context.tableState.rows.push([...context.tableState.currentRow]); | |
| context.tableState.currentRow = []; | |
| } | |
| break; | |
| case 'th_open': | |
| case 'td_open': { | |
| if (context.tableState) { | |
| context.tableState.currentCell = { | |
| text: '', | |
| isHeader: context.tableState.inHeader || token.type === 'th_open', | |
| textRanges: [], | |
| }; | |
| context.inTableCell = true; | |
| } | |
| break; | |
| } | |
| case 'th_close': | |
| case 'td_close': | |
| if (context.tableState?.currentCell) { | |
| context.tableState.currentRow.push(context.tableState.currentCell); | |
| context.tableState.currentCell = null; | |
| } | |
| context.inTableCell = false; | |
| break; | |
| case 'table_close': | |
| if (context.tableState) { | |
| handleTableClose(context.tableState, context); | |
| context.tableState = undefined; | |
| context.inTableCell = false; | |
| } | |
| break; | |
| // Code blocks | |
| case 'fence': | |
| case 'code_block': | |
| handleCodeBlockToken(token, context); | |
| break; | |
| // Horizontal rules | |
| case 'hr': | |
| handleHorizontalRule(context); | |
| break; | |
| // Blockquotes (skip for now) | |
| case 'blockquote_open': | |
| case 'blockquote_close': | |
| break; | |
| default: | |
| break; | |
| } | |
| } | |
| // --- Heading Handlers --- | |
| function handleHeadingOpen(token: Token, context: ConversionContext): void { | |
| const level = getHeadingLevel(token); | |
| if (level) { | |
| context.currentHeadingLevel = level; | |
| context.currentParagraphStart = context.currentIndex; | |
| } | |
| } | |
| function handleHeadingClose(context: ConversionContext): void { | |
| if (context.currentHeadingLevel && context.currentParagraphStart !== undefined) { | |
| // When firstHeadingAsTitle is enabled, the very first H1 becomes a TITLE. | |
| const useTitle = | |
| context.firstHeadingAsTitle && !context.titleConsumed && context.currentHeadingLevel === 1; | |
| if (useTitle) { | |
| context.titleConsumed = true; | |
| } | |
| context.paragraphRanges.push({ | |
| startIndex: context.currentParagraphStart, | |
| endIndex: context.currentIndex, | |
| namedStyleType: useTitle ? 'TITLE' : `HEADING_${context.currentHeadingLevel}`, | |
| }); | |
| insertText('\n', context); | |
| context.currentHeadingLevel = undefined; | |
| context.currentParagraphStart = undefined; | |
| } | |
| } | |
| // --- Horizontal Rule --- | |
| function handleHorizontalRule(context: ConversionContext): void { | |
| if (!lastInsertEndsWithNewline(context)) { | |
| insertText('\n', context); | |
| } | |
| const start = context.currentIndex; | |
| insertText('\n', context); | |
| context.hrRanges.push({ startIndex: start, endIndex: context.currentIndex }); | |
| } | |
| // --- Paragraph Handlers --- | |
| function handleParagraphOpen(context: ConversionContext): void { | |
| if (context.listStack.length === 0) { | |
| context.currentParagraphStart = context.currentIndex; | |
| } | |
| } | |
| function handleParagraphClose(context: ConversionContext): void { | |
| // Track normal (non-list) paragraph ranges for spacing | |
| const paragraphStart = context.currentParagraphStart; | |
| if (!lastInsertEndsWithNewline(context)) { | |
| insertText('\n', context); | |
| } | |
| const currentListItem = getCurrentOpenListItem(context); | |
| if (currentListItem) { | |
| const paragraphEndIndex = lastInsertEndsWithNewline(context) | |
| ? context.currentIndex - 1 | |
| : context.currentIndex; | |
| if (paragraphEndIndex > currentListItem.startIndex) { | |
| currentListItem.endIndex = paragraphEndIndex; | |
| } | |
| } | |
| // Record the range for normal paragraphs (not list items) so we can apply spacing later | |
| if (paragraphStart !== undefined && context.listStack.length === 0) { | |
| context.normalParagraphRanges.push({ | |
| startIndex: paragraphStart, | |
| endIndex: context.currentIndex, | |
| }); | |
| } | |
| context.currentParagraphStart = undefined; | |
| } | |
| // --- List Handlers --- | |
| function handleListItemOpen(context: ConversionContext): void { | |
| if (context.listStack.length === 0) { | |
| throw new MarkdownConversionError('List item found outside of list context'); | |
| } | |
| const currentList = context.listStack[context.listStack.length - 1]; | |
| const itemStart = context.currentIndex; | |
| if (currentList.level > 0) { | |
| insertText('\t'.repeat(currentList.level), context); | |
| } | |
| const listItem: PendingListItem = { | |
| startIndex: itemStart, | |
| nestingLevel: currentList.level, | |
| bulletPreset: | |
| currentList.type === 'ordered' ? 'NUMBERED_DECIMAL_ALPHA_ROMAN' : 'BULLET_DISC_CIRCLE_SQUARE', | |
| taskPrefixProcessed: false, | |
| }; | |
| context.pendingListItems.push(listItem); | |
| context.openListItemStack.push(context.pendingListItems.length - 1); | |
| } | |
| function handleListItemClose(context: ConversionContext): void { | |
| const openIndex = context.openListItemStack.pop(); | |
| if (openIndex === undefined) return; | |
| const listItem = context.pendingListItems[openIndex]; | |
| if (listItem.endIndex === undefined) { | |
| const computedEndIndex = lastInsertEndsWithNewline(context) | |
| ? context.currentIndex - 1 | |
| : context.currentIndex; | |
| if (computedEndIndex > listItem.startIndex) { | |
| listItem.endIndex = computedEndIndex; | |
| } | |
| } | |
| if (!lastInsertEndsWithNewline(context)) { | |
| insertText('\n', context); | |
| } | |
| } | |
| function handleListClose(context: ConversionContext): void { | |
| context.listStack.pop(); | |
| // When a top-level list closes (stack becomes empty), record the range of the | |
| // last list item's paragraph so we can apply spaceBelow to it. This creates a | |
| // visible gap between the end of a list and the following content. | |
| if (context.listStack.length === 0) { | |
| // Find the last pending list item that has a valid endIndex | |
| for (let i = context.pendingListItems.length - 1; i >= 0; i--) { | |
| const item = context.pendingListItems[i]; | |
| if (item.endIndex !== undefined && item.endIndex > item.startIndex) { | |
| context.listSpacingRanges.push({ | |
| startIndex: item.startIndex, | |
| endIndex: item.endIndex, | |
| }); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| // --- Text Handling --- | |
| function handleTextToken(token: Token, context: ConversionContext): void { | |
| let text = token.content; | |
| if (!text) return; | |
| // Intercept text when inside a table cell — collect into cell buffer | |
| if (context.inTableCell && context.tableState?.currentCell) { | |
| const cell = context.tableState.currentCell; | |
| const startIndex = cell.text.length; | |
| cell.text += text; | |
| const formatting = mergeFormattingStack(context.formattingStack); | |
| if (hasFormatting(formatting)) { | |
| cell.textRanges.push({ startIndex, endIndex: cell.text.length, formatting }); | |
| } | |
| return; | |
| } | |
| const currentListItem = getCurrentOpenListItem(context); | |
| if (currentListItem && !currentListItem.taskPrefixProcessed) { | |
| currentListItem.taskPrefixProcessed = true; | |
| const taskPrefixMatch = text.match(/^\[( |x|X)\]\s+/); | |
| if (taskPrefixMatch) { | |
| currentListItem.bulletPreset = 'BULLET_CHECKBOX'; | |
| text = text.slice(taskPrefixMatch[0].length); | |
| if (!text) return; | |
| } | |
| } | |
| const startIndex = context.currentIndex; | |
| const endIndex = startIndex + text.length; | |
| insertText(text, context); | |
| const currentFormatting = mergeFormattingStack(context.formattingStack); | |
| if (hasFormatting(currentFormatting)) { | |
| context.textRanges.push({ startIndex, endIndex, formatting: currentFormatting }); | |
| } | |
| } | |
| function handleCodeInlineToken(token: Token, context: ConversionContext): void { | |
| context.formattingStack.push({ code: true }); | |
| handleTextToken(token, context); | |
| popFormatting(context, 'code'); | |
| } | |
| function handleCodeBlockToken(token: Token, context: ConversionContext): void { | |
| const normalizedContent = token.content.endsWith('\n') | |
| ? token.content.slice(0, -1) | |
| : token.content; | |
| const language = token.info?.trim() || undefined; | |
| // Ensure previous content ends with a newline before inserting the table | |
| if (context.insertRequests.length > 0 && !lastInsertEndsWithNewline(context)) { | |
| insertText('\n', context); | |
| } | |
| const tableStartIndex = context.currentIndex; | |
| // 1. Insert a 1x1 table (creates the table structure with an empty paragraph in the cell) | |
| const tableLocation: Record<string, unknown> = { index: tableStartIndex }; | |
| if (context.tabId) tableLocation.tabId = context.tabId; | |
| context.insertRequests.push({ | |
| insertTable: { | |
| location: tableLocation as docs_v1.Schema$Location, | |
| rows: 1, | |
| columns: 1, | |
| }, | |
| }); | |
| // 2. Insert code text into the cell paragraph | |
| // For a 1x1 table at index N, the cell paragraph content starts at N + CELL_CONTENT_OFFSET | |
| const cellContentIndex = tableStartIndex + CELL_CONTENT_OFFSET; | |
| const textLength = normalizedContent.length; | |
| if (textLength > 0) { | |
| const cellLocation: Record<string, unknown> = { index: cellContentIndex }; | |
| if (context.tabId) cellLocation.tabId = context.tabId; | |
| context.insertRequests.push({ | |
| insertText: { | |
| location: cellLocation as docs_v1.Schema$Location, | |
| text: normalizedContent, | |
| }, | |
| }); | |
| } | |
| // 3. Track the code block for table/text formatting in finalization | |
| context.codeBlockRanges.push({ | |
| tableStartIndex, | |
| textStartIndex: cellContentIndex, | |
| textEndIndex: cellContentIndex + textLength, | |
| language, | |
| }); | |
| // 4. Advance currentIndex past the entire table structure | |
| // Total table size = EMPTY_1x1_TABLE_SIZE + inserted text length | |
| context.currentIndex = tableStartIndex + EMPTY_1x1_TABLE_SIZE + textLength; | |
| // 5. Ensure a newline after the table for paragraph separation | |
| insertText('\n', context); | |
| } | |
| // --- Table Handler --- | |
| // | |
| // Google Docs API table index layout for an R×C table inserted at T: | |
| // | |
| // T → newline (auto-inserted before table) | |
| // T + 1 → table element | |
| // T + 2 → row[0] | |
| // T + 3 → cell[0][0] | |
| // T + 4 → paragraph in cell[0][0] ← content insertion point | |
| // T + 5 → cell[0][1] (for C > 1) | |
| // T + 6 → paragraph in cell[0][1] | |
| // ... | |
| // T + 2+C → row[1] (for R > 1) | |
| // ... | |
| // T + end → table end | |
| // | |
| // Formulas (verified empirically via CELL_CONTENT_OFFSET and EMPTY_1x1_TABLE_SIZE): | |
| // cellContentIndex(T, r, c, C) = T + 4 + r * (1 + 2*C) + 2*c | |
| // emptyTableSize(R, C) = 3 + R * (1 + 2*C) | |
| function handleTableClose(tableState: TableState, context: ConversionContext): void { | |
| const rows = tableState.rows; | |
| if (rows.length === 0) return; | |
| const numRows = rows.length; | |
| const numCols = rows.reduce((max, row) => Math.max(max, row.length), 0); | |
| if (numCols === 0) return; | |
| // Ensure newline before table | |
| if (!lastInsertEndsWithNewline(context)) { | |
| insertText('\n', context); | |
| } | |
| const tableStartIndex = context.currentIndex; | |
| // Insert the table structure | |
| const tableLocation: Record<string, unknown> = { index: tableStartIndex }; | |
| if (context.tabId) tableLocation.tabId = context.tabId; | |
| context.insertRequests.push({ | |
| insertTable: { | |
| location: tableLocation as docs_v1.Schema$Location, | |
| rows: numRows, | |
| columns: numCols, | |
| }, | |
| }); | |
| // Insert text into each cell, tracking cumulative offset from prior insertions | |
| let cumulativeTextLength = 0; | |
| for (let r = 0; r < numRows; r++) { | |
| for (let c = 0; c < numCols; c++) { | |
| const cell = rows[r]?.[c]; | |
| if (!cell?.text) continue; | |
| const baseCellIndex = tableStartIndex + 4 + r * (1 + 2 * numCols) + 2 * c; | |
| const adjustedIndex = baseCellIndex + cumulativeTextLength; | |
| const cellLocation: Record<string, unknown> = { index: adjustedIndex }; | |
| if (context.tabId) cellLocation.tabId = context.tabId; | |
| context.insertRequests.push({ | |
| insertText: { | |
| location: cellLocation as docs_v1.Schema$Location, | |
| text: cell.text, | |
| }, | |
| }); | |
| // Apply inline formatting (bold, italic, links, etc.) from within the cell | |
| for (const range of cell.textRanges) { | |
| const absStart = adjustedIndex + range.startIndex; | |
| const absEnd = adjustedIndex + range.endIndex; | |
| if ( | |
| range.formatting.bold || | |
| range.formatting.italic || | |
| range.formatting.strikethrough || | |
| range.formatting.code | |
| ) { | |
| const styleReq = buildUpdateTextStyleRequest( | |
| absStart, | |
| absEnd, | |
| { | |
| bold: range.formatting.bold, | |
| italic: range.formatting.italic, | |
| strikethrough: range.formatting.strikethrough, | |
| fontFamily: range.formatting.code ? CODE_FONT_FAMILY : undefined, | |
| foregroundColor: range.formatting.code ? CODE_TEXT_HEX : undefined, | |
| backgroundColor: range.formatting.code ? CODE_BACKGROUND_HEX : undefined, | |
| }, | |
| context.tabId | |
| ); | |
| if (styleReq) context.formatRequests.push(styleReq.request); | |
| } | |
| if (range.formatting.link) { | |
| const linkReq = buildUpdateTextStyleRequest( | |
| absStart, | |
| absEnd, | |
| { linkUrl: range.formatting.link }, | |
| context.tabId | |
| ); | |
| if (linkReq) context.formatRequests.push(linkReq.request); | |
| } | |
| } | |
| // Bold all text in header cells | |
| if (cell.isHeader && cell.text.length > 0) { | |
| const headerStyleReq = buildUpdateTextStyleRequest( | |
| adjustedIndex, | |
| adjustedIndex + cell.text.length, | |
| { bold: true }, | |
| context.tabId | |
| ); | |
| if (headerStyleReq) context.formatRequests.push(headerStyleReq.request); | |
| } | |
| cumulativeTextLength += cell.text.length; | |
| } | |
| } | |
| // Advance currentIndex past the full table (empty structure + all inserted text) | |
| const emptyTableSize = 3 + numRows * (1 + 2 * numCols); | |
| context.currentIndex = tableStartIndex + emptyTableSize + cumulativeTextLength; | |
| // Ensure newline after table | |
| insertText('\n', context); | |
| } | |
| // --- Insert Helper --- | |
| function insertText(text: string, context: ConversionContext): void { | |
| const location: Record<string, unknown> = { index: context.currentIndex }; | |
| if (context.tabId) { | |
| location.tabId = context.tabId; | |
| } | |
| context.insertRequests.push({ | |
| insertText: { location: location as docs_v1.Schema$Location, text }, | |
| }); | |
| context.currentIndex += text.length; | |
| } | |
| // --- Formatting Stack --- | |
| function mergeFormattingStack(stack: FormattingState[]): FormattingState { | |
| const merged: FormattingState = {}; | |
| for (const state of stack) { | |
| if (state.bold !== undefined) merged.bold = state.bold; | |
| if (state.italic !== undefined) merged.italic = state.italic; | |
| if (state.strikethrough !== undefined) merged.strikethrough = state.strikethrough; | |
| if (state.code !== undefined) merged.code = state.code; | |
| if (state.link !== undefined) merged.link = state.link; | |
| } | |
| return merged; | |
| } | |
| function hasFormatting(formatting: FormattingState): boolean { | |
| return ( | |
| formatting.bold === true || | |
| formatting.italic === true || | |
| formatting.strikethrough === true || | |
| formatting.code === true || | |
| formatting.link !== undefined | |
| ); | |
| } | |
| function popFormatting(context: ConversionContext, type: keyof FormattingState): void { | |
| for (let i = context.formattingStack.length - 1; i >= 0; i--) { | |
| if (context.formattingStack[i][type] !== undefined) { | |
| context.formattingStack.splice(i, 1); | |
| break; | |
| } | |
| } | |
| } | |
| // --- Finalization --- | |
| function finalizeFormatting(context: ConversionContext): void { | |
| // Character-level formatting (bold, italic, strikethrough, code, links) | |
| for (const range of context.textRanges) { | |
| const rangeLocation: docs_v1.Schema$Range = { | |
| startIndex: range.startIndex, | |
| endIndex: range.endIndex, | |
| }; | |
| if (context.tabId) { | |
| rangeLocation.tabId = context.tabId; | |
| } | |
| if ( | |
| range.formatting.bold || | |
| range.formatting.italic || | |
| range.formatting.strikethrough || | |
| range.formatting.code | |
| ) { | |
| const styleRequest = buildUpdateTextStyleRequest( | |
| range.startIndex, | |
| range.endIndex, | |
| { | |
| bold: range.formatting.bold, | |
| italic: range.formatting.italic, | |
| strikethrough: range.formatting.strikethrough, | |
| fontFamily: range.formatting.code ? CODE_FONT_FAMILY : undefined, | |
| foregroundColor: range.formatting.code ? CODE_TEXT_HEX : undefined, | |
| backgroundColor: range.formatting.code ? CODE_BACKGROUND_HEX : undefined, | |
| }, | |
| context.tabId | |
| ); | |
| if (styleRequest) { | |
| context.formatRequests.push(styleRequest.request); | |
| } | |
| } | |
| if (range.formatting.link) { | |
| const linkRequest = buildUpdateTextStyleRequest( | |
| range.startIndex, | |
| range.endIndex, | |
| { linkUrl: range.formatting.link }, | |
| context.tabId | |
| ); | |
| if (linkRequest) { | |
| context.formatRequests.push(linkRequest.request); | |
| } | |
| } | |
| } | |
| // Paragraph-level formatting (headings) | |
| for (const paraRange of context.paragraphRanges) { | |
| if (paraRange.namedStyleType) { | |
| const paraRequest = buildUpdateParagraphStyleRequest( | |
| paraRange.startIndex, | |
| paraRange.endIndex, | |
| { namedStyleType: paraRange.namedStyleType as any }, | |
| context.tabId | |
| ); | |
| if (paraRequest) { | |
| context.formatRequests.push(paraRequest.request); | |
| } | |
| } | |
| } | |
| // Normal paragraph spacing (spaceBelow so paragraphs have visible gaps between them, | |
| // matching the visual separation expected from markdown-rendered paragraphs). | |
| // The default Google Docs NORMAL_TEXT style has 0pt spacing, so without this | |
| // paragraphs would appear crammed together with no gap. | |
| for (const normalRange of context.normalParagraphRanges) { | |
| const range: docs_v1.Schema$Range = { | |
| startIndex: normalRange.startIndex, | |
| endIndex: normalRange.endIndex, | |
| }; | |
| if (context.tabId) { | |
| range.tabId = context.tabId; | |
| } | |
| context.formatRequests.push({ | |
| updateParagraphStyle: { | |
| range, | |
| paragraphStyle: { | |
| spaceBelow: { magnitude: 8, unit: 'PT' }, | |
| }, | |
| fields: 'spaceBelow', | |
| }, | |
| }); | |
| } | |
| // List trailing spacing: apply spaceBelow to the last paragraph of each | |
| // top-level list so there is a visible gap between the list and the content | |
| // that follows it. | |
| for (const listRange of context.listSpacingRanges) { | |
| const range: docs_v1.Schema$Range = { | |
| startIndex: listRange.startIndex, | |
| endIndex: listRange.endIndex, | |
| }; | |
| if (context.tabId) { | |
| range.tabId = context.tabId; | |
| } | |
| context.formatRequests.push({ | |
| updateParagraphStyle: { | |
| range, | |
| paragraphStyle: { | |
| spaceBelow: { magnitude: 8, unit: 'PT' }, | |
| }, | |
| fields: 'spaceBelow', | |
| }, | |
| }); | |
| } | |
| // Code block table formatting (1x1 table with background + monospace text) | |
| for (const codeBlock of context.codeBlockRanges) { | |
| // The actual table element starts at tableStartIndex + 1 because insertTable | |
| // auto-inserts a preceding newline at tableStartIndex (see constants comment above). | |
| const tableStartLocation: Record<string, unknown> = { index: codeBlock.tableStartIndex + 1 }; | |
| if (context.tabId) tableStartLocation.tabId = context.tabId; | |
| // Style the text inside the cell as monospace | |
| if (codeBlock.textEndIndex > codeBlock.textStartIndex) { | |
| const codeTextStyle = buildUpdateTextStyleRequest( | |
| codeBlock.textStartIndex, | |
| codeBlock.textEndIndex, | |
| { fontFamily: CODE_FONT_FAMILY }, | |
| context.tabId | |
| ); | |
| if (codeTextStyle) { | |
| context.formatRequests.push(codeTextStyle.request); | |
| } | |
| } | |
| // Set cell background color to light gray | |
| const borderStyle = { | |
| color: { color: { rgbColor: CODE_BLOCK_BORDER_RGB } }, | |
| width: { magnitude: 0.5, unit: 'PT' }, | |
| dashStyle: 'SOLID', | |
| }; | |
| context.formatRequests.push({ | |
| updateTableCellStyle: { | |
| tableRange: { | |
| tableCellLocation: { | |
| tableStartLocation: tableStartLocation as docs_v1.Schema$Location, | |
| rowIndex: 0, | |
| columnIndex: 0, | |
| }, | |
| rowSpan: 1, | |
| columnSpan: 1, | |
| }, | |
| tableCellStyle: { | |
| backgroundColor: { color: { rgbColor: CODE_BLOCK_BG_RGB } }, | |
| paddingTop: { magnitude: 8, unit: 'PT' }, | |
| paddingBottom: { magnitude: 8, unit: 'PT' }, | |
| paddingLeft: { magnitude: 12, unit: 'PT' }, | |
| paddingRight: { magnitude: 12, unit: 'PT' }, | |
| borderTop: borderStyle, | |
| borderBottom: borderStyle, | |
| borderLeft: borderStyle, | |
| borderRight: borderStyle, | |
| }, | |
| fields: | |
| 'backgroundColor,paddingTop,paddingBottom,paddingLeft,paddingRight,borderTop,borderBottom,borderLeft,borderRight', | |
| }, | |
| }); | |
| } | |
| // Horizontal rule styling (bottom border on empty paragraphs) | |
| for (const hrRange of context.hrRanges) { | |
| const range: docs_v1.Schema$Range = { | |
| startIndex: hrRange.startIndex, | |
| endIndex: hrRange.endIndex, | |
| }; | |
| if (context.tabId) { | |
| range.tabId = context.tabId; | |
| } | |
| context.formatRequests.push({ | |
| updateParagraphStyle: { | |
| range, | |
| paragraphStyle: { | |
| borderBottom: { | |
| color: { | |
| color: { rgbColor: { red: 0.75, green: 0.75, blue: 0.75 } }, | |
| }, | |
| width: { magnitude: 1, unit: 'PT' }, | |
| padding: { magnitude: 6, unit: 'PT' }, | |
| dashStyle: 'SOLID', | |
| }, | |
| }, | |
| fields: 'borderBottom', | |
| }, | |
| }); | |
| } | |
| // List formatting: merge *adjacent* items of the same bullet type into single | |
| // ranges so Google Docs treats them as one list (with sequential numbering). | |
| // Items are only merged when they're truly adjacent (gap of at most 1 char | |
| // for the newline between them). Separate lists with paragraphs, headings, or | |
| // other content between them must NOT be merged, otherwise | |
| // createParagraphBullets would turn all intervening content into bullets. | |
| const validListItems = context.pendingListItems | |
| .filter((item) => item.endIndex !== undefined && item.endIndex > item.startIndex) | |
| .sort((a, b) => a.startIndex - b.startIndex); | |
| const mergedListRanges: { startIndex: number; endIndex: number; bulletPreset: string }[] = []; | |
| for (const item of validListItems) { | |
| const last = mergedListRanges[mergedListRanges.length - 1]; | |
| if (last && last.bulletPreset === item.bulletPreset && item.startIndex <= last.endIndex + 1) { | |
| last.endIndex = Math.max(last.endIndex, item.endIndex!); | |
| } else { | |
| mergedListRanges.push({ | |
| startIndex: item.startIndex, | |
| endIndex: item.endIndex!, | |
| bulletPreset: item.bulletPreset, | |
| }); | |
| } | |
| } | |
| // Apply bottom-to-top to avoid index shifts from tab consumption | |
| mergedListRanges.sort((a, b) => b.startIndex - a.startIndex); | |
| for (const merged of mergedListRanges) { | |
| const rangeLocation: docs_v1.Schema$Range = { | |
| startIndex: merged.startIndex, | |
| endIndex: merged.endIndex, | |
| }; | |
| if (context.tabId) { | |
| rangeLocation.tabId = context.tabId; | |
| } | |
| context.formatRequests.push({ | |
| createParagraphBullets: { | |
| range: rangeLocation, | |
| bulletPreset: merged.bulletPreset, | |
| }, | |
| }); | |
| } | |
| } | |
| // --- Utility --- | |
| function getCurrentOpenListItem(context: ConversionContext): PendingListItem | null { | |
| const openIndex = context.openListItemStack[context.openListItemStack.length - 1]; | |
| if (openIndex === undefined) return null; | |
| return context.pendingListItems[openIndex] ?? null; | |
| } | |
| function lastInsertEndsWithNewline(context: ConversionContext): boolean { | |
| const lastInsert = context.insertRequests[context.insertRequests.length - 1]?.insertText?.text; | |
| return Boolean(lastInsert && lastInsert.endsWith('\n')); | |
| } | |