@bendyline/squisq-formats 1.4.1 → 2.0.0

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 (139) hide show
  1. package/README.md +48 -2
  2. package/dist/{chunk-WWBIKRNP.js → chunk-2CSZJEAL.js} +13 -11
  3. package/dist/chunk-2CSZJEAL.js.map +1 -0
  4. package/dist/{chunk-RTS5XBZ7.js → chunk-2EFDWKC6.js} +262 -84
  5. package/dist/chunk-2EFDWKC6.js.map +1 -0
  6. package/dist/chunk-2PRVLAU3.js +740 -0
  7. package/dist/chunk-2PRVLAU3.js.map +1 -0
  8. package/dist/{chunk-SHLPSADL.js → chunk-4HJ6AK2Q.js} +54 -193
  9. package/dist/chunk-4HJ6AK2Q.js.map +1 -0
  10. package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
  11. package/dist/chunk-4VUWTSGM.js.map +1 -0
  12. package/dist/{chunk-YESS7VY3.js → chunk-CVNVD7VH.js} +154 -39
  13. package/dist/chunk-CVNVD7VH.js.map +1 -0
  14. package/dist/chunk-CX3LH47J.js +99 -0
  15. package/dist/chunk-CX3LH47J.js.map +1 -0
  16. package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
  17. package/dist/chunk-GVS2XXV6.js.map +1 -0
  18. package/dist/chunk-H7T5DXUU.js +479 -0
  19. package/dist/chunk-H7T5DXUU.js.map +1 -0
  20. package/dist/{chunk-EHLGMSTE.js → chunk-HBSCRS5P.js} +10 -8
  21. package/dist/chunk-HBSCRS5P.js.map +1 -0
  22. package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
  23. package/dist/chunk-LXYLOOST.js.map +1 -0
  24. package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
  25. package/dist/chunk-MLX2BOJC.js.map +1 -0
  26. package/dist/chunk-NKAJPJ4G.js +106 -0
  27. package/dist/chunk-NKAJPJ4G.js.map +1 -0
  28. package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
  29. package/dist/chunk-ODL3SSPT.js +18 -0
  30. package/dist/chunk-ODL3SSPT.js.map +1 -0
  31. package/dist/chunk-X4USWOQ2.js +131 -0
  32. package/dist/chunk-X4USWOQ2.js.map +1 -0
  33. package/dist/chunk-XTLQXR4U.js +195 -0
  34. package/dist/chunk-XTLQXR4U.js.map +1 -0
  35. package/dist/chunk-YRT7GQ5Y.js +28 -0
  36. package/dist/chunk-YRT7GQ5Y.js.map +1 -0
  37. package/dist/chunk-ZZ7D4DN6.js +97 -0
  38. package/dist/chunk-ZZ7D4DN6.js.map +1 -0
  39. package/dist/container/index.d.ts +6 -2
  40. package/dist/container/index.js +15 -17
  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-Djev9BLU.d.ts +69 -0
  47. package/dist/extract-LNOA7DBK.js +15 -0
  48. package/dist/extract-LNOA7DBK.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-Yivc3kZM.d.ts +136 -0
  55. package/dist/index.d.ts +13 -4
  56. package/dist/index.js +30 -11
  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-7OUUVAGU.js +12 -0
  61. package/dist/layouts-7OUUVAGU.js.map +1 -0
  62. package/dist/layouts-CZ0bdsNL.d.ts +150 -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-Gof6OrJX.d.ts +140 -0
  72. package/dist/registry/index.d.ts +46 -8
  73. package/dist/registry/index.js +5 -2
  74. package/dist/themeReader-DPw4J5Ss.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-bGYMS9cI.d.ts +29 -0
  78. package/package.json +8 -2
  79. package/src/__tests__/container.test.ts +41 -0
  80. package/src/__tests__/convert.test.ts +167 -0
  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 +59 -2
  86. package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
  87. package/src/__tests__/plainHtml.test.ts +15 -1
  88. package/src/__tests__/plainHtmlBundle.test.ts +18 -0
  89. package/src/__tests__/pptxImport.test.ts +30 -0
  90. package/src/__tests__/pptxInferFixtures.ts +314 -0
  91. package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
  92. package/src/__tests__/zipSafety.test.ts +317 -0
  93. package/src/container/index.ts +20 -29
  94. package/src/docx/export.ts +4 -2
  95. package/src/docx/import.ts +4 -3
  96. package/src/epub/export.ts +94 -25
  97. package/src/html/docsHtmlBundle.ts +6 -2
  98. package/src/html/htmlTemplate.ts +31 -6
  99. package/src/html/imageUtils.ts +14 -5
  100. package/src/html/index.ts +12 -1
  101. package/src/html/plainHtml.ts +7 -3
  102. package/src/html/plainHtmlBundle.ts +11 -9
  103. package/src/index.ts +20 -0
  104. package/src/infer/extract.ts +127 -0
  105. package/src/infer/index.ts +168 -0
  106. package/src/infer/mapTheme.ts +176 -0
  107. package/src/infer/types.ts +27 -0
  108. package/src/ooxml/index.ts +14 -0
  109. package/src/ooxml/readUtils.ts +44 -0
  110. package/src/ooxml/reader.ts +87 -28
  111. package/src/ooxml/themeReader.ts +197 -0
  112. package/src/ooxml/types.ts +8 -9
  113. package/src/ooxml/writer.ts +28 -10
  114. package/src/pdf/export.ts +4 -2
  115. package/src/pdf/import.ts +40 -34
  116. package/src/pptx/export.ts +4 -2
  117. package/src/pptx/import.ts +214 -32
  118. package/src/pptx/index.ts +14 -0
  119. package/src/pptx/layouts.ts +1168 -0
  120. package/src/registry/convert.ts +54 -23
  121. package/src/registry/defaultFormats.ts +131 -44
  122. package/src/registry/index.ts +3 -0
  123. package/src/registry/types.ts +52 -6
  124. package/src/shared/boundedZipArchive.ts +342 -0
  125. package/src/shared/fidelity.ts +130 -0
  126. package/src/shared/zipEntryCount.ts +97 -0
  127. package/src/shared/zipLimits.ts +286 -0
  128. package/src/shared/zipSafety.ts +19 -0
  129. package/src/xlsx/import.ts +3 -2
  130. package/dist/chunk-46FMDOWZ.js.map +0 -1
  131. package/dist/chunk-5LSSWZPU.js.map +0 -1
  132. package/dist/chunk-D7IFPWNZ.js.map +0 -1
  133. package/dist/chunk-EHLGMSTE.js.map +0 -1
  134. package/dist/chunk-MJGRI6XR.js.map +0 -1
  135. package/dist/chunk-RTS5XBZ7.js.map +0 -1
  136. package/dist/chunk-SHLPSADL.js.map +0 -1
  137. package/dist/chunk-WWBIKRNP.js.map +0 -1
  138. package/dist/chunk-YESS7VY3.js.map +0 -1
  139. /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
@@ -8,6 +8,7 @@
8
8
  import { describe, it, expect } from 'vitest';
9
9
  import JSZip from 'jszip';
10
10
  import type { Doc, Block, ImageLayer } from '@bendyline/squisq/schemas';
11
+ import { compileTheme, createThemeRegistry } from '@bendyline/squisq/schemas';
11
12
  import { docToHtml, docToHtmlZip, collectImagePaths } from '../html/index';
12
13
  import { inferMimeType, arrayBufferToBase64DataUrl, extractFilename } from '../html/imageUtils';
13
14
 
@@ -95,6 +96,16 @@ describe('arrayBufferToBase64DataUrl', () => {
95
96
  expect(result).toMatch(/^data:text\/plain;base64,/);
96
97
  expect(result).toBe('data:text/plain;base64,aGVsbG8=');
97
98
  });
99
+
100
+ it('encodes large buffers correctly across chunk boundaries', () => {
101
+ const bytes = Uint8Array.from({ length: 100_003 }, (_, index) => index % 251);
102
+ const result = arrayBufferToBase64DataUrl(bytes.buffer, 'application/octet-stream');
103
+ const decoded = Uint8Array.from(atob(result.split(',')[1]), (character) =>
104
+ character.charCodeAt(0),
105
+ );
106
+
107
+ expect(decoded).toEqual(bytes);
108
+ });
98
109
  });
99
110
 
100
111
  describe('extractFilename', () => {
@@ -225,6 +236,41 @@ describe('docToHtml', () => {
225
236
  expect(html).toContain('test-doc');
226
237
  });
227
238
 
239
+ it('embeds an explicitly registered theme selected by the document', () => {
240
+ const external = compileTheme({
241
+ id: 'tenant-brand',
242
+ name: 'External Tenant Brand',
243
+ seedColors: { primary: '#6633cc' },
244
+ });
245
+ const html = docToHtml(makeDoc({ themeId: external.id }), {
246
+ playerScript: MOCK_PLAYER_SCRIPT,
247
+ themeRegistry: createThemeRegistry([external]),
248
+ });
249
+
250
+ expect(html).toContain('External Tenant Brand');
251
+ expect(html).toContain('\\"themeId\\":\\"tenant-brand\\"');
252
+ });
253
+
254
+ it('keeps a document-scoped theme ahead of the explicit registry', () => {
255
+ const inline = compileTheme({
256
+ id: 'tenant-brand',
257
+ name: 'Inline Tenant Brand',
258
+ seedColors: { primary: '#112233' },
259
+ });
260
+ const external = compileTheme({
261
+ id: 'tenant-brand',
262
+ name: 'External Tenant Brand',
263
+ seedColors: { primary: '#6633cc' },
264
+ });
265
+ const html = docToHtml(makeDoc({ themeId: inline.id, customThemes: [inline] }), {
266
+ playerScript: MOCK_PLAYER_SCRIPT,
267
+ themeRegistry: createThemeRegistry([external]),
268
+ });
269
+
270
+ expect(html).toContain('Inline Tenant Brand');
271
+ expect(html).not.toContain('External Tenant Brand');
272
+ });
273
+
228
274
  it('calls SquisqPlayer.mount', () => {
229
275
  const doc = makeDoc();
230
276
  const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
@@ -310,6 +356,22 @@ describe('docToHtmlZip', () => {
310
356
  expect(html).not.toContain(MOCK_PLAYER_SCRIPT);
311
357
  });
312
358
 
359
+ it('embeds a caller-owned theme selected by the document', async () => {
360
+ const external = compileTheme({
361
+ id: 'tenant-zip-brand',
362
+ name: 'Tenant ZIP Brand',
363
+ seedColors: { primary: '#224466' },
364
+ });
365
+ const blob = await docToHtmlZip(makeDoc({ themeId: external.id }), {
366
+ playerScript: MOCK_PLAYER_SCRIPT,
367
+ themeRegistry: createThemeRegistry([external]),
368
+ });
369
+
370
+ const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
371
+ const html = await zip.file('index.html')!.async('text');
372
+ expect(html).toContain('Tenant ZIP Brand');
373
+ });
374
+
313
375
  it('preserves the original image path inside the zip', async () => {
314
376
  // Direct `<img src="folder/file.png">` references in the rendered HTML
315
377
  // resolve only if the zip mirrors the doc's path layout — flattening
@@ -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,45 @@ 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('applies archive resource limits before parsing OOXML parts', async () => {
130
+ const pkg = createPackage();
131
+ pkg.addPart('word/document.xml', '<doc/>', 'application/xml');
132
+ const buffer = await pkg.toArrayBuffer();
133
+
134
+ await expect(openPackage(buffer, { maxEntries: 1 })).rejects.toMatchObject({
135
+ code: 'too-many-entries',
136
+ actual: 3,
137
+ });
138
+ await expect(openPackage(buffer, { maxUncompressedBytes: 1 })).rejects.toThrow(
139
+ /uncompressed content exceeds 1 byte limit/,
140
+ );
141
+ });
142
+
100
143
  it('creates a valid ZIP with content types and parts', async () => {
101
144
  const pkg = createPackage();
102
145
  pkg.addPart(
@@ -116,7 +159,7 @@ describe('createPackage / openPackage round-trip', () => {
116
159
 
117
160
  // Read it back
118
161
  const opened = await openPackage(arrayBuffer);
119
- expect(opened.zip).toBeDefined();
162
+ expect(opened).not.toHaveProperty('zip');
120
163
  expect(opened.contentTypes.overrides.has('word/document.xml')).toBe(true);
121
164
  expect(opened.rootRelationships.length).toBeGreaterThanOrEqual(1);
122
165
  expect(opened.rootRelationships[0].type).toBe(REL_OFFICE_DOCUMENT);
@@ -156,6 +199,20 @@ describe('createPackage / openPackage round-trip', () => {
156
199
  expect(props.description).toBe('Test description with <special> & chars');
157
200
  });
158
201
 
202
+ it('serializes repeatedly without accumulating generated relationships', async () => {
203
+ const pkg = createPackage();
204
+ pkg.addPart('word/document.xml', '<doc/>', 'application/xml');
205
+ pkg.setCoreProperties({ title: 'Stable' });
206
+
207
+ await pkg.toArrayBuffer();
208
+ const second = await pkg.toArrayBuffer();
209
+ const opened = await openPackage(second);
210
+ const coreRelationships = opened.rootRelationships.filter((rel) =>
211
+ rel.type.endsWith('/metadata/core-properties'),
212
+ );
213
+ expect(coreRelationships).toHaveLength(1);
214
+ });
215
+
159
216
  it('round-trips part relationships', async () => {
160
217
  const pkg = createPackage();
161
218
  pkg.addPart('word/document.xml', '<doc/>', 'application/xml');
@@ -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
+ });