@1agh/maude 0.45.1 → 0.46.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 (91) hide show
  1. package/apps/studio/acp/bridge.ts +628 -74
  2. package/apps/studio/acp/index.ts +172 -23
  3. package/apps/studio/acp/probe.ts +31 -7
  4. package/apps/studio/acp/transcript.ts +91 -5
  5. package/apps/studio/annotations-snap.ts +50 -10
  6. package/apps/studio/api.ts +344 -14
  7. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  8. package/apps/studio/bin/_import-asset.mjs +35 -5
  9. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  10. package/apps/studio/bin/_png-playwright.mjs +38 -4
  11. package/apps/studio/bin/_pw-launch.mjs +52 -0
  12. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  13. package/apps/studio/bin/_smart-frames.mjs +419 -0
  14. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  15. package/apps/studio/bin/smart-frames.sh +30 -0
  16. package/apps/studio/canvas-edit.ts +302 -3
  17. package/apps/studio/canvas-lib.tsx +203 -11
  18. package/apps/studio/canvas-shell.tsx +167 -28
  19. package/apps/studio/client/app.jsx +678 -176
  20. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  21. package/apps/studio/client/panels/ChatPanel.jsx +846 -133
  22. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  23. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  24. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  25. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  26. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  27. package/apps/studio/client/panels/acp-capabilities.js +82 -0
  28. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  29. package/apps/studio/client/panels/acp-runtime.js +248 -9
  30. package/apps/studio/client/panels/acp-usage.js +65 -0
  31. package/apps/studio/client/panels/transcript-view.js +36 -0
  32. package/apps/studio/client/styles/6-acp-chat.css +689 -11
  33. package/apps/studio/dist/client.bundle.js +1738 -1736
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/generation/whisper-models.test.ts +28 -1
  44. package/apps/studio/generation/whisper-models.ts +22 -7
  45. package/apps/studio/http.ts +246 -9
  46. package/apps/studio/print/marks.ts +113 -0
  47. package/apps/studio/print/units.ts +269 -0
  48. package/apps/studio/print-overlay-content.tsx +132 -0
  49. package/apps/studio/test/acp-bridge.test.ts +11 -6
  50. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  51. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  52. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  53. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  54. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  56. package/apps/studio/test/acp-permission.test.ts +262 -0
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
  58. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  59. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  60. package/apps/studio/test/acp-transcript.test.ts +75 -2
  61. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  62. package/apps/studio/test/acp-usage.test.ts +143 -0
  63. package/apps/studio/test/annotations-snap.test.ts +56 -0
  64. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  65. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  66. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  67. package/apps/studio/test/canvas-meta-api.test.ts +167 -0
  68. package/apps/studio/test/element-structural-edit.test.ts +250 -0
  69. package/apps/studio/test/exporters/png.test.ts +49 -1
  70. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  73. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  74. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  75. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  76. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  77. package/apps/studio/test/import-asset.test.ts +31 -3
  78. package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
  79. package/apps/studio/test/print-marks.test.ts +113 -0
  80. package/apps/studio/test/print-units.test.ts +173 -0
  81. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  82. package/apps/studio/use-chrome-visibility.tsx +19 -0
  83. package/apps/studio/use-element-resize.tsx +21 -2
  84. package/apps/studio/use-snap-guides.tsx +73 -5
  85. package/apps/studio/use-spacing-handles.tsx +9 -5
  86. package/apps/studio/whats-new.json +62 -0
  87. package/cli/commands/design.mjs +116 -3
  88. package/cli/lib/pkg-root.mjs +42 -10
  89. package/cli/lib/pkg-root.test.mjs +33 -1
  90. package/package.json +11 -9
  91. package/plugins/design/dependencies.json +35 -0
@@ -17,7 +17,7 @@ import {
17
17
  startSignin,
18
18
  } from './acp/login-state.ts';
19
19
  import { probeAcpAvailabilityAuthed } from './acp/probe.ts';
20
- import { deleteChat, listChats, readChatMessages } from './acp/transcript.ts';
20
+ import { deleteChat, listChats, readChatMessages, writeChatMeta } from './acp/transcript.ts';
21
21
  import { type Api, ASSET_MAX_BYTES, ASSET_MAX_VIDEO_BYTES } from './api.ts';
22
22
  import { ImportAssetError, importSvg, SVG_MAX_BYTES } from './bin/_import-asset.mjs';
23
23
  import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
@@ -40,6 +40,12 @@ import {
40
40
  sanitizeReuseText,
41
41
  } from './generation/audio-library.ts';
42
42
  import { localizeGenAsset } from './generation/download.ts';
43
+ import {
44
+ downloadGemmaModel,
45
+ ffmpegAvailable,
46
+ listGemmaModels,
47
+ mlxVlmAvailable,
48
+ } from './generation/gemma-models.ts';
43
49
  import { type GenerationJobQueue, GenerationQueueFullError } from './generation/jobs.ts';
44
50
  import {
45
51
  configuredProviders,
@@ -49,8 +55,11 @@ import {
49
55
  setProviderKey,
50
56
  } from './generation/keys.ts';
51
57
  import {
58
+ isKeyframeEngine,
52
59
  isTranscriptionProvider,
60
+ readKeyframeEngine,
53
61
  readTranscriptionProvider,
62
+ writeKeyframeEngine,
54
63
  writeTranscriptionProvider,
55
64
  } from './generation/prefs.ts';
56
65
  import {
@@ -691,6 +700,12 @@ export function createHttp(
691
700
  error?: string;
692
701
  } | null = null;
693
702
 
703
+ // feature-scene-aware-keyframes — in-flight Gemma-model download state, polled by
704
+ // the Settings "Scene-aware keyframes" card via GET /_api/generate/keyframe-model.
705
+ // Closure-scoped: one server, one download.
706
+ let keyframeDownload: { id: string; received: number; total: number; error?: string } | null =
707
+ null;
708
+
694
709
  // Cache invalidation — when canvas-lib changes, every cached canvas bundle
695
710
  // is stale because canvas-lib is inlined into each one via the resolver
696
711
  // plugin. Drop the whole cache so the next request rebuilds with the fresh
@@ -1017,7 +1032,7 @@ export function createHttp(
1017
1032
  Response.json(listChats(ctx.paths.designRoot), {
1018
1033
  headers: { 'Cache-Control': 'no-store' },
1019
1034
  }),
1020
- '/_api/acp/chat': (req: Request) => {
1035
+ '/_api/acp/chat': async (req: Request) => {
1021
1036
  const id = (new URL(req.url).searchParams.get('id') ?? '')
1022
1037
  .replace(/[^a-z0-9_-]/gi, '')
1023
1038
  .slice(0, 64);
@@ -1025,6 +1040,37 @@ export function createHttp(
1025
1040
  const removed = id ? deleteChat(ctx.paths.designRoot, id) : false;
1026
1041
  return Response.json({ ok: removed }, { headers: { 'Cache-Control': 'no-store' } });
1027
1042
  }
1043
+ // Task C5 — Rename / Archive from the chat switcher's overflow menu.
1044
+ // Same CSRF + loopback gate as every other privileged write route
1045
+ // (DDR-088); id is already sanitized/contained above.
1046
+ if (req.method === 'PATCH') {
1047
+ if (!sameOriginWrite(req))
1048
+ return new Response('cross-origin write rejected', { status: 403 });
1049
+ if (!isLoopbackHost(req.headers.get('host')))
1050
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1051
+ if (!id) return new Response('missing id', { status: 400 });
1052
+ let body: unknown;
1053
+ try {
1054
+ body = await req.json();
1055
+ } catch {
1056
+ return new Response('invalid JSON body', { status: 400 });
1057
+ }
1058
+ if (!body || typeof body !== 'object') return new Response('invalid body', { status: 400 });
1059
+ const b = body as { title?: unknown; archived?: unknown };
1060
+ const patch: { title?: string | null; archived?: boolean } = {};
1061
+ if ('title' in b) {
1062
+ if (b.title === null) patch.title = null;
1063
+ else if (typeof b.title === 'string') patch.title = b.title;
1064
+ else return new Response('title must be a string or null', { status: 400 });
1065
+ }
1066
+ if ('archived' in b) {
1067
+ if (typeof b.archived !== 'boolean')
1068
+ return new Response('archived must be a boolean', { status: 400 });
1069
+ patch.archived = b.archived;
1070
+ }
1071
+ const meta = writeChatMeta(ctx.paths.designRoot, id, patch);
1072
+ return Response.json(meta, { headers: { 'Cache-Control': 'no-store' } });
1073
+ }
1028
1074
  if (!id) return Response.json([], { headers: { 'Cache-Control': 'no-store' } });
1029
1075
  return Response.json(readChatMessages(ctx.paths.designRoot, id), {
1030
1076
  headers: { 'Cache-Control': 'no-store' },
@@ -2390,6 +2436,98 @@ export function createHttp(
2390
2436
  );
2391
2437
  },
2392
2438
 
2439
+ '/_api/set-artboard-kind': async (req: Request) => {
2440
+ // feature-1-artboard-kinds-foundation, T8. POST { canvas, artboardId,
2441
+ // kind: 'digital'|'print'|'web'|'video'|null } → api.setArtboardKindOp.
2442
+ // `null` clears the explicit prop back to the implicit default. Whole-
2443
+ // file undo seq. MAIN-ORIGIN ONLY; same gate pair as set-artboard-style
2444
+ // (the kind-switch surfaces — context menu, Inspector — are shell UI,
2445
+ // never reachable from the untrusted canvas origin).
2446
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2447
+ if (!sameOriginWrite(req))
2448
+ return new Response('cross-origin write rejected', { status: 403 });
2449
+ if (!isLoopbackHost(req.headers.get('host')))
2450
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2451
+ const body = await readJson<{
2452
+ canvas?: unknown;
2453
+ artboardId?: unknown;
2454
+ kind?: unknown;
2455
+ }>(req, 2 * 1024);
2456
+ if (!body) return new Response('body required', { status: 400 });
2457
+ const result = await api.setArtboardKindOp(body);
2458
+ if (!result.ok) {
2459
+ return Response.json(
2460
+ { ok: false, error: result.error },
2461
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2462
+ );
2463
+ }
2464
+ return Response.json(
2465
+ { ok: true, seq: result.seq },
2466
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2467
+ );
2468
+ },
2469
+
2470
+ '/_api/set-artboard-guides': async (req: Request) => {
2471
+ // feature-1-artboard-kinds-foundation, T5. POST { canvas, artboardId,
2472
+ // guides: {...}|null } → api.setArtboardGuidesOp. REPLACE-whole-prop
2473
+ // (see applySetArtboardGuides doc comment) — the caller sends the full
2474
+ // merged object, not a delta. MAIN-ORIGIN ONLY; same gate pair as
2475
+ // set-artboard-style.
2476
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2477
+ if (!sameOriginWrite(req))
2478
+ return new Response('cross-origin write rejected', { status: 403 });
2479
+ if (!isLoopbackHost(req.headers.get('host')))
2480
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2481
+ const body = await readJson<{
2482
+ canvas?: unknown;
2483
+ artboardId?: unknown;
2484
+ guides?: unknown;
2485
+ }>(req, 8 * 1024);
2486
+ if (!body) return new Response('body required', { status: 400 });
2487
+ const result = await api.setArtboardGuidesOp(body);
2488
+ if (!result.ok) {
2489
+ return Response.json(
2490
+ { ok: false, error: result.error },
2491
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2492
+ );
2493
+ }
2494
+ return Response.json(
2495
+ { ok: true, seq: result.seq },
2496
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2497
+ );
2498
+ },
2499
+
2500
+ '/_api/set-artboard-print': async (req: Request) => {
2501
+ // feature-2-print-artboards T2. POST { canvas, artboardId, print:
2502
+ // {paper, orientation?, bleedMm?, marginsMm?}|null } → api.setArtboardPrintOp.
2503
+ // REPLACE-whole-prop (see applySetArtboardPrint's own doc comment) — the
2504
+ // caller sends the full merged object, not a delta. MAIN-ORIGIN ONLY;
2505
+ // same gate pair as set-artboard-guides (Inspector picker is shell UI,
2506
+ // never reachable from the untrusted canvas origin).
2507
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2508
+ if (!sameOriginWrite(req))
2509
+ return new Response('cross-origin write rejected', { status: 403 });
2510
+ if (!isLoopbackHost(req.headers.get('host')))
2511
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2512
+ const body = await readJson<{
2513
+ canvas?: unknown;
2514
+ artboardId?: unknown;
2515
+ print?: unknown;
2516
+ }>(req, 2 * 1024);
2517
+ if (!body) return new Response('body required', { status: 400 });
2518
+ const result = await api.setArtboardPrintOp(body);
2519
+ if (!result.ok) {
2520
+ return Response.json(
2521
+ { ok: false, error: result.error },
2522
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2523
+ );
2524
+ }
2525
+ return Response.json(
2526
+ { ok: true, seq: result.seq },
2527
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2528
+ );
2529
+ },
2530
+
2393
2531
  '/_api/delete-artboard': async (req: Request) => {
2394
2532
  // Delete an artboard by its `id` prop (Backspace / context-menu on a frame).
2395
2533
  // POST { canvas, artboardId } → api.deleteArtboardOp (removes the
@@ -2911,20 +3049,38 @@ export function createHttp(
2911
3049
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2912
3050
  if (req.method === 'GET') {
2913
3051
  return Response.json(
2914
- { transcriptionProvider: readTranscriptionProvider(ctx.paths.repoRoot) },
3052
+ {
3053
+ transcriptionProvider: readTranscriptionProvider(ctx.paths.repoRoot),
3054
+ keyframeEngine: readKeyframeEngine(ctx.paths.repoRoot),
3055
+ },
2915
3056
  { headers: { 'Cache-Control': 'no-store' } }
2916
3057
  );
2917
3058
  }
2918
3059
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2919
3060
  if (!sameOriginWrite(req))
2920
3061
  return new Response('cross-origin write rejected', { status: 403 });
2921
- const body = await readJson<{ transcriptionProvider?: unknown }>(req, 4 * 1024);
2922
- const provider = body?.transcriptionProvider;
2923
- if (!isTranscriptionProvider(provider))
2924
- return new Response('transcriptionProvider must be whisper|elevenlabs|groq', {
2925
- status: 400,
2926
- });
3062
+ const body = await readJson<{ transcriptionProvider?: unknown; keyframeEngine?: unknown }>(
3063
+ req,
3064
+ 4 * 1024
3065
+ );
3066
+ // The panel POSTs ONE pref at a time (transcription engine OR keyframe engine).
2927
3067
  try {
3068
+ if ('keyframeEngine' in (body ?? {})) {
3069
+ const engine = body?.keyframeEngine;
3070
+ if (!isKeyframeEngine(engine))
3071
+ return new Response('keyframeEngine must be auto|gemma|ffmpeg|blind', { status: 400 });
3072
+ await writeKeyframeEngine(ctx.paths.repoRoot, engine);
3073
+ reloadConfig(ctx);
3074
+ return Response.json(
3075
+ { keyframeEngine: engine },
3076
+ { headers: { 'Cache-Control': 'no-store' } }
3077
+ );
3078
+ }
3079
+ const provider = body?.transcriptionProvider;
3080
+ if (!isTranscriptionProvider(provider))
3081
+ return new Response('transcriptionProvider must be whisper|elevenlabs|groq', {
3082
+ status: 400,
3083
+ });
2928
3084
  await writeTranscriptionProvider(ctx.paths.repoRoot, provider);
2929
3085
  reloadConfig(ctx);
2930
3086
  return Response.json(
@@ -3146,6 +3302,87 @@ export function createHttp(
3146
3302
  );
3147
3303
  },
3148
3304
 
3305
+ // feature-scene-aware-keyframes — managed local Gemma-4 MLX scout models: GET
3306
+ // lists the registry + which are downloaded + any in-flight download + the two
3307
+ // availability signals (mlx-vlm runtime, ffmpeg) the Settings card needs to say
3308
+ // which tier will run; POST {id} downloads one via huggingface_hub (gated on
3309
+ // mlx-vlm being installed — the model is useless without it). MAIN-ORIGIN ONLY.
3310
+ '/_api/generate/keyframe-model': async (req: Request) => {
3311
+ if (!isLoopbackHost(req.headers.get('host')))
3312
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3313
+ if (req.method === 'GET') {
3314
+ // The GET builds availability by spawning subprocesses (import mlx_vlm is
3315
+ // heavy). Gate it same-origin so a cross-origin no-cors flood can't drive a
3316
+ // spawn-storm against the loopback server (DDR-183 security finding — the
3317
+ // sibling audio-search GET added this guard for the same reason). The probes
3318
+ // are also TTL-cached in gemma-models.ts as defence-in-depth.
3319
+ if (!sameOriginRead(req))
3320
+ return new Response('cross-origin read rejected', { status: 403 });
3321
+ return Response.json(
3322
+ {
3323
+ models: listGemmaModels(),
3324
+ downloading: keyframeDownload,
3325
+ mlxVlmAvailable: mlxVlmAvailable(),
3326
+ ffmpegAvailable: ffmpegAvailable(),
3327
+ },
3328
+ { headers: { 'Cache-Control': 'no-store' } }
3329
+ );
3330
+ }
3331
+ if (req.method === 'DELETE') {
3332
+ // Free a wedged download slot (a stalled HF connection) without waiting out
3333
+ // the 60-min abort timeout (DDR-183 F4). Same-origin write-gated.
3334
+ if (!sameOriginWrite(req))
3335
+ return new Response('cross-origin write rejected', { status: 403 });
3336
+ keyframeDownload = null;
3337
+ return Response.json({ cancelled: true }, { headers: { 'Cache-Control': 'no-store' } });
3338
+ }
3339
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3340
+ if (!sameOriginWrite(req))
3341
+ return new Response('cross-origin write rejected', { status: 403 });
3342
+ if (!mlxVlmAvailable())
3343
+ return new Response(
3344
+ 'mlx-vlm not installed — the Gemma scout needs an Apple-Silicon Mac + `pip install mlx-vlm`.',
3345
+ {
3346
+ status: 400,
3347
+ }
3348
+ );
3349
+ const body = await readJson<{ id?: unknown }>(req, 4 * 1024);
3350
+ const id = typeof body?.id === 'string' ? body.id : '';
3351
+ if (!listGemmaModels().some((m) => m.id === id))
3352
+ return new Response('unknown model id', { status: 400 });
3353
+ if (keyframeDownload && !keyframeDownload.error)
3354
+ return new Response(`a download is already in progress (${keyframeDownload.id})`, {
3355
+ status: 409,
3356
+ });
3357
+ keyframeDownload = { id, received: 0, total: 0 };
3358
+ void downloadGemmaModel(
3359
+ id,
3360
+ (received, total) => {
3361
+ if (keyframeDownload && keyframeDownload.id === id) {
3362
+ keyframeDownload.received = received;
3363
+ keyframeDownload.total = total;
3364
+ }
3365
+ },
3366
+ AbortSignal.timeout(60 * 60_000) // multi-GB snapshots on a slow link
3367
+ )
3368
+ .then(() => {
3369
+ if (keyframeDownload?.id === id) keyframeDownload = null;
3370
+ })
3371
+ .catch((err) => {
3372
+ if (keyframeDownload?.id === id)
3373
+ keyframeDownload = {
3374
+ id,
3375
+ received: keyframeDownload.received,
3376
+ total: keyframeDownload.total,
3377
+ error: err instanceof Error ? err.message : 'download failed',
3378
+ };
3379
+ });
3380
+ return Response.json(
3381
+ { started: id },
3382
+ { status: 202, headers: { 'Cache-Control': 'no-store' } }
3383
+ );
3384
+ },
3385
+
3149
3386
  '/_canvas-state': async (req: Request) => {
3150
3387
  const url = new URL(req.url);
3151
3388
  if (req.method === 'GET') {
@@ -0,0 +1,113 @@
1
+ // feature-2-print-artboards T1/T5 — pure crop/registration-mark geometry.
2
+ //
3
+ // Consumed by the pdf-lib post-pass (T5) to draw vector marks, and reusable
4
+ // by a future on-canvas marks preview (T3 left that optional — see the plan).
5
+ // Coordinates are PDF POINTS in the BLEED-BOX-origin space: x ∈ [0, pageWidthPt],
6
+ // y ∈ [0, pageHeightPt] is the rendered page itself (Chromium's page.pdf()
7
+ // output, unchanged); marks live OUTSIDE that box, at negative coordinates or
8
+ // beyond pageWidthPt/pageHeightPt. The pdf-lib post-pass enlarges MediaBox
9
+ // with a negative origin to make room for them without moving page content.
10
+
11
+ import { MARK_LENGTH_MM, MARK_STROKE_MM, mmToPt } from './units.ts';
12
+
13
+ export interface LineSegment {
14
+ x1: number;
15
+ y1: number;
16
+ x2: number;
17
+ y2: number;
18
+ }
19
+
20
+ export interface Circle {
21
+ cx: number;
22
+ cy: number;
23
+ r: number;
24
+ }
25
+
26
+ export interface RegistrationMark {
27
+ circle: Circle;
28
+ crosshair: LineSegment[];
29
+ }
30
+
31
+ export interface MarksGeometry {
32
+ cropMarks: LineSegment[];
33
+ registrationMarks: RegistrationMark[];
34
+ }
35
+
36
+ const REG_RADIUS_MM = 1.5;
37
+ const REG_GAP_MM = 1;
38
+
39
+ /**
40
+ * The extra margin MediaBox must extend beyond the bleed box on every side to
41
+ * fit the requested marks without clipping. Callers ask for this BEFORE
42
+ * calling computeMarksGeometry, to size the enlarged MediaBox first.
43
+ */
44
+ export function requiredSlugPt(args: { crop?: boolean; registration?: boolean }): number {
45
+ const markLen = mmToPt(MARK_LENGTH_MM);
46
+ let slug = 0;
47
+ if (args.crop) slug = Math.max(slug, markLen);
48
+ if (args.registration) {
49
+ slug = Math.max(slug, markLen + mmToPt(REG_GAP_MM) + 2 * mmToPt(REG_RADIUS_MM));
50
+ }
51
+ return slug;
52
+ }
53
+
54
+ /**
55
+ * Compute crop-mark (8 segments, 2 per corner) and registration-mark (4,
56
+ * side-midpoints) geometry for a page of the given size + bleed. Crop marks
57
+ * are aligned to the TRIM line but drawn starting exactly at the bleed-box
58
+ * edge (T1's "offset = bleed"), extending outward by `MARK_LENGTH_MM`.
59
+ * Registration marks sit just beyond the crop marks' reach.
60
+ */
61
+ export function computeMarksGeometry(args: {
62
+ pageWidthPt: number;
63
+ pageHeightPt: number;
64
+ bleedPt: number;
65
+ crop?: boolean;
66
+ registration?: boolean;
67
+ }): MarksGeometry {
68
+ const { pageWidthPt, pageHeightPt, bleedPt } = args;
69
+ const markLen = mmToPt(MARK_LENGTH_MM);
70
+ const trimX0 = bleedPt;
71
+ const trimY0 = bleedPt;
72
+ const trimX1 = pageWidthPt - bleedPt;
73
+ const trimY1 = pageHeightPt - bleedPt;
74
+
75
+ const cropMarks: LineSegment[] = [];
76
+ if (args.crop) {
77
+ // Bottom-left
78
+ cropMarks.push({ x1: trimX0, y1: 0, x2: trimX0, y2: -markLen });
79
+ cropMarks.push({ x1: 0, y1: trimY0, x2: -markLen, y2: trimY0 });
80
+ // Bottom-right
81
+ cropMarks.push({ x1: trimX1, y1: 0, x2: trimX1, y2: -markLen });
82
+ cropMarks.push({ x1: pageWidthPt, y1: trimY0, x2: pageWidthPt + markLen, y2: trimY0 });
83
+ // Top-left
84
+ cropMarks.push({ x1: trimX0, y1: pageHeightPt, x2: trimX0, y2: pageHeightPt + markLen });
85
+ cropMarks.push({ x1: 0, y1: trimY1, x2: -markLen, y2: trimY1 });
86
+ // Top-right
87
+ cropMarks.push({ x1: trimX1, y1: pageHeightPt, x2: trimX1, y2: pageHeightPt + markLen });
88
+ cropMarks.push({ x1: pageWidthPt, y1: trimY1, x2: pageWidthPt + markLen, y2: trimY1 });
89
+ }
90
+
91
+ const registrationMarks: RegistrationMark[] = [];
92
+ if (args.registration) {
93
+ const regR = mmToPt(REG_RADIUS_MM);
94
+ const offset = markLen + mmToPt(REG_GAP_MM) + regR;
95
+ const mk = (cx: number, cy: number): RegistrationMark => ({
96
+ circle: { cx, cy, r: regR },
97
+ crosshair: [
98
+ { x1: cx - regR, y1: cy, x2: cx + regR, y2: cy },
99
+ { x1: cx, y1: cy - regR, x2: cx, y2: cy + regR },
100
+ ],
101
+ });
102
+ registrationMarks.push(
103
+ mk(pageWidthPt / 2, pageHeightPt + offset), // top-center
104
+ mk(pageWidthPt / 2, -offset), // bottom-center
105
+ mk(-offset, pageHeightPt / 2), // left-center
106
+ mk(pageWidthPt + offset, pageHeightPt / 2) // right-center
107
+ );
108
+ }
109
+
110
+ return { cropMarks, registrationMarks };
111
+ }
112
+
113
+ export const MARK_STROKE_PT = mmToPt(MARK_STROKE_MM);