@bendyline/squisq-formats 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +180 -43
  2. package/dist/{chunk-33YRFXZZ.js → chunk-46FMDOWZ.js} +260 -14
  3. package/dist/chunk-46FMDOWZ.js.map +1 -0
  4. package/dist/{chunk-ERZ627GR.js → chunk-5LSSWZPU.js} +12 -27
  5. package/dist/chunk-5LSSWZPU.js.map +1 -0
  6. package/dist/chunk-6M7Z25LA.js +46 -0
  7. package/dist/chunk-6M7Z25LA.js.map +1 -0
  8. package/dist/{chunk-S3Y7H2BK.js → chunk-D7IFPWNZ.js} +95 -50
  9. package/dist/chunk-D7IFPWNZ.js.map +1 -0
  10. package/dist/chunk-DQAZR57U.js +33 -0
  11. package/dist/chunk-DQAZR57U.js.map +1 -0
  12. package/dist/chunk-EHLGMSTE.js +273 -0
  13. package/dist/chunk-EHLGMSTE.js.map +1 -0
  14. package/dist/{chunk-A3FHLTY5.js → chunk-MJGRI6XR.js} +6 -19
  15. package/dist/chunk-MJGRI6XR.js.map +1 -0
  16. package/dist/chunk-PN52A5AA.js +32 -0
  17. package/dist/chunk-PN52A5AA.js.map +1 -0
  18. package/dist/chunk-RFAPOKHJ.js +123 -0
  19. package/dist/chunk-RFAPOKHJ.js.map +1 -0
  20. package/dist/chunk-RTS5XBZ7.js +526 -0
  21. package/dist/chunk-RTS5XBZ7.js.map +1 -0
  22. package/dist/{chunk-7DEUGIOO.js → chunk-SHLPSADL.js} +120 -2
  23. package/dist/chunk-SHLPSADL.js.map +1 -0
  24. package/dist/chunk-SSUPBUF5.js +15 -0
  25. package/dist/chunk-SSUPBUF5.js.map +1 -0
  26. package/dist/{chunk-UDS45KUJ.js → chunk-WWBIKRNP.js} +40 -80
  27. package/dist/chunk-WWBIKRNP.js.map +1 -0
  28. package/dist/{chunk-VN2KEOYB.js → chunk-YESS7VY3.js} +379 -87
  29. package/dist/chunk-YESS7VY3.js.map +1 -0
  30. package/dist/csv/index.d.ts +50 -0
  31. package/dist/csv/index.js +13 -0
  32. package/dist/csv/index.js.map +1 -0
  33. package/dist/docx/index.js +6 -3
  34. package/dist/epub/index.js +4 -2
  35. package/dist/html/index.d.ts +42 -2
  36. package/dist/html/index.js +9 -2
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.js +47 -14
  39. package/dist/ooxml/index.d.ts +5 -1
  40. package/dist/ooxml/index.js +13 -9
  41. package/dist/pdf/index.js +2 -1
  42. package/dist/pptx/index.d.ts +36 -19
  43. package/dist/pptx/index.js +9 -2
  44. package/dist/registry/index.d.ts +167 -0
  45. package/dist/registry/index.js +17 -0
  46. package/dist/registry/index.js.map +1 -0
  47. package/dist/xlsx/index.d.ts +60 -29
  48. package/dist/xlsx/index.js +4 -1
  49. package/package.json +12 -2
  50. package/src/__tests__/convert.test.ts +186 -0
  51. package/src/__tests__/csvImport.test.ts +84 -0
  52. package/src/__tests__/exportThemeReconciliation.test.ts +87 -0
  53. package/src/__tests__/formatRegistry.test.ts +174 -0
  54. package/src/__tests__/htmlImport.test.ts +57 -0
  55. package/src/__tests__/lossyWarnings.test.ts +105 -0
  56. package/src/__tests__/pdfImport.test.ts +95 -1
  57. package/src/__tests__/plainHtml.test.ts +31 -0
  58. package/src/__tests__/pptxExport.test.ts +138 -0
  59. package/src/__tests__/pptxImport.test.ts +115 -0
  60. package/src/__tests__/roundTripAssets.test.ts +50 -0
  61. package/src/__tests__/roundTripMatrix.fixtures.ts +86 -0
  62. package/src/__tests__/roundTripMatrix.helpers.ts +154 -0
  63. package/src/__tests__/roundTripMatrix.test.ts +142 -0
  64. package/src/__tests__/sharedContainer.test.ts +41 -0
  65. package/src/__tests__/sharedImages.test.ts +61 -0
  66. package/src/__tests__/xlsxExport.test.ts +164 -0
  67. package/src/__tests__/xlsxImport.test.ts +80 -0
  68. package/src/csv/index.ts +188 -0
  69. package/src/docx/export.ts +27 -44
  70. package/src/docx/import.ts +7 -39
  71. package/src/epub/export.ts +10 -27
  72. package/src/html/imageUtils.ts +5 -19
  73. package/src/html/import.ts +297 -0
  74. package/src/html/index.ts +4 -0
  75. package/src/html/plainHtml.ts +66 -16
  76. package/src/index.ts +36 -5
  77. package/src/ooxml/index.ts +3 -0
  78. package/src/ooxml/namespaces.ts +13 -0
  79. package/src/pdf/export.ts +10 -5
  80. package/src/pdf/import.ts +146 -51
  81. package/src/pptx/export.ts +225 -85
  82. package/src/pptx/import.ts +273 -0
  83. package/src/pptx/index.ts +10 -28
  84. package/src/registry/convert.ts +318 -0
  85. package/src/registry/defaultFormats.ts +323 -0
  86. package/src/registry/errors.ts +46 -0
  87. package/src/registry/index.ts +38 -0
  88. package/src/registry/registry.ts +48 -0
  89. package/src/registry/types.ts +107 -0
  90. package/src/shared/container.ts +28 -0
  91. package/src/shared/images.ts +44 -0
  92. package/src/shared/inlineRuns.ts +99 -0
  93. package/src/shared/text.ts +41 -0
  94. package/src/xlsx/export.ts +253 -0
  95. package/src/xlsx/import.ts +159 -0
  96. package/src/xlsx/index.ts +18 -61
  97. package/dist/chunk-33YRFXZZ.js.map +0 -1
  98. package/dist/chunk-67KIJHV2.js +0 -21
  99. package/dist/chunk-67KIJHV2.js.map +0 -1
  100. package/dist/chunk-7DEUGIOO.js.map +0 -1
  101. package/dist/chunk-A3FHLTY5.js.map +0 -1
  102. package/dist/chunk-ERZ627GR.js.map +0 -1
  103. package/dist/chunk-S3Y7H2BK.js.map +0 -1
  104. package/dist/chunk-UDS45KUJ.js.map +0 -1
  105. package/dist/chunk-VN2KEOYB.js.map +0 -1
  106. package/dist/chunk-YN5HFCEW.js +0 -120
  107. package/dist/chunk-YN5HFCEW.js.map +0 -1
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tests for CSV import/export: csvToMarkdownDoc, markdownDocToCsv, parseCsv.
3
+ */
4
+
5
+ import { describe, expect, it } from 'vitest';
6
+ import type { MarkdownDocument, MarkdownTable, MarkdownText } from '@bendyline/squisq/markdown';
7
+ import { csvToMarkdownDoc, markdownDocToCsv, parseCsv } from '../csv/index';
8
+
9
+ function cellValue(table: MarkdownTable, row: number, col: number): string {
10
+ const cell = table.children[row]?.children[col];
11
+ const first = cell?.children[0] as MarkdownText | undefined;
12
+ return first?.value ?? '';
13
+ }
14
+
15
+ describe('parseCsv', () => {
16
+ it('parses quoted fields, escaped quotes, and embedded delimiters', () => {
17
+ const rows = parseCsv('a,b,c\n"x,y","he said ""hi""",z\n');
18
+ expect(rows).toEqual([
19
+ ['a', 'b', 'c'],
20
+ ['x,y', 'he said "hi"', 'z'],
21
+ ]);
22
+ });
23
+
24
+ it('handles CRLF line endings', () => {
25
+ expect(parseCsv('a,b\r\n1,2\r\n')).toEqual([
26
+ ['a', 'b'],
27
+ ['1', '2'],
28
+ ]);
29
+ });
30
+ });
31
+
32
+ describe('csvToMarkdownDoc', () => {
33
+ it('builds a table with a header row by default', async () => {
34
+ const doc = await csvToMarkdownDoc('Name,Age\nAlice,30\n"Bob, Jr.",40\n');
35
+ expect(doc.type).toBe('document');
36
+ const table = doc.children[0] as MarkdownTable;
37
+ expect(table.type).toBe('table');
38
+ expect(table.children).toHaveLength(3);
39
+ expect(table.children[0]!.children[0]!.isHeader).toBe(true);
40
+ expect(cellValue(table, 0, 0)).toBe('Name');
41
+ expect(cellValue(table, 0, 1)).toBe('Age');
42
+ expect(cellValue(table, 2, 0)).toBe('Bob, Jr.');
43
+ expect(cellValue(table, 2, 1)).toBe('40');
44
+ });
45
+
46
+ it('round-trips through markdownDocToCsv', async () => {
47
+ const csv = 'Name,Age\r\nAlice,30\r\n"Bob, Jr.",40';
48
+ const doc = await csvToMarkdownDoc(csv);
49
+ expect(markdownDocToCsv(doc)).toBe(csv);
50
+ });
51
+ });
52
+
53
+ describe('markdownDocToCsv tableIndex', () => {
54
+ async function docWithTwoTables(): Promise<MarkdownDocument> {
55
+ const first = await csvToMarkdownDoc('a,b\r\n1,2');
56
+ const second = await csvToMarkdownDoc('c,d\r\n3,4');
57
+ return { type: 'document', children: [...first.children, ...second.children] };
58
+ }
59
+
60
+ it('exports the first table by default', async () => {
61
+ const doc = await docWithTwoTables();
62
+ expect(markdownDocToCsv(doc)).toBe('a,b\r\n1,2');
63
+ });
64
+
65
+ it('picks the Nth table via tableIndex', async () => {
66
+ const doc = await docWithTwoTables();
67
+ expect(markdownDocToCsv(doc, { tableIndex: 0 })).toBe('a,b\r\n1,2');
68
+ expect(markdownDocToCsv(doc, { tableIndex: 1 })).toBe('c,d\r\n3,4');
69
+ });
70
+
71
+ it('throws a clear error for an out-of-range tableIndex', async () => {
72
+ const doc = await docWithTwoTables();
73
+ expect(() => markdownDocToCsv(doc, { tableIndex: 2 })).toThrow(
74
+ 'CSV export: tableIndex 2 is out of range — the document contains 2 table(s).',
75
+ );
76
+ expect(() => markdownDocToCsv(doc, { tableIndex: -1 })).toThrow('out of range');
77
+ });
78
+
79
+ it('returns an empty string for a table-less document with the implicit default', () => {
80
+ const doc: MarkdownDocument = { type: 'document', children: [] };
81
+ expect(markdownDocToCsv(doc)).toBe('');
82
+ expect(() => markdownDocToCsv(doc, { tableIndex: 0 })).toThrow('out of range');
83
+ });
84
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Export theme reconciliation.
3
+ *
4
+ * Every export format must honor the theme the document selects in its
5
+ * frontmatter (`squisq-theme`) — including an inline custom theme stored in
6
+ * `squisq-custom-themes` — without an explicit `themeId` option. This guards
7
+ * the fix that made PDF/EPUB read the frontmatter theme like PPTX/DOCX/HTML,
8
+ * and that custom themes resolve doc-scoped in exports (no global register).
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import JSZip from 'jszip';
13
+ import { compileTheme } from '@bendyline/squisq/schemas';
14
+ import { writeCustomThemesToFrontmatter } from '@bendyline/squisq/doc';
15
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
16
+ import { markdownDocToPptx } from '../pptx/export';
17
+ import { markdownDocToDocx } from '../docx/export';
18
+ import { markdownDocToPdf } from '../pdf/export';
19
+ import { markdownDocToEpub } from '../epub/export';
20
+ import { markdownDocToPlainHtml } from '../html/plainHtml';
21
+
22
+ const brand = compileTheme({
23
+ id: 'custom-brand',
24
+ name: 'Brand',
25
+ seedColors: { primary: '#ff0088', background: '#123456', text: '#abcdef' },
26
+ });
27
+
28
+ const withTheme = parseMarkdown(`---
29
+ title: Audit
30
+ squisq-theme: custom-brand
31
+ squisq-custom-themes: ${writeCustomThemesToFrontmatter([brand])}
32
+ ---
33
+
34
+ # Heading One
35
+
36
+ Body with a [link](https://example.com).
37
+ `);
38
+
39
+ const noTheme = parseMarkdown('# Heading One\n\nBody.\n');
40
+
41
+ /** Concatenate the text of every XML/CSS/HTML entry in a ZIP export. */
42
+ async function zipText(buf: ArrayBuffer): Promise<string> {
43
+ const zip = await JSZip.loadAsync(buf);
44
+ let text = '';
45
+ for (const name of Object.keys(zip.files)) {
46
+ if (/\.(xml|css|html|xhtml|rels)$/i.test(name)) {
47
+ text += await zip.files[name].async('string');
48
+ }
49
+ }
50
+ return text.toLowerCase();
51
+ }
52
+
53
+ describe('export theme reconciliation (frontmatter-only custom theme)', () => {
54
+ it('HTML applies the frontmatter custom theme colors', () => {
55
+ const html = markdownDocToPlainHtml(withTheme, {}).toLowerCase();
56
+ expect(html).toContain('123456'); // background
57
+ expect(html).toContain('abcdef'); // text
58
+ });
59
+
60
+ it('PPTX applies the frontmatter custom theme colors', async () => {
61
+ const text = await zipText(await markdownDocToPptx(withTheme, {}));
62
+ expect(text).toContain('123456');
63
+ });
64
+
65
+ it('DOCX applies the frontmatter custom theme colors', async () => {
66
+ const text = await zipText(await markdownDocToDocx(withTheme, {}));
67
+ expect(text).toContain('123456');
68
+ });
69
+
70
+ it('EPUB applies the frontmatter custom theme colors (frontmatter fallback)', async () => {
71
+ const text = await zipText(await markdownDocToEpub(withTheme, {}));
72
+ expect(text).toContain('123456');
73
+ });
74
+
75
+ it('PDF themes from frontmatter — themed output differs from un-themed', async () => {
76
+ const themed = new Uint8Array(await markdownDocToPdf(withTheme, {}));
77
+ const plain = new Uint8Array(await markdownDocToPdf(noTheme, {}));
78
+ const differs = themed.length !== plain.length || !themed.every((b, i) => b === plain[i]);
79
+ expect(differs).toBe(true);
80
+ });
81
+
82
+ it('an explicit themeId option still overrides the frontmatter theme', async () => {
83
+ const bare = await zipText(await markdownDocToPptx(withTheme, { themeId: 'standard' }));
84
+ // The custom background must NOT appear when the caller forces 'standard'.
85
+ expect(bare).not.toContain('123456');
86
+ });
87
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Tests for the format registry: createRegistry / defaultRegistry, capability
3
+ * consistency across every built-in, and the structured error codes.
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
8
+ import { markdownToDoc } from '@bendyline/squisq/doc';
9
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
10
+ import {
11
+ createRegistry,
12
+ defaultRegistry,
13
+ defaultFormats,
14
+ ConversionError,
15
+ BUILTIN_FORMAT_IDS,
16
+ } from '../registry/index';
17
+ import type { FormatDefinition, NormalizedInput } from '../registry/index';
18
+
19
+ const SAMPLE_MD = `# Title
20
+
21
+ Some intro text.
22
+
23
+ | Name | Value |
24
+ | --- | --- |
25
+ | A | 1 |
26
+ | B | 2 |
27
+ `;
28
+
29
+ async function makeInput(baseName = 'sample'): Promise<NormalizedInput> {
30
+ const markdownDoc = parseMarkdown(SAMPLE_MD);
31
+ const doc = markdownToDoc(markdownDoc);
32
+ const container = new MemoryContentContainer();
33
+ await container.writeDocument(SAMPLE_MD);
34
+ return { doc, markdownDoc, container, baseName };
35
+ }
36
+
37
+ const stubDef = (id: string, extensions: string[]): FormatDefinition => ({
38
+ id,
39
+ label: id.toUpperCase(),
40
+ mimeType: 'application/octet-stream',
41
+ extensions,
42
+ });
43
+
44
+ // ── Registry mechanics ──────────────────────────────────────────────
45
+
46
+ describe('createRegistry', () => {
47
+ it('registers and gets definitions by id', () => {
48
+ const reg = createRegistry();
49
+ reg.register(stubDef('foo', ['.foo']));
50
+ expect(reg.get('foo')?.id).toBe('foo');
51
+ expect(reg.get('missing')).toBeUndefined();
52
+ });
53
+
54
+ it('resolves byExtension, normalizing leading dot + case', () => {
55
+ const reg = createRegistry();
56
+ reg.register(stubDef('foo', ['.foo', '.f']));
57
+ expect(reg.byExtension('foo')?.id).toBe('foo');
58
+ expect(reg.byExtension('.FOO')?.id).toBe('foo');
59
+ expect(reg.byExtension('f')?.id).toBe('foo');
60
+ expect(reg.byExtension('nope')).toBeUndefined();
61
+ });
62
+
63
+ it('is last-write-wins by id', () => {
64
+ const reg = createRegistry();
65
+ reg.register(stubDef('foo', ['.foo']));
66
+ reg.register({ ...stubDef('foo', ['.foo']), label: 'SECOND' });
67
+ expect(reg.get('foo')?.label).toBe('SECOND');
68
+ expect(reg.list().filter((d) => d.id === 'foo')).toHaveLength(1);
69
+ });
70
+
71
+ it('lists all registered definitions', () => {
72
+ const reg = createRegistry();
73
+ reg.register(stubDef('a', ['.a']));
74
+ reg.register(stubDef('b', ['.b']));
75
+ expect(
76
+ reg
77
+ .list()
78
+ .map((d) => d.id)
79
+ .sort(),
80
+ ).toEqual(['a', 'b']);
81
+ });
82
+ });
83
+
84
+ describe('defaultRegistry', () => {
85
+ it('registers all built-in formats', () => {
86
+ const reg = defaultRegistry();
87
+ for (const id of BUILTIN_FORMAT_IDS) {
88
+ expect(reg.get(id), `built-in "${id}" should be registered`).toBeDefined();
89
+ }
90
+ expect(reg.list()).toHaveLength(BUILTIN_FORMAT_IDS.length);
91
+ });
92
+
93
+ it('defaultFormats matches BUILTIN_FORMAT_IDS exactly', () => {
94
+ const ids = defaultFormats()
95
+ .map((d) => d.id)
96
+ .sort();
97
+ expect(ids).toEqual([...BUILTIN_FORMAT_IDS].sort());
98
+ });
99
+ });
100
+
101
+ // ── Capability consistency ──────────────────────────────────────────
102
+
103
+ describe('built-in capability consistency', () => {
104
+ const formats = defaultFormats();
105
+
106
+ for (const def of formats) {
107
+ if (!def.exportDoc) continue;
108
+ it(`${def.id}: exportDoc produces bytes (or a tolerated not-implemented error)`, async () => {
109
+ const input = await makeInput();
110
+ // HTML formats need a player script; provide a trivial stub.
111
+ const options = { resolvePlayerScript: async () => '/* stub */' };
112
+ try {
113
+ const result = await def.exportDoc!(input, options);
114
+ // Use ArrayBuffer.isView rather than `instanceof Uint8Array`: under the
115
+ // jsdom test env, bytes built from node's TextEncoder/Uint8Array live in
116
+ // a different realm than the test file's Uint8Array global.
117
+ expect(ArrayBuffer.isView(result.bytes)).toBe(true);
118
+ expect(result.bytes.byteLength).toBeGreaterThan(0);
119
+ expect(typeof result.mimeType).toBe('string');
120
+ expect(Array.isArray(result.warnings)).toBe(true);
121
+ } catch (err: unknown) {
122
+ // xlsx export is being implemented concurrently and may still throw
123
+ // 'not yet implemented'. Tolerate that specific case only.
124
+ const message = err instanceof Error ? err.message : String(err);
125
+ expect(def.id, `unexpected export failure for "${def.id}": ${message}`).toBe('xlsx');
126
+ expect(message).toMatch(/not yet implemented/i);
127
+ }
128
+ });
129
+ }
130
+
131
+ it('csv export warns when the document has multiple tables', async () => {
132
+ const multiTable = parseMarkdown(
133
+ `| A | B |\n| - | - |\n| 1 | 2 |\n\n| C | D |\n| - | - |\n| 3 | 4 |\n`,
134
+ );
135
+ const container = new MemoryContentContainer();
136
+ const input: NormalizedInput = {
137
+ doc: markdownToDoc(multiTable),
138
+ markdownDoc: multiTable,
139
+ container,
140
+ baseName: 'tables',
141
+ };
142
+ const csv = defaultFormats().find((d) => d.id === 'csv')!;
143
+ const result = await csv.exportDoc!(input, {});
144
+ expect(result.warnings.length).toBeGreaterThan(0);
145
+ expect(result.warnings[0]).toMatch(/table/i);
146
+ });
147
+ });
148
+
149
+ // ── Error codes ─────────────────────────────────────────────────────
150
+
151
+ describe('error codes', () => {
152
+ it('ConversionError carries a stable code + optional fields', () => {
153
+ const err = new ConversionError('invalid-input', 'boom', {
154
+ format: 'docx',
155
+ hint: 'try again',
156
+ cause: new Error('root'),
157
+ });
158
+ expect(err).toBeInstanceOf(Error);
159
+ expect(err.name).toBe('ConversionError');
160
+ expect(err.code).toBe('invalid-input');
161
+ expect(err.format).toBe('docx');
162
+ expect(err.hint).toBe('try again');
163
+ expect((err as Error & { cause?: unknown }).cause).toBeInstanceOf(Error);
164
+ });
165
+
166
+ it('html export without a player script → missing-dependency', async () => {
167
+ const input = await makeInput();
168
+ const html = defaultFormats().find((d) => d.id === 'html')!;
169
+ await expect(html.exportDoc!(input, {})).rejects.toMatchObject({
170
+ name: 'ConversionError',
171
+ code: 'missing-dependency',
172
+ });
173
+ });
174
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { htmlToMarkdown, htmlToMarkdownDocSync } from '../html/import.js';
3
+
4
+ describe('htmlToMarkdown', () => {
5
+ it('converts headings and paragraphs', () => {
6
+ const md = htmlToMarkdown('<h1>Title</h1><p>Hello <strong>world</strong>.</p>');
7
+ expect(md).toContain('# Title');
8
+ expect(md).toContain('Hello **world**.');
9
+ });
10
+
11
+ it('converts links and inline code', () => {
12
+ const md = htmlToMarkdown(
13
+ '<p>See <a href="https://x.test">site</a> and <code>npm i</code></p>',
14
+ );
15
+ expect(md).toContain('[site](https://x.test)');
16
+ expect(md).toContain('`npm i`');
17
+ });
18
+
19
+ it('converts unordered and ordered lists', () => {
20
+ const md = htmlToMarkdown('<ul><li>a</li><li>b</li></ul><ol><li>one</li><li>two</li></ol>');
21
+ expect(md).toMatch(/[-*]\s+a/);
22
+ expect(md).toMatch(/[-*]\s+b/);
23
+ expect(md).toMatch(/1\.\s+one/);
24
+ });
25
+
26
+ it('converts blockquotes and tables', () => {
27
+ const md = htmlToMarkdown(
28
+ '<blockquote><p>quoted</p></blockquote>' +
29
+ '<table><thead><tr><th>H</th></tr></thead><tbody><tr><td>cell</td></tr></tbody></table>',
30
+ );
31
+ expect(md).toContain('> quoted');
32
+ expect(md).toContain('cell');
33
+ });
34
+
35
+ it('drops scripts and styles (sanitized by default)', () => {
36
+ const md = htmlToMarkdown('<p>safe</p><script>alert(1)</script><style>.x{color:red}</style>');
37
+ expect(md).toContain('safe');
38
+ expect(md).not.toContain('alert(1)');
39
+ expect(md).not.toContain('color:red');
40
+ });
41
+
42
+ it('strips javascript: links via sanitizer', () => {
43
+ const md = htmlToMarkdown('<a href="javascript:alert(1)">click</a>');
44
+ expect(md).not.toContain('javascript:alert(1)');
45
+ });
46
+
47
+ it('builds a document node tree', () => {
48
+ const doc = htmlToMarkdownDocSync('<h2>Hi</h2>');
49
+ expect(doc.type).toBe('document');
50
+ expect(doc.children[0]?.type).toBe('heading');
51
+ });
52
+
53
+ it('handles unwrapped div/span containers', () => {
54
+ const md = htmlToMarkdown('<div><span>plain </span><em>text</em></div>');
55
+ expect(md).toContain('plain *text*');
56
+ });
57
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Tests for the registry's consolidated lossy-path warnings, all surfaced
3
+ * through `ConversionResult.warnings`:
4
+ * - xlsx export drops non-table content (tables-only fidelity)
5
+ * - pdf import under Node skips embedded images (no DOM canvas)
6
+ * - transform → markdown produces blocks that don't round-trip
7
+ */
8
+
9
+ import { describe, it, expect, afterEach, vi } from 'vitest';
10
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
11
+ import { convert, defaultRegistry } from '../registry/index';
12
+
13
+ // ── xlsx: tables-only fidelity ──────────────────────────────────────
14
+
15
+ describe('xlsx export warns about omitted non-table content', () => {
16
+ it('warns when prose / lists accompany the table(s)', async () => {
17
+ const md =
18
+ '# Report\n\nIntro prose paragraph.\n\n- a\n- b\n\n| A | B |\n| - | - |\n| 1 | 2 |\n';
19
+ const result = await convert({ kind: 'markdown', markdown: md }, 'xlsx');
20
+ expect(result.warnings.some((w) => /tables-only/i.test(w))).toBe(true);
21
+ // Two omitted blocks: the paragraph and the list (heading names the sheet).
22
+ expect(result.warnings.find((w) => /tables-only/i.test(w))).toMatch(/2 non-table block/);
23
+ });
24
+
25
+ it('does not warn for a heading + table only document', async () => {
26
+ const md = '# Sales\n\n| A |\n| - |\n| x |\n';
27
+ const result = await convert({ kind: 'markdown', markdown: md }, 'xlsx');
28
+ expect(result.warnings.some((w) => /tables-only/i.test(w))).toBe(false);
29
+ });
30
+ });
31
+
32
+ // ── pdf: Node has no DOM canvas → images skipped ────────────────────
33
+
34
+ describe('pdf import warns when running without a DOM (Node)', () => {
35
+ afterEach(() => {
36
+ vi.unstubAllGlobals();
37
+ });
38
+
39
+ /** A pdf definition whose importContainer is stubbed (avoids pdfjs/canvas). */
40
+ function registryWithStubPdf() {
41
+ const registry = defaultRegistry();
42
+ registry.register({
43
+ ...registry.get('pdf')!,
44
+ async importContainer() {
45
+ const c = new MemoryContentContainer();
46
+ await c.writeDocument('# From PDF\n\nBody.');
47
+ return c;
48
+ },
49
+ });
50
+ return registry;
51
+ }
52
+
53
+ const pdfBytes = new TextEncoder().encode('%PDF-1.4\n%mock\n');
54
+
55
+ it('surfaces the skipped-images warning when document is undefined', async () => {
56
+ vi.stubGlobal('document', undefined);
57
+ const registry = registryWithStubPdf();
58
+ const result = await convert({ kind: 'bytes', data: pdfBytes, filename: 'x.pdf' }, 'md', {
59
+ from: 'pdf',
60
+ registry,
61
+ });
62
+ expect(result.warnings.some((w) => /embedded images were skipped/i.test(w))).toBe(true);
63
+ });
64
+
65
+ it('does not warn when a DOM is present (jsdom default)', async () => {
66
+ const registry = registryWithStubPdf();
67
+ const result = await convert({ kind: 'bytes', data: pdfBytes, filename: 'x.pdf' }, 'md', {
68
+ from: 'pdf',
69
+ registry,
70
+ });
71
+ expect(result.warnings.some((w) => /embedded images were skipped/i.test(w))).toBe(false);
72
+ });
73
+ });
74
+
75
+ // ── transform → markdown round-trip loss ────────────────────────────
76
+
77
+ describe('transform-to-markdown export warns about non-round-tripping blocks', () => {
78
+ const CONTENT_MD = `# Big Title
79
+
80
+ An intro paragraph with plenty of words so the transform has real content.
81
+
82
+ ## Numbers
83
+
84
+ Revenue grew by 45% last quarter across every region and product line worldwide.
85
+
86
+ - Point one with several words to analyze here
87
+ - Point two with several words to analyze here
88
+
89
+ | Metric | Value |
90
+ | - | - |
91
+ | Users | 1000 |
92
+ `;
93
+
94
+ it('warns when a transform reshapes into template blocks that markdown can’t hold', async () => {
95
+ const result = await convert({ kind: 'markdown', markdown: CONTENT_MD }, 'md', {
96
+ transformStyle: 'magazine',
97
+ });
98
+ expect(result.warnings.some((w) => /round-trip to markdown/i.test(w))).toBe(true);
99
+ });
100
+
101
+ it('does not warn without a transform', async () => {
102
+ const result = await convert({ kind: 'markdown', markdown: CONTENT_MD }, 'md');
103
+ expect(result.warnings.some((w) => /round-trip to markdown/i.test(w))).toBe(false);
104
+ });
105
+ });
@@ -11,11 +11,13 @@
11
11
  import { describe, it, expect } from 'vitest';
12
12
  import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
13
13
 
14
- import { pdfToMarkdownDoc, pdfToDoc } from '../pdf/import';
14
+ import { pdfToMarkdownDoc, pdfToDoc, insertImageBlocks } from '../pdf/import';
15
+ import type { ExtractedImage } from '../pdf/import';
15
16
  import type {
16
17
  MarkdownHeading,
17
18
  MarkdownBlockNode,
18
19
  MarkdownCodeBlock,
20
+ MarkdownParagraph,
19
21
  } from '@bendyline/squisq/markdown';
20
22
 
21
23
  /** Structural shape shared by all markdown node types, for recursive walkers. */
@@ -276,6 +278,98 @@ describe('pdfToMarkdownDoc', () => {
276
278
  // pdfToDoc convenience wrapper
277
279
  // ============================================
278
280
 
281
+ // ============================================
282
+ // insertImageBlocks — page-level image placement (pure)
283
+ // ============================================
284
+
285
+ describe('insertImageBlocks', () => {
286
+ /** Build a synthetic paragraph block carrying a marker text value. */
287
+ function para(label: string): MarkdownParagraph {
288
+ return { type: 'paragraph', children: [{ type: 'text', value: label }] };
289
+ }
290
+
291
+ /** Build a synthetic extracted image on a given page (data/y are irrelevant here). */
292
+ function img(index: number, page: number): ExtractedImage {
293
+ return { path: `images/image${index}.png`, data: new ArrayBuffer(0), page, y: 0 };
294
+ }
295
+
296
+ /** Extract a flat list of block descriptors: paragraph label or image url. */
297
+ function describeBlocks(blocks: MarkdownBlockNode[]): string[] {
298
+ return blocks.map((b) => {
299
+ if (b.type === 'paragraph') {
300
+ const child = (b as MarkdownParagraph).children[0];
301
+ if (child && child.type === 'image') return `img:${child.url}`;
302
+ if (child && child.type === 'text') return `p:${child.value}`;
303
+ }
304
+ return b.type;
305
+ });
306
+ }
307
+
308
+ it('is a no-op when there are no images', () => {
309
+ const blocks = [para('A'), para('B')];
310
+ const pages = [0, 0];
311
+ const result = insertImageBlocks(blocks, pages, []);
312
+ expect(result).toBe(blocks);
313
+ });
314
+
315
+ it('places an image after the last block of its page', () => {
316
+ // Page 0: A, B Page 1: C
317
+ const blocks = [para('A'), para('B'), para('C')];
318
+ const pages = [0, 0, 1];
319
+ const result = insertImageBlocks(blocks, pages, [img(1, 0)]);
320
+ expect(describeBlocks(result)).toEqual(['p:A', 'p:B', 'img:images/image1.png', 'p:C']);
321
+ });
322
+
323
+ it('places images on the correct page in a multi-page document', () => {
324
+ // Page 0: A Page 1: B Page 2: C
325
+ const blocks = [para('A'), para('B'), para('C')];
326
+ const pages = [0, 1, 2];
327
+ const result = insertImageBlocks(blocks, pages, [img(1, 2), img(2, 0)]);
328
+ // image2 → after A (page 0), image1 → after C (page 2)
329
+ expect(describeBlocks(result)).toEqual([
330
+ 'p:A',
331
+ 'img:images/image2.png',
332
+ 'p:B',
333
+ 'p:C',
334
+ 'img:images/image1.png',
335
+ ]);
336
+ });
337
+
338
+ it('preserves image order within the same page', () => {
339
+ const blocks = [para('A'), para('B')];
340
+ const pages = [0, 0];
341
+ const result = insertImageBlocks(blocks, pages, [img(1, 0), img(2, 0)]);
342
+ expect(describeBlocks(result)).toEqual([
343
+ 'p:A',
344
+ 'p:B',
345
+ 'img:images/image1.png',
346
+ 'img:images/image2.png',
347
+ ]);
348
+ });
349
+
350
+ it('falls back to the nearest preceding page for an image-only page', () => {
351
+ // Page 0: A Page 1: (no blocks) image on page 1 → after A
352
+ const blocks = [para('A'), para('B')];
353
+ const pages = [0, 2];
354
+ // Image on page 1 has no blocks; nearest preceding page with blocks is 0 → after A.
355
+ const result = insertImageBlocks(blocks, pages, [img(1, 1)]);
356
+ expect(describeBlocks(result)).toEqual(['p:A', 'img:images/image1.png', 'p:B']);
357
+ });
358
+
359
+ it('appends at the end when no preceding page has blocks', () => {
360
+ // Only page 5 has blocks; image on page 2 has no preceding page → append at end.
361
+ const blocks = [para('A'), para('B')];
362
+ const pages = [5, 5];
363
+ const result = insertImageBlocks(blocks, pages, [img(1, 2)]);
364
+ expect(describeBlocks(result)).toEqual(['p:A', 'p:B', 'img:images/image1.png']);
365
+ });
366
+
367
+ it('appends all images when there are no text blocks', () => {
368
+ const result = insertImageBlocks([], [], [img(1, 0), img(2, 3)]);
369
+ expect(describeBlocks(result)).toEqual(['img:images/image1.png', 'img:images/image2.png']);
370
+ });
371
+ });
372
+
279
373
  describe('pdfToDoc', () => {
280
374
  it('converts PDF to a Doc object', async () => {
281
375
  const buffer = await buildSimplePdf([
@@ -43,6 +43,12 @@ describe('markdownDocToPlainHtml', () => {
43
43
  );
44
44
  });
45
45
 
46
+ it('renders unsafe markdown links as inert text by default', () => {
47
+ const html = render('[x](javascript:alert(1))');
48
+ expect(html).toContain('<p>x</p>');
49
+ expect(html).not.toContain('javascript:');
50
+ });
51
+
46
52
  it('renders ordered and unordered lists', () => {
47
53
  const html = render('- one\n- two\n\n1. first\n2. second');
48
54
  expect(html).toContain('<ul>');
@@ -112,6 +118,31 @@ describe('markdownDocToPlainHtml', () => {
112
118
  expect(html).not.toContain('src="resized.png"');
113
119
  });
114
120
 
121
+ it('sanitizes raw HTML by default', () => {
122
+ const html = render(
123
+ '<div><img src="x.jpg" onerror="alert(1)"><script>alert(1)</script><span onclick="alert(1)">ok</span></div>',
124
+ );
125
+ expect(html).toContain('<img src="x.jpg" />');
126
+ expect(html).toContain('<span>ok</span>');
127
+ expect(html).not.toContain('<script>');
128
+ expect(html).not.toContain('onerror');
129
+ expect(html).not.toContain('onclick');
130
+ expect(html).not.toContain('javascript:');
131
+ });
132
+
133
+ it('strips raw HTML when requested', () => {
134
+ const html = render('<div><span>hidden</span></div>', { htmlPolicy: 'strip' });
135
+ expect(html).not.toContain('<span>hidden</span>');
136
+ expect(html).not.toContain('hidden');
137
+ });
138
+
139
+ it('preserves raw HTML only with the trusted opt-in', () => {
140
+ const html = render('<div><span onclick="alert(1)">ok</span></div>', {
141
+ htmlPolicy: 'trusted',
142
+ });
143
+ expect(html).toContain('<div><span onclick="alert(1)">ok</span></div>');
144
+ });
145
+
115
146
  it('escapes quotes in href and src attributes', () => {
116
147
  const html = render('![a](a"b.jpg)\n\n[c](d"e)');
117
148
  expect(html).toContain('&quot;');