diff --git a/src/main/export/rich-text.test.ts b/src/main/export/rich-text.test.ts new file mode 100644 index 0000000..b871a92 --- /dev/null +++ b/src/main/export/rich-text.test.ts @@ -0,0 +1,237 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + buildRichText, + checkboxesToGlyphs, + cleanDeclarations, + embedImages, + inlineStyles, + insertColgroups, + parseTokens, + resolveVars, + stripPositions +} from './rich-text' + +/* The sheets themselves: a ?raw import is stubbed away under vitest, and + * only the real palette proves the copy carries the app's own values. */ +const sheet = (path: string): string => + readFileSync(new URL(`../../renderer/styles/${path}`, import.meta.url), 'utf8') +const PALETTE = parseTokens(sheet('tokens.css'), sheet('themes/ledger.css')) + +describe('parseTokens — the app’s own values, not a second palette', () => { + it('reads declarations and ignores comments', () => { + const tokens = parseTokens(`/* --ink: #ffffff; */\n:root { --ink: #1a1d1b; --rule: #d6dad5; }`) + expect(tokens.get('ink')).toBe('#1a1d1b') + expect(tokens.get('rule')).toBe('#d6dad5') + }) + + it('a later sheet wins — the theme palette lands over the defaults', () => { + const tokens = parseTokens(`:root { --paper: #000; }`, `:root { --paper: #eff1ee; }`) + expect(tokens.get('paper')).toBe('#eff1ee') + }) +}) + +describe('resolveVars — every var() becomes a literal', () => { + const tokens = parseTokens( + `:root { --ink: #1a1d1b; --shiki-token-comment: var(--ink-ghost); --ink-ghost: #9ba39c; }` + ) + + it('resolves a plain reference', () => { + expect(resolveVars('color: var(--ink)', tokens)).toBe('color: #1a1d1b') + }) + + it('follows a reference through another token', () => { + expect(resolveVars('color: var(--shiki-token-comment)', tokens)).toBe('color: #9ba39c') + }) + + it('uses the fallback when the token is unknown, and empties it when there is none', () => { + expect(resolveVars('color: var(--nope, #123456)', tokens)).toBe('color: #123456') + expect(resolveVars('background: var(--nope)', tokens)).toBe('background: ') + }) + + it('leaves var-free css alone', () => { + expect(resolveVars('padding: 0 0.15em', tokens)).toBe('padding: 0 0.15em') + }) +}) + +describe('cleanDeclarations — no litter in the clipboard', () => { + it('drops a declaration whose value resolved to nothing', () => { + expect(cleanDeclarations('background: ; color: #1a1d1b')).toBe('color: #1a1d1b') + }) + + it('keeps order and normalizes spacing', () => { + expect(cleanDeclarations(' color: red ; padding: 0 ')).toBe('color: red; padding: 0') + }) +}) + +describe('the finished HTML', () => { + const DOC = [ + '# Title', + '', + 'A paragraph with **bold**, `code`, and a [link](https://example.com).', + '', + '| ID | Note |', + '| ---- | -------------- |', + '| R01 | first |', + '', + '| L | C | R |', + '| :-- | :-: | --: |', + '| a | b | c |', + '', + '> quoted', + '', + '- [ ] todo', + '- [x] done', + '', + '```js', + 'const x = 1 // note', + '```', + '', + '---', + '' + ].join('\n') + + const built = buildRichText(DOC, null, PALETTE) + + it('is one wrapped fragment — the platform states the encoding itself', async () => { + const { html } = await built + expect(html.startsWith('
link') + }) + + it('keeps GFM column alignment, which a style would otherwise override', async () => { + const { html } = await built + expect(html).toMatch(/]+text-align: center[^>]*>b<\/td>/) + expect(html).toMatch(/]+text-align: right[^>]*>c<\/td>/) + expect(html).toMatch(/]+text-align: left[^>]*>a<\/td>/) + }) + + it('proportions columns the way the source does, so a resized column travels', async () => { + const { html } = await built + // 4-wide ID against a 14-wide Note: the delimiter row's dash runs. + expect(html).toContain('') + }) + + it('turns task checkboxes into glyphs that survive any destination', async () => { + const { html } = await built + expect(html).not.toContain(']+list-style: none/) + }) + + it('resolves Shiki token colors to literals', async () => { + const { html } = await built + // The comment token points at --ink-ghost through the theme. + expect(html).toMatch(/\s*\/\/ note<\/span>/) + }) + + it('hands plain-text destinations the markdown itself', async () => { + const { text } = await built + expect(text).toBe(DOC) + }) +}) + +describe('the passes over finished HTML, on their own', () => { + it('insertColgroups: no source table, no colgroup', () => { + const html = '
x
' + expect(insertColgroups(html, 'no table here')).toBe(html) + }) + + it('stripPositions leaves other data attributes alone', () => { + expect(stripPositions('

x

')).toBe('

x

') + }) + + it('checkboxesToGlyphs handles both states and nothing else', () => { + expect(checkboxesToGlyphs(' a')).toBe('☐ a') + expect(checkboxesToGlyphs(' b')).toBe('☑ b') + expect(checkboxesToGlyphs('')).toBe('') + }) +}) + +describe('inlineStyles — the rewrite of our own markup', () => { + it('a > inside an attribute value does not end the tag early', () => { + const out = inlineStyles('a > b
', PALETTE) + expect(out).toContain('alt="a > b"') + expect(out).toMatch(/]+border-top/) + }) + + it('code is fence code only inside a fence, and inline code after it', () => { + const out = inlineStyles('
x

y

', PALETTE) + expect(out).toMatch(/]+padding: 0\.8em 1em/) + expect(out).toMatch(/x/) + expect(out).toMatch(/y/) + }) + + it('keeps ids, which footnote links are built on', () => { + expect(inlineStyles('

Footnotes

', PALETTE)).toContain( + 'id="notes"' + ) + }) + + it('leaves closing tags alone', () => { + expect(inlineStyles('', PALETTE)).toBe('') + }) +}) + +describe('embedImages', () => { + it('leaves remote and data sources alone', async () => { + const html = '' + expect(await embedImages(html, '/tmp')).toBe(html) + }) + + it('an unreadable local image still gets an absolute file url', async () => { + const out = await embedImages('', '/tmp/nowhere') + expect(out).toContain('src="file:///tmp/nowhere/missing.png"') + }) + + it('without a document folder a relative source is left as it is', async () => { + expect(await embedImages('', null)).toBe('') + }) +}) + +describe('the palette the copy is built from', () => { + it('resolves the app’s own tokens, so a copy is never a second theme', () => { + expect(PALETTE.get('rule')).toBe('#d6dad5') + expect(PALETTE.get('paper-sunk')).toBe('#e5e8e4') + // Shiki's tokens point through the palette — that indirection is what + // makes a theme restyle code for free, and it has to survive the copy. + expect(resolveVars('var(--shiki-token-comment)', PALETTE)).toBe('#9ba39c') + }) +}) diff --git a/src/main/export/rich-text.ts b/src/main/export/rich-text.ts new file mode 100644 index 0000000..c5d2598 --- /dev/null +++ b/src/main/export/rich-text.ts @@ -0,0 +1,331 @@ +import { readFile } from 'node:fs/promises' +import { extname, isAbsolute, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { columnDisplayWidth, parseTable, tableSourceAt } from '../../renderer/editor/table-model' +import ledgerCss from '../../renderer/styles/themes/ledger.css?raw' +import tokensCss from '../../renderer/styles/tokens.css?raw' +import { renderMarkdown } from '../../shared/markdown' + +/* Copy as Rich Text: the document as the preview renders it, on the + * clipboard, ready to paste into Google Docs, Word, or a CMS with its + * formatting intact. An export writes a file that carries its own + * stylesheet; a paste carries nothing — every destination throws away + *