@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.
- package/README.md +48 -2
- package/dist/chunk-2KPARF2P.js +99 -0
- package/dist/chunk-2KPARF2P.js.map +1 -0
- package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
- package/dist/chunk-4VUWTSGM.js.map +1 -0
- package/dist/{chunk-YESS7VY3.js → chunk-ABVI556T.js} +154 -39
- package/dist/chunk-ABVI556T.js.map +1 -0
- package/dist/{chunk-WWBIKRNP.js → chunk-CRAVSMPZ.js} +13 -11
- package/dist/chunk-CRAVSMPZ.js.map +1 -0
- package/dist/chunk-FE6OJV6O.js +560 -0
- package/dist/chunk-FE6OJV6O.js.map +1 -0
- package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
- package/dist/chunk-GVS2XXV6.js.map +1 -0
- package/dist/{chunk-RTS5XBZ7.js → chunk-HTW2M27H.js} +341 -100
- package/dist/chunk-HTW2M27H.js.map +1 -0
- package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
- package/dist/chunk-LXYLOOST.js.map +1 -0
- package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
- package/dist/chunk-MLX2BOJC.js.map +1 -0
- package/dist/chunk-NKAJPJ4G.js +106 -0
- package/dist/chunk-NKAJPJ4G.js.map +1 -0
- package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
- package/dist/chunk-ODL3SSPT.js +18 -0
- package/dist/chunk-ODL3SSPT.js.map +1 -0
- package/dist/chunk-QRVN6A6E.js +127 -0
- package/dist/chunk-QRVN6A6E.js.map +1 -0
- package/dist/chunk-U32AG3G3.js +195 -0
- package/dist/chunk-U32AG3G3.js.map +1 -0
- package/dist/{chunk-EHLGMSTE.js → chunk-VJJM2SSH.js} +10 -8
- package/dist/chunk-VJJM2SSH.js.map +1 -0
- package/dist/chunk-VSYHZECT.js +778 -0
- package/dist/chunk-VSYHZECT.js.map +1 -0
- package/dist/{chunk-SHLPSADL.js → chunk-WC7WULGV.js} +81 -189
- package/dist/chunk-WC7WULGV.js.map +1 -0
- package/dist/chunk-XKUMNGBW.js +131 -0
- package/dist/chunk-XKUMNGBW.js.map +1 -0
- package/dist/chunk-YRT7GQ5Y.js +28 -0
- package/dist/chunk-YRT7GQ5Y.js.map +1 -0
- package/dist/container/index.d.ts +13 -3
- package/dist/container/index.js +23 -20
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +6 -2
- package/dist/docx/index.js +5 -3
- package/dist/epub/index.d.ts +3 -1
- package/dist/epub/index.js +3 -3
- package/dist/export-D2NkylDT.d.ts +69 -0
- package/dist/extract-H6RXJMHP.js +15 -0
- package/dist/extract-H6RXJMHP.js.map +1 -0
- package/dist/html/index.d.ts +15 -85
- package/dist/html/index.js +3 -3
- package/dist/images-7FBWPKE3.js +7 -0
- package/dist/images-7FBWPKE3.js.map +1 -0
- package/dist/import-DTkDxHmZ.d.ts +84 -0
- package/dist/import-K8mfc0fz.d.ts +136 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.js +33 -12
- package/dist/infer/index.d.ts +138 -0
- package/dist/infer/index.js +28 -0
- package/dist/infer/index.js.map +1 -0
- package/dist/layouts-BHrgZ5FS.d.ts +150 -0
- package/dist/layouts-QVPK3ZCU.js +12 -0
- package/dist/layouts-QVPK3ZCU.js.map +1 -0
- package/dist/mapTheme-IR27S6IV.js +11 -0
- package/dist/mapTheme-IR27S6IV.js.map +1 -0
- package/dist/ooxml/index.d.ts +21 -127
- package/dist/ooxml/index.js +31 -9
- package/dist/pdf/index.d.ts +3 -1
- package/dist/pdf/index.js +1 -1
- package/dist/pptx/index.d.ts +8 -108
- package/dist/pptx/index.js +13 -4
- package/dist/reader-B9L8Ucbj.d.ts +140 -0
- package/dist/registry/index.d.ts +60 -8
- package/dist/registry/index.js +9 -4
- package/dist/themeReader-DJKErl_j.d.ts +56 -0
- package/dist/xlsx/index.d.ts +6 -66
- package/dist/xlsx/index.js +4 -2
- package/dist/zipLimits-BOKCB7qk.d.ts +31 -0
- package/package.json +8 -2
- package/src/__tests__/container.test.ts +41 -0
- package/src/__tests__/convert.test.ts +310 -1
- package/src/__tests__/epub.test.ts +102 -1
- package/src/__tests__/html.test.ts +62 -0
- package/src/__tests__/inferTheme.test.ts +135 -0
- package/src/__tests__/lossyWarnings.test.ts +41 -0
- package/src/__tests__/ooxml.test.ts +76 -2
- package/src/__tests__/ooxmlCancellation.test.ts +113 -0
- package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
- package/src/__tests__/plainHtml.test.ts +15 -1
- package/src/__tests__/plainHtmlBundle.test.ts +18 -0
- package/src/__tests__/pptxImport.test.ts +30 -0
- package/src/__tests__/pptxInferFixtures.ts +314 -0
- package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
- package/src/__tests__/zipSafety.test.ts +317 -0
- package/src/container/index.ts +37 -32
- package/src/docx/export.ts +4 -2
- package/src/docx/import.ts +4 -3
- package/src/epub/export.ts +94 -25
- package/src/html/docsHtmlBundle.ts +6 -2
- package/src/html/htmlTemplate.ts +31 -6
- package/src/html/imageUtils.ts +14 -5
- package/src/html/index.ts +12 -1
- package/src/html/plainHtml.ts +7 -3
- package/src/html/plainHtmlBundle.ts +11 -9
- package/src/index.ts +23 -0
- package/src/infer/extract.ts +127 -0
- package/src/infer/index.ts +199 -0
- package/src/infer/mapTheme.ts +176 -0
- package/src/infer/types.ts +27 -0
- package/src/ooxml/index.ts +14 -0
- package/src/ooxml/readUtils.ts +44 -0
- package/src/ooxml/reader.ts +129 -29
- package/src/ooxml/themeReader.ts +197 -0
- package/src/ooxml/types.ts +8 -9
- package/src/ooxml/writer.ts +28 -10
- package/src/pdf/export.ts +4 -2
- package/src/pdf/import.ts +40 -34
- package/src/pptx/export.ts +4 -2
- package/src/pptx/import.ts +214 -32
- package/src/pptx/index.ts +14 -0
- package/src/pptx/layouts.ts +1222 -0
- package/src/registry/convert.ts +168 -53
- package/src/registry/defaultFormats.ts +135 -45
- package/src/registry/index.ts +6 -1
- package/src/registry/types.ts +69 -6
- package/src/shared/boundedZipArchive.ts +383 -0
- package/src/shared/fidelity.ts +130 -0
- package/src/shared/zipEntryCount.ts +151 -0
- package/src/shared/zipLimits.ts +296 -0
- package/src/shared/zipSafety.ts +19 -0
- package/src/xlsx/import.ts +3 -2
- package/dist/chunk-46FMDOWZ.js.map +0 -1
- package/dist/chunk-5LSSWZPU.js.map +0 -1
- package/dist/chunk-D7IFPWNZ.js.map +0 -1
- package/dist/chunk-EHLGMSTE.js.map +0 -1
- package/dist/chunk-MJGRI6XR.js.map +0 -1
- package/dist/chunk-RTS5XBZ7.js.map +0 -1
- package/dist/chunk-SHLPSADL.js.map +0 -1
- package/dist/chunk-WWBIKRNP.js.map +0 -1
- package/dist/chunk-YESS7VY3.js.map +0 -1
- /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
|
|
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 <script> & 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
|
+
});
|