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('
| x |
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('
xy
]+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
+ *