@bendyline/squisq-formats 2.0.0 → 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/dist/{chunk-CX3LH47J.js → chunk-2KPARF2P.js} +3 -3
- package/dist/{chunk-CVNVD7VH.js → chunk-ABVI556T.js} +5 -5
- package/dist/{chunk-2CSZJEAL.js → chunk-CRAVSMPZ.js} +3 -3
- package/dist/{chunk-H7T5DXUU.js → chunk-FE6OJV6O.js} +95 -14
- package/dist/chunk-FE6OJV6O.js.map +1 -0
- package/dist/{chunk-2EFDWKC6.js → chunk-HTW2M27H.js} +86 -23
- package/dist/chunk-HTW2M27H.js.map +1 -0
- package/dist/{chunk-ZZ7D4DN6.js → chunk-QRVN6A6E.js} +39 -9
- package/dist/chunk-QRVN6A6E.js.map +1 -0
- package/dist/{chunk-XTLQXR4U.js → chunk-U32AG3G3.js} +3 -3
- package/dist/{chunk-HBSCRS5P.js → chunk-VJJM2SSH.js} +3 -3
- package/dist/{chunk-2PRVLAU3.js → chunk-VSYHZECT.js} +55 -17
- package/dist/chunk-VSYHZECT.js.map +1 -0
- package/dist/{chunk-4HJ6AK2Q.js → chunk-WC7WULGV.js} +44 -13
- package/dist/chunk-WC7WULGV.js.map +1 -0
- package/dist/{chunk-X4USWOQ2.js → chunk-XKUMNGBW.js} +2 -2
- package/dist/container/index.d.ts +10 -4
- package/dist/container/index.js +9 -4
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +2 -2
- package/dist/docx/index.js +4 -4
- package/dist/{export-Djev9BLU.d.ts → export-D2NkylDT.d.ts} +1 -1
- package/dist/{extract-LNOA7DBK.js → extract-H6RXJMHP.js} +5 -5
- package/dist/{import-Yivc3kZM.d.ts → import-K8mfc0fz.d.ts} +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.js +14 -12
- package/dist/infer/index.d.ts +4 -4
- package/dist/infer/index.js +5 -5
- package/dist/{layouts-CZ0bdsNL.d.ts → layouts-BHrgZ5FS.d.ts} +1 -1
- package/dist/{layouts-7OUUVAGU.js → layouts-QVPK3ZCU.js} +4 -4
- package/dist/ooxml/index.d.ts +4 -4
- package/dist/ooxml/index.js +4 -4
- package/dist/pptx/index.d.ts +5 -5
- package/dist/pptx/index.js +5 -5
- package/dist/{reader-Gof6OrJX.d.ts → reader-B9L8Ucbj.d.ts} +3 -3
- package/dist/registry/index.d.ts +20 -6
- package/dist/registry/index.js +6 -4
- package/dist/{themeReader-DPw4J5Ss.d.ts → themeReader-DJKErl_j.d.ts} +1 -1
- package/dist/xlsx/index.d.ts +4 -4
- package/dist/xlsx/index.js +4 -4
- package/dist/{zipLimits-bGYMS9cI.d.ts → zipLimits-BOKCB7qk.d.ts} +2 -0
- package/package.json +2 -2
- package/src/__tests__/convert.test.ts +143 -1
- package/src/__tests__/ooxml.test.ts +17 -0
- package/src/__tests__/ooxmlCancellation.test.ts +113 -0
- package/src/container/index.ts +17 -3
- package/src/index.ts +3 -0
- package/src/infer/index.ts +35 -4
- package/src/ooxml/reader.ts +55 -14
- package/src/pptx/layouts.ts +68 -14
- package/src/registry/convert.ts +115 -31
- package/src/registry/defaultFormats.ts +5 -2
- package/src/registry/index.ts +3 -1
- package/src/registry/types.ts +18 -1
- package/src/shared/boundedZipArchive.ts +49 -8
- package/src/shared/zipEntryCount.ts +59 -5
- package/src/shared/zipLimits.ts +10 -0
- package/dist/chunk-2EFDWKC6.js.map +0 -1
- package/dist/chunk-2PRVLAU3.js.map +0 -1
- package/dist/chunk-4HJ6AK2Q.js.map +0 -1
- package/dist/chunk-H7T5DXUU.js.map +0 -1
- package/dist/chunk-ZZ7D4DN6.js.map +0 -1
- /package/dist/{chunk-CX3LH47J.js.map → chunk-2KPARF2P.js.map} +0 -0
- /package/dist/{chunk-CVNVD7VH.js.map → chunk-ABVI556T.js.map} +0 -0
- /package/dist/{chunk-2CSZJEAL.js.map → chunk-CRAVSMPZ.js.map} +0 -0
- /package/dist/{chunk-XTLQXR4U.js.map → chunk-U32AG3G3.js.map} +0 -0
- /package/dist/{chunk-HBSCRS5P.js.map → chunk-VJJM2SSH.js.map} +0 -0
- /package/dist/{chunk-X4USWOQ2.js.map → chunk-XKUMNGBW.js.map} +0 -0
- /package/dist/{extract-LNOA7DBK.js.map → extract-H6RXJMHP.js.map} +0 -0
- /package/dist/{layouts-7OUUVAGU.js.map → layouts-QVPK3ZCU.js.map} +0 -0
package/src/pptx/layouts.ts
CHANGED
|
@@ -24,7 +24,12 @@ import type {
|
|
|
24
24
|
TextLayer,
|
|
25
25
|
TextStyle,
|
|
26
26
|
} from '@bendyline/squisq/schemas';
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
getPartRelationships,
|
|
29
|
+
getPartXml,
|
|
30
|
+
openPackage,
|
|
31
|
+
throwIfOoxmlAborted,
|
|
32
|
+
} from '../ooxml/reader.js';
|
|
28
33
|
import type { OoxmlOpenOptions } from '../ooxml/reader.js';
|
|
29
34
|
import type { OoxmlPackage } from '../ooxml/types.js';
|
|
30
35
|
import {
|
|
@@ -162,6 +167,7 @@ const DEFAULT_SLIDE_SIZE = { cx: 12192000, cy: 6858000 };
|
|
|
162
167
|
const CHROME_PH_TYPES = new Set(['dt', 'ftr', 'sldNum', 'hdr']);
|
|
163
168
|
const MAX_INFERRED_TEMPLATES = 12;
|
|
164
169
|
const MAX_DECORATIONS = 4;
|
|
170
|
+
const CANCELLATION_CHECKPOINT_INTERVAL = 128;
|
|
165
171
|
/** Decorative rects smaller than this share of the slide are clutter. */
|
|
166
172
|
const MIN_DECORATION_AREA_PCT = 2;
|
|
167
173
|
|
|
@@ -173,6 +179,14 @@ const DEFAULT_TITLE_PT = 44;
|
|
|
173
179
|
const DEFAULT_SUBTITLE_PT = 24;
|
|
174
180
|
const DEFAULT_BODY_PT = 22;
|
|
175
181
|
|
|
182
|
+
async function cancellationCheckpoint(index: number, signal?: AbortSignal): Promise<void> {
|
|
183
|
+
throwIfOoxmlAborted(signal);
|
|
184
|
+
if (index > 0 && index % CANCELLATION_CHECKPOINT_INTERVAL === 0) {
|
|
185
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
186
|
+
throwIfOoxmlAborted(signal);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
176
190
|
// ── Small helpers ────────────────────────────────────────────────────
|
|
177
191
|
|
|
178
192
|
function firstPml(parent: Element | Document, local: string): Element | null {
|
|
@@ -316,7 +330,12 @@ interface MasterIndex {
|
|
|
316
330
|
bodyStyle?: PlaceholderTextStyle;
|
|
317
331
|
}
|
|
318
332
|
|
|
319
|
-
async function readMasterIndex(
|
|
333
|
+
async function readMasterIndex(
|
|
334
|
+
pkg: OoxmlPackage,
|
|
335
|
+
masterPath: string,
|
|
336
|
+
signal?: AbortSignal,
|
|
337
|
+
): Promise<MasterIndex> {
|
|
338
|
+
throwIfOoxmlAborted(signal);
|
|
320
339
|
const index: MasterIndex = { name: '', exact: new Map(), baseIdx: new Map(), base: new Map() };
|
|
321
340
|
const doc = await getPartXml(pkg, masterPath);
|
|
322
341
|
if (!doc) return index;
|
|
@@ -333,6 +352,7 @@ async function readMasterIndex(pkg: OoxmlPackage, masterPath: string): Promise<M
|
|
|
333
352
|
|
|
334
353
|
const shapes = doc.getElementsByTagNameNS(NS_PML, 'sp');
|
|
335
354
|
for (let i = 0; i < shapes.length; i++) {
|
|
355
|
+
await cancellationCheckpoint(i, signal);
|
|
336
356
|
const sp = shapes[i]!;
|
|
337
357
|
const ph = firstPml(sp, 'ph');
|
|
338
358
|
if (!ph) continue;
|
|
@@ -425,7 +445,9 @@ async function extractLayout(
|
|
|
425
445
|
pxPerPt: number,
|
|
426
446
|
hints: PptxColorHints,
|
|
427
447
|
masterCache: Map<string, Promise<MasterIndex>>,
|
|
448
|
+
signal?: AbortSignal,
|
|
428
449
|
): Promise<ExtractedSlideLayout | null> {
|
|
450
|
+
throwIfOoxmlAborted(signal);
|
|
429
451
|
const doc = await getPartXml(pkg, layoutPath);
|
|
430
452
|
if (!doc) return null;
|
|
431
453
|
|
|
@@ -440,7 +462,7 @@ async function extractLayout(
|
|
|
440
462
|
masterPath = resolveTarget(baseDirOf(layoutPath), masterRel.target);
|
|
441
463
|
let cached = masterCache.get(masterPath);
|
|
442
464
|
if (!cached) {
|
|
443
|
-
cached = readMasterIndex(pkg, masterPath);
|
|
465
|
+
cached = readMasterIndex(pkg, masterPath, signal);
|
|
444
466
|
masterCache.set(masterPath, cached);
|
|
445
467
|
}
|
|
446
468
|
master = await cached;
|
|
@@ -451,6 +473,7 @@ async function extractLayout(
|
|
|
451
473
|
|
|
452
474
|
const shapes = doc.getElementsByTagNameNS(NS_PML, 'sp');
|
|
453
475
|
for (let i = 0; i < shapes.length; i++) {
|
|
476
|
+
await cancellationCheckpoint(i, signal);
|
|
454
477
|
const sp = shapes[i]!;
|
|
455
478
|
const ph = firstPml(sp, 'ph');
|
|
456
479
|
|
|
@@ -1003,7 +1026,11 @@ function resolveVerdict(
|
|
|
1003
1026
|
|
|
1004
1027
|
// ── Deck-level analysis ──────────────────────────────────────────────
|
|
1005
1028
|
|
|
1006
|
-
async function readSlideSize(
|
|
1029
|
+
async function readSlideSize(
|
|
1030
|
+
pkg: OoxmlPackage,
|
|
1031
|
+
signal?: AbortSignal,
|
|
1032
|
+
): Promise<{ cx: number; cy: number }> {
|
|
1033
|
+
throwIfOoxmlAborted(signal);
|
|
1007
1034
|
const pres = await getPartXml(pkg, 'ppt/presentation.xml');
|
|
1008
1035
|
const sldSz = pres ? firstPml(pres, 'sldSz') : null;
|
|
1009
1036
|
const cx = intAttr(sldSz, 'cx');
|
|
@@ -1012,7 +1039,11 @@ async function readSlideSize(pkg: OoxmlPackage): Promise<{ cx: number; cy: numbe
|
|
|
1012
1039
|
return { ...DEFAULT_SLIDE_SIZE };
|
|
1013
1040
|
}
|
|
1014
1041
|
|
|
1015
|
-
async function orderedSlidePathsForLayouts(
|
|
1042
|
+
async function orderedSlidePathsForLayouts(
|
|
1043
|
+
pkg: OoxmlPackage,
|
|
1044
|
+
signal?: AbortSignal,
|
|
1045
|
+
): Promise<string[]> {
|
|
1046
|
+
throwIfOoxmlAborted(signal);
|
|
1016
1047
|
const pres = await getPartXml(pkg, 'ppt/presentation.xml');
|
|
1017
1048
|
if (!pres) return [];
|
|
1018
1049
|
const rels = await getPartRelationships(pkg, 'ppt/presentation.xml');
|
|
@@ -1020,6 +1051,7 @@ async function orderedSlidePathsForLayouts(pkg: OoxmlPackage): Promise<string[]>
|
|
|
1020
1051
|
const out: string[] = [];
|
|
1021
1052
|
const ids = pres.getElementsByTagNameNS(NS_PML, 'sldId');
|
|
1022
1053
|
for (let i = 0; i < ids.length; i++) {
|
|
1054
|
+
await cancellationCheckpoint(i, signal);
|
|
1023
1055
|
const rid = attrNS(ids[i]!, NS_R, 'id', 'r:id');
|
|
1024
1056
|
const target = rid ? relById.get(rid) : undefined;
|
|
1025
1057
|
if (target) out.push(resolveTarget('ppt', target));
|
|
@@ -1043,20 +1075,23 @@ export async function analyzePptxLayouts(
|
|
|
1043
1075
|
pkg: OoxmlPackage,
|
|
1044
1076
|
options: AnalyzePptxLayoutsOptions = {},
|
|
1045
1077
|
): Promise<PptxLayoutInference> {
|
|
1078
|
+
throwIfOoxmlAborted(options.signal);
|
|
1046
1079
|
const warnings: string[] = [];
|
|
1047
1080
|
const hints = options.colors ?? {};
|
|
1048
1081
|
const maxTemplates = options.maxTemplates ?? MAX_INFERRED_TEMPLATES;
|
|
1049
1082
|
|
|
1050
|
-
const sldSz = await readSlideSize(pkg);
|
|
1083
|
+
const sldSz = await readSlideSize(pkg, options.signal);
|
|
1051
1084
|
const viewport = viewportFor(sldSz);
|
|
1052
1085
|
const pxPerPt = viewport.height / (sldSz.cy / 12700);
|
|
1053
1086
|
|
|
1054
1087
|
// Layouts used by slides, in first-use order, with usage counts.
|
|
1055
|
-
const slidePaths = await orderedSlidePathsForLayouts(pkg);
|
|
1088
|
+
const slidePaths = await orderedSlidePathsForLayouts(pkg, options.signal);
|
|
1056
1089
|
const layoutPathBySlide = new Map<string, string>();
|
|
1057
1090
|
const usage = new Map<string, number>();
|
|
1058
1091
|
const orderedLayoutPaths: string[] = [];
|
|
1059
|
-
for (
|
|
1092
|
+
for (let slideIndex = 0; slideIndex < slidePaths.length; slideIndex++) {
|
|
1093
|
+
await cancellationCheckpoint(slideIndex, options.signal);
|
|
1094
|
+
const slidePath = slidePaths[slideIndex]!;
|
|
1060
1095
|
const rels = await getPartRelationships(pkg, slidePath);
|
|
1061
1096
|
const layoutRel = findRelByType(rels, REL_SLIDE_LAYOUT);
|
|
1062
1097
|
if (!layoutRel) continue;
|
|
@@ -1068,7 +1103,9 @@ export async function analyzePptxLayouts(
|
|
|
1068
1103
|
|
|
1069
1104
|
if (options.includeUnused) {
|
|
1070
1105
|
const unused: string[] = [];
|
|
1106
|
+
let partIndex = 0;
|
|
1071
1107
|
for (const [partPath, contentType] of pkg.contentTypes.overrides) {
|
|
1108
|
+
await cancellationCheckpoint(partIndex++, options.signal);
|
|
1072
1109
|
if (contentType === CONTENT_TYPE_PPTX_SLIDE_LAYOUT && !usage.has(partPath)) {
|
|
1073
1110
|
unused.push(partPath);
|
|
1074
1111
|
}
|
|
@@ -1082,8 +1119,17 @@ export async function analyzePptxLayouts(
|
|
|
1082
1119
|
const layouts: AnalyzedLayout[] = [];
|
|
1083
1120
|
const usedNames = new Set<string>();
|
|
1084
1121
|
for (let i = 0; i < orderedLayoutPaths.length; i++) {
|
|
1122
|
+
await cancellationCheckpoint(i, options.signal);
|
|
1085
1123
|
const layoutPath = orderedLayoutPaths[i]!;
|
|
1086
|
-
const extracted = await extractLayout(
|
|
1124
|
+
const extracted = await extractLayout(
|
|
1125
|
+
pkg,
|
|
1126
|
+
layoutPath,
|
|
1127
|
+
sldSz,
|
|
1128
|
+
pxPerPt,
|
|
1129
|
+
hints,
|
|
1130
|
+
masterCache,
|
|
1131
|
+
options.signal,
|
|
1132
|
+
);
|
|
1087
1133
|
if (!extracted) {
|
|
1088
1134
|
warnings.push(`layout ${layoutPath} could not be read; its slides import plain`);
|
|
1089
1135
|
continue;
|
|
@@ -1108,7 +1154,10 @@ export async function analyzePptxLayouts(
|
|
|
1108
1154
|
const customs = layouts.filter((l) => l.verdict.kind === 'custom');
|
|
1109
1155
|
if (customs.length > maxTemplates) {
|
|
1110
1156
|
const ranked = [...customs].sort((a, b) => b.extracted.slideCount - a.extracted.slideCount);
|
|
1111
|
-
|
|
1157
|
+
const excess = ranked.slice(maxTemplates);
|
|
1158
|
+
for (let index = 0; index < excess.length; index++) {
|
|
1159
|
+
await cancellationCheckpoint(index, options.signal);
|
|
1160
|
+
const layout = excess[index]!;
|
|
1112
1161
|
layout.verdict = { kind: 'plain' };
|
|
1113
1162
|
layout.notes.push('dropped: more inferred layouts than the template cap');
|
|
1114
1163
|
}
|
|
@@ -1118,6 +1167,7 @@ export async function analyzePptxLayouts(
|
|
|
1118
1167
|
}
|
|
1119
1168
|
|
|
1120
1169
|
const byLayoutPath = new Map(layouts.map((l) => [l.extracted.partPath, l]));
|
|
1170
|
+
throwIfOoxmlAborted(options.signal);
|
|
1121
1171
|
return { slideSize: sldSz, layouts, byLayoutPath, layoutPathBySlide, warnings };
|
|
1122
1172
|
}
|
|
1123
1173
|
|
|
@@ -1148,11 +1198,15 @@ export async function inspectPptxLayouts(
|
|
|
1148
1198
|
data: ArrayBuffer | Blob,
|
|
1149
1199
|
options: InspectPptxLayoutsOptions = {},
|
|
1150
1200
|
): Promise<{ layouts: PptxLayoutSummary[]; slideSize: { cx: number; cy: number } }> {
|
|
1201
|
+
throwIfOoxmlAborted(options.signal);
|
|
1151
1202
|
const pkg = await openPackage(data, options);
|
|
1152
1203
|
const analysis = await analyzePptxLayouts(pkg, options);
|
|
1153
|
-
const layouts
|
|
1204
|
+
const layouts: PptxLayoutSummary[] = [];
|
|
1205
|
+
for (let i = 0; i < analysis.layouts.length; i++) {
|
|
1206
|
+
await cancellationCheckpoint(i, options.signal);
|
|
1207
|
+
const l = analysis.layouts[i]!;
|
|
1154
1208
|
const { extracted, verdict, notes } = l;
|
|
1155
|
-
|
|
1209
|
+
layouts.push({
|
|
1156
1210
|
layoutPath: extracted.partPath,
|
|
1157
1211
|
name: extracted.name || `Layout ${i + 1}`,
|
|
1158
1212
|
...(extracted.masterName ? { masterName: extracted.masterName } : {}),
|
|
@@ -1162,7 +1216,7 @@ export async function inspectPptxLayouts(
|
|
|
1162
1216
|
...(verdict.kind === 'builtin' ? { builtinTemplate: verdict.template } : {}),
|
|
1163
1217
|
...(verdict.kind === 'custom' ? { customTemplate: verdict.def } : {}),
|
|
1164
1218
|
...(notes.length > 0 ? { notes } : {}),
|
|
1165
|
-
};
|
|
1166
|
-
}
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1167
1221
|
return { layouts, slideSize: analysis.slideSize };
|
|
1168
1222
|
}
|
package/src/registry/convert.ts
CHANGED
|
@@ -22,12 +22,22 @@ import type {
|
|
|
22
22
|
FormatId,
|
|
23
23
|
FormatRegistry,
|
|
24
24
|
NormalizedInput,
|
|
25
|
+
PreparedConversion,
|
|
26
|
+
PreparedExportOptions,
|
|
25
27
|
} from './types.js';
|
|
26
28
|
|
|
27
29
|
function markdownFormatOptions(options: ConvertOptions): BuiltinFormatOptions['md'] {
|
|
28
30
|
return (options.formatOptions?.md ?? {}) as BuiltinFormatOptions['md'];
|
|
29
31
|
}
|
|
30
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
|
+
|
|
31
41
|
// ── Byte sniffing ───────────────────────────────────────────────────
|
|
32
42
|
|
|
33
43
|
function hasPrefix(bytes: Uint8Array, prefix: number[]): boolean {
|
|
@@ -274,39 +284,30 @@ async function applyTransformStyle(
|
|
|
274
284
|
return [];
|
|
275
285
|
}
|
|
276
286
|
|
|
277
|
-
// ── convert
|
|
287
|
+
// ── prepare / convert ───────────────────────────────────────────────
|
|
278
288
|
|
|
279
|
-
/**
|
|
280
|
-
|
|
281
|
-
*
|
|
282
|
-
* @param source - A bytes / markdown / doc source.
|
|
283
|
-
* @param to - The target format id (must be registered and support export).
|
|
284
|
-
* @param options - Conversion options (registry, theme, transform, …).
|
|
285
|
-
* @throws {@link ConversionError} on any failure, with a stable `code`.
|
|
286
|
-
*/
|
|
287
|
-
export async function convert(
|
|
289
|
+
/** Normalize and transform a source once, then safely export that snapshot many times. */
|
|
290
|
+
export async function prepareConversion(
|
|
288
291
|
source: ConvertSource,
|
|
289
|
-
to: FormatId,
|
|
290
292
|
options: ConvertOptions = {},
|
|
291
|
-
): Promise<
|
|
292
|
-
|
|
293
|
+
): Promise<PreparedConversion> {
|
|
294
|
+
return prepareConversionInternal(source, options);
|
|
295
|
+
}
|
|
293
296
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
`Format "${target.label}" does not support export.`,
|
|
302
|
-
{ format: to, hint: 'This format is import-only.' },
|
|
303
|
-
);
|
|
304
|
-
}
|
|
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();
|
|
305
304
|
|
|
306
305
|
let normalized: Normalized;
|
|
307
306
|
try {
|
|
308
307
|
normalized = await normalize(source, options, registry);
|
|
308
|
+
throwIfAborted(options.signal);
|
|
309
309
|
} catch (err: unknown) {
|
|
310
|
+
throwIfAborted(options.signal);
|
|
310
311
|
if (err instanceof ConversionError) throw err;
|
|
311
312
|
const message = err instanceof Error ? err.message : String(err);
|
|
312
313
|
throw new ConversionError('invalid-input', message, {
|
|
@@ -321,7 +322,6 @@ export async function convert(
|
|
|
321
322
|
input.doc = { ...input.doc, themeId: options.themeId };
|
|
322
323
|
}
|
|
323
324
|
|
|
324
|
-
let result: ConversionResult;
|
|
325
325
|
try {
|
|
326
326
|
if (options.transformStyle) {
|
|
327
327
|
warnings.push(
|
|
@@ -332,18 +332,102 @@ export async function convert(
|
|
|
332
332
|
options.transformRegistry,
|
|
333
333
|
)),
|
|
334
334
|
);
|
|
335
|
+
throwIfAborted(options.signal);
|
|
335
336
|
}
|
|
336
|
-
result = await target.exportDoc(input, options);
|
|
337
337
|
} catch (err: unknown) {
|
|
338
|
+
throwIfAborted(options.signal);
|
|
338
339
|
if (err instanceof ConversionError) throw err;
|
|
339
340
|
const message = err instanceof Error ? err.message : String(err);
|
|
340
|
-
throw new ConversionError('conversion-failed', message, {
|
|
341
|
+
throw new ConversionError('conversion-failed', message, {
|
|
342
|
+
format: failureFormat,
|
|
343
|
+
cause: err,
|
|
344
|
+
});
|
|
341
345
|
}
|
|
342
346
|
|
|
343
|
-
const
|
|
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 {
|
|
344
394
|
return {
|
|
345
|
-
...
|
|
346
|
-
|
|
347
|
-
|
|
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 },
|
|
348
403
|
};
|
|
349
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
|
+
}
|
|
@@ -399,9 +399,12 @@ export function defaultFormats(): FormatDefinition[] {
|
|
|
399
399
|
const { zipToContainer } = await import('../container/index.js');
|
|
400
400
|
return zipToContainer(data, optionsFor(options, 'dbk'));
|
|
401
401
|
},
|
|
402
|
-
async exportDoc(input): Promise<ConversionResult> {
|
|
402
|
+
async exportDoc(input, options): Promise<ConversionResult> {
|
|
403
403
|
const { containerToZip } = await import('../container/index.js');
|
|
404
|
-
const blob = await containerToZip(
|
|
404
|
+
const blob = await containerToZip(
|
|
405
|
+
await snapshotContainerWithDocument(input),
|
|
406
|
+
optionsFor(options, 'dbk'),
|
|
407
|
+
);
|
|
405
408
|
return ok(await toBytes(blob), MIME.zip);
|
|
406
409
|
},
|
|
407
410
|
};
|
package/src/registry/index.ts
CHANGED
|
@@ -30,6 +30,8 @@ export type {
|
|
|
30
30
|
BuiltinFormatOptions,
|
|
31
31
|
MarkdownFormatOptions,
|
|
32
32
|
DbkFormatOptions,
|
|
33
|
+
PreparedConversion,
|
|
34
|
+
PreparedExportOptions,
|
|
33
35
|
} from './types.js';
|
|
34
36
|
export { BUILTIN_FORMAT_IDS } from './types.js';
|
|
35
37
|
|
|
@@ -38,4 +40,4 @@ export type { ConversionErrorCode, ConversionErrorOptions } from './errors.js';
|
|
|
38
40
|
|
|
39
41
|
export { createRegistry, defaultRegistry } from './registry.js';
|
|
40
42
|
export { defaultFormats } from './defaultFormats.js';
|
|
41
|
-
export { convert } from './convert.js';
|
|
43
|
+
export { convert, prepareConversion } from './convert.js';
|
package/src/registry/types.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { PdfImportOptions } from '../pdf/import.js';
|
|
|
25
25
|
import type { HtmlExportOptions } from '../html/htmlTemplate.js';
|
|
26
26
|
import type { HtmlImportOptions } from '../html/import.js';
|
|
27
27
|
import type { EpubExportOptions } from '../epub/export.js';
|
|
28
|
+
import type { ContainerToZipOptions } from '../container/index.js';
|
|
28
29
|
import type { ZipSafetyLimits } from '../shared/zipSafety.js';
|
|
29
30
|
|
|
30
31
|
/** A format identifier (e.g. `'docx'`). Strings so hosts can register their own. */
|
|
@@ -62,7 +63,7 @@ export interface MarkdownFormatOptions {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/** Resource limits applied when importing a DBK/ZIP container. */
|
|
65
|
-
export type DbkFormatOptions = ZipSafetyLimits;
|
|
66
|
+
export type DbkFormatOptions = ZipSafetyLimits & ContainerToZipOptions;
|
|
66
67
|
|
|
67
68
|
/**
|
|
68
69
|
* Strongly typed option bags for built-in formats. Import and export options
|
|
@@ -97,6 +98,8 @@ export interface NormalizedInput {
|
|
|
97
98
|
|
|
98
99
|
/** Options threaded through `convert()` and into every format method. */
|
|
99
100
|
export interface ConvertOptions {
|
|
101
|
+
/** Cancel normalization, transformation, or export at the next bounded work boundary. */
|
|
102
|
+
signal?: AbortSignal;
|
|
100
103
|
/** Registry to resolve formats against. Defaults to `defaultRegistry()`. */
|
|
101
104
|
registry?: FormatRegistry;
|
|
102
105
|
/** Explicit source format id (skips extension/byte sniffing). */
|
|
@@ -119,6 +122,20 @@ export interface ConvertOptions {
|
|
|
119
122
|
formatOptions?: Partial<BuiltinFormatOptions> & Record<FormatId, unknown>;
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
/** Target-only options accepted after a source has already been normalized and transformed. */
|
|
126
|
+
export type PreparedExportOptions = Pick<
|
|
127
|
+
ConvertOptions,
|
|
128
|
+
'signal' | 'title' | 'resolvePlayerScript' | 'formatOptions'
|
|
129
|
+
>;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* An opaque normalized conversion snapshot. The source and transform pipeline
|
|
133
|
+
* has already completed; each call exports that same snapshot to one target.
|
|
134
|
+
*/
|
|
135
|
+
export interface PreparedConversion {
|
|
136
|
+
convert(to: FormatId, options?: PreparedExportOptions): Promise<ConversionResult>;
|
|
137
|
+
}
|
|
138
|
+
|
|
122
139
|
/** Describes how a single format imports to / exports from the squisq model. */
|
|
123
140
|
export interface FormatDefinition {
|
|
124
141
|
id: FormatId;
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
compressionRatioError,
|
|
9
9
|
entryTooLargeError,
|
|
10
10
|
resolveZipSafetyLimits,
|
|
11
|
+
throwIfZipAborted,
|
|
11
12
|
validateZipArchive,
|
|
12
13
|
tooManyEntriesError,
|
|
13
14
|
ZipSafetyError,
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
import { declaredZipEntryCount, prepareZipInput, type ZipInput } from './zipEntryCount.js';
|
|
20
21
|
|
|
21
22
|
interface ActiveZipRead {
|
|
22
|
-
stop(error:
|
|
23
|
+
stop(error: unknown): void;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
interface ZipChunkStream {
|
|
@@ -58,15 +59,22 @@ class JsZipBoundedArchive implements BoundedZipArchive {
|
|
|
58
59
|
|
|
59
60
|
private readonly limits: ResolvedZipSafetyLimits;
|
|
60
61
|
private readonly entriesByPath: ReadonlyMap<string, ValidatedZipEntry>;
|
|
62
|
+
private readonly signal?: AbortSignal;
|
|
61
63
|
private readonly reads = new Map<string, Promise<Uint8Array>>();
|
|
62
64
|
private readonly activeReads = new Set<ActiveZipRead>();
|
|
63
65
|
private terminalError?: ZipSafetyError;
|
|
64
66
|
private emittedBytes = 0;
|
|
65
67
|
|
|
66
|
-
constructor(
|
|
68
|
+
constructor(
|
|
69
|
+
zip: JSZip,
|
|
70
|
+
entries: readonly ValidatedZipEntry[],
|
|
71
|
+
limits: ResolvedZipSafetyLimits,
|
|
72
|
+
signal?: AbortSignal,
|
|
73
|
+
) {
|
|
67
74
|
this.zip = zip;
|
|
68
75
|
this.entries = entries;
|
|
69
76
|
this.limits = limits;
|
|
77
|
+
this.signal = signal;
|
|
70
78
|
this.entriesByPath = new Map(entries.map((entry) => [entry.path, entry]));
|
|
71
79
|
}
|
|
72
80
|
|
|
@@ -77,6 +85,7 @@ class JsZipBoundedArchive implements BoundedZipArchive {
|
|
|
77
85
|
|
|
78
86
|
/** Read and cache one member, returning null when it does not exist. */
|
|
79
87
|
async read(path: string, maxBytes?: number): Promise<Uint8Array | null> {
|
|
88
|
+
throwIfZipAborted(this.signal);
|
|
80
89
|
this.throwIfFailed();
|
|
81
90
|
if (maxBytes !== undefined) assertNonNegativeFinite('member read limit', maxBytes);
|
|
82
91
|
const entry = this.entriesByPath.get(path);
|
|
@@ -87,7 +96,16 @@ class JsZipBoundedArchive implements BoundedZipArchive {
|
|
|
87
96
|
pending = this.streamEntry(entry, maxBytes);
|
|
88
97
|
this.reads.set(path, pending);
|
|
89
98
|
}
|
|
90
|
-
|
|
99
|
+
let bytes: Uint8Array;
|
|
100
|
+
try {
|
|
101
|
+
bytes = await pending;
|
|
102
|
+
} catch (error: unknown) {
|
|
103
|
+
// Cancellation is scoped to the current operation, not archive
|
|
104
|
+
// corruption. Do not leave a cancelled read cached for a later caller.
|
|
105
|
+
if (this.reads.get(path) === pending && !this.terminalError) this.reads.delete(path);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
throwIfZipAborted(this.signal);
|
|
91
109
|
if (maxBytes !== undefined && bytes.byteLength > maxBytes) {
|
|
92
110
|
const error = entryTooLargeError(path, bytes.byteLength, maxBytes);
|
|
93
111
|
this.failArchive(error);
|
|
@@ -108,6 +126,7 @@ class JsZipBoundedArchive implements BoundedZipArchive {
|
|
|
108
126
|
}
|
|
109
127
|
|
|
110
128
|
private streamEntry(metadata: ValidatedZipEntry, readLimit?: number): Promise<Uint8Array> {
|
|
129
|
+
throwIfZipAborted(this.signal);
|
|
111
130
|
const { path, entry, declaredSize, crc32: expectedCrc } = metadata;
|
|
112
131
|
const effectiveEntryLimit = Math.min(
|
|
113
132
|
this.limits.maxEntryUncompressedBytes,
|
|
@@ -129,12 +148,17 @@ class JsZipBoundedArchive implements BoundedZipArchive {
|
|
|
129
148
|
let settled = false;
|
|
130
149
|
let written = 0;
|
|
131
150
|
let crc = 0xffffffff;
|
|
151
|
+
const removeAbortListener = (): void =>
|
|
152
|
+
this.signal?.removeEventListener('abort', handleAbort);
|
|
153
|
+
const handleAbort = (): void =>
|
|
154
|
+
controller.stop(this.signal?.reason ?? new Error('ZIP operation was cancelled'));
|
|
132
155
|
|
|
133
156
|
const controller: ActiveZipRead = {
|
|
134
157
|
stop: (error) => {
|
|
135
158
|
if (settled) return;
|
|
136
159
|
settled = true;
|
|
137
160
|
this.activeReads.delete(controller);
|
|
161
|
+
removeAbortListener();
|
|
138
162
|
// Public JSZip cancellation is cooperative. It prevents another
|
|
139
163
|
// compressed-input tick; data already emitted synchronously by the
|
|
140
164
|
// current tick is ignored because `settled` is now true.
|
|
@@ -148,19 +172,28 @@ class JsZipBoundedArchive implements BoundedZipArchive {
|
|
|
148
172
|
},
|
|
149
173
|
};
|
|
150
174
|
this.activeReads.add(controller);
|
|
175
|
+
if (this.signal) {
|
|
176
|
+
this.signal.addEventListener('abort', handleAbort, { once: true });
|
|
177
|
+
if (this.signal.aborted) {
|
|
178
|
+
handleAbort();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
151
182
|
|
|
152
183
|
stream
|
|
153
184
|
.on('data', (chunk: Uint8Array) => {
|
|
154
185
|
if (settled) return;
|
|
155
186
|
const nextWritten = written + chunk.byteLength;
|
|
156
187
|
try {
|
|
188
|
+
throwIfZipAborted(this.signal);
|
|
157
189
|
this.assertChunkAllowed(metadata, nextWritten, chunk.byteLength, effectiveEntryLimit);
|
|
158
190
|
output = growOutput(output, nextWritten, declaredSize, effectiveEntryLimit);
|
|
159
191
|
output.set(chunk, written);
|
|
160
192
|
written = nextWritten;
|
|
161
193
|
crc = updateCrc32(crc, chunk);
|
|
162
194
|
} catch (error: unknown) {
|
|
163
|
-
this.
|
|
195
|
+
if (this.signal?.aborted) controller.stop(error);
|
|
196
|
+
else this.failArchive(asZipSafetyError(error, path));
|
|
164
197
|
}
|
|
165
198
|
})
|
|
166
199
|
.on('error', (error: unknown) => {
|
|
@@ -200,6 +233,7 @@ class JsZipBoundedArchive implements BoundedZipArchive {
|
|
|
200
233
|
|
|
201
234
|
settled = true;
|
|
202
235
|
this.activeReads.delete(controller);
|
|
236
|
+
removeAbortListener();
|
|
203
237
|
resolve(output.byteLength === written ? output : output.slice(0, written));
|
|
204
238
|
})
|
|
205
239
|
.resume();
|
|
@@ -260,13 +294,16 @@ export async function openBoundedZipArchive(
|
|
|
260
294
|
data: ZipInput,
|
|
261
295
|
limits: ZipSafetyLimits = {},
|
|
262
296
|
): Promise<BoundedZipArchive> {
|
|
297
|
+
throwIfZipAborted(limits.signal);
|
|
263
298
|
const resolved = resolveZipSafetyLimits(limits);
|
|
264
299
|
let prepared: { input: ZipInput; bytes?: Uint8Array };
|
|
265
300
|
try {
|
|
266
|
-
prepared = await prepareZipInput(data);
|
|
301
|
+
prepared = await prepareZipInput(data, limits.signal);
|
|
267
302
|
} catch (error: unknown) {
|
|
303
|
+
if (limits.signal?.aborted) throw limits.signal.reason ?? error;
|
|
268
304
|
throw invalidArchiveError(error);
|
|
269
305
|
}
|
|
306
|
+
throwIfZipAborted(limits.signal);
|
|
270
307
|
if (prepared.bytes) {
|
|
271
308
|
const declaredEntries = declaredZipEntryCount(prepared.bytes);
|
|
272
309
|
if (declaredEntries !== undefined && declaredEntries > resolved.maxEntries) {
|
|
@@ -277,9 +314,11 @@ export async function openBoundedZipArchive(
|
|
|
277
314
|
try {
|
|
278
315
|
zip = await JSZip.loadAsync(prepared.input);
|
|
279
316
|
} catch (error: unknown) {
|
|
317
|
+
if (limits.signal?.aborted) throw limits.signal.reason ?? error;
|
|
280
318
|
throw invalidArchiveError(error);
|
|
281
319
|
}
|
|
282
|
-
|
|
320
|
+
throwIfZipAborted(limits.signal);
|
|
321
|
+
return createBoundedZipArchive(zip, limits);
|
|
283
322
|
}
|
|
284
323
|
|
|
285
324
|
function invalidArchiveError(cause: unknown): ZipSafetyError {
|
|
@@ -291,9 +330,11 @@ export function createBoundedZipArchive(
|
|
|
291
330
|
zip: JSZip,
|
|
292
331
|
limits: ZipSafetyLimits = {},
|
|
293
332
|
): BoundedZipArchive {
|
|
333
|
+
throwIfZipAborted(limits.signal);
|
|
294
334
|
const resolved = resolveZipSafetyLimits(limits);
|
|
295
|
-
const entries = validateZipArchive(zip,
|
|
296
|
-
|
|
335
|
+
const entries = validateZipArchive(zip, limits);
|
|
336
|
+
throwIfZipAborted(limits.signal);
|
|
337
|
+
return new JsZipBoundedArchive(zip, entries, resolved, limits.signal);
|
|
297
338
|
}
|
|
298
339
|
|
|
299
340
|
function asZipSafetyError(error: unknown, path: string): ZipSafetyError {
|