@json-to-office/jto 0.28.1 → 0.29.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.
@@ -1,5 +1,5 @@
1
1
  import { Hono } from 'hono';
2
- import { PptxRasterizer } from '@json-to-office/shared';
2
+ import { PptxRasterizer, PptxBatchRasterizer } from '@json-to-office/shared';
3
3
 
4
4
  type ApiAuthMode = 'auto' | 'required' | 'disabled';
5
5
  type OutboundSourceMode = 'development' | 'safe';
@@ -34,6 +34,7 @@ interface RenderServerOptions {
34
34
  sourcePolicy?: OutboundSourcePolicy;
35
35
  fetch?: FetchImplementation;
36
36
  getRasterizer?: () => PptxRasterizer;
37
+ getBatchRasterizer?: () => PptxBatchRasterizer;
37
38
  }
38
39
  declare function createRenderServerApp(options?: RenderServerOptions): Hono;
39
40
 
@@ -15,9 +15,13 @@ import {
15
15
  clampVisualDpi,
16
16
  DEFAULT_VISUAL_DPI,
17
17
  MIN_VISUAL_DPI,
18
- MAX_VISUAL_DPI
18
+ MAX_VISUAL_DPI,
19
+ MAX_RASTERIZE_BATCH_SLIDES
19
20
  } from "@json-to-office/shared";
20
- import { createLibreOfficePptxRasterizer } from "@json-to-office/jto-cli";
21
+ import {
22
+ createLibreOfficePptxRasterizer,
23
+ createLibreOfficePptxBatchRasterizer
24
+ } from "@json-to-office/jto-cli";
21
25
 
22
26
  // src/server/lib/typebox-validator.ts
23
27
  import { Value } from "@sinclair/typebox/value";
@@ -392,6 +396,24 @@ var RasterizeRequestSchema = Type.Object(
392
396
  },
393
397
  { additionalProperties: false }
394
398
  );
399
+ var RasterizeBatchRequestSchema = Type.Object(
400
+ {
401
+ slides: Type.Array(
402
+ Type.Object(
403
+ {
404
+ presentation: Type.Object({}, { additionalProperties: true }),
405
+ dpi: Type.Optional(
406
+ Type.Number({ minimum: MIN_VISUAL_DPI, maximum: MAX_VISUAL_DPI })
407
+ )
408
+ },
409
+ { additionalProperties: false }
410
+ ),
411
+ { minItems: 1, maxItems: MAX_RASTERIZE_BATCH_SLIDES }
412
+ ),
413
+ baseDir: Type.Optional(Type.String())
414
+ },
415
+ { additionalProperties: false }
416
+ );
395
417
  var sharedRasterizer;
396
418
  function getSharedRasterizer() {
397
419
  if (!sharedRasterizer) {
@@ -399,6 +421,13 @@ function getSharedRasterizer() {
399
421
  }
400
422
  return sharedRasterizer;
401
423
  }
424
+ var sharedBatchRasterizer;
425
+ function getSharedBatchRasterizer() {
426
+ if (!sharedBatchRasterizer) {
427
+ sharedBatchRasterizer = createLibreOfficePptxBatchRasterizer();
428
+ }
429
+ return sharedBatchRasterizer;
430
+ }
402
431
  var jsonOnly = async (c, next) => {
403
432
  const contentType = c.req.header("content-type");
404
433
  if (!contentType || !contentType.includes("application/json")) {
@@ -408,10 +437,65 @@ var jsonOnly = async (c, next) => {
408
437
  }
409
438
  await next();
410
439
  };
440
+ var DEFAULT_SLIDE_WIDTH_IN = 13.34;
441
+ var DEFAULT_SLIDE_HEIGHT_IN = 7.5;
442
+ var MAX_SLIDE_PIXELS = 64e6;
443
+ var MAX_BATCH_PIXELS = 256e6;
444
+ function estimateSlidePixels(presentation, dpi) {
445
+ const props = presentation?.props;
446
+ const dim = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
447
+ const widthIn = dim(props?.slideWidth, DEFAULT_SLIDE_WIDTH_IN);
448
+ const heightIn = dim(props?.slideHeight, DEFAULT_SLIDE_HEIGHT_IN);
449
+ return widthIn * dpi * (heightIn * dpi);
450
+ }
451
+ function assertPixelBudget(slides) {
452
+ let total = 0;
453
+ for (const slide of slides) {
454
+ const pixels = estimateSlidePixels(slide.presentation, slide.dpi);
455
+ if (pixels > MAX_SLIDE_PIXELS) {
456
+ throw new HTTPException2(400, {
457
+ message: "Requested slide dimensions are too large"
458
+ });
459
+ }
460
+ total += pixels;
461
+ }
462
+ if (total > MAX_BATCH_PIXELS) {
463
+ throw new HTTPException2(400, {
464
+ message: "Requested batch raster size is too large"
465
+ });
466
+ }
467
+ }
468
+ function resolveSafeBaseDir(baseDir) {
469
+ if (baseDir === void 0) return void 0;
470
+ const resolved = path.resolve(baseDir);
471
+ const cwd = process.cwd();
472
+ if (resolved !== cwd && !resolved.startsWith(cwd + path.sep)) {
473
+ throw new HTTPException2(400, {
474
+ message: "baseDir must be inside the server working directory"
475
+ });
476
+ }
477
+ return resolved;
478
+ }
479
+ function toHttpException(error) {
480
+ if (error instanceof HTTPException2) return error;
481
+ if (error instanceof UnsafeOutboundSourceError) {
482
+ return new HTTPException2(400, { message: error.message });
483
+ }
484
+ const msg = error instanceof Error ? error.message.toLowerCase() : String(error);
485
+ if (msg.includes("not found") || msg.includes("rasterization needs")) {
486
+ return new HTTPException2(503, { message: error.message });
487
+ }
488
+ if (msg.includes("invalid") || msg.includes("validation")) {
489
+ return new HTTPException2(400, { message: error.message });
490
+ }
491
+ return new HTTPException2(500, {
492
+ message: "Internal server error during rasterization"
493
+ });
494
+ }
411
495
  function registerRasterizeRoute(router, options = {}) {
412
496
  const getRasterizer = options.getRasterizer ?? getSharedRasterizer;
413
- router.post(
414
- "/rasterize",
497
+ const getBatchRasterizer = options.getBatchRasterizer ?? getSharedBatchRasterizer;
498
+ const shared = [
415
499
  ...options.preMiddleware ?? [],
416
500
  bodyLimit({
417
501
  maxSize: 32 * 1024 * 1024,
@@ -419,22 +503,26 @@ function registerRasterizeRoute(router, options = {}) {
419
503
  throw new HTTPException2(413, { message: "Request body too large" });
420
504
  }
421
505
  }),
422
- jsonOnly,
506
+ jsonOnly
507
+ ];
508
+ const guard = async (run) => {
509
+ try {
510
+ return await run();
511
+ } catch (error) {
512
+ options.onError?.(error);
513
+ throw toHttpException(error);
514
+ }
515
+ };
516
+ router.post(
517
+ "/rasterize",
518
+ ...shared,
423
519
  tbValidator(RasterizeRequestSchema),
424
520
  async (c) => {
425
521
  const { presentation, dpi, baseDir } = getValidated(c, "json");
426
- let safeBaseDir;
427
- if (baseDir !== void 0) {
428
- const resolved = path.resolve(baseDir);
429
- const cwd = process.cwd();
430
- if (resolved !== cwd && !resolved.startsWith(cwd + path.sep)) {
431
- throw new HTTPException2(400, {
432
- message: "baseDir must be inside the server working directory"
433
- });
434
- }
435
- safeBaseDir = resolved;
436
- }
437
- try {
522
+ const safeBaseDir = resolveSafeBaseDir(baseDir);
523
+ const effectiveDpi = clampVisualDpi(dpi ?? DEFAULT_VISUAL_DPI);
524
+ assertPixelBudget([{ presentation, dpi: effectiveDpi }]);
525
+ const result = await guard(async () => {
438
526
  if (options.sourcePolicy) {
439
527
  assertSafeOutboundSources(
440
528
  presentation,
@@ -442,29 +530,53 @@ function registerRasterizeRoute(router, options = {}) {
442
530
  "presentation"
443
531
  );
444
532
  }
445
- const result = await getRasterizer()({
533
+ return getRasterizer()({
446
534
  presentation,
447
- dpi: clampVisualDpi(dpi ?? DEFAULT_VISUAL_DPI),
535
+ dpi: effectiveDpi,
448
536
  baseDir: safeBaseDir
449
537
  });
450
- return c.json(result);
451
- } catch (error) {
452
- options.onError?.(error);
453
- if (error instanceof HTTPException2) throw error;
454
- if (error instanceof UnsafeOutboundSourceError) {
455
- throw new HTTPException2(400, { message: error.message });
456
- }
457
- const msg = error instanceof Error ? error.message.toLowerCase() : String(error);
458
- if (msg.includes("not found") || msg.includes("rasterization needs")) {
459
- throw new HTTPException2(503, { message: error.message });
460
- }
461
- if (msg.includes("invalid") || msg.includes("validation")) {
462
- throw new HTTPException2(400, { message: error.message });
538
+ });
539
+ return c.json(result);
540
+ }
541
+ );
542
+ router.post(
543
+ "/rasterize/batch",
544
+ ...shared,
545
+ tbValidator(RasterizeBatchRequestSchema),
546
+ async (c) => {
547
+ const { slides, baseDir } = getValidated(c, "json");
548
+ const safeBaseDir = resolveSafeBaseDir(baseDir);
549
+ const effectiveSlides = slides.map((slide) => ({
550
+ presentation: slide.presentation,
551
+ dpi: clampVisualDpi(slide.dpi ?? DEFAULT_VISUAL_DPI)
552
+ }));
553
+ assertPixelBudget(effectiveSlides);
554
+ const result = await guard(async () => {
555
+ if (options.sourcePolicy) {
556
+ slides.forEach(
557
+ (slide, index) => assertSafeOutboundSources(
558
+ slide.presentation,
559
+ options.sourcePolicy,
560
+ `slides[${index}].presentation`
561
+ )
562
+ );
463
563
  }
464
- throw new HTTPException2(500, {
465
- message: "Internal server error during rasterization"
564
+ return getBatchRasterizer()({
565
+ slides: effectiveSlides,
566
+ baseDir: safeBaseDir
466
567
  });
467
- }
568
+ });
569
+ return c.json({
570
+ results: result.results.map((slide) => {
571
+ if (slide.ok) return slide;
572
+ options.onError?.(new Error(slide.error));
573
+ return slide.stage === "build" ? { ok: false, error: slide.error, stage: slide.stage } : {
574
+ ok: false,
575
+ error: "Slide rasterization failed",
576
+ stage: slide.stage
577
+ };
578
+ })
579
+ });
468
580
  }
469
581
  );
470
582
  }
@@ -903,6 +1015,7 @@ function createRenderServerApp(options = {}) {
903
1015
  });
904
1016
  registerRasterizeRoute(app, {
905
1017
  getRasterizer: options.getRasterizer,
1018
+ getBatchRasterizer: options.getBatchRasterizer,
906
1019
  preMiddleware: [
907
1020
  rateLimiter({
908
1021
  limit: options.rasterizeRateLimit ?? positiveInteger2(
@@ -1010,7 +1123,7 @@ function createRenderServerApp(options = {}) {
1010
1123
  return c.body(payload, response.status, headers);
1011
1124
  }
1012
1125
  );
1013
- for (const route of ["/export", "/rasterize"]) {
1126
+ for (const route of ["/export", "/rasterize", "/rasterize/batch"]) {
1014
1127
  app.all(route, (c) => {
1015
1128
  c.header("Allow", "POST");
1016
1129
  return c.json({ success: false, error: "Method not allowed" }, 405);