diff --git a/src/main/services/DocsToMarkdownConverter.ts b/src/main/services/DocsToMarkdownConverter.ts index 910bbee..66d3e3b 100644 --- a/src/main/services/DocsToMarkdownConverter.ts +++ b/src/main/services/DocsToMarkdownConverter.ts @@ -23,6 +23,7 @@ */ import { delimit, escapeText } from '@shared/markdown/inlineMarks'; +import { DOCS_LINE_BREAK } from '@shared/constants'; import { CODE_FONT_FAMILY } from './DocsDocumentBuilder'; import type { GDocsApiDocument, @@ -51,7 +52,10 @@ function readRuns(el: GDocsStructuralElement): Run[] { const ts = (pe.textRun?.textStyle ?? {}) as StyleBag; const family = (ts['weightedFontFamily'] as { fontFamily?: string } | undefined)?.fontFamily; runs.push({ - text: content, + // A vertical tab is a line break inside the paragraph; markdown spells + // that as two spaces and a newline. Leaving it as a raw control + // character would show up as a difference on every later sync. + text: content.split(DOCS_LINE_BREAK).join(' \n'), bold: ts['bold'] === true, italic: ts['italic'] === true, strikethrough: ts['strikethrough'] === true, diff --git a/src/main/services/MarkdownToDocsConverter.ts b/src/main/services/MarkdownToDocsConverter.ts index ea7f142..7366074 100644 --- a/src/main/services/MarkdownToDocsConverter.ts +++ b/src/main/services/MarkdownToDocsConverter.ts @@ -5,6 +5,7 @@ * to build an array of DocsElement objects suitable for the Google Docs API. */ import MarkdownIt from 'markdown-it'; +import { DOCS_LINE_BREAK } from '@shared/constants'; import type Token from 'markdown-it/lib/token.mjs'; import type { DocsDocument, DocsElement, DocsTextRun } from '@shared/types/google-docs'; @@ -62,9 +63,18 @@ function parseInlineTokens(children: Token[]): DocsTextRun[] { case 'link_close': link = undefined; break; + // A newline inside a run is not a line break to Docs: insertText ends + // the paragraph there and starts another. Every later paragraph then + // fails to match its model element, so nothing is styled and the text + // keeps whatever it inherited. A soft-wrapped source file was enough to + // lose the formatting of a whole document. case 'softbreak': + // Markdown renders a wrapped line as a single space. + runs.push({ text: ' ' }); + break; case 'hardbreak': - runs.push({ text: '\n' }); + // Docs spells a within-paragraph line break as a vertical tab. + runs.push({ text: DOCS_LINE_BREAK }); break; case 'image': // Inline images: use alt text from children content, src from attrs diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index 7c8d7e4..abc6960 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -66,3 +66,11 @@ export const THEMES = { GITHUB_LIGHT: 'github-light', GITHUB_DARK: 'github-dark', } as const; + +/** + * How Google Docs spells a line break inside a paragraph (SHIFT+ENTER). + * + * A plain newline is not one: insertText ends the paragraph there and starts + * another, which breaks every later paragraph's match against the model. + */ +export const DOCS_LINE_BREAK = '\u000b'; diff --git a/tests/unit/main/services/DocsToMarkdownConverter.test.ts b/tests/unit/main/services/DocsToMarkdownConverter.test.ts index cffc2dd..1a968a3 100644 --- a/tests/unit/main/services/DocsToMarkdownConverter.test.ts +++ b/tests/unit/main/services/DocsToMarkdownConverter.test.ts @@ -250,3 +250,24 @@ describe('artefacts that make a document differ from itself', () => { expect(md).toContain('\\~~b'); }); }); + +describe('a line break inside a paragraph', () => { + it('comes back as a markdown hard break, not a control character', () => { + // Docs spells a within-paragraph break as a vertical tab. Left raw it + // would read as a difference against the file on every later sync. + const md = convertDocsToMarkdown({ + body: { + content: [ + { + startIndex: 1, + endIndex: 26, + paragraph: { elements: [{ textRun: { content: 'First line.\u000bSecond line.\n' } }] }, + }, + ], + }, + }); + + expect(md).not.toContain('\u000b'); + expect(md).toContain('First line. \nSecond line.'); + }); +}); diff --git a/tests/unit/main/services/MarkdownToDocsConverter.test.ts b/tests/unit/main/services/MarkdownToDocsConverter.test.ts index d0d88b1..8f0a648 100644 --- a/tests/unit/main/services/MarkdownToDocsConverter.test.ts +++ b/tests/unit/main/services/MarkdownToDocsConverter.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { convertMarkdownToDocs } from '@main/services/MarkdownToDocsConverter'; +import { flattenElements, getLeafText } from '@main/services/DocsDocumentBuilder'; describe('MarkdownToDocsConverter', () => { it('should convert a plain paragraph', () => { @@ -171,3 +172,40 @@ describe('a table written directly under the line above it', () => { expect(doc.elements.map((e) => e.type)).toEqual(['list_item']); }); }); + +describe('line breaks inside a paragraph', () => { + // A newline in a run's text is not a line break to Docs -- insertText splits + // it into a whole new paragraph. Every later paragraph then fails to match + // its model element, so nothing gets styled and the inserted text keeps + // whatever heading style it inherited. A soft-wrapped source file was enough + // to lose the formatting of an entire document. + const leafText = (md: string): string[] => + flattenElements(convertMarkdownToDocs(md).elements).map((e) => getLeafText(e)); + + it('joins a soft-wrapped line with a space, as markdown renders it', () => { + expect(leafText('Scaffolds the thing if absent,\nthen reloads Caddy.\n')).toEqual([ + 'Scaffolds the thing if absent, then reloads Caddy.', + ]); + }); + + it('keeps a hard break inside the paragraph, as Docs spells it', () => { + // Docs writes a within-paragraph line break as a vertical tab. + expect(leafText('First line. \nSecond line.\n')).toEqual([ + 'First line.\u000bSecond line.', + ]); + }); + + it('never leaves a newline in an inline element', () => { + const md = [ + '# A heading that\nwraps', + '', + '- a list item that\n wraps too', + '', + 'Body text that\nwraps as well.', + ].join('\n'); + + for (const text of leafText(md)) { + expect(text).not.toContain('\n'); + } + }); +});