@bendyline/squisq-formats 1.4.2 → 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
@@ -0,0 +1,56 @@
1
+ import { a as OoxmlPackage } from './reader-Gof6OrJX.js';
2
+
3
+ /**
4
+ * OOXML theme reader — parses a `theme1.xml` part (DrawingML `a:theme`)
5
+ * into normalized color and font schemes.
6
+ *
7
+ * Shared by DOCX/PPTX/XLSX theme inference: all three formats carry the same
8
+ * theme part shape, differing only in where the part hangs (document/master/
9
+ * workbook relationships). This module never fails hard — unreadable slots
10
+ * degrade to fallbacks or absence, with a warning per degradation.
11
+ */
12
+
13
+ /**
14
+ * The clrScheme slots as normalized `#rrggbb` strings. The four surface/text
15
+ * slots always resolve (falling back to black/white when degraded); accent and
16
+ * hyperlink slots are omitted when unresolvable.
17
+ */
18
+ interface OoxmlColorScheme {
19
+ dk1: string;
20
+ lt1: string;
21
+ dk2: string;
22
+ lt2: string;
23
+ accent1?: string;
24
+ accent2?: string;
25
+ accent3?: string;
26
+ accent4?: string;
27
+ accent5?: string;
28
+ accent6?: string;
29
+ hlink?: string;
30
+ folHlink?: string;
31
+ }
32
+ interface OoxmlFontScheme {
33
+ /** `a:fontScheme > a:majorFont > a:latin@typeface` (headings). */
34
+ majorLatin?: string;
35
+ /** `a:fontScheme > a:minorFont > a:latin@typeface` (body). */
36
+ minorLatin?: string;
37
+ }
38
+ interface OoxmlTheme {
39
+ /** `a:theme@name` (e.g. "Office Theme", "Ion"). */
40
+ name?: string;
41
+ /** Absent when the theme carries no parseable `a:clrScheme`. */
42
+ colors?: OoxmlColorScheme;
43
+ /** Absent when the theme carries no `a:fontScheme` latin typefaces. */
44
+ fonts?: OoxmlFontScheme;
45
+ warnings: string[];
46
+ }
47
+ /** Parse an already-loaded theme part Document. */
48
+ declare function parseThemeXml(themeDoc: Document): OoxmlTheme;
49
+ /**
50
+ * Resolve the theme part related from `fromPartPath` (via `REL_THEME`) and
51
+ * parse it. Returns null when the part has no theme relationship or the
52
+ * target part is missing — callers decide whether that's an error.
53
+ */
54
+ declare function readThemePart(pkg: OoxmlPackage, fromPartPath: string): Promise<OoxmlTheme | null>;
55
+
56
+ export { type OoxmlColorScheme as O, type OoxmlFontScheme as a, type OoxmlTheme as b, parseThemeXml as p, readThemePart as r };
@@ -1,69 +1,9 @@
1
1
  import { Doc } from '@bendyline/squisq/schemas';
2
- import { MarkdownDocument } from '@bendyline/squisq/markdown';
3
-
4
- /**
5
- * XLSX import — SpreadsheetML (.xlsx) → MarkdownDocument.
6
- *
7
- * Reuses the shared ooxml/ reader (zip + DOMParser). Reads the workbook's
8
- * sheet list, resolves each sheet part via relationships, pulls shared strings,
9
- * and turns each worksheet's cell grid into a markdown table (first row treated
10
- * as the header). By default every sheet is imported, each preceded by an H1 of
11
- * the sheet name; pass `options.sheet` (index or name) to import just one.
12
- */
13
-
14
- interface XlsxImportOptions {
15
- /** Which sheet to import (0-based index or sheet name). Default: all sheets. */
16
- sheet?: number | string;
17
- }
18
- declare function xlsxToMarkdownDoc(data: ArrayBuffer | Blob, options?: XlsxImportOptions): Promise<MarkdownDocument>;
19
-
20
- /**
21
- * XLSX export — MarkdownDocument → SpreadsheetML (.xlsx).
22
- *
23
- * Tables-only fidelity (honestly documented): every `table` node in the
24
- * markdown AST becomes one worksheet; all other content (headings, prose,
25
- * lists, images, …) is dropped except that the nearest preceding heading is
26
- * used to name the sheet. This mirrors the import side (`xlsxToMarkdownDoc`),
27
- * which turns each worksheet grid back into a markdown table.
28
- *
29
- * Cells are emitted as inline strings (`t="inlineStr"`) so no sharedStrings
30
- * part is needed, except values matching a plain-number pattern, which are
31
- * emitted as numeric cells. The package is assembled with the shared ooxml/
32
- * writer (auto-generates `[Content_Types].xml` + `_rels`), so only the
33
- * SpreadsheetML-specific parts (workbook, worksheets, styles) are written here.
34
- *
35
- * @example
36
- * ```ts
37
- * import { parseMarkdown } from '@bendyline/squisq/markdown';
38
- * import { markdownDocToXlsx } from '@bendyline/squisq-formats/xlsx';
39
- *
40
- * const md = parseMarkdown('# Metrics\n\n| A | B |\n| - | - |\n| 1 | 2 |');
41
- * const buffer = await markdownDocToXlsx(md);
42
- * ```
43
- */
44
-
45
- /**
46
- * Options for XLSX export.
47
- */
48
- interface XlsxExportOptions {
49
- /** Workbook title (written to core properties). */
50
- title?: string;
51
- /** Workbook author (written to core properties). */
52
- author?: string;
53
- /** Prefix used for auto-named sheets when no heading precedes a table. Default: "Sheet". */
54
- sheetNamePrefix?: string;
55
- }
56
- /**
57
- * Convert a MarkdownDocument to a .xlsx file (tables-only fidelity).
58
- *
59
- * Each markdown `table` becomes one worksheet; a document with no tables
60
- * yields a single empty sheet (a valid, openable file — never throws).
61
- */
62
- declare function markdownDocToXlsx(doc: MarkdownDocument, options?: XlsxExportOptions): Promise<ArrayBuffer>;
63
- /**
64
- * Convert a squisq Doc to a .xlsx file (via the markdown table model).
65
- */
66
- declare function docToXlsx(doc: Doc, options?: XlsxExportOptions): Promise<ArrayBuffer>;
2
+ import { a as XlsxImportOptions } from '../export-Djev9BLU.js';
3
+ export { X as XlsxExportOptions, d as docToXlsx, m as markdownDocToXlsx, x as xlsxToMarkdownDoc } from '../export-Djev9BLU.js';
4
+ import '@bendyline/squisq/markdown';
5
+ import '../reader-Gof6OrJX.js';
6
+ import '../zipLimits-bGYMS9cI.js';
67
7
 
68
8
  /**
69
9
  * @bendyline/squisq-formats XLSX Module
@@ -86,4 +26,4 @@ declare function docToXlsx(doc: Doc, options?: XlsxExportOptions): Promise<Array
86
26
  */
87
27
  declare function xlsxToDoc(data: ArrayBuffer | Blob, options?: XlsxImportOptions): Promise<Doc>;
88
28
 
89
- export { type XlsxExportOptions, type XlsxImportOptions, docToXlsx, markdownDocToXlsx, xlsxToDoc, xlsxToMarkdownDoc };
29
+ export { XlsxImportOptions, xlsxToDoc };
@@ -3,10 +3,12 @@ import {
3
3
  markdownDocToXlsx,
4
4
  xlsxToDoc,
5
5
  xlsxToMarkdownDoc
6
- } from "../chunk-EHLGMSTE.js";
6
+ } from "../chunk-HBSCRS5P.js";
7
7
  import "../chunk-PN52A5AA.js";
8
- import "../chunk-SHLPSADL.js";
8
+ import "../chunk-XTLQXR4U.js";
9
9
  import "../chunk-U4MRIFKL.js";
10
+ import "../chunk-4HJ6AK2Q.js";
11
+ import "../chunk-H7T5DXUU.js";
10
12
  export {
11
13
  docToXlsx,
12
14
  markdownDocToXlsx,
@@ -0,0 +1,29 @@
1
+ /** ZIP safety policy, structured errors, path checks, and metadata validation. */
2
+
3
+ interface ZipSafetyLimits {
4
+ /** Maximum number of archive records, including directories. Default: 10,000. */
5
+ maxEntries?: number;
6
+ /** Maximum uncompressed bytes for one member. Defaults to maxUncompressedBytes. */
7
+ maxEntryUncompressedBytes?: number;
8
+ /** Maximum total uncompressed bytes across the archive. Default: 512 MiB. */
9
+ maxUncompressedBytes?: number;
10
+ /** Maximum uncompressed/compressed ratio for a member. Default: 1,000. */
11
+ maxCompressionRatio?: number;
12
+ }
13
+ type ZipSafetyErrorCode = 'invalid-limit' | 'invalid-archive' | 'unsafe-path' | 'invalid-entry-metadata' | 'too-many-entries' | 'entry-too-large' | 'archive-too-large' | 'compression-ratio-exceeded' | 'size-mismatch' | 'crc-mismatch' | 'decompression-failed';
14
+ interface ZipSafetyErrorOptions {
15
+ path?: string;
16
+ limit?: number;
17
+ actual?: number;
18
+ cause?: unknown;
19
+ }
20
+ /** A stable, machine-readable error raised while validating or reading a ZIP. */
21
+ declare class ZipSafetyError extends Error {
22
+ readonly code: ZipSafetyErrorCode;
23
+ readonly path?: string;
24
+ readonly limit?: number;
25
+ readonly actual?: number;
26
+ constructor(code: ZipSafetyErrorCode, message: string, options?: ZipSafetyErrorOptions);
27
+ }
28
+
29
+ export { ZipSafetyError as Z, type ZipSafetyErrorCode as a, type ZipSafetyErrorOptions as b, type ZipSafetyLimits as c };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-formats",
3
- "version": "1.4.2",
3
+ "version": "2.0.0",
4
4
  "description": "Document format converters — DOCX, PDF, OOXML import/export",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -86,6 +86,11 @@
86
86
  "types": "./dist/registry/index.d.ts",
87
87
  "import": "./dist/registry/index.js",
88
88
  "default": "./dist/registry/index.js"
89
+ },
90
+ "./infer": {
91
+ "types": "./dist/infer/index.d.ts",
92
+ "import": "./dist/infer/index.js",
93
+ "default": "./dist/infer/index.js"
89
94
  }
90
95
  },
91
96
  "scripts": {
@@ -94,7 +99,8 @@
94
99
  "typecheck": "tsc --noEmit"
95
100
  },
96
101
  "dependencies": {
97
- "@bendyline/squisq": "1.5.2",
102
+ "@xmldom/xmldom": "0.9.10",
103
+ "@bendyline/squisq": "2.0.0",
98
104
  "jszip": "3.10.1",
99
105
  "pdf-lib": "1.17.1",
100
106
  "pdfjs-dist": "4.10.38"
@@ -78,6 +78,13 @@ describe('containerToZip', () => {
78
78
  const data = await zip.file('image.png')!.async('uint8array');
79
79
  expect(data).toEqual(binaryData);
80
80
  });
81
+
82
+ it('refuses to create a ZIP with an unsafe container path', async () => {
83
+ const container = new MemoryContentContainer();
84
+ await container.writeFile('../outside.txt', new TextEncoder().encode('nope'));
85
+
86
+ await expect(containerToZip(container)).rejects.toThrow(/path is unsafe/i);
87
+ });
81
88
  });
82
89
 
83
90
  // ============================================
@@ -137,6 +144,40 @@ describe('zipToContainer', () => {
137
144
  const container = await zipToContainer(ab);
138
145
  expect(await container.readDocument()).toBe('# Read me');
139
146
  });
147
+
148
+ it('rejects archives that exceed the configured entry limit', async () => {
149
+ const zip = new JSZip();
150
+ zip.file('one.txt', '1');
151
+ zip.file('two.txt', '2');
152
+ const data = await zip.generateAsync({ type: 'arraybuffer' });
153
+ await expect(zipToContainer(data, { maxEntries: 1 })).rejects.toThrow(/archive has 2 files/);
154
+ });
155
+
156
+ it('rejects archives that exceed the configured uncompressed byte limit', async () => {
157
+ const zip = new JSZip();
158
+ zip.file('large.txt', '12345');
159
+ const data = await zip.generateAsync({ type: 'arraybuffer' });
160
+ await expect(zipToContainer(data, { maxUncompressedBytes: 4 })).rejects.toThrow(
161
+ /uncompressed content exceeds 4 byte limit/,
162
+ );
163
+ });
164
+
165
+ it('rejects traversal paths even when JSZip sanitizes their loaded key', async () => {
166
+ const zip = new JSZip();
167
+ zip.file('../outside.txt', 'nope');
168
+ const data = await zip.generateAsync({ type: 'arraybuffer' });
169
+
170
+ await expect(zipToContainer(data)).rejects.toThrow(/unsafe path.*\.\.\/outside\.txt/i);
171
+ });
172
+
173
+ it('rejects absolute and Windows-style archive paths', async () => {
174
+ for (const path of ['/absolute.txt', 'C:\\outside.txt']) {
175
+ const zip = new JSZip();
176
+ zip.file(path, 'nope');
177
+ const data = await zip.generateAsync({ type: 'arraybuffer' });
178
+ await expect(zipToContainer(data)).rejects.toThrow(/unsafe path/i);
179
+ }
180
+ });
140
181
  });
141
182
 
142
183
  // ============================================
@@ -5,9 +5,15 @@
5
5
  */
6
6
 
7
7
  import { describe, it, expect } from 'vitest';
8
+ import JSZip from 'jszip';
8
9
  import { parseMarkdown } from '@bendyline/squisq/markdown';
9
10
  import { markdownToDoc } from '@bendyline/squisq/doc';
11
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
12
+ import { createTransformStyleRegistry, resolveTransformStyle } from '@bendyline/squisq/transform';
10
13
  import { convert, ConversionError, defaultRegistry } from '../registry/index';
14
+ import { zipToContainer } from '../container/index';
15
+ import { markdownDocToPptx } from '../pptx/export';
16
+ import { buildThemedPptx } from './pptxInferFixtures';
11
17
 
12
18
  const SAMPLE_MD = `# Round Trip
13
19
 
@@ -40,6 +46,70 @@ describe('convert error paths', () => {
40
46
  convert({ kind: 'markdown', markdown: '# hi' }, 'importonly', { registry }),
41
47
  ).rejects.toMatchObject({ name: 'ConversionError', code: 'unsupported-output' });
42
48
  });
49
+
50
+ it('normalizes importer failures to ConversionError', async () => {
51
+ const promise = convert({ kind: 'bytes', data: new TextEncoder().encode('not a zip') }, 'md', {
52
+ from: 'docx',
53
+ });
54
+ await expect(promise).rejects.toMatchObject({
55
+ name: 'ConversionError',
56
+ code: 'invalid-input',
57
+ format: 'docx',
58
+ });
59
+ });
60
+ });
61
+
62
+ describe('format option threading', () => {
63
+ it('threads CSV tableIndex and delimiter through the conversion facade', async () => {
64
+ const markdown =
65
+ '| A | B |\n|---|---|\n| first | row |\n\n| C | D |\n|---|---|\n| second | row |\n';
66
+ const result = await convert({ kind: 'markdown', markdown }, 'csv', {
67
+ formatOptions: { csv: { tableIndex: 1, delimiter: ';' } },
68
+ });
69
+ expect(new TextDecoder().decode(result.bytes)).toBe('C;D\r\nsecond;row');
70
+ expect(result.warnings).toEqual([]);
71
+ });
72
+
73
+ it('honors Markdown parse options for direct Markdown sources', async () => {
74
+ let captured: import('../registry/index').NormalizedInput | undefined;
75
+ const registry = defaultRegistry();
76
+ registry.register({
77
+ id: 'capture-md-options',
78
+ label: 'Capture Markdown Options',
79
+ mimeType: 'application/x-capture',
80
+ extensions: ['.capture'],
81
+ async exportDoc(input) {
82
+ captured = input;
83
+ return {
84
+ bytes: new Uint8Array(),
85
+ mimeType: 'application/x-capture',
86
+ suggestedFilename: '',
87
+ warnings: [],
88
+ };
89
+ },
90
+ });
91
+
92
+ await convert(
93
+ { kind: 'markdown', markdown: '---\ntitle: Hidden metadata\n---\n\n# Body' },
94
+ 'capture-md-options',
95
+ {
96
+ registry,
97
+ formatOptions: { md: { parse: { frontmatter: false } } },
98
+ },
99
+ );
100
+
101
+ expect(captured?.markdownDoc?.frontmatter).toBeUndefined();
102
+ });
103
+
104
+ it('threads archive safety limits into OOXML importers', async () => {
105
+ const docx = await convert({ kind: 'markdown', markdown: '# Limited' }, 'docx');
106
+ await expect(
107
+ convert({ kind: 'bytes', data: docx.bytes }, 'md', {
108
+ from: 'docx',
109
+ formatOptions: { docx: { maxEntries: 1 } },
110
+ }),
111
+ ).rejects.toMatchObject({ code: 'invalid-input', format: 'docx' });
112
+ });
43
113
  });
44
114
 
45
115
  // ── round trip: md → docx → md ──────────────────────────────────────
@@ -94,6 +164,21 @@ describe('byte sniffing', () => {
94
164
  expect(text).toContain('In a container');
95
165
  });
96
166
 
167
+ it('DBK export snapshots the supplied source instead of stale container markdown', async () => {
168
+ const container = new MemoryContentContainer();
169
+ await container.writeDocument('# Old');
170
+ await container.writeFile('asset.bin', new Uint8Array([1, 2, 3]));
171
+
172
+ const result = await convert({ kind: 'markdown', markdown: '# New', container }, 'dbk');
173
+ const restored = await zipToContainer(result.bytes);
174
+ expect(await restored.readDocument()).toContain('# New');
175
+ expect(new Uint8Array((await restored.readFile('asset.bin'))!)).toEqual(
176
+ new Uint8Array([1, 2, 3]),
177
+ );
178
+ // Snapshotting must not mutate the caller's container.
179
+ expect(await container.readDocument()).toBe('# Old');
180
+ });
181
+
97
182
  it('disambiguates a docx zip via its content-types part', async () => {
98
183
  const docx = await convert({ kind: 'markdown', markdown: '# Word doc' }, 'docx');
99
184
  // No filename, no `from` — must read [Content_Types].xml to pick docx.
@@ -140,6 +225,40 @@ describe('transform threading', () => {
140
225
  // markdownDoc is re-derived from the transformed Doc.
141
226
  expect(captured!.markdownDoc).toBeDefined();
142
227
  });
228
+
229
+ it('resolves transform ids only from the explicitly supplied registry', async () => {
230
+ let captured: import('../registry/index').NormalizedInput | undefined;
231
+ const formats = defaultRegistry();
232
+ formats.register({
233
+ id: 'capture-custom-transform',
234
+ label: 'Capture Custom Transform',
235
+ mimeType: 'application/x-capture',
236
+ extensions: ['.cap'],
237
+ async exportDoc(input) {
238
+ captured = input;
239
+ return {
240
+ bytes: new Uint8Array([1]),
241
+ mimeType: 'application/x-capture',
242
+ suggestedFilename: '',
243
+ warnings: [],
244
+ };
245
+ },
246
+ });
247
+ const custom = {
248
+ ...resolveTransformStyle('minimal'),
249
+ id: 'tenant-transform',
250
+ suggestedThemeId: 'cinematic',
251
+ };
252
+ const transformRegistry = createTransformStyleRegistry([custom]);
253
+
254
+ await convert({ kind: 'markdown', markdown: SAMPLE_MD }, 'capture-custom-transform', {
255
+ registry: formats,
256
+ transformStyle: 'tenant-transform',
257
+ transformRegistry,
258
+ });
259
+
260
+ expect(captured?.doc.themeId).toBe('cinematic');
261
+ });
143
262
  });
144
263
 
145
264
  // ── doc-kind source ─────────────────────────────────────────────────
@@ -178,9 +297,57 @@ describe('suggestedFilename', () => {
178
297
  const out = await convert({ kind: 'markdown', markdown: '# t' }, 'md');
179
298
  expect(out.suggestedFilename).toBe('document.md');
180
299
  });
300
+
301
+ it('uses only the basename for Windows-style input paths', async () => {
302
+ const out = await convert(
303
+ {
304
+ kind: 'bytes',
305
+ data: new TextEncoder().encode('# Windows'),
306
+ filename: 'C:\\docs\\report.md',
307
+ },
308
+ 'md',
309
+ );
310
+ expect(out.suggestedFilename).toBe('report.md');
311
+ });
181
312
  });
182
313
 
183
314
  // sanity: ConversionError is exported and usable
315
+ // ── pptx theme/layout inference threading ───────────────────────────
316
+
317
+ describe('pptx import inference threading (default ON)', () => {
318
+ it('carries an inferred theme through convert() by default', async () => {
319
+ const deck = await markdownDocToPptx(parseMarkdown('# Hi\n\nBody.\n'), {});
320
+ const result = await convert({ kind: 'bytes', data: deck, filename: 'deck.pptx' }, 'md');
321
+ const md = new TextDecoder().decode(result.bytes);
322
+ expect(md).toContain('squisq-theme: custom-office-theme');
323
+ expect(md).toContain('squisq-custom-themes');
324
+ });
325
+
326
+ it('omits inference with formatOptions.pptx.inferTheme=false', async () => {
327
+ const deck = await markdownDocToPptx(parseMarkdown('# Hi\n\nBody.\n'), {});
328
+ const result = await convert({ kind: 'bytes', data: deck, filename: 'deck.pptx' }, 'md', {
329
+ formatOptions: { pptx: { inferTheme: false, inferLayouts: false } },
330
+ });
331
+ const md = new TextDecoder().decode(result.bytes);
332
+ expect(md).not.toContain('squisq-theme');
333
+ expect(md).not.toContain('squisq-custom-themes');
334
+ });
335
+
336
+ it('end-to-end: inferred theme colors reach a downstream docx export', async () => {
337
+ // Distinctive fixture theme (bg #fdfdf8, text #1a1a2e) → pptx → docx.
338
+ const result = await convert(
339
+ { kind: 'bytes', data: await buildThemedPptx(), filename: 'deck.pptx' },
340
+ 'docx',
341
+ );
342
+ const zip = await JSZip.loadAsync(result.bytes);
343
+ let text = '';
344
+ for (const name of Object.keys(zip.files)) {
345
+ if (/\.(xml|rels)$/i.test(name)) text += await zip.files[name]!.async('string');
346
+ }
347
+ expect(text.toLowerCase()).toContain('1a1a2e');
348
+ });
349
+ });
350
+
184
351
  it('exports ConversionError', () => {
185
352
  expect(new ConversionError('conversion-failed', 'x')).toBeInstanceOf(Error);
186
353
  });
@@ -5,7 +5,7 @@
5
5
  * image embedding, metadata, and stylesheet generation.
6
6
  */
7
7
 
8
- import { describe, it, expect } from 'vitest';
8
+ import { afterEach, describe, it, expect, vi } from 'vitest';
9
9
  import JSZip from 'jszip';
10
10
  import type { MarkdownDocument, MarkdownBlockNode } from '@bendyline/squisq/markdown';
11
11
  import { markdownDocToEpub } from '../epub/export';
@@ -65,6 +65,10 @@ async function exportAndUnzip(doc: MarkdownDocument, options?: EpubExportOptions
65
65
  // ============================================
66
66
 
67
67
  describe('markdownDocToEpub', () => {
68
+ afterEach(() => {
69
+ vi.unstubAllGlobals();
70
+ });
71
+
68
72
  describe('EPUB structure', () => {
69
73
  it('produces a valid ZIP with required EPUB files', async () => {
70
74
  const doc = makeDoc([heading(1, 'Title'), paragraph('Content')]);
@@ -175,6 +179,15 @@ describe('markdownDocToEpub', () => {
175
179
  expect(ch).toContain('<a href="https://example.com">click</a>');
176
180
  });
177
181
 
182
+ it('strips executable link schemes while preserving link text', async () => {
183
+ const doc = makeDoc([heading(1, 'Test'), linkParagraph('click', 'javascript:alert(1)')]);
184
+ const zip = await exportAndUnzip(doc);
185
+ const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
186
+ expect(ch).not.toContain('javascript:');
187
+ expect(ch).not.toContain('<a ');
188
+ expect(ch).toContain('click');
189
+ });
190
+
178
191
  it('renders code blocks with language class', async () => {
179
192
  const doc = makeDoc([heading(1, 'Test'), codeBlock('const x = 1;', 'javascript')]);
180
193
  const zip = await exportAndUnzip(doc);
@@ -298,6 +311,17 @@ describe('markdownDocToEpub', () => {
298
311
  expect(opf).toContain('image/png');
299
312
  });
300
313
 
314
+ it('flattens backslash-separated image paths to safe archive basenames', async () => {
315
+ const source = '..\\private\\logo.png';
316
+ const doc = makeDoc([heading(1, 'Test'), imageParagraph('Logo', source)]);
317
+ const zip = await exportAndUnzip(doc, {
318
+ images: new Map([[source, new Uint8Array([0x89]).buffer]]),
319
+ });
320
+
321
+ expect(zip.file('OEBPS/images/logo.png')).toBeTruthy();
322
+ expect(Object.keys(zip.files).some((path) => path.includes('\\'))).toBe(false);
323
+ });
324
+
301
325
  it('embeds cover image with cover-image property', async () => {
302
326
  const coverData = new Uint8Array([0xff, 0xd8]).buffer;
303
327
  const doc = makeDoc([heading(1, 'Test')]);
@@ -307,6 +331,23 @@ describe('markdownDocToEpub', () => {
307
331
  const opf = await zip.file('OEBPS/content.opf')!.async('string');
308
332
  expect(opf).toContain('cover-image');
309
333
  });
334
+
335
+ it('does not overwrite a document image whose basename collides with the cover', async () => {
336
+ const documentImage = new Uint8Array([1, 2, 3]).buffer;
337
+ const coverImage = new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer;
338
+ const doc = makeDoc([heading(1, 'Test'), imageParagraph('Inline cover', 'cover.png')]);
339
+ const zip = await exportAndUnzip(doc, {
340
+ images: new Map([['cover.png', documentImage]]),
341
+ coverImage,
342
+ });
343
+
344
+ expect(await zip.file('OEBPS/images/cover.png')!.async('uint8array')).toEqual(
345
+ new Uint8Array(documentImage),
346
+ );
347
+ expect(zip.file('OEBPS/images/cover-2.png')).toBeTruthy();
348
+ const opf = await zip.file('OEBPS/content.opf')!.async('string');
349
+ expect(opf).toContain('href="images/cover-2.png"');
350
+ });
310
351
  });
311
352
 
312
353
  describe('metadata', () => {
@@ -356,6 +397,18 @@ describe('markdownDocToEpub', () => {
356
397
  expect(opf).toContain('urn:uuid:');
357
398
  });
358
399
 
400
+ it('falls back to getRandomValues when randomUUID is unavailable', async () => {
401
+ vi.stubGlobal('crypto', {
402
+ getRandomValues<T extends ArrayBufferView>(array: T): T {
403
+ new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(0xab);
404
+ return array;
405
+ },
406
+ });
407
+ const zip = await exportAndUnzip(makeDoc([heading(1, 'Test')]));
408
+ const opf = await zip.file('OEBPS/content.opf')!.async('string');
409
+ expect(opf).toMatch(/urn:uuid:abababab-abab-4bab-abab-abababababab/);
410
+ });
411
+
359
412
  it('uses frontmatter title as fallback', async () => {
360
413
  const doc = makeDoc([heading(1, 'Intro')], { title: 'Frontmatter Title' });
361
414
  const zip = await exportAndUnzip(doc);
@@ -446,6 +499,54 @@ describe('markdownDocToEpub', () => {
446
499
  expect(zip.file('OEBPS/audio/body.mp3')).toBeTruthy();
447
500
  });
448
501
 
502
+ it('deduplicates colliding audio basenames without overwriting either segment', async () => {
503
+ const doc = makeDoc([
504
+ heading(1, 'One'),
505
+ paragraph('First'),
506
+ heading(1, 'Two'),
507
+ paragraph('Second'),
508
+ ]);
509
+ const segments = [
510
+ { src: 'a/narration.mp3', name: 'one', duration: 10, startTime: 0 },
511
+ { src: 'b/narration.mp3', name: 'two', duration: 10, startTime: 10 },
512
+ ];
513
+ const zip = await exportAndUnzip(doc, {
514
+ audio: new Map([
515
+ ['a/narration.mp3', new Uint8Array([1]).buffer],
516
+ ['b/narration.mp3', new Uint8Array([2]).buffer],
517
+ ]),
518
+ audioSegments: segments,
519
+ totalDuration: 20,
520
+ });
521
+
522
+ expect(zip.file('OEBPS/audio/narration.mp3')).toBeTruthy();
523
+ expect(zip.file('OEBPS/audio/narration-2.mp3')).toBeTruthy();
524
+ const firstSmil = await zip.file('OEBPS/chapters/chapter-001.smil')!.async('string');
525
+ const secondSmil = await zip.file('OEBPS/chapters/chapter-002.smil')!.async('string');
526
+ expect(firstSmil).toContain('narration.mp3');
527
+ expect(secondSmil).toContain('narration-2.mp3');
528
+ });
529
+
530
+ it('writes one manifest item when multiple segments reuse one audio source', async () => {
531
+ const doc = makeDoc([
532
+ heading(1, 'One'),
533
+ paragraph('First'),
534
+ heading(1, 'Two'),
535
+ paragraph('Second'),
536
+ ]);
537
+ const zip = await exportAndUnzip(doc, {
538
+ audio: new Map([['narration.mp3', new Uint8Array([1, 2, 3]).buffer]]),
539
+ audioSegments: [
540
+ { src: 'narration.mp3', name: 'one', duration: 10, startTime: 0 },
541
+ { src: 'narration.mp3', name: 'two', duration: 10, startTime: 10 },
542
+ ],
543
+ totalDuration: 20,
544
+ });
545
+ const opf = await zip.file('OEBPS/content.opf')!.async('string');
546
+
547
+ expect(opf.match(/href="audio\/narration\.mp3"/g)).toHaveLength(1);
548
+ });
549
+
449
550
  it('generates SMIL overlay files for each chapter', async () => {
450
551
  const doc = makeDoc([
451
552
  heading(1, 'Intro'),