@ai-sdk/quiverai 2.0.43 → 2.0.45
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/CHANGELOG.md +18 -0
- package/dist/index.d.ts +25 -2
- package/dist/index.js +698 -14
- package/dist/index.js.map +1 -1
- package/docs/180-quiverai.mdx +125 -13
- package/package.json +3 -2
- package/src/index.ts +5 -0
- package/src/prepare-quiverai-image-reference.ts +161 -0
- package/src/quiverai-image-model-options.ts +41 -1
- package/src/quiverai-image-model.ts +684 -3
- package/src/quiverai-image-settings.ts +3 -1
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from '@ai-sdk/provider';
|
|
8
8
|
import {
|
|
9
9
|
combineHeaders,
|
|
10
|
+
convertBase64ToUint8Array,
|
|
10
11
|
convertUint8ArrayToBase64,
|
|
11
12
|
createJsonErrorResponseHandler,
|
|
12
13
|
createJsonResponseHandler,
|
|
@@ -22,6 +23,10 @@ import {
|
|
|
22
23
|
quiveraiImageModelOptionsSchema,
|
|
23
24
|
type QuiverAIImageModelOptions,
|
|
24
25
|
} from './quiverai-image-model-options';
|
|
26
|
+
import {
|
|
27
|
+
validateQuiverAIImageUrl,
|
|
28
|
+
validateQuiverAIReferenceBase64,
|
|
29
|
+
} from './prepare-quiverai-image-reference';
|
|
25
30
|
import type {
|
|
26
31
|
QuiverAIImageModelId,
|
|
27
32
|
QuiverAIOperation,
|
|
@@ -94,6 +99,7 @@ export class QuiverAIImageModel implements ImageModelV4 {
|
|
|
94
99
|
n,
|
|
95
100
|
prompt,
|
|
96
101
|
files,
|
|
102
|
+
mask,
|
|
97
103
|
operation,
|
|
98
104
|
options: quiveraiOptions ?? {},
|
|
99
105
|
});
|
|
@@ -125,6 +131,12 @@ export class QuiverAIImageModel implements ImageModelV4 {
|
|
|
125
131
|
images: response.data.map((image, index) => ({
|
|
126
132
|
index,
|
|
127
133
|
mimeType: image.mime_type,
|
|
134
|
+
...(image.loop_period_ms !== undefined && {
|
|
135
|
+
loopPeriodMs: image.loop_period_ms,
|
|
136
|
+
}),
|
|
137
|
+
...(image.opening_animation_ms !== undefined && {
|
|
138
|
+
openingAnimationMs: image.opening_animation_ms,
|
|
139
|
+
}),
|
|
128
140
|
})),
|
|
129
141
|
},
|
|
130
142
|
},
|
|
@@ -147,9 +159,16 @@ export class QuiverAIImageModel implements ImageModelV4 {
|
|
|
147
159
|
}
|
|
148
160
|
|
|
149
161
|
function getOperationPath(operation: QuiverAIOperation) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
switch (operation) {
|
|
163
|
+
case 'generate':
|
|
164
|
+
return '/svgs/generations';
|
|
165
|
+
case 'vectorize':
|
|
166
|
+
return '/svgs/vectorizations';
|
|
167
|
+
case 'edit':
|
|
168
|
+
return '/svgs/edits';
|
|
169
|
+
case 'animate':
|
|
170
|
+
return '/svgs/animations';
|
|
171
|
+
}
|
|
153
172
|
}
|
|
154
173
|
|
|
155
174
|
function getGenerateReferenceLimit(modelId: string) {
|
|
@@ -170,11 +189,86 @@ function toQuiverAIImageReference(image: ImageModelV4File) {
|
|
|
170
189
|
};
|
|
171
190
|
}
|
|
172
191
|
|
|
192
|
+
const maxAnimationSourceBase64Length = 1_066_668;
|
|
193
|
+
|
|
194
|
+
function toQuiverAIAnimationSource(image: ImageModelV4File) {
|
|
195
|
+
if (image.type === 'url') {
|
|
196
|
+
let url: URL;
|
|
197
|
+
try {
|
|
198
|
+
url = new URL(image.url);
|
|
199
|
+
} catch {
|
|
200
|
+
throw new InvalidArgumentError({
|
|
201
|
+
argument: 'files',
|
|
202
|
+
message: 'QuiverAI animate requires a valid HTTP or HTTPS SVG URL.',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
207
|
+
throw new InvalidArgumentError({
|
|
208
|
+
argument: 'files',
|
|
209
|
+
message: 'QuiverAI animate requires an HTTP or HTTPS SVG URL.',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { url: image.url };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let base64: string;
|
|
217
|
+
let bytes: Uint8Array;
|
|
218
|
+
|
|
219
|
+
if (typeof image.data === 'string') {
|
|
220
|
+
const dataUrlMatch =
|
|
221
|
+
/^data:image\/svg\+xml(?:;[^,]*)?;base64,([\s\S]+)$/i.exec(image.data);
|
|
222
|
+
const encodedData = dataUrlMatch?.[1] ?? image.data;
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
bytes = convertBase64ToUint8Array(encodedData);
|
|
226
|
+
} catch {
|
|
227
|
+
throw new InvalidArgumentError({
|
|
228
|
+
argument: 'files',
|
|
229
|
+
message:
|
|
230
|
+
'QuiverAI animate requires the source SVG string to be valid base64 or an SVG data URL.',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
base64 = convertUint8ArrayToBase64(bytes);
|
|
234
|
+
} else {
|
|
235
|
+
bytes = image.data;
|
|
236
|
+
base64 = convertUint8ArrayToBase64(bytes);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!isSvg(bytes)) {
|
|
240
|
+
throw new InvalidArgumentError({
|
|
241
|
+
argument: 'files',
|
|
242
|
+
message: 'QuiverAI animate requires the input file to contain SVG data.',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (base64.length > maxAnimationSourceBase64Length) {
|
|
247
|
+
throw new InvalidArgumentError({
|
|
248
|
+
argument: 'files',
|
|
249
|
+
message: `QuiverAI animate accepts at most ${maxAnimationSourceBase64Length} base64 characters for the source SVG.`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { base64 };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isSvg(data: Uint8Array): boolean {
|
|
257
|
+
const head = new TextDecoder('utf-8', { fatal: false })
|
|
258
|
+
.decode(data.subarray(0, 4096))
|
|
259
|
+
.trimStart();
|
|
260
|
+
|
|
261
|
+
return /^(?:(?:<\?xml[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE[\s\S]*?>)\s*)*<svg(?:\s|>)/i.test(
|
|
262
|
+
head,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
173
266
|
function buildRequestBody({
|
|
174
267
|
modelId,
|
|
175
268
|
n,
|
|
176
269
|
prompt,
|
|
177
270
|
files,
|
|
271
|
+
mask,
|
|
178
272
|
operation,
|
|
179
273
|
options,
|
|
180
274
|
}: {
|
|
@@ -182,6 +276,7 @@ function buildRequestBody({
|
|
|
182
276
|
n: number;
|
|
183
277
|
prompt: string | undefined;
|
|
184
278
|
files: ImageModelV4File[] | undefined;
|
|
279
|
+
mask: ImageModelV4File | undefined;
|
|
185
280
|
operation: QuiverAIOperation;
|
|
186
281
|
options: QuiverAIImageModelOptions;
|
|
187
282
|
}) {
|
|
@@ -206,6 +301,10 @@ function buildRequestBody({
|
|
|
206
301
|
stream: false as const,
|
|
207
302
|
};
|
|
208
303
|
|
|
304
|
+
if (operation !== 'edit') {
|
|
305
|
+
rejectEditOnlyOptions(operation, options);
|
|
306
|
+
}
|
|
307
|
+
|
|
209
308
|
if (operation === 'generate') {
|
|
210
309
|
if (prompt == null || prompt.trim().length === 0) {
|
|
211
310
|
throw new InvalidArgumentError({
|
|
@@ -235,6 +334,28 @@ function buildRequestBody({
|
|
|
235
334
|
};
|
|
236
335
|
}
|
|
237
336
|
|
|
337
|
+
if (operation === 'edit') {
|
|
338
|
+
return buildEditRequestBody({
|
|
339
|
+
modelId,
|
|
340
|
+
n,
|
|
341
|
+
prompt,
|
|
342
|
+
files,
|
|
343
|
+
mask,
|
|
344
|
+
options,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (operation === 'animate') {
|
|
349
|
+
return buildAnimationRequestBody({
|
|
350
|
+
modelId,
|
|
351
|
+
n,
|
|
352
|
+
prompt,
|
|
353
|
+
files,
|
|
354
|
+
mask,
|
|
355
|
+
options,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
238
359
|
if (files == null || files.length === 0) {
|
|
239
360
|
throw new InvalidArgumentError({
|
|
240
361
|
argument: 'files',
|
|
@@ -267,6 +388,564 @@ function buildRequestBody({
|
|
|
267
388
|
};
|
|
268
389
|
}
|
|
269
390
|
|
|
391
|
+
function buildAnimationRequestBody({
|
|
392
|
+
modelId,
|
|
393
|
+
n,
|
|
394
|
+
prompt,
|
|
395
|
+
files,
|
|
396
|
+
mask,
|
|
397
|
+
options,
|
|
398
|
+
}: {
|
|
399
|
+
modelId: string;
|
|
400
|
+
n: number;
|
|
401
|
+
prompt: string | undefined;
|
|
402
|
+
files: ImageModelV4File[] | undefined;
|
|
403
|
+
mask: ImageModelV4File | undefined;
|
|
404
|
+
options: QuiverAIImageModelOptions;
|
|
405
|
+
}) {
|
|
406
|
+
if (modelId !== 'arrow-2' && modelId !== 'arrow-2-telos') {
|
|
407
|
+
throw new InvalidArgumentError({
|
|
408
|
+
argument: 'modelId',
|
|
409
|
+
message:
|
|
410
|
+
'QuiverAI animate is supported by the "arrow-2" and "arrow-2-telos" models.',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (files == null || files.length === 0) {
|
|
415
|
+
throw new InvalidArgumentError({
|
|
416
|
+
argument: 'files',
|
|
417
|
+
message:
|
|
418
|
+
'QuiverAI animate requires exactly one source SVG in prompt.images.',
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (files.length !== 1) {
|
|
423
|
+
throw new InvalidArgumentError({
|
|
424
|
+
argument: 'files',
|
|
425
|
+
message:
|
|
426
|
+
'QuiverAI animate accepts exactly one source SVG in prompt.images.',
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (n !== 1) {
|
|
431
|
+
throw new InvalidArgumentError({
|
|
432
|
+
argument: 'n',
|
|
433
|
+
message:
|
|
434
|
+
'QuiverAI animate returns one SVG per request. Set maxImagesPerCall to 1 in generateImage to animate multiple times.',
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (mask != null) {
|
|
439
|
+
throw new InvalidArgumentError({
|
|
440
|
+
argument: 'mask',
|
|
441
|
+
message: 'QuiverAI animate does not support masks.',
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (prompt != null && prompt.trim().length === 0) {
|
|
446
|
+
throw new InvalidArgumentError({
|
|
447
|
+
argument: 'prompt',
|
|
448
|
+
message:
|
|
449
|
+
'QuiverAI animate requires a non-empty prompt when an animation instruction is provided.',
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const unsupportedOptions = [
|
|
454
|
+
['instructions', options.instructions],
|
|
455
|
+
['topP', options.topP],
|
|
456
|
+
['presencePenalty', options.presencePenalty],
|
|
457
|
+
['attributes', options.attributes],
|
|
458
|
+
['autoCrop', options.autoCrop],
|
|
459
|
+
['targetSize', options.targetSize],
|
|
460
|
+
].filter((option): option is [string, NonNullable<unknown>] => {
|
|
461
|
+
return option[1] !== undefined;
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
if (unsupportedOptions.length > 0) {
|
|
465
|
+
throw new InvalidArgumentError({
|
|
466
|
+
argument: `providerOptions.quiverai.${unsupportedOptions[0][0]}`,
|
|
467
|
+
message: `QuiverAI animate does not support providerOptions.quiverai.${unsupportedOptions[0][0]}.`,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
model: modelId,
|
|
473
|
+
svg_source: toQuiverAIAnimationSource(files[0]),
|
|
474
|
+
prompt,
|
|
475
|
+
temperature: options.temperature,
|
|
476
|
+
max_output_tokens: options.maxOutputTokens,
|
|
477
|
+
reasoning_effort: options.reasoningEffort,
|
|
478
|
+
stream: false as const,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const editModelIds = new Set(['arrow-2', 'arrow-2-telos']);
|
|
483
|
+
const MAX_EDIT_SVG_BYTES = 200_000;
|
|
484
|
+
|
|
485
|
+
function buildEditRequestBody({
|
|
486
|
+
modelId,
|
|
487
|
+
n,
|
|
488
|
+
prompt,
|
|
489
|
+
files,
|
|
490
|
+
mask,
|
|
491
|
+
options,
|
|
492
|
+
}: {
|
|
493
|
+
modelId: string;
|
|
494
|
+
n: number;
|
|
495
|
+
prompt: string | undefined;
|
|
496
|
+
files: ImageModelV4File[] | undefined;
|
|
497
|
+
mask: ImageModelV4File | undefined;
|
|
498
|
+
options: QuiverAIImageModelOptions;
|
|
499
|
+
}) {
|
|
500
|
+
if (!editModelIds.has(modelId)) {
|
|
501
|
+
throw new InvalidArgumentError({
|
|
502
|
+
argument: 'modelId',
|
|
503
|
+
message:
|
|
504
|
+
'QuiverAI SVG editing is supported by the "arrow-2" and "arrow-2-telos" models.',
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (prompt == null || prompt.trim().length === 0) {
|
|
509
|
+
throw new InvalidArgumentError({
|
|
510
|
+
argument: 'prompt',
|
|
511
|
+
message:
|
|
512
|
+
'QuiverAI SVG editing requires a non-empty instruction in generateImage prompt.text.',
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (prompt.length > 4000) {
|
|
517
|
+
throw new InvalidArgumentError({
|
|
518
|
+
argument: 'prompt',
|
|
519
|
+
message:
|
|
520
|
+
'QuiverAI SVG editing instructions must contain at most 4000 characters.',
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (files == null || files.length === 0) {
|
|
525
|
+
throw new InvalidArgumentError({
|
|
526
|
+
argument: 'files',
|
|
527
|
+
message:
|
|
528
|
+
'QuiverAI SVG editing requires one source SVG in generateImage prompt.images.',
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (files.length !== 1) {
|
|
533
|
+
throw new InvalidArgumentError({
|
|
534
|
+
argument: 'files',
|
|
535
|
+
message: 'QuiverAI SVG editing accepts exactly one source SVG.',
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (n !== 1) {
|
|
540
|
+
throw new InvalidArgumentError({
|
|
541
|
+
argument: 'n',
|
|
542
|
+
message:
|
|
543
|
+
'QuiverAI SVG editing returns exactly one SVG per request. Set maxImagesPerCall to 1 in generateImage to edit multiple times.',
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (mask != null) {
|
|
548
|
+
throw new InvalidArgumentError({
|
|
549
|
+
argument: 'mask',
|
|
550
|
+
message: 'QuiverAI SVG editing does not support masks.',
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const unsupportedOptions = [
|
|
555
|
+
['instructions', options.instructions],
|
|
556
|
+
['attributes', options.attributes],
|
|
557
|
+
['topP', options.topP],
|
|
558
|
+
['presencePenalty', options.presencePenalty],
|
|
559
|
+
['autoCrop', options.autoCrop],
|
|
560
|
+
['targetSize', options.targetSize],
|
|
561
|
+
].flatMap(([name, value]) => (value == null ? [] : [name]));
|
|
562
|
+
|
|
563
|
+
if (unsupportedOptions.length > 0) {
|
|
564
|
+
throw new InvalidArgumentError({
|
|
565
|
+
argument: 'providerOptions',
|
|
566
|
+
message: `QuiverAI SVG editing does not support these provider options: ${unsupportedOptions.join(
|
|
567
|
+
', ',
|
|
568
|
+
)}.`,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const referenceImages = options.referenceImages?.map((reference, index) => {
|
|
573
|
+
if ('url' in reference) {
|
|
574
|
+
return {
|
|
575
|
+
url: validateQuiverAIImageUrl(reference.url),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
validateQuiverAIReferenceBase64(
|
|
580
|
+
reference.base64,
|
|
581
|
+
`providerOptions.quiverai.referenceImages[${index}]`,
|
|
582
|
+
);
|
|
583
|
+
return { base64: reference.base64 };
|
|
584
|
+
});
|
|
585
|
+
const settings = {
|
|
586
|
+
max_output_tokens: options.maxOutputTokens,
|
|
587
|
+
orchestrator_max_output_tokens: options.orchestratorMaxOutputTokens,
|
|
588
|
+
shallow_max_output_tokens: options.shallowMaxOutputTokens,
|
|
589
|
+
temperature: options.temperature,
|
|
590
|
+
};
|
|
591
|
+
const hasSettings = Object.values(settings).some(value => value != null);
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
model: modelId,
|
|
595
|
+
prompt,
|
|
596
|
+
...toQuiverAIEditSource(files[0]),
|
|
597
|
+
reference_images: referenceImages,
|
|
598
|
+
max_review_steps: options.maxReviewSteps,
|
|
599
|
+
reasoning_effort: options.reasoningEffort,
|
|
600
|
+
...(hasSettings && { settings }),
|
|
601
|
+
stream: false as const,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function rejectEditOnlyOptions(
|
|
606
|
+
operation: Exclude<QuiverAIOperation, 'edit'>,
|
|
607
|
+
options: QuiverAIImageModelOptions,
|
|
608
|
+
) {
|
|
609
|
+
const editOnlyOptions = [
|
|
610
|
+
['referenceImages', options.referenceImages],
|
|
611
|
+
['maxReviewSteps', options.maxReviewSteps],
|
|
612
|
+
['orchestratorMaxOutputTokens', options.orchestratorMaxOutputTokens],
|
|
613
|
+
['shallowMaxOutputTokens', options.shallowMaxOutputTokens],
|
|
614
|
+
].flatMap(([name, value]) => (value == null ? [] : [name]));
|
|
615
|
+
|
|
616
|
+
if (editOnlyOptions.length > 0) {
|
|
617
|
+
throw new InvalidArgumentError({
|
|
618
|
+
argument: 'providerOptions',
|
|
619
|
+
message: `QuiverAI ${operation} does not support these edit-only provider options: ${editOnlyOptions.join(
|
|
620
|
+
', ',
|
|
621
|
+
)}.`,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function toQuiverAIEditSource(file: ImageModelV4File) {
|
|
627
|
+
if (file.type === 'url') {
|
|
628
|
+
return {
|
|
629
|
+
svg_source: {
|
|
630
|
+
url: validateQuiverAIImageUrl(file.url),
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let data: Uint8Array;
|
|
636
|
+
try {
|
|
637
|
+
data =
|
|
638
|
+
typeof file.data === 'string'
|
|
639
|
+
? convertBase64ToUint8Array(file.data)
|
|
640
|
+
: file.data;
|
|
641
|
+
} catch (cause) {
|
|
642
|
+
throw new InvalidArgumentError({
|
|
643
|
+
argument: 'files',
|
|
644
|
+
message: 'QuiverAI SVG source data must be valid base64 or binary data.',
|
|
645
|
+
cause,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (data.length === 0 || data.length > MAX_EDIT_SVG_BYTES) {
|
|
650
|
+
throw new InvalidArgumentError({
|
|
651
|
+
argument: 'files',
|
|
652
|
+
message: `QuiverAI SVG source data must contain 1-${MAX_EDIT_SVG_BYTES} bytes.`,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
let svg: string;
|
|
657
|
+
try {
|
|
658
|
+
svg = new TextDecoder('utf-8', { fatal: true }).decode(data);
|
|
659
|
+
} catch (cause) {
|
|
660
|
+
throw new InvalidArgumentError({
|
|
661
|
+
argument: 'files',
|
|
662
|
+
message: 'QuiverAI SVG source data must be valid UTF-8.',
|
|
663
|
+
cause,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (svg.length > MAX_EDIT_SVG_BYTES || !isSvgMarkup(svg)) {
|
|
668
|
+
throw new InvalidArgumentError({
|
|
669
|
+
argument: 'files',
|
|
670
|
+
message: 'QuiverAI SVG source data must contain a complete SVG document.',
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
svg_source: {
|
|
676
|
+
base64: convertUint8ArrayToBase64(data),
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function isSvgMarkup(svg: string) {
|
|
682
|
+
const document = svg.replace(/^\uFEFF/, '');
|
|
683
|
+
const elements: string[] = [];
|
|
684
|
+
let position = 0;
|
|
685
|
+
let rootSeen = false;
|
|
686
|
+
let rootClosed = false;
|
|
687
|
+
let doctypeSeen = false;
|
|
688
|
+
|
|
689
|
+
while (position < document.length) {
|
|
690
|
+
if (document[position] !== '<') {
|
|
691
|
+
const nextTag = document.indexOf('<', position);
|
|
692
|
+
const end = nextTag === -1 ? document.length : nextTag;
|
|
693
|
+
const text = document.slice(position, end);
|
|
694
|
+
|
|
695
|
+
if (
|
|
696
|
+
(elements.length === 0 && text.trim().length > 0) ||
|
|
697
|
+
text.includes(']]>') ||
|
|
698
|
+
!hasValidXmlReferences(text)
|
|
699
|
+
) {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
position = end;
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (document.startsWith('<!--', position)) {
|
|
708
|
+
const commentEnd = document.indexOf('-->', position + 4);
|
|
709
|
+
if (
|
|
710
|
+
commentEnd === -1 ||
|
|
711
|
+
document.slice(position + 4, commentEnd).includes('--')
|
|
712
|
+
) {
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
position = commentEnd + 3;
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (document.startsWith('<?', position)) {
|
|
720
|
+
const instructionEnd = document.indexOf('?>', position + 2);
|
|
721
|
+
if (instructionEnd === -1) {
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
position = instructionEnd + 2;
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (document.startsWith('<![CDATA[', position)) {
|
|
729
|
+
if (elements.length === 0) {
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
const cdataEnd = document.indexOf(']]>', position + 9);
|
|
733
|
+
if (cdataEnd === -1) {
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
position = cdataEnd + 3;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (document.slice(position, position + 9).toUpperCase() === '<!DOCTYPE') {
|
|
741
|
+
if (
|
|
742
|
+
rootSeen ||
|
|
743
|
+
doctypeSeen ||
|
|
744
|
+
elements.length > 0 ||
|
|
745
|
+
!/[\t\n\r ]/.test(document[position + 9] ?? '')
|
|
746
|
+
) {
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
const doctypeName = readXmlName(
|
|
750
|
+
document,
|
|
751
|
+
skipXmlWhitespace(document, position + 9),
|
|
752
|
+
);
|
|
753
|
+
if (doctypeName?.name.toLowerCase() !== 'svg') {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
const doctypeEnd = findDoctypeEnd(document, doctypeName.end);
|
|
757
|
+
if (doctypeEnd === -1) {
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
doctypeSeen = true;
|
|
761
|
+
position = doctypeEnd;
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (document.startsWith('<!', position)) {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (document.startsWith('</', position)) {
|
|
770
|
+
const closingTag = readXmlName(document, position + 2);
|
|
771
|
+
if (closingTag == null) {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
let tagEnd = skipXmlWhitespace(document, closingTag.end);
|
|
775
|
+
if (document[tagEnd] !== '>') {
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
const expectedTag = elements.pop();
|
|
779
|
+
if (expectedTag !== closingTag.name) {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
tagEnd += 1;
|
|
783
|
+
if (elements.length === 0) {
|
|
784
|
+
rootClosed = true;
|
|
785
|
+
}
|
|
786
|
+
position = tagEnd;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (rootClosed) {
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const openingTag = readXmlName(document, position + 1);
|
|
795
|
+
if (openingTag == null) {
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
if (!rootSeen) {
|
|
799
|
+
if (openingTag.name.toLowerCase() !== 'svg') {
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
rootSeen = true;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const attributes = new Set<string>();
|
|
806
|
+
let tagPosition = openingTag.end;
|
|
807
|
+
while (tagPosition < document.length) {
|
|
808
|
+
const beforeWhitespace = tagPosition;
|
|
809
|
+
tagPosition = skipXmlWhitespace(document, tagPosition);
|
|
810
|
+
|
|
811
|
+
if (document.startsWith('/>', tagPosition)) {
|
|
812
|
+
tagPosition += 2;
|
|
813
|
+
if (elements.length === 0) {
|
|
814
|
+
rootClosed = true;
|
|
815
|
+
}
|
|
816
|
+
position = tagPosition;
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (document[tagPosition] === '>') {
|
|
821
|
+
elements.push(openingTag.name);
|
|
822
|
+
position = tagPosition + 1;
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (tagPosition === beforeWhitespace) {
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const attribute = readXmlName(document, tagPosition);
|
|
831
|
+
if (attribute == null || attributes.has(attribute.name)) {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
attributes.add(attribute.name);
|
|
835
|
+
|
|
836
|
+
tagPosition = skipXmlWhitespace(document, attribute.end);
|
|
837
|
+
if (document[tagPosition] !== '=') {
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
tagPosition = skipXmlWhitespace(document, tagPosition + 1);
|
|
841
|
+
|
|
842
|
+
const quote = document[tagPosition];
|
|
843
|
+
if (quote !== '"' && quote !== "'") {
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
const valueEnd = document.indexOf(quote, tagPosition + 1);
|
|
847
|
+
if (
|
|
848
|
+
valueEnd === -1 ||
|
|
849
|
+
document.slice(tagPosition + 1, valueEnd).includes('<') ||
|
|
850
|
+
!hasValidXmlReferences(document.slice(tagPosition + 1, valueEnd))
|
|
851
|
+
) {
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
tagPosition = valueEnd + 1;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (tagPosition >= document.length && position !== document.length) {
|
|
858
|
+
return false;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return rootSeen && rootClosed && elements.length === 0;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function isXmlNameStart(character: string | undefined) {
|
|
866
|
+
return character != null && /[A-Z_a-z:\u0080-\uFFFF]/.test(character);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function isXmlNameCharacter(character: string | undefined) {
|
|
870
|
+
return (
|
|
871
|
+
character != null && /[-.0-9A-Z_a-z:\u00B7\u0080-\uFFFF]/.test(character)
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function readXmlName(value: string, position: number) {
|
|
876
|
+
if (!isXmlNameStart(value[position])) {
|
|
877
|
+
return undefined;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const start = position;
|
|
881
|
+
position += 1;
|
|
882
|
+
while (isXmlNameCharacter(value[position])) {
|
|
883
|
+
position += 1;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return {
|
|
887
|
+
name: value.slice(start, position),
|
|
888
|
+
end: position,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function skipXmlWhitespace(value: string, position: number) {
|
|
893
|
+
while (/[\t\n\r ]/.test(value[position] ?? '')) {
|
|
894
|
+
position += 1;
|
|
895
|
+
}
|
|
896
|
+
return position;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function hasValidXmlReferences(value: string) {
|
|
900
|
+
let position = value.indexOf('&');
|
|
901
|
+
while (position !== -1) {
|
|
902
|
+
const end = value.indexOf(';', position + 1);
|
|
903
|
+
if (end === -1) {
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
const reference = value.slice(position + 1, end);
|
|
907
|
+
if (
|
|
908
|
+
!/^#\d+$/.test(reference) &&
|
|
909
|
+
!/^#x[\dA-Fa-f]+$/.test(reference) &&
|
|
910
|
+
!/^[A-Z_a-z:\u0080-\uFFFF][-.0-9A-Z_a-z:\u00B7\u0080-\uFFFF]*$/.test(
|
|
911
|
+
reference,
|
|
912
|
+
)
|
|
913
|
+
) {
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
position = value.indexOf('&', end + 1);
|
|
917
|
+
}
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function findDoctypeEnd(value: string, position: number) {
|
|
922
|
+
let subsetDepth = 0;
|
|
923
|
+
let quote: '"' | "'" | undefined;
|
|
924
|
+
|
|
925
|
+
while (position < value.length) {
|
|
926
|
+
const character = value[position];
|
|
927
|
+
if (quote != null) {
|
|
928
|
+
if (character === quote) {
|
|
929
|
+
quote = undefined;
|
|
930
|
+
}
|
|
931
|
+
} else if (character === '"' || character === "'") {
|
|
932
|
+
quote = character;
|
|
933
|
+
} else if (character === '[') {
|
|
934
|
+
subsetDepth += 1;
|
|
935
|
+
} else if (character === ']') {
|
|
936
|
+
if (subsetDepth === 0) {
|
|
937
|
+
return -1;
|
|
938
|
+
}
|
|
939
|
+
subsetDepth -= 1;
|
|
940
|
+
} else if (character === '>' && subsetDepth === 0) {
|
|
941
|
+
return position + 1;
|
|
942
|
+
}
|
|
943
|
+
position += 1;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return -1;
|
|
947
|
+
}
|
|
948
|
+
|
|
270
949
|
function collectWarnings({
|
|
271
950
|
size,
|
|
272
951
|
aspectRatio,
|
|
@@ -331,6 +1010,8 @@ const svgUsageSchema = z.object({
|
|
|
331
1010
|
const svgDocumentSchema = z.object({
|
|
332
1011
|
svg: z.string().min(1),
|
|
333
1012
|
mime_type: z.literal('image/svg+xml'),
|
|
1013
|
+
loop_period_ms: z.number().int().nonnegative().nullish(),
|
|
1014
|
+
opening_animation_ms: z.number().int().nonnegative().nullish(),
|
|
334
1015
|
});
|
|
335
1016
|
|
|
336
1017
|
const svgGenerationResponseSchema = z.object({
|