@bendyline/squisq-formats 1.4.2 → 2.0.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 (140) hide show
  1. package/README.md +48 -2
  2. package/dist/chunk-2KPARF2P.js +99 -0
  3. package/dist/chunk-2KPARF2P.js.map +1 -0
  4. package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
  5. package/dist/chunk-4VUWTSGM.js.map +1 -0
  6. package/dist/{chunk-YESS7VY3.js → chunk-ABVI556T.js} +154 -39
  7. package/dist/chunk-ABVI556T.js.map +1 -0
  8. package/dist/{chunk-WWBIKRNP.js → chunk-CRAVSMPZ.js} +13 -11
  9. package/dist/chunk-CRAVSMPZ.js.map +1 -0
  10. package/dist/chunk-FE6OJV6O.js +560 -0
  11. package/dist/chunk-FE6OJV6O.js.map +1 -0
  12. package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
  13. package/dist/chunk-GVS2XXV6.js.map +1 -0
  14. package/dist/{chunk-RTS5XBZ7.js → chunk-HTW2M27H.js} +341 -100
  15. package/dist/chunk-HTW2M27H.js.map +1 -0
  16. package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
  17. package/dist/chunk-LXYLOOST.js.map +1 -0
  18. package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
  19. package/dist/chunk-MLX2BOJC.js.map +1 -0
  20. package/dist/chunk-NKAJPJ4G.js +106 -0
  21. package/dist/chunk-NKAJPJ4G.js.map +1 -0
  22. package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
  23. package/dist/chunk-ODL3SSPT.js +18 -0
  24. package/dist/chunk-ODL3SSPT.js.map +1 -0
  25. package/dist/chunk-QRVN6A6E.js +127 -0
  26. package/dist/chunk-QRVN6A6E.js.map +1 -0
  27. package/dist/chunk-U32AG3G3.js +195 -0
  28. package/dist/chunk-U32AG3G3.js.map +1 -0
  29. package/dist/{chunk-EHLGMSTE.js → chunk-VJJM2SSH.js} +10 -8
  30. package/dist/chunk-VJJM2SSH.js.map +1 -0
  31. package/dist/chunk-VSYHZECT.js +778 -0
  32. package/dist/chunk-VSYHZECT.js.map +1 -0
  33. package/dist/{chunk-SHLPSADL.js → chunk-WC7WULGV.js} +81 -189
  34. package/dist/chunk-WC7WULGV.js.map +1 -0
  35. package/dist/chunk-XKUMNGBW.js +131 -0
  36. package/dist/chunk-XKUMNGBW.js.map +1 -0
  37. package/dist/chunk-YRT7GQ5Y.js +28 -0
  38. package/dist/chunk-YRT7GQ5Y.js.map +1 -0
  39. package/dist/container/index.d.ts +13 -3
  40. package/dist/container/index.js +23 -20
  41. package/dist/container/index.js.map +1 -1
  42. package/dist/docx/index.d.ts +6 -2
  43. package/dist/docx/index.js +5 -3
  44. package/dist/epub/index.d.ts +3 -1
  45. package/dist/epub/index.js +3 -3
  46. package/dist/export-D2NkylDT.d.ts +69 -0
  47. package/dist/extract-H6RXJMHP.js +15 -0
  48. package/dist/extract-H6RXJMHP.js.map +1 -0
  49. package/dist/html/index.d.ts +15 -85
  50. package/dist/html/index.js +3 -3
  51. package/dist/images-7FBWPKE3.js +7 -0
  52. package/dist/images-7FBWPKE3.js.map +1 -0
  53. package/dist/import-DTkDxHmZ.d.ts +84 -0
  54. package/dist/import-K8mfc0fz.d.ts +136 -0
  55. package/dist/index.d.ts +14 -4
  56. package/dist/index.js +33 -12
  57. package/dist/infer/index.d.ts +138 -0
  58. package/dist/infer/index.js +28 -0
  59. package/dist/infer/index.js.map +1 -0
  60. package/dist/layouts-BHrgZ5FS.d.ts +150 -0
  61. package/dist/layouts-QVPK3ZCU.js +12 -0
  62. package/dist/layouts-QVPK3ZCU.js.map +1 -0
  63. package/dist/mapTheme-IR27S6IV.js +11 -0
  64. package/dist/mapTheme-IR27S6IV.js.map +1 -0
  65. package/dist/ooxml/index.d.ts +21 -127
  66. package/dist/ooxml/index.js +31 -9
  67. package/dist/pdf/index.d.ts +3 -1
  68. package/dist/pdf/index.js +1 -1
  69. package/dist/pptx/index.d.ts +8 -108
  70. package/dist/pptx/index.js +13 -4
  71. package/dist/reader-B9L8Ucbj.d.ts +140 -0
  72. package/dist/registry/index.d.ts +60 -8
  73. package/dist/registry/index.js +9 -4
  74. package/dist/themeReader-DJKErl_j.d.ts +56 -0
  75. package/dist/xlsx/index.d.ts +6 -66
  76. package/dist/xlsx/index.js +4 -2
  77. package/dist/zipLimits-BOKCB7qk.d.ts +31 -0
  78. package/package.json +8 -2
  79. package/src/__tests__/container.test.ts +41 -0
  80. package/src/__tests__/convert.test.ts +310 -1
  81. package/src/__tests__/epub.test.ts +102 -1
  82. package/src/__tests__/html.test.ts +62 -0
  83. package/src/__tests__/inferTheme.test.ts +135 -0
  84. package/src/__tests__/lossyWarnings.test.ts +41 -0
  85. package/src/__tests__/ooxml.test.ts +76 -2
  86. package/src/__tests__/ooxmlCancellation.test.ts +113 -0
  87. package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
  88. package/src/__tests__/plainHtml.test.ts +15 -1
  89. package/src/__tests__/plainHtmlBundle.test.ts +18 -0
  90. package/src/__tests__/pptxImport.test.ts +30 -0
  91. package/src/__tests__/pptxInferFixtures.ts +314 -0
  92. package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
  93. package/src/__tests__/zipSafety.test.ts +317 -0
  94. package/src/container/index.ts +37 -32
  95. package/src/docx/export.ts +4 -2
  96. package/src/docx/import.ts +4 -3
  97. package/src/epub/export.ts +94 -25
  98. package/src/html/docsHtmlBundle.ts +6 -2
  99. package/src/html/htmlTemplate.ts +31 -6
  100. package/src/html/imageUtils.ts +14 -5
  101. package/src/html/index.ts +12 -1
  102. package/src/html/plainHtml.ts +7 -3
  103. package/src/html/plainHtmlBundle.ts +11 -9
  104. package/src/index.ts +23 -0
  105. package/src/infer/extract.ts +127 -0
  106. package/src/infer/index.ts +199 -0
  107. package/src/infer/mapTheme.ts +176 -0
  108. package/src/infer/types.ts +27 -0
  109. package/src/ooxml/index.ts +14 -0
  110. package/src/ooxml/readUtils.ts +44 -0
  111. package/src/ooxml/reader.ts +129 -29
  112. package/src/ooxml/themeReader.ts +197 -0
  113. package/src/ooxml/types.ts +8 -9
  114. package/src/ooxml/writer.ts +28 -10
  115. package/src/pdf/export.ts +4 -2
  116. package/src/pdf/import.ts +40 -34
  117. package/src/pptx/export.ts +4 -2
  118. package/src/pptx/import.ts +214 -32
  119. package/src/pptx/index.ts +14 -0
  120. package/src/pptx/layouts.ts +1222 -0
  121. package/src/registry/convert.ts +168 -53
  122. package/src/registry/defaultFormats.ts +135 -45
  123. package/src/registry/index.ts +6 -1
  124. package/src/registry/types.ts +69 -6
  125. package/src/shared/boundedZipArchive.ts +383 -0
  126. package/src/shared/fidelity.ts +130 -0
  127. package/src/shared/zipEntryCount.ts +151 -0
  128. package/src/shared/zipLimits.ts +296 -0
  129. package/src/shared/zipSafety.ts +19 -0
  130. package/src/xlsx/import.ts +3 -2
  131. package/dist/chunk-46FMDOWZ.js.map +0 -1
  132. package/dist/chunk-5LSSWZPU.js.map +0 -1
  133. package/dist/chunk-D7IFPWNZ.js.map +0 -1
  134. package/dist/chunk-EHLGMSTE.js.map +0 -1
  135. package/dist/chunk-MJGRI6XR.js.map +0 -1
  136. package/dist/chunk-RTS5XBZ7.js.map +0 -1
  137. package/dist/chunk-SHLPSADL.js.map +0 -1
  138. package/dist/chunk-WWBIKRNP.js.map +0 -1
  139. package/dist/chunk-YESS7VY3.js.map +0 -1
  140. /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Tests for `inferThemeFromFile`: format sniffing, per-format extraction
3
+ * (incl. dark-deck clrMap inversion), theme compilation, and error paths.
4
+ */
5
+
6
+ import { describe, expect, it } from 'vitest';
7
+ import { ConversionError } from '../registry/errors';
8
+ import { inferThemeFromFile } from '../infer/index';
9
+ import {
10
+ buildThemeXml,
11
+ buildThemedDocx,
12
+ buildThemedPptx,
13
+ buildThemedXlsx,
14
+ } from './pptxInferFixtures';
15
+
16
+ describe('inferThemeFromFile — PPTX', () => {
17
+ it('maps a light deck onto seeds, palette, schemes, and fonts', async () => {
18
+ const data = await buildThemedPptx();
19
+ const result = await inferThemeFromFile(data);
20
+
21
+ expect(result.extraction.sourceFormat).toBe('pptx');
22
+ expect(result.extraction.colorMap).toEqual({ bg1: 'lt1', tx1: 'dk1' });
23
+
24
+ const { theme } = result;
25
+ expect(theme.seedColors).toMatchObject({
26
+ primary: '#4472c4',
27
+ secondary: '#ed7d31',
28
+ accent: '#a5a5a5',
29
+ background: '#fdfdf8',
30
+ text: '#1a1a2e',
31
+ });
32
+ expect(theme.colors.background).toBe('#fdfdf8');
33
+ expect(theme.colors.text).toBe('#1a1a2e');
34
+ // Companion slot (lt2) becomes backgroundLight; hlink becomes highlight.
35
+ expect(theme.colors.backgroundLight).toBe('#efefe4');
36
+ expect(theme.colors.highlight).toBe('#0563c1');
37
+ // Six accents → six color schemes keyed by slot.
38
+ expect(Object.keys(theme.colorSchemes)).toEqual([
39
+ 'accent1',
40
+ 'accent2',
41
+ 'accent3',
42
+ 'accent4',
43
+ 'accent5',
44
+ 'accent6',
45
+ ]);
46
+ // Fonts: curated stack match + custom preservation.
47
+ expect(theme.typography.titleFont).toEqual({ stackId: 'playfair' });
48
+ expect(theme.typography.bodyFont).toEqual({
49
+ custom: { name: 'Aptos', fallback: 'sans-serif' },
50
+ });
51
+ // Compiled + validated, named from the theme part, re-editable via seeds.
52
+ expect(theme.name).toBe('Fixture Theme');
53
+ expect(theme.id).toBe('custom-fixture-theme');
54
+ });
55
+
56
+ it('inverts background/text for a dark deck (clrMap bg1="dk1")', async () => {
57
+ const data = await buildThemedPptx({
58
+ clrMapAttrs: 'bg1="dk1" tx1="lt1" bg2="dk2" tx2="lt2"',
59
+ });
60
+ const result = await inferThemeFromFile(data);
61
+ expect(result.theme.seedColors?.background).toBe('#1a1a2e');
62
+ expect(result.theme.seedColors?.text).toBe('#fdfdf8');
63
+ // Dark companion slot backs backgroundLight.
64
+ expect(result.theme.colors.backgroundLight).toBe('#30304a');
65
+ });
66
+
67
+ it('respects a nameHint over the file theme name', async () => {
68
+ const result = await inferThemeFromFile(await buildThemedPptx(), { nameHint: 'Q3 Deck' });
69
+ expect(result.theme.name).toBe('Q3 Deck');
70
+ expect(result.theme.id).toBe('custom-q3-deck');
71
+ });
72
+
73
+ it('throws invalid-input when the file has no theme part', async () => {
74
+ const data = await buildThemedPptx({ themeXml: null });
75
+ await expect(inferThemeFromFile(data)).rejects.toMatchObject({
76
+ name: 'ConversionError',
77
+ code: 'invalid-input',
78
+ });
79
+ });
80
+ });
81
+
82
+ describe('inferThemeFromFile — DOCX / XLSX', () => {
83
+ it('extracts from a DOCX theme part', async () => {
84
+ const result = await inferThemeFromFile(await buildThemedDocx());
85
+ expect(result.extraction.sourceFormat).toBe('docx');
86
+ expect(result.theme.seedColors?.primary).toBe('#4472c4');
87
+ expect(result.theme.colors.background).toBe('#fdfdf8');
88
+ });
89
+
90
+ it('extracts from an XLSX theme part', async () => {
91
+ const result = await inferThemeFromFile(await buildThemedXlsx());
92
+ expect(result.extraction.sourceFormat).toBe('xlsx');
93
+ expect(result.theme.seedColors?.primary).toBe('#4472c4');
94
+ });
95
+
96
+ it('ignores inferLayouts for non-PPTX sources with a warning', async () => {
97
+ const result = await inferThemeFromFile(await buildThemedDocx(), { inferLayouts: true });
98
+ expect(result.layouts).toBeUndefined();
99
+ expect(result.warnings.some((w) => w.includes('PowerPoint'))).toBe(true);
100
+ });
101
+ });
102
+
103
+ describe('inferThemeFromFile — rejection paths', () => {
104
+ it('rejects PDF bytes with unsupported-input', async () => {
105
+ const pdf = new TextEncoder().encode('%PDF-1.4 fake').buffer as ArrayBuffer;
106
+ await expect(inferThemeFromFile(pdf)).rejects.toMatchObject({
107
+ code: 'unsupported-input',
108
+ });
109
+ });
110
+
111
+ it('rejects non-zip bytes with invalid-input', async () => {
112
+ const junk = new TextEncoder().encode('definitely not a zip').buffer as ArrayBuffer;
113
+ const err = await inferThemeFromFile(junk).catch((e: unknown) => e);
114
+ expect(err).toBeInstanceOf(ConversionError);
115
+ expect((err as ConversionError).code).toBe('invalid-input');
116
+ });
117
+ });
118
+
119
+ describe('inferThemeFromFile — degraded themes', () => {
120
+ it('drops near-background accents from the scheme list with a warning', async () => {
121
+ // accent2 equals the background color — should be dropped from schemes.
122
+ const themeXml = buildThemeXml({ accents: ['4472c4', 'fdfdf8', 'a5a5a5'] });
123
+ const result = await inferThemeFromFile(await buildThemedPptx({ themeXml }));
124
+ expect(Object.keys(result.theme.colorSchemes)).toEqual(['accent1', 'accent3']);
125
+ expect(result.warnings.some((w) => w.includes('accent2'))).toBe(true);
126
+ });
127
+
128
+ it('keeps fonts inherited when the theme has no fontScheme', async () => {
129
+ const themeXml = buildThemeXml({ omitFontScheme: true });
130
+ const result = await inferThemeFromFile(await buildThemedPptx({ themeXml }));
131
+ // STARTER defaults survive.
132
+ expect(result.theme.typography.titleFont).toEqual({ stackId: 'system-serif' });
133
+ expect(result.warnings.some((w) => w.includes('fontScheme'))).toBe(true);
134
+ });
135
+ });
@@ -8,8 +8,49 @@
8
8
 
9
9
  import { describe, it, expect, afterEach, vi } from 'vitest';
10
10
  import { MemoryContentContainer } from '@bendyline/squisq/storage';
11
+ import type { MarkdownDocument } from '@bendyline/squisq/markdown';
11
12
  import { convert, defaultRegistry } from '../registry/index';
12
13
 
14
+ // ── shared Markdown exporter capability profiles ───────────────────
15
+
16
+ describe('Markdown exporters warn about unsupported AST extensions', () => {
17
+ const extendedMarkdown: MarkdownDocument = {
18
+ type: 'document',
19
+ children: [
20
+ {
21
+ type: 'paragraph',
22
+ children: [
23
+ { type: 'text', value: 'Assigned to ' },
24
+ {
25
+ type: 'mention',
26
+ targetKind: 'user',
27
+ targetId: '42',
28
+ displayName: 'Ada',
29
+ },
30
+ ],
31
+ },
32
+ {
33
+ type: 'containerDirective',
34
+ name: 'note',
35
+ children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Important' }] }],
36
+ },
37
+ ],
38
+ };
39
+
40
+ it('surfaces the omitted node types through ConversionResult', async () => {
41
+ const result = await convert({ kind: 'markdown', markdown: extendedMarkdown }, 'docx');
42
+ const warning = result.warnings.find((item) => /unsupported Markdown node/i.test(item));
43
+
44
+ expect(warning).toMatch(/mention \(1\)/);
45
+ expect(warning).toMatch(/containerDirective \(1\)/);
46
+ });
47
+
48
+ it('does not warn for the supported Markdown subset', async () => {
49
+ const result = await convert({ kind: 'markdown', markdown: '# Title\n\nBody.' }, 'docx');
50
+ expect(result.warnings.some((item) => /unsupported Markdown node/i.test(item))).toBe(false);
51
+ });
52
+ });
53
+
13
54
  // ── xlsx: tables-only fidelity ──────────────────────────────────────
14
55
 
15
56
  describe('xlsx export warns about omitted non-table content', () => {
@@ -2,7 +2,7 @@
2
2
  * Tests for the shared OOXML layer: xmlUtils, writer, reader round-trips.
3
3
  */
4
4
 
5
- import { describe, it, expect } from 'vitest';
5
+ import { afterEach, describe, it, expect, vi } from 'vitest';
6
6
  import {
7
7
  escapeXml,
8
8
  xmlElement,
@@ -15,6 +15,10 @@ import { createPackage } from '../ooxml/writer';
15
15
  import { openPackage, getPartXml, getCoreProperties, getPartRelationships } from '../ooxml/reader';
16
16
  import { REL_OFFICE_DOCUMENT } from '../ooxml/namespaces';
17
17
 
18
+ afterEach(() => {
19
+ vi.unstubAllGlobals();
20
+ });
21
+
18
22
  // ============================================
19
23
  // XML Utilities
20
24
  // ============================================
@@ -97,6 +101,62 @@ describe('xmlDeclaration', () => {
97
101
  // ============================================
98
102
 
99
103
  describe('createPackage / openPackage round-trip', () => {
104
+ it('rejects unsafe authored part paths', () => {
105
+ const pkg = createPackage();
106
+ expect(() => pkg.addPart('../outside.xml', '<doc/>', 'application/xml')).toThrow(
107
+ /path is unsafe/i,
108
+ );
109
+ expect(() =>
110
+ pkg.addRelationship('..\\outside.xml', {
111
+ id: 'rId1',
112
+ type: REL_OFFICE_DOCUMENT,
113
+ target: 'word/document.xml',
114
+ }),
115
+ ).toThrow(/path is unsafe/i);
116
+ });
117
+
118
+ it('parses packages when the browser DOMParser global is unavailable', async () => {
119
+ const pkg = createPackage();
120
+ pkg.addPart('word/document.xml', '<doc><value>node</value></doc>', 'application/xml');
121
+ const buffer = await pkg.toArrayBuffer();
122
+
123
+ vi.stubGlobal('DOMParser', undefined);
124
+ const opened = await openPackage(buffer);
125
+ const doc = await getPartXml(opened, 'word/document.xml');
126
+ expect(doc?.getElementsByTagName('value')[0]?.textContent).toBe('node');
127
+ });
128
+
129
+ it('ignores an incomplete ambient DOMParser when running in Node', async () => {
130
+ const pkg = createPackage();
131
+ pkg.addPart('word/document.xml', '<doc><value>node</value></doc>', 'application/xml');
132
+ const buffer = await pkg.toArrayBuffer();
133
+
134
+ class IncompleteDomParser {
135
+ parseFromString(): never {
136
+ throw new Error('ambient parser must not be used in Node');
137
+ }
138
+ }
139
+
140
+ vi.stubGlobal('DOMParser', IncompleteDomParser);
141
+ const opened = await openPackage(buffer);
142
+ const doc = await getPartXml(opened, 'word/document.xml');
143
+ expect(doc?.getElementsByTagName('value')[0]?.textContent).toBe('node');
144
+ });
145
+
146
+ it('applies archive resource limits before parsing OOXML parts', async () => {
147
+ const pkg = createPackage();
148
+ pkg.addPart('word/document.xml', '<doc/>', 'application/xml');
149
+ const buffer = await pkg.toArrayBuffer();
150
+
151
+ await expect(openPackage(buffer, { maxEntries: 1 })).rejects.toMatchObject({
152
+ code: 'too-many-entries',
153
+ actual: 3,
154
+ });
155
+ await expect(openPackage(buffer, { maxUncompressedBytes: 1 })).rejects.toThrow(
156
+ /uncompressed content exceeds 1 byte limit/,
157
+ );
158
+ });
159
+
100
160
  it('creates a valid ZIP with content types and parts', async () => {
101
161
  const pkg = createPackage();
102
162
  pkg.addPart(
@@ -116,7 +176,7 @@ describe('createPackage / openPackage round-trip', () => {
116
176
 
117
177
  // Read it back
118
178
  const opened = await openPackage(arrayBuffer);
119
- expect(opened.zip).toBeDefined();
179
+ expect(opened).not.toHaveProperty('zip');
120
180
  expect(opened.contentTypes.overrides.has('word/document.xml')).toBe(true);
121
181
  expect(opened.rootRelationships.length).toBeGreaterThanOrEqual(1);
122
182
  expect(opened.rootRelationships[0].type).toBe(REL_OFFICE_DOCUMENT);
@@ -156,6 +216,20 @@ describe('createPackage / openPackage round-trip', () => {
156
216
  expect(props.description).toBe('Test description with <special> & chars');
157
217
  });
158
218
 
219
+ it('serializes repeatedly without accumulating generated relationships', async () => {
220
+ const pkg = createPackage();
221
+ pkg.addPart('word/document.xml', '<doc/>', 'application/xml');
222
+ pkg.setCoreProperties({ title: 'Stable' });
223
+
224
+ await pkg.toArrayBuffer();
225
+ const second = await pkg.toArrayBuffer();
226
+ const opened = await openPackage(second);
227
+ const coreRelationships = opened.rootRelationships.filter((rel) =>
228
+ rel.type.endsWith('/metadata/core-properties'),
229
+ );
230
+ expect(coreRelationships).toHaveLength(1);
231
+ });
232
+
159
233
  it('round-trips part relationships', async () => {
160
234
  const pkg = createPackage();
161
235
  pkg.addPart('word/document.xml', '<doc/>', 'application/xml');
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { inferThemeFromFile } from '../infer/index';
3
+ import { getPartXml, openPackage } from '../ooxml/reader';
4
+ import { inspectPptxLayouts } from '../pptx/layouts';
5
+ import { prepareZipInput } from '../shared/zipEntryCount';
6
+ import { buildThemedPptx } from './pptxInferFixtures';
7
+
8
+ function blockingBlob(onRead: () => void): Blob {
9
+ const blob = {
10
+ size: 128,
11
+ slice: () => blob,
12
+ arrayBuffer: () => {
13
+ onRead();
14
+ return new Promise<ArrayBuffer>(() => undefined);
15
+ },
16
+ };
17
+ return blob as unknown as Blob;
18
+ }
19
+
20
+ describe('OOXML cancellation', () => {
21
+ it('preserves the exact reason while theme inference reads Blob input', async () => {
22
+ let markStarted: (() => void) | undefined;
23
+ const started = new Promise<void>((resolve) => {
24
+ markStarted = resolve;
25
+ });
26
+ const controller = new AbortController();
27
+ const reason = new Error('stop inference');
28
+ const pending = inferThemeFromFile(
29
+ blockingBlob(() => markStarted?.()),
30
+ {
31
+ signal: controller.signal,
32
+ },
33
+ );
34
+
35
+ await started;
36
+ controller.abort(reason);
37
+
38
+ await expect(pending).rejects.toBe(reason);
39
+ });
40
+
41
+ it('preserves the exact reason while layout inspection opens Blob input', async () => {
42
+ let markStarted: (() => void) | undefined;
43
+ const started = new Promise<void>((resolve) => {
44
+ markStarted = resolve;
45
+ });
46
+ const controller = new AbortController();
47
+ const reason = new Error('stop layout inspection');
48
+ const pending = inspectPptxLayouts(
49
+ blockingBlob(() => markStarted?.()),
50
+ {
51
+ signal: controller.signal,
52
+ },
53
+ );
54
+
55
+ await started;
56
+ controller.abort(reason);
57
+
58
+ await expect(pending).rejects.toBe(reason);
59
+ });
60
+
61
+ it('propagates package cancellation through later OOXML part traversal', async () => {
62
+ const controller = new AbortController();
63
+ const pkg = await openPackage(await buildThemedPptx(), { signal: controller.signal });
64
+ const reason = new Error('stop OOXML traversal');
65
+ controller.abort(reason);
66
+
67
+ await expect(getPartXml(pkg, 'ppt/presentation.xml')).rejects.toBe(reason);
68
+ });
69
+
70
+ it('handles a synchronously re-entrant FileReader abort exactly once', async () => {
71
+ const previous = globalThis.FileReader;
72
+ let abortCalls = 0;
73
+ class ReentrantFileReader {
74
+ result: ArrayBuffer | null = null;
75
+ error: DOMException | null = null;
76
+ onload: ((event: ProgressEvent<FileReader>) => void) | null = null;
77
+ onerror: ((event: ProgressEvent<FileReader>) => void) | null = null;
78
+ onabort: ((event: ProgressEvent<FileReader>) => void) | null = null;
79
+ readAsArrayBuffer(): void {
80
+ // Remain pending until the caller aborts.
81
+ }
82
+
83
+ abort(): void {
84
+ abortCalls += 1;
85
+ this.onabort?.({} as ProgressEvent<FileReader>);
86
+ }
87
+ }
88
+ Object.defineProperty(globalThis, 'FileReader', {
89
+ configurable: true,
90
+ writable: true,
91
+ value: ReentrantFileReader,
92
+ });
93
+
94
+ const blobLike = { size: 1, slice: () => blobLike } as unknown as Blob;
95
+ const controller = new AbortController();
96
+ const reason = new Error('stop FileReader');
97
+ try {
98
+ const pending = prepareZipInput(blobLike, controller.signal);
99
+ controller.abort(reason);
100
+ await expect(pending).rejects.toBe(reason);
101
+ expect(abortCalls).toBe(1);
102
+ } finally {
103
+ if (previous === undefined) Reflect.deleteProperty(globalThis, 'FileReader');
104
+ else {
105
+ Object.defineProperty(globalThis, 'FileReader', {
106
+ configurable: true,
107
+ writable: true,
108
+ value: previous,
109
+ });
110
+ }
111
+ }
112
+ });
113
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Tests for the shared OOXML theme reader: clrScheme slot resolution
3
+ * (srgbClr / sysClr / fallbacks) and fontScheme parsing.
4
+ */
5
+
6
+ import { describe, expect, it } from 'vitest';
7
+ import { NS_DRAWINGML } from '../ooxml/namespaces';
8
+ import { parseThemeXml } from '../ooxml/themeReader';
9
+ import { buildThemeXml } from './pptxInferFixtures';
10
+
11
+ function parse(xml: string): Document {
12
+ return new DOMParser().parseFromString(xml, 'application/xml');
13
+ }
14
+
15
+ describe('parseThemeXml', () => {
16
+ it('reads srgbClr slots, theme name, and latin fonts', () => {
17
+ const theme = parseThemeXml(parse(buildThemeXml({ name: 'Ion' })));
18
+ expect(theme.name).toBe('Ion');
19
+ expect(theme.colors).toMatchObject({
20
+ dk1: '#1a1a2e',
21
+ lt1: '#fdfdf8',
22
+ dk2: '#30304a',
23
+ lt2: '#efefe4',
24
+ accent1: '#4472c4',
25
+ accent6: '#70ad47',
26
+ hlink: '#0563c1',
27
+ });
28
+ expect(theme.fonts).toEqual({ majorLatin: 'Playfair Display', minorLatin: 'Aptos' });
29
+ });
30
+
31
+ it('prefers sysClr lastClr when present', () => {
32
+ const xml = buildThemeXml({
33
+ dk1: '<a:sysClr val="windowText" lastClr="0B0B0B"/>',
34
+ lt1: '<a:sysClr val="window" lastClr="FAFAFA"/>',
35
+ });
36
+ const theme = parseThemeXml(parse(xml));
37
+ expect(theme.colors?.dk1).toBe('#0b0b0b');
38
+ expect(theme.colors?.lt1).toBe('#fafafa');
39
+ });
40
+
41
+ it('resolves windowText/window sysClr without lastClr', () => {
42
+ const xml = buildThemeXml({
43
+ dk1: '<a:sysClr val="windowText"/>',
44
+ lt1: '<a:sysClr val="window"/>',
45
+ });
46
+ const theme = parseThemeXml(parse(xml));
47
+ expect(theme.colors?.dk1).toBe('#000000');
48
+ expect(theme.colors?.lt1).toBe('#ffffff');
49
+ });
50
+
51
+ it('falls back with a warning for unknown sysClr values', () => {
52
+ const xml = buildThemeXml({ dk1: '<a:sysClr val="btnFace"/>' });
53
+ const theme = parseThemeXml(parse(xml));
54
+ expect(theme.colors?.dk1).toBe('#000000');
55
+ expect(theme.warnings.some((w) => w.includes('dk1'))).toBe(true);
56
+ });
57
+
58
+ it('drops unresolvable accent slots with warnings', () => {
59
+ // accent list of 2 → accent3..6 slots missing entirely.
60
+ const xml = buildThemeXml({ accents: ['112233', '445566'] });
61
+ const theme = parseThemeXml(parse(xml));
62
+ expect(theme.colors?.accent1).toBe('#112233');
63
+ expect(theme.colors?.accent2).toBe('#445566');
64
+ expect(theme.colors?.accent3).toBeUndefined();
65
+ expect(theme.warnings.some((w) => w.includes('accent3'))).toBe(true);
66
+ });
67
+
68
+ it('handles a missing fontScheme', () => {
69
+ const theme = parseThemeXml(parse(buildThemeXml({ omitFontScheme: true })));
70
+ expect(theme.fonts).toBeUndefined();
71
+ expect(theme.warnings.some((w) => w.includes('fontScheme'))).toBe(true);
72
+ });
73
+
74
+ it('treats an empty typeface as absent', () => {
75
+ const xml =
76
+ `<a:theme xmlns:a="${NS_DRAWINGML}" name="T"><a:themeElements>` +
77
+ `<a:fontScheme name="T">` +
78
+ `<a:majorFont><a:latin typeface=""/></a:majorFont>` +
79
+ `<a:minorFont><a:latin typeface="Georgia"/></a:minorFont>` +
80
+ `</a:fontScheme></a:themeElements></a:theme>`;
81
+ const theme = parseThemeXml(parse(xml));
82
+ expect(theme.fonts).toEqual({ minorLatin: 'Georgia' });
83
+ expect(theme.warnings.some((w) => w.includes('majorFont'))).toBe(true);
84
+ });
85
+
86
+ it('reports a theme with no clrScheme', () => {
87
+ const xml = `<a:theme xmlns:a="${NS_DRAWINGML}" name="Empty"><a:themeElements/></a:theme>`;
88
+ const theme = parseThemeXml(parse(xml));
89
+ expect(theme.colors).toBeUndefined();
90
+ expect(theme.warnings.some((w) => w.includes('clrScheme'))).toBe(true);
91
+ });
92
+ });
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { describe, it, expect } from 'vitest';
6
6
  import { parseMarkdown } from '@bendyline/squisq/markdown';
7
- import { resolveTheme } from '@bendyline/squisq/schemas';
7
+ import { compileTheme, createThemeRegistry, resolveTheme } from '@bendyline/squisq/schemas';
8
8
  import { markdownDocToPlainHtml } from '../html/plainHtml';
9
9
 
10
10
  function render(md: string, options?: Parameters<typeof markdownDocToPlainHtml>[1]): string {
@@ -26,6 +26,20 @@ describe('markdownDocToPlainHtml', () => {
26
26
  expect(html).toContain('<title>A &lt;script&gt; &amp; more</title>');
27
27
  });
28
28
 
29
+ it('resolves custom themes only through an explicit caller-owned registry', () => {
30
+ const custom = compileTheme({
31
+ id: 'tenant-theme',
32
+ name: 'Tenant Theme',
33
+ seedColors: { primary: '#123456' },
34
+ });
35
+ const registry = createThemeRegistry([custom]);
36
+ const withoutRegistry = render('# Title', { themeId: 'tenant-theme' });
37
+ const withRegistry = render('# Title', { themeId: 'tenant-theme', themeRegistry: registry });
38
+
39
+ expect(withoutRegistry).not.toContain('--plain-primary: #123456');
40
+ expect(withRegistry).toContain('--plain-primary: #123456');
41
+ });
42
+
29
43
  it('renders headings at the correct depth', () => {
30
44
  const html = render('# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six');
31
45
  expect(html).toContain('<h1>One</h1>');
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { describe, it, expect } from 'vitest';
12
12
  import JSZip from 'jszip';
13
+ import { compileTheme, createThemeRegistry } from '@bendyline/squisq/schemas';
13
14
  import { markdownDocsToPlainHtmlBundle } from '../html/plainHtmlBundle';
14
15
 
15
16
  /** Helper to read a Blob as Uint8Array (works in jsdom). */
@@ -58,6 +59,23 @@ describe('markdownDocsToPlainHtmlBundle', () => {
58
59
  expect(html).toContain('<h1>Home</h1>');
59
60
  });
60
61
 
62
+ it('resolves a caller-owned theme without process-global registration', async () => {
63
+ const theme = compileTheme({
64
+ id: 'tenant-brand',
65
+ name: 'Tenant Brand',
66
+ seedColors: { primary: '#123456' },
67
+ });
68
+ const c = makeContainer({ 'home.md': '# Home' });
69
+ const blob = await markdownDocsToPlainHtmlBundle({
70
+ entryPath: 'home.md',
71
+ themeId: theme.id,
72
+ themeRegistry: createThemeRegistry([theme]),
73
+ ...c,
74
+ });
75
+
76
+ expect(await readZipPath(blob, 'home.html')).toContain('--plain-primary: #123456');
77
+ });
78
+
61
79
  it('follows a linear chain home.md → resume.md and bundles both', async () => {
62
80
  const c = makeContainer({
63
81
  'home.md': '# Home\n\nMy [resume](resume.md) is here.',
@@ -11,6 +11,7 @@ import type {
11
11
  MarkdownParagraph,
12
12
  MarkdownText,
13
13
  } from '@bendyline/squisq/markdown';
14
+ import { readCustomThemesFromFrontmatter } from '@bendyline/squisq/doc';
14
15
  import { NS_DRAWINGML, NS_PML, NS_R, REL_OFFICE_DOCUMENT, REL_SLIDE } from '../ooxml/namespaces';
15
16
  import { createPackage } from '../ooxml/writer';
16
17
  import { xmlDeclaration } from '../ooxml/xmlUtils';
@@ -113,3 +114,32 @@ describe('pptxToContainer (embedded image import)', () => {
113
114
  expect(hasImage).toBe(false);
114
115
  });
115
116
  });
117
+
118
+ describe('theme inference defaults (ON)', () => {
119
+ const simpleDoc: MarkdownDocument = {
120
+ type: 'document',
121
+ children: [{ type: 'heading', depth: 2, children: [{ type: 'text', value: 'Hello' }] }],
122
+ };
123
+
124
+ it('carries the deck theme in frontmatter on an export → import round trip', async () => {
125
+ const deck = await markdownDocToPptx(simpleDoc, {});
126
+ const imported = await pptxToMarkdownDoc(deck);
127
+ // The exporter writes the stock Office theme part; import infers it.
128
+ expect(imported.frontmatter?.['squisq-theme']).toBe('custom-office-theme');
129
+ const themes = readCustomThemesFromFrontmatter(imported.frontmatter);
130
+ expect(themes).toHaveLength(1);
131
+ expect(themes![0]!.seedColors?.primary).toBe('#4472c4');
132
+ expect(themes![0]!.seedColors?.background).toBe('#ffffff');
133
+ });
134
+
135
+ it('produces the legacy frontmatter-free import when inference is disabled', async () => {
136
+ const deck = await markdownDocToPptx(simpleDoc, {});
137
+ const imported = await pptxToMarkdownDoc(deck, { inferTheme: false, inferLayouts: false });
138
+ expect(imported.frontmatter).toBeUndefined();
139
+ });
140
+
141
+ it('imports decks without theme parts cleanly (no frontmatter, no failure)', async () => {
142
+ const doc = await pptxToMarkdownDoc(await buildTestPptx());
143
+ expect(doc.frontmatter).toBeUndefined();
144
+ });
145
+ });