@bendyline/squisq-formats 2.0.1 → 2.2.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 (195) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +1 -1
  4. package/dist/{chunk-CRAVSMPZ.js → chunk-26ISNJ7Y.js} +356 -114
  5. package/dist/{chunk-NKAJPJ4G.js → chunk-2JJ5RFDZ.js} +0 -1
  6. package/dist/{chunk-HTW2M27H.js → chunk-3NKXBZSR.js} +193 -42
  7. package/dist/{chunk-U32AG3G3.js → chunk-4V3KCHAP.js} +3 -4
  8. package/dist/{chunk-MLX2BOJC.js → chunk-6RQOV3B3.js} +1 -2
  9. package/dist/{chunk-QRVN6A6E.js → chunk-6S6GU3ZG.js} +5 -6
  10. package/dist/{chunk-FE6OJV6O.js → chunk-7AWFHP5U.js} +1 -1
  11. package/dist/{chunk-RFAPOKHJ.js → chunk-AD2WT564.js} +59 -9
  12. package/dist/{chunk-O3GVVND4.js → chunk-AONELFLA.js} +0 -1
  13. package/dist/{chunk-XKUMNGBW.js → chunk-EJTNGKEA.js} +5 -8
  14. package/dist/chunk-GX7RAUME.js +121 -0
  15. package/dist/{chunk-SSUPBUF5.js → chunk-IIQYS2YH.js} +0 -1
  16. package/dist/{chunk-ABVI556T.js → chunk-IPN56VLW.js} +83 -58
  17. package/dist/{chunk-LXYLOOST.js → chunk-JE6LSIHE.js} +83 -22
  18. package/dist/{chunk-U4MRIFKL.js → chunk-JU2RHXUB.js} +0 -1
  19. package/dist/{chunk-4VUWTSGM.js → chunk-K6XRMVPW.js} +64 -31
  20. package/dist/{chunk-ODL3SSPT.js → chunk-KXOZMWBS.js} +0 -1
  21. package/dist/chunk-OGS5VCGJ.js +446 -0
  22. package/dist/{chunk-GVS2XXV6.js → chunk-PJXJI2LY.js} +449 -57
  23. package/dist/{chunk-2KPARF2P.js → chunk-PU7REGWV.js} +5 -8
  24. package/dist/{chunk-PN52A5AA.js → chunk-SBUW7NHR.js} +0 -1
  25. package/dist/{chunk-VSYHZECT.js → chunk-TAAENIRB.js} +5 -8
  26. package/dist/{chunk-WC7WULGV.js → chunk-X2DEAXNK.js} +62 -2
  27. package/dist/container/index.js +1 -2
  28. package/dist/csv/index.d.ts +27 -2
  29. package/dist/csv/index.js +1 -2
  30. package/dist/docx/index.d.ts +5 -1
  31. package/dist/docx/index.js +9 -10
  32. package/dist/epub/index.d.ts +2 -0
  33. package/dist/epub/index.js +5 -6
  34. package/dist/{export-D2NkylDT.d.ts → export-D9msROJS.d.ts} +18 -6
  35. package/dist/extract-MN7LA3NL.js +13 -0
  36. package/dist/html/index.d.ts +11 -4
  37. package/dist/html/index.js +3 -4
  38. package/dist/images-ESPQKVTW.js +6 -0
  39. package/dist/{import-K8mfc0fz.d.ts → import-C3htUTss.d.ts} +5 -1
  40. package/dist/{import-DTkDxHmZ.d.ts → import-C8whCC7_.d.ts} +6 -0
  41. package/dist/index.d.ts +7 -7
  42. package/dist/index.js +28 -26
  43. package/dist/infer/index.d.ts +3 -3
  44. package/dist/infer/index.js +7 -9
  45. package/dist/{layouts-BHrgZ5FS.d.ts → layouts-CTdPlB-u.d.ts} +1 -1
  46. package/dist/layouts-DRWZGSPD.js +10 -0
  47. package/dist/{mapTheme-IR27S6IV.js → mapTheme-4TWH25FT.js} +1 -2
  48. package/dist/ooxml/index.d.ts +3 -3
  49. package/dist/ooxml/index.js +14 -13
  50. package/dist/pdf/index.d.ts +18 -0
  51. package/dist/pdf/index.js +2 -3
  52. package/dist/pptx/index.d.ts +4 -4
  53. package/dist/pptx/index.js +11 -13
  54. package/dist/{reader-B9L8Ucbj.d.ts → reader-B_m1aKZC.d.ts} +30 -1
  55. package/dist/registry/index.d.ts +21 -5
  56. package/dist/registry/index.js +9 -6
  57. package/dist/{themeReader-DJKErl_j.d.ts → themeReader-DCtwC83Q.d.ts} +1 -1
  58. package/dist/xlsx/index.d.ts +3 -3
  59. package/dist/xlsx/index.js +6 -7
  60. package/package.json +6 -3
  61. package/dist/chunk-2KPARF2P.js.map +0 -1
  62. package/dist/chunk-4VUWTSGM.js.map +0 -1
  63. package/dist/chunk-6M7Z25LA.js +0 -46
  64. package/dist/chunk-6M7Z25LA.js.map +0 -1
  65. package/dist/chunk-ABVI556T.js.map +0 -1
  66. package/dist/chunk-CRAVSMPZ.js.map +0 -1
  67. package/dist/chunk-FE6OJV6O.js.map +0 -1
  68. package/dist/chunk-GVS2XXV6.js.map +0 -1
  69. package/dist/chunk-HTW2M27H.js.map +0 -1
  70. package/dist/chunk-LXYLOOST.js.map +0 -1
  71. package/dist/chunk-MLX2BOJC.js.map +0 -1
  72. package/dist/chunk-NKAJPJ4G.js.map +0 -1
  73. package/dist/chunk-O3GVVND4.js.map +0 -1
  74. package/dist/chunk-ODL3SSPT.js.map +0 -1
  75. package/dist/chunk-PN52A5AA.js.map +0 -1
  76. package/dist/chunk-QRVN6A6E.js.map +0 -1
  77. package/dist/chunk-RFAPOKHJ.js.map +0 -1
  78. package/dist/chunk-SSUPBUF5.js.map +0 -1
  79. package/dist/chunk-U32AG3G3.js.map +0 -1
  80. package/dist/chunk-U4MRIFKL.js.map +0 -1
  81. package/dist/chunk-VJJM2SSH.js +0 -275
  82. package/dist/chunk-VJJM2SSH.js.map +0 -1
  83. package/dist/chunk-VSYHZECT.js.map +0 -1
  84. package/dist/chunk-WC7WULGV.js.map +0 -1
  85. package/dist/chunk-XKUMNGBW.js.map +0 -1
  86. package/dist/chunk-YRT7GQ5Y.js +0 -28
  87. package/dist/chunk-YRT7GQ5Y.js.map +0 -1
  88. package/dist/container/index.js.map +0 -1
  89. package/dist/csv/index.js.map +0 -1
  90. package/dist/docx/index.js.map +0 -1
  91. package/dist/epub/index.js.map +0 -1
  92. package/dist/extract-H6RXJMHP.js +0 -15
  93. package/dist/extract-H6RXJMHP.js.map +0 -1
  94. package/dist/html/index.js.map +0 -1
  95. package/dist/images-7FBWPKE3.js +0 -7
  96. package/dist/images-7FBWPKE3.js.map +0 -1
  97. package/dist/index.js.map +0 -1
  98. package/dist/infer/index.js.map +0 -1
  99. package/dist/layouts-QVPK3ZCU.js +0 -12
  100. package/dist/layouts-QVPK3ZCU.js.map +0 -1
  101. package/dist/mapTheme-IR27S6IV.js.map +0 -1
  102. package/dist/ooxml/index.js.map +0 -1
  103. package/dist/pdf/index.js.map +0 -1
  104. package/dist/pptx/index.js.map +0 -1
  105. package/dist/registry/index.js.map +0 -1
  106. package/dist/xlsx/index.js.map +0 -1
  107. package/src/__tests__/container.test.ts +0 -230
  108. package/src/__tests__/convert.test.ts +0 -495
  109. package/src/__tests__/csvImport.test.ts +0 -84
  110. package/src/__tests__/docxExport.test.ts +0 -457
  111. package/src/__tests__/docxImport.test.ts +0 -410
  112. package/src/__tests__/epub.test.ts +0 -649
  113. package/src/__tests__/exportThemeReconciliation.test.ts +0 -87
  114. package/src/__tests__/formatRegistry.test.ts +0 -174
  115. package/src/__tests__/html.test.ts +0 -435
  116. package/src/__tests__/htmlImport.test.ts +0 -57
  117. package/src/__tests__/inferTheme.test.ts +0 -135
  118. package/src/__tests__/lossyWarnings.test.ts +0 -146
  119. package/src/__tests__/ooxml.test.ts +0 -271
  120. package/src/__tests__/ooxmlCancellation.test.ts +0 -113
  121. package/src/__tests__/ooxmlThemeReader.test.ts +0 -92
  122. package/src/__tests__/pdfExport.test.ts +0 -322
  123. package/src/__tests__/pdfImport.test.ts +0 -384
  124. package/src/__tests__/plainHtml.test.ts +0 -417
  125. package/src/__tests__/plainHtmlBundle.test.ts +0 -253
  126. package/src/__tests__/pptxExport.test.ts +0 -138
  127. package/src/__tests__/pptxImport.test.ts +0 -145
  128. package/src/__tests__/pptxInferFixtures.ts +0 -314
  129. package/src/__tests__/pptxLayoutInfer.test.ts +0 -395
  130. package/src/__tests__/roundTrip.test.ts +0 -201
  131. package/src/__tests__/roundTripAssets.test.ts +0 -50
  132. package/src/__tests__/roundTripMatrix.fixtures.ts +0 -86
  133. package/src/__tests__/roundTripMatrix.helpers.ts +0 -154
  134. package/src/__tests__/roundTripMatrix.test.ts +0 -142
  135. package/src/__tests__/sharedContainer.test.ts +0 -41
  136. package/src/__tests__/sharedImages.test.ts +0 -61
  137. package/src/__tests__/xlsxExport.test.ts +0 -164
  138. package/src/__tests__/xlsxImport.test.ts +0 -80
  139. package/src/__tests__/zipSafety.test.ts +0 -317
  140. package/src/container/index.ts +0 -94
  141. package/src/csv/index.ts +0 -188
  142. package/src/docx/export.ts +0 -1267
  143. package/src/docx/import.ts +0 -995
  144. package/src/docx/index.ts +0 -26
  145. package/src/docx/styles.ts +0 -145
  146. package/src/epub/export.ts +0 -968
  147. package/src/epub/index.ts +0 -20
  148. package/src/html/docsHtmlBundle.ts +0 -373
  149. package/src/html/htmlTemplate.ts +0 -385
  150. package/src/html/imageUtils.ts +0 -61
  151. package/src/html/import.ts +0 -297
  152. package/src/html/index.ts +0 -212
  153. package/src/html/plainHtml.ts +0 -790
  154. package/src/html/plainHtmlBundle.ts +0 -421
  155. package/src/index.ts +0 -109
  156. package/src/infer/extract.ts +0 -127
  157. package/src/infer/index.ts +0 -199
  158. package/src/infer/mapTheme.ts +0 -176
  159. package/src/infer/types.ts +0 -27
  160. package/src/ooxml/index.ts +0 -111
  161. package/src/ooxml/namespaces.ts +0 -196
  162. package/src/ooxml/readUtils.ts +0 -44
  163. package/src/ooxml/reader.ts +0 -318
  164. package/src/ooxml/themeReader.ts +0 -197
  165. package/src/ooxml/types.ts +0 -103
  166. package/src/ooxml/writer.ts +0 -339
  167. package/src/ooxml/xmlUtils.ts +0 -123
  168. package/src/pdf/export.ts +0 -1084
  169. package/src/pdf/import.ts +0 -1164
  170. package/src/pdf/index.ts +0 -29
  171. package/src/pdf/styles.ts +0 -180
  172. package/src/pptx/export.ts +0 -1184
  173. package/src/pptx/import.ts +0 -455
  174. package/src/pptx/index.ts +0 -52
  175. package/src/pptx/layouts.ts +0 -1222
  176. package/src/pptx/styles.ts +0 -96
  177. package/src/pptx/templates.ts +0 -187
  178. package/src/registry/convert.ts +0 -433
  179. package/src/registry/defaultFormats.ts +0 -413
  180. package/src/registry/errors.ts +0 -46
  181. package/src/registry/index.ts +0 -43
  182. package/src/registry/registry.ts +0 -48
  183. package/src/registry/types.ts +0 -170
  184. package/src/shared/boundedZipArchive.ts +0 -383
  185. package/src/shared/container.ts +0 -28
  186. package/src/shared/fidelity.ts +0 -130
  187. package/src/shared/images.ts +0 -44
  188. package/src/shared/inlineRuns.ts +0 -99
  189. package/src/shared/text.ts +0 -41
  190. package/src/shared/zipEntryCount.ts +0 -151
  191. package/src/shared/zipLimits.ts +0 -296
  192. package/src/shared/zipSafety.ts +0 -19
  193. package/src/xlsx/export.ts +0 -253
  194. package/src/xlsx/import.ts +0 -160
  195. package/src/xlsx/index.ts +0 -35
@@ -1,433 +0,0 @@
1
- /**
2
- * Programmatic `convert()` — the format-registry front door.
3
- *
4
- * Normalizes any {@link ConvertSource} into a {@link NormalizedInput} (always
5
- * yielding a `Doc`, keeping the `MarkdownDocument` when the source was
6
- * markdown-shaped, and always carrying a `ContentContainer` for media), applies
7
- * an optional transform, then hands off to the target format's exporter.
8
- */
9
-
10
- import type { MarkdownDocument } from '@bendyline/squisq/markdown';
11
- import type { ContentContainer } from '@bendyline/squisq/storage';
12
- import type { TransformStyleInput, TransformStyleRegistry } from '@bendyline/squisq/transform';
13
- import { ConversionError } from './errors.js';
14
- import { defaultRegistry } from './registry.js';
15
- import { openBoundedZipArchive, ZipSafetyError } from '../shared/zipSafety.js';
16
- import type {
17
- BuiltinFormatOptions,
18
- ConversionResult,
19
- ConvertOptions,
20
- ConvertSource,
21
- FormatDefinition,
22
- FormatId,
23
- FormatRegistry,
24
- NormalizedInput,
25
- PreparedConversion,
26
- PreparedExportOptions,
27
- } from './types.js';
28
-
29
- function markdownFormatOptions(options: ConvertOptions): BuiltinFormatOptions['md'] {
30
- return (options.formatOptions?.md ?? {}) as BuiltinFormatOptions['md'];
31
- }
32
-
33
- function throwIfAborted(signal?: AbortSignal): void {
34
- if (!signal?.aborted) return;
35
- if (signal.reason !== undefined) throw signal.reason;
36
- const error = new Error('Document conversion was cancelled.');
37
- error.name = 'AbortError';
38
- throw error;
39
- }
40
-
41
- // ── Byte sniffing ───────────────────────────────────────────────────
42
-
43
- function hasPrefix(bytes: Uint8Array, prefix: number[]): boolean {
44
- if (bytes.length < prefix.length) return false;
45
- for (let i = 0; i < prefix.length; i++) {
46
- if (bytes[i] !== prefix[i]) return false;
47
- }
48
- return true;
49
- }
50
-
51
- const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04"
52
- const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46]; // "%PDF"
53
-
54
- /**
55
- * Disambiguate a ZIP-based file. OOXML formats (docx/pptx/xlsx) all carry a
56
- * `[Content_Types].xml` whose content-type strings name the flavor; a squisq
57
- * container (.dbk) is a plain ZIP with no such part, so absence of the OOXML
58
- * marker means `dbk`.
59
- */
60
- async function sniffZip(bytes: Uint8Array): Promise<FormatId> {
61
- const archive = await openBoundedZipArchive(bytes).catch((cause: unknown) => {
62
- if (cause instanceof ZipSafetyError && cause.code !== 'invalid-archive') throw cause;
63
- throw new ConversionError('invalid-input', 'Input is not a readable ZIP archive.', { cause });
64
- });
65
- const contentTypes = archive.entries.find((entry) => entry.path === '[Content_Types].xml');
66
- if (!contentTypes) return 'dbk';
67
- const maxContentTypesBytes = 1024 * 1024;
68
- if ((contentTypes.declaredSize ?? 0) > maxContentTypesBytes) {
69
- throw new ConversionError('invalid-input', 'OOXML content-types metadata is too large.');
70
- }
71
- const xmlBytes = await archive.read(contentTypes.path, maxContentTypesBytes);
72
- if (!xmlBytes) return 'dbk';
73
- const xml = new TextDecoder().decode(xmlBytes);
74
- if (xml.includes('wordprocessingml')) return 'docx';
75
- if (xml.includes('presentationml')) return 'pptx';
76
- if (xml.includes('spreadsheetml')) return 'xlsx';
77
- return 'dbk';
78
- }
79
-
80
- /** Determine the source format for raw bytes. */
81
- async function detectByteFormat(
82
- bytes: Uint8Array,
83
- filename: string | undefined,
84
- options: ConvertOptions,
85
- registry: FormatRegistry,
86
- ): Promise<FormatId> {
87
- if (options.from) return options.from;
88
-
89
- const ext = filename ? extractExt(filename) : undefined;
90
- if (ext) {
91
- const byExt = registry.byExtension(ext);
92
- if (byExt) return byExt.id;
93
- }
94
-
95
- if (hasPrefix(bytes, PDF_MAGIC)) return 'pdf';
96
- if (hasPrefix(bytes, ZIP_MAGIC)) return sniffZip(bytes);
97
-
98
- // No magic and no recognized extension → assume UTF-8 markdown.
99
- return 'md';
100
- }
101
-
102
- // ── Filename helpers ────────────────────────────────────────────────
103
-
104
- /** Lowercased extension without the leading dot (`report.DocX` → `docx`). */
105
- function extractExt(filename: string): string {
106
- const normalized = filename.replace(/\\/g, '/');
107
- const base = normalized.split('/').pop() ?? normalized;
108
- const dot = base.lastIndexOf('.');
109
- if (dot <= 0) return '';
110
- return base.slice(dot + 1).toLowerCase();
111
- }
112
-
113
- /** Basename without its final extension (`a/b/report.docx` → `report`). */
114
- function baseNameOf(filename: string): string {
115
- const normalized = filename.replace(/\\/g, '/');
116
- const base = normalized.split('/').pop() ?? normalized;
117
- const dot = base.lastIndexOf('.');
118
- return dot > 0 ? base.slice(0, dot) : base;
119
- }
120
-
121
- // ── Normalization ───────────────────────────────────────────────────
122
-
123
- interface Normalized {
124
- input: NormalizedInput;
125
- warnings: string[];
126
- }
127
-
128
- async function normalizeBytes(
129
- source: Extract<ConvertSource, { kind: 'bytes' }>,
130
- options: ConvertOptions,
131
- registry: FormatRegistry,
132
- ): Promise<Normalized> {
133
- const bytes = source.data instanceof Uint8Array ? source.data : new Uint8Array(source.data);
134
- const buffer = bytes.buffer.slice(
135
- bytes.byteOffset,
136
- bytes.byteOffset + bytes.byteLength,
137
- ) as ArrayBuffer;
138
-
139
- const fromId = await detectByteFormat(bytes, source.filename, options, registry);
140
- const fromDef = registry.get(fromId);
141
- if (!fromDef) {
142
- throw new ConversionError('unknown-format', `Unknown source format "${fromId}".`, {
143
- format: fromId,
144
- });
145
- }
146
- if (!fromDef.importContainer && !fromDef.importDoc) {
147
- throw new ConversionError(
148
- 'unsupported-input',
149
- `Format "${fromDef.label}" cannot be used as a conversion source.`,
150
- {
151
- format: fromId,
152
- hint: 'This format is export-only.',
153
- },
154
- );
155
- }
156
-
157
- const { markdownToDoc } = await import('@bendyline/squisq/doc');
158
- const { parseMarkdown, stringifyMarkdown } = await import('@bendyline/squisq/markdown');
159
-
160
- const warnings: string[] = [];
161
- let markdownDoc: MarkdownDocument;
162
- let container: ContentContainer;
163
-
164
- if (fromDef.importContainer) {
165
- container = await fromDef.importContainer(buffer, options);
166
- // PDF embedded-image extraction needs a browser canvas; under Node it's
167
- // skipped inside pdfToContainer. importContainer has no warnings channel of
168
- // its own, so surface that degraded path here where warnings are collected.
169
- if (fromDef.id === 'pdf' && typeof document === 'undefined') {
170
- warnings.push(
171
- 'PDF embedded images were skipped — image decoding requires a browser canvas (running under Node).',
172
- );
173
- }
174
- const text = await container.readDocument();
175
- markdownDoc = text
176
- ? parseMarkdown(text, markdownFormatOptions(options).parse)
177
- : { type: 'document', children: [] };
178
- } else {
179
- // importDoc is guaranteed present by the guard above.
180
- markdownDoc = await fromDef.importDoc!(buffer, options);
181
- const { MemoryContentContainer } = await import('@bendyline/squisq/storage');
182
- const mem = new MemoryContentContainer();
183
- await mem.writeDocument(stringifyMarkdown(markdownDoc));
184
- container = mem;
185
- }
186
-
187
- const doc = markdownToDoc(markdownDoc, { autoTemplates: options.autoTemplates });
188
- const baseName = source.filename ? baseNameOf(source.filename) : 'document';
189
-
190
- return {
191
- input: { doc, markdownDoc, container, baseName },
192
- warnings,
193
- };
194
- }
195
-
196
- async function normalizeMarkdown(
197
- source: Extract<ConvertSource, { kind: 'markdown' }>,
198
- options: ConvertOptions,
199
- ): Promise<Normalized> {
200
- const { markdownToDoc } = await import('@bendyline/squisq/doc');
201
- const { parseMarkdown } = await import('@bendyline/squisq/markdown');
202
-
203
- const { stringifyMarkdown } = await import('@bendyline/squisq/markdown');
204
-
205
- const markdownDoc =
206
- typeof source.markdown === 'string'
207
- ? parseMarkdown(source.markdown, markdownFormatOptions(options).parse)
208
- : source.markdown;
209
-
210
- let container = source.container;
211
- if (!container) {
212
- // Seed a fresh container with the document so container-backed exports
213
- // (dbk) and media resolution have the markdown to serialize.
214
- const { MemoryContentContainer } = await import('@bendyline/squisq/storage');
215
- const mem = new MemoryContentContainer();
216
- await mem.writeDocument(stringifyMarkdown(markdownDoc));
217
- container = mem;
218
- }
219
-
220
- const doc = markdownToDoc(markdownDoc, { autoTemplates: options.autoTemplates });
221
- const baseName = source.baseName ?? 'document';
222
-
223
- return { input: { doc, markdownDoc, container, baseName }, warnings: [] };
224
- }
225
-
226
- async function normalizeDoc(source: Extract<ConvertSource, { kind: 'doc' }>): Promise<Normalized> {
227
- let container = source.container;
228
- if (!container) {
229
- const { MemoryContentContainer } = await import('@bendyline/squisq/storage');
230
- const { docToMarkdown } = await import('@bendyline/squisq/doc');
231
- const { stringifyMarkdown } = await import('@bendyline/squisq/markdown');
232
- const mem = new MemoryContentContainer();
233
- await mem.writeDocument(stringifyMarkdown(docToMarkdown(source.doc)));
234
- container = mem;
235
- }
236
- const baseName = source.baseName ?? 'document';
237
- // markdownDoc is intentionally omitted — exporters derive it lazily via docToMarkdown.
238
- return { input: { doc: source.doc, container, baseName }, warnings: [] };
239
- }
240
-
241
- async function normalize(
242
- source: ConvertSource,
243
- options: ConvertOptions,
244
- registry: FormatRegistry,
245
- ): Promise<Normalized> {
246
- switch (source.kind) {
247
- case 'bytes':
248
- return normalizeBytes(source, options, registry);
249
- case 'markdown':
250
- return normalizeMarkdown(source, options);
251
- case 'doc':
252
- return normalizeDoc(source);
253
- }
254
- }
255
-
256
- // ── Transform ───────────────────────────────────────────────────────
257
-
258
- async function applyTransformStyle(
259
- input: NormalizedInput,
260
- transformStyle: TransformStyleInput,
261
- themeId?: string,
262
- registry?: TransformStyleRegistry,
263
- ): Promise<string[]> {
264
- const { applyTransform, extractDocImages } = await import('@bendyline/squisq/transform');
265
- const { docToMarkdown, markdownToDoc } = await import('@bendyline/squisq/doc');
266
- const images = extractDocImages(input.doc.blocks);
267
- const result = applyTransform(input.doc, transformStyle, { themeId, images, registry });
268
- input.doc = result.doc;
269
- const markdownDoc = docToMarkdown(result.doc);
270
- input.markdownDoc = markdownDoc;
271
-
272
- // Lossy-path detection: transforms can emit template blocks whose data lives
273
- // in template inputs rather than in round-trippable markdown `contents`, so
274
- // `docToMarkdown` silently drops them. Re-parse with auto-templating off (so
275
- // the comparison stays ~1:1 and isn't confused by content-aware promotion)
276
- // and compare block counts. Best-effort — most blocks carry `contents` and
277
- // round-trip fine, so this only fires on a genuine collapse.
278
- const roundTripped = markdownToDoc(markdownDoc, { autoTemplates: false });
279
- if (roundTripped.blocks.length < result.doc.blocks.length) {
280
- return [
281
- 'Transform produced blocks that don’t round-trip to markdown; export may be incomplete.',
282
- ];
283
- }
284
- return [];
285
- }
286
-
287
- // ── prepare / convert ───────────────────────────────────────────────
288
-
289
- /** Normalize and transform a source once, then safely export that snapshot many times. */
290
- export async function prepareConversion(
291
- source: ConvertSource,
292
- options: ConvertOptions = {},
293
- ): Promise<PreparedConversion> {
294
- return prepareConversionInternal(source, options);
295
- }
296
-
297
- async function prepareConversionInternal(
298
- source: ConvertSource,
299
- options: ConvertOptions,
300
- failureFormat?: FormatId,
301
- ): Promise<PreparedConversion> {
302
- throwIfAborted(options.signal);
303
- const registry = options.registry ?? defaultRegistry();
304
-
305
- let normalized: Normalized;
306
- try {
307
- normalized = await normalize(source, options, registry);
308
- throwIfAborted(options.signal);
309
- } catch (err: unknown) {
310
- throwIfAborted(options.signal);
311
- if (err instanceof ConversionError) throw err;
312
- const message = err instanceof Error ? err.message : String(err);
313
- throw new ConversionError('invalid-input', message, {
314
- format: options.from,
315
- cause: err,
316
- });
317
- }
318
- const { input, warnings } = normalized;
319
-
320
- // Respect an existing doc theme; only fill it in from options when absent.
321
- if (options.themeId && !input.doc.themeId) {
322
- input.doc = { ...input.doc, themeId: options.themeId };
323
- }
324
-
325
- try {
326
- if (options.transformStyle) {
327
- warnings.push(
328
- ...(await applyTransformStyle(
329
- input,
330
- options.transformStyle,
331
- options.themeId ?? input.doc.themeId,
332
- options.transformRegistry,
333
- )),
334
- );
335
- throwIfAborted(options.signal);
336
- }
337
- } catch (err: unknown) {
338
- throwIfAborted(options.signal);
339
- if (err instanceof ConversionError) throw err;
340
- const message = err instanceof Error ? err.message : String(err);
341
- throw new ConversionError('conversion-failed', message, {
342
- format: failureFormat,
343
- cause: err,
344
- });
345
- }
346
-
347
- const preparedWarnings = Object.freeze([...warnings]);
348
- const preparedInput = { ...input };
349
- return Object.freeze({
350
- async convert(to: FormatId, targetOptions: PreparedExportOptions = {}) {
351
- const target = requireExportTarget(registry, to);
352
- const exportOptions = mergePreparedExportOptions(options, targetOptions);
353
- throwIfAborted(exportOptions.signal);
354
- let result: ConversionResult;
355
- try {
356
- result = await target.exportDoc!({ ...preparedInput }, exportOptions);
357
- throwIfAborted(exportOptions.signal);
358
- } catch (err: unknown) {
359
- throwIfAborted(exportOptions.signal);
360
- if (err instanceof ConversionError) throw err;
361
- const message = err instanceof Error ? err.message : String(err);
362
- throw new ConversionError('conversion-failed', message, { format: to, cause: err });
363
- }
364
-
365
- const ext = target.extensions[0]?.replace(/^\.+/, '') ?? to;
366
- return {
367
- ...result,
368
- suggestedFilename: `${preparedInput.baseName}.${ext}`,
369
- warnings: [...preparedWarnings, ...result.warnings],
370
- };
371
- },
372
- });
373
- }
374
-
375
- function requireExportTarget(registry: FormatRegistry, to: FormatId): FormatDefinition {
376
- const target = registry.get(to);
377
- if (!target) {
378
- throw new ConversionError('unknown-format', `Unknown target format "${to}".`, { format: to });
379
- }
380
- if (!target.exportDoc) {
381
- throw new ConversionError(
382
- 'unsupported-output',
383
- `Format "${target.label}" does not support export.`,
384
- { format: to, hint: 'This format is import-only.' },
385
- );
386
- }
387
- return target;
388
- }
389
-
390
- function mergePreparedExportOptions(
391
- prepared: ConvertOptions,
392
- target: PreparedExportOptions,
393
- ): ConvertOptions {
394
- return {
395
- ...prepared,
396
- signal: mergeAbortSignals(prepared.signal, target.signal),
397
- title: target.title ?? prepared.title,
398
- resolvePlayerScript: target.resolvePlayerScript ?? prepared.resolvePlayerScript,
399
- formatOptions:
400
- target.formatOptions === undefined
401
- ? prepared.formatOptions
402
- : { ...(prepared.formatOptions ?? {}), ...target.formatOptions },
403
- };
404
- }
405
-
406
- function mergeAbortSignals(
407
- preparedSignal?: AbortSignal,
408
- targetSignal?: AbortSignal,
409
- ): AbortSignal | undefined {
410
- if (!preparedSignal) return targetSignal;
411
- if (!targetSignal || preparedSignal === targetSignal) return preparedSignal;
412
- return AbortSignal.any([preparedSignal, targetSignal]);
413
- }
414
-
415
- /**
416
- * Convert a source document to a target format.
417
- *
418
- * @param source - A bytes / markdown / doc source.
419
- * @param to - The target format id (must be registered and support export).
420
- * @param options - Conversion options (registry, theme, transform, …).
421
- * @throws {@link ConversionError} on any failure, with a stable `code`.
422
- */
423
- export async function convert(
424
- source: ConvertSource,
425
- to: FormatId,
426
- options: ConvertOptions = {},
427
- ): Promise<ConversionResult> {
428
- throwIfAborted(options.signal);
429
- const registry = options.registry ?? defaultRegistry();
430
- requireExportTarget(registry, to);
431
- const prepared = await prepareConversionInternal(source, { ...options, registry }, to);
432
- return prepared.convert(to);
433
- }