@1agh/maude 0.45.2 → 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 (54) hide show
  1. package/apps/studio/acp/bridge.ts +43 -1
  2. package/apps/studio/annotations-snap.ts +50 -10
  3. package/apps/studio/api.ts +344 -14
  4. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  5. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  6. package/apps/studio/bin/_png-playwright.mjs +38 -4
  7. package/apps/studio/bin/_pw-launch.mjs +52 -0
  8. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  9. package/apps/studio/bin/_smart-frames.mjs +419 -0
  10. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  11. package/apps/studio/bin/smart-frames.sh +30 -0
  12. package/apps/studio/canvas-edit.ts +302 -3
  13. package/apps/studio/canvas-lib.tsx +203 -11
  14. package/apps/studio/canvas-shell.tsx +167 -28
  15. package/apps/studio/client/app.jsx +678 -176
  16. package/apps/studio/client/panels/ChatPanel.jsx +45 -1
  17. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  18. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  19. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  20. package/apps/studio/dist/client.bundle.js +1398 -1398
  21. package/apps/studio/dist/comment-mount.js +2 -2
  22. package/apps/studio/dist/styles.css +1 -1
  23. package/apps/studio/dom-selection.ts +20 -0
  24. package/apps/studio/export-dialog.tsx +138 -18
  25. package/apps/studio/exporters/pdf.ts +332 -18
  26. package/apps/studio/exporters/png.ts +45 -3
  27. package/apps/studio/footage/schema.ts +17 -1
  28. package/apps/studio/generation/gemma-models.ts +224 -0
  29. package/apps/studio/generation/prefs.ts +43 -0
  30. package/apps/studio/http.ts +213 -7
  31. package/apps/studio/print/marks.ts +113 -0
  32. package/apps/studio/print/units.ts +269 -0
  33. package/apps/studio/print-overlay-content.tsx +132 -0
  34. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  35. package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
  36. package/apps/studio/test/annotations-snap.test.ts +56 -0
  37. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  38. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  39. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  40. package/apps/studio/test/canvas-meta-api.test.ts +167 -0
  41. package/apps/studio/test/element-structural-edit.test.ts +250 -0
  42. package/apps/studio/test/exporters/png.test.ts +49 -1
  43. package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
  44. package/apps/studio/test/print-marks.test.ts +113 -0
  45. package/apps/studio/test/print-units.test.ts +173 -0
  46. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  47. package/apps/studio/use-chrome-visibility.tsx +19 -0
  48. package/apps/studio/use-element-resize.tsx +21 -2
  49. package/apps/studio/use-snap-guides.tsx +73 -5
  50. package/apps/studio/use-spacing-handles.tsx +9 -5
  51. package/apps/studio/whats-new.json +28 -0
  52. package/cli/commands/design.mjs +9 -1
  53. package/package.json +8 -8
  54. package/plugins/design/dependencies.json +35 -0
@@ -12,9 +12,13 @@ import {
12
12
  applyInsertElement,
13
13
  applyInsertElementIntoArtboard,
14
14
  applyResizeArtboard,
15
+ applySetArtboardGuides,
15
16
  applySetArtboardHug,
17
+ applySetArtboardKind,
18
+ applySetArtboardPrint,
16
19
  applySetArtboardStyle,
17
20
  CanvasEditError,
21
+ readArtboardPrintProp,
18
22
  } from '../canvas-edit.ts';
19
23
  import { transpileCanvasSource } from '../canvas-pipeline.ts';
20
24
 
@@ -337,6 +341,252 @@ describe('canvas-edit / applySetArtboardStyle (background/padding/layout/gap)',
337
341
  CanvasEditError
338
342
  );
339
343
  });
344
+
345
+ // Dogfood (artboard panel ↔ shared inspector controls) — padding/gap accept
346
+ // a var(--token) STRING binding alongside raw px numbers.
347
+ test('padding/gap accept a var(--token) string (space-token binding)', () => {
348
+ const out = applySetArtboardStyle(CANVAS, canvas, 'home', {
349
+ padding: 'var(--space-4)',
350
+ gap: 'var(--space-2)',
351
+ });
352
+ expect(out.source).toContain('padding="var(--space-4)"');
353
+ expect(out.source).toContain('gap="var(--space-2)"');
354
+ expect(parses(out.source)).toBe(true);
355
+ });
356
+
357
+ test('a token binding round-trips back to a raw number (detach) and vice versa', () => {
358
+ const bound = applySetArtboardStyle(CANVAS, canvas, 'home', { padding: 'var(--space-4)' });
359
+ const detached = applySetArtboardStyle(CANVAS, bound.source, 'home', { padding: 24 });
360
+ expect(detached.source).toContain('padding={24}');
361
+ expect(detached.source).not.toContain('var(--space-4)');
362
+ const rebound = applySetArtboardStyle(CANVAS, detached.source, 'home', {
363
+ padding: 'var(--space-6)',
364
+ });
365
+ expect(rebound.source).toContain('padding="var(--space-6)"');
366
+ expect(rebound.source).not.toContain('padding={24}');
367
+ expect(parses(rebound.source)).toBe(true);
368
+ });
369
+ });
370
+
371
+ // feature-1-artboard-kinds-foundation, T5/T8 — kind-switch write lane.
372
+ describe('canvas-edit / applySetArtboardKind', () => {
373
+ const canvas = [
374
+ 'export default function Demo() {',
375
+ ' return (',
376
+ ' <DesignCanvas>',
377
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
378
+ ' <div>content</div>',
379
+ ' </DCArtboard>',
380
+ ' </DesignCanvas>',
381
+ ' );',
382
+ '}',
383
+ ].join('\n');
384
+
385
+ test('writes kind as a plain string-literal prop', () => {
386
+ const out = applySetArtboardKind(CANVAS, canvas, 'home', 'print');
387
+ expect(out.source).toContain('kind="print"');
388
+ expect(parses(out.source)).toBe(true);
389
+ });
390
+
391
+ test('switching kind replaces the previous value, no duplicate attr', () => {
392
+ const printed = applySetArtboardKind(CANVAS, canvas, 'home', 'print');
393
+ const out = applySetArtboardKind(CANVAS, printed.source, 'home', 'web');
394
+ expect(out.source).toContain('kind="web"');
395
+ expect(out.source).not.toContain('kind="print"');
396
+ expect(out.source.match(/\bkind=/g)?.length).toBe(1);
397
+ });
398
+
399
+ test('kind=null clears back to the implicit default', () => {
400
+ const printed = applySetArtboardKind(CANVAS, canvas, 'home', 'print');
401
+ const out = applySetArtboardKind(CANVAS, printed.source, 'home', null);
402
+ expect(out.source).not.toContain('kind=');
403
+ expect(parses(out.source)).toBe(true);
404
+ });
405
+
406
+ test('an invalid kind value throws rather than writing garbage', () => {
407
+ expect(() => applySetArtboardKind(CANVAS, canvas, 'home', 'poster')).toThrow(CanvasEditError);
408
+ });
409
+
410
+ test('unknown artboard id throws', () => {
411
+ expect(() => applySetArtboardKind(CANVAS, canvas, 'nope', 'print')).toThrow(CanvasEditError);
412
+ });
413
+ });
414
+
415
+ // feature-1-artboard-kinds-foundation, T5 — generic layout guides write lane.
416
+ describe('canvas-edit / applySetArtboardGuides (replace-whole-prop)', () => {
417
+ const canvas = [
418
+ 'export default function Demo() {',
419
+ ' return (',
420
+ ' <DesignCanvas>',
421
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
422
+ ' <div>content</div>',
423
+ ' </DCArtboard>',
424
+ ' </DesignCanvas>',
425
+ ' );',
426
+ '}',
427
+ ].join('\n');
428
+
429
+ test('inserts guides as a {{...}} object-expression prop', () => {
430
+ const out = applySetArtboardGuides(CANVAS, canvas, 'home', {
431
+ columns: { count: 12, gutter: 24, margin: 80 },
432
+ });
433
+ expect(out.source).toContain('guides={{"columns":{"count":12,"gutter":24,"margin":80}}}');
434
+ expect(parses(out.source)).toBe(true);
435
+ });
436
+
437
+ test('a second write REPLACES the whole prop rather than merging', () => {
438
+ const first = applySetArtboardGuides(CANVAS, canvas, 'home', {
439
+ columns: { count: 12, gutter: 24, margin: 80 },
440
+ });
441
+ const out = applySetArtboardGuides(CANVAS, first.source, 'home', { grid: { size: 8 } });
442
+ expect(out.source).toContain('guides={{"grid":{"size":8}}}');
443
+ expect(out.source).not.toContain('columns');
444
+ expect(out.source.match(/\bguides=/g)?.length).toBe(1);
445
+ expect(parses(out.source)).toBe(true);
446
+ });
447
+
448
+ test('guides=null removes the prop', () => {
449
+ const written = applySetArtboardGuides(CANVAS, canvas, 'home', { grid: { size: 8 } });
450
+ const out = applySetArtboardGuides(CANVAS, written.source, 'home', null);
451
+ expect(out.source).not.toContain('guides=');
452
+ expect(parses(out.source)).toBe(true);
453
+ });
454
+
455
+ test('unknown artboard id throws', () => {
456
+ expect(() => applySetArtboardGuides(CANVAS, canvas, 'nope', { grid: { size: 8 } })).toThrow(
457
+ CanvasEditError
458
+ );
459
+ });
460
+ });
461
+
462
+ // feature-2-print-artboards T2 — print prop write lane (same replace-whole-
463
+ // prop shape as applySetArtboardGuides).
464
+ describe('canvas-edit / applySetArtboardPrint (replace-whole-prop)', () => {
465
+ const canvas = [
466
+ 'export default function Demo() {',
467
+ ' return (',
468
+ ' <DesignCanvas>',
469
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
470
+ ' <div>content</div>',
471
+ ' </DCArtboard>',
472
+ ' </DesignCanvas>',
473
+ ' );',
474
+ '}',
475
+ ].join('\n');
476
+
477
+ test('inserts print as a {{...}} object-expression prop', () => {
478
+ const out = applySetArtboardPrint(CANVAS, canvas, 'home', { paper: 'a4', bleedMm: 3 });
479
+ expect(out.source).toContain('print={{"paper":"a4","bleedMm":3}}');
480
+ expect(parses(out.source)).toBe(true);
481
+ });
482
+
483
+ test('a second write REPLACES the whole prop rather than merging', () => {
484
+ const first = applySetArtboardPrint(CANVAS, canvas, 'home', { paper: 'a4', bleedMm: 3 });
485
+ const out = applySetArtboardPrint(CANVAS, first.source, 'home', {
486
+ paper: 'letter',
487
+ orientation: 'landscape',
488
+ });
489
+ expect(out.source).toContain('print={{"paper":"letter","orientation":"landscape"}}');
490
+ expect(out.source).not.toContain('a4');
491
+ expect(out.source.match(/\bprint=/g)?.length).toBe(1);
492
+ expect(parses(out.source)).toBe(true);
493
+ });
494
+
495
+ test('print=null removes the prop', () => {
496
+ const written = applySetArtboardPrint(CANVAS, canvas, 'home', { paper: 'a4' });
497
+ const out = applySetArtboardPrint(CANVAS, written.source, 'home', null);
498
+ expect(out.source).not.toContain('print=');
499
+ expect(parses(out.source)).toBe(true);
500
+ });
501
+
502
+ test('unknown artboard id throws', () => {
503
+ expect(() => applySetArtboardPrint(CANVAS, canvas, 'nope', { paper: 'a4' })).toThrow(
504
+ CanvasEditError
505
+ );
506
+ });
507
+ });
508
+
509
+ // feature-2-print-artboards T5 — the AST-only (no-eval) reader the PDF
510
+ // exporter uses to resolve an artboard's print geometry.
511
+ describe('canvas-edit / readArtboardPrintProp (no-eval AST read)', () => {
512
+ test('reads a JSON-shaped print prop on a kind="print" artboard (the picker\'s own write shape)', () => {
513
+ const canvas = [
514
+ 'export default function Demo() {',
515
+ ' return (',
516
+ ' <DesignCanvas>',
517
+ ' <DCArtboard id="home" label="Home" kind="print" width={816} height={1146} print={{"paper":"a4","bleedMm":3}}>',
518
+ ' <div>content</div>',
519
+ ' </DCArtboard>',
520
+ ' </DesignCanvas>',
521
+ ' );',
522
+ '}',
523
+ ].join('\n');
524
+ expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toEqual({ paper: 'a4', bleedMm: 3 });
525
+ });
526
+
527
+ test('reads a hand-authored JS-literal print prop (unquoted keys, nested object)', () => {
528
+ const canvas = [
529
+ 'export default function Demo() {',
530
+ ' return (',
531
+ ' <DesignCanvas>',
532
+ ' <DCArtboard id="home" label="Home" kind="print" width={816} height={1146} print={{ paper: \'a4\', orientation: \'landscape\', marginsMm: { top: 10, left: 5 } }}>',
533
+ ' <div>content</div>',
534
+ ' </DCArtboard>',
535
+ ' </DesignCanvas>',
536
+ ' );',
537
+ '}',
538
+ ].join('\n');
539
+ expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toEqual({
540
+ paper: 'a4',
541
+ orientation: 'landscape',
542
+ marginsMm: { top: 10, left: 5 },
543
+ });
544
+ });
545
+
546
+ test('no print prop → null', () => {
547
+ const canvas = [
548
+ 'export default function Demo() {',
549
+ ' return (',
550
+ ' <DesignCanvas>',
551
+ ' <DCArtboard id="home" label="Home" kind="print" width={1440} height={1024}>',
552
+ ' <div>content</div>',
553
+ ' </DCArtboard>',
554
+ ' </DesignCanvas>',
555
+ ' );',
556
+ '}',
557
+ ].join('\n');
558
+ expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toBeNull();
559
+ });
560
+
561
+ test('unknown artboard id → null (not a throw — read-only, best-effort)', () => {
562
+ const canvas = [
563
+ 'export default function Demo() {',
564
+ ' return (',
565
+ ' <DesignCanvas>',
566
+ ' <DCArtboard id="home" label="Home" kind="print" width={1440} height={1024}>',
567
+ ' <div>content</div>',
568
+ ' </DCArtboard>',
569
+ ' </DesignCanvas>',
570
+ ' );',
571
+ '}',
572
+ ].join('\n');
573
+ expect(readArtboardPrintProp(CANVAS, canvas, 'nope')).toBeNull();
574
+ });
575
+
576
+ test('dogfood regression — a print prop present but kind NOT "print" → null (a stale prop from a prior kind switch must never leak into export)', () => {
577
+ const canvas = [
578
+ 'export default function Demo() {',
579
+ ' return (',
580
+ ' <DesignCanvas>',
581
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024} print={{"paper":"a4","bleedMm":3}}>',
582
+ ' <div>content</div>',
583
+ ' </DCArtboard>',
584
+ ' </DesignCanvas>',
585
+ ' );',
586
+ '}',
587
+ ].join('\n');
588
+ expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toBeNull();
589
+ });
340
590
  });
341
591
 
342
592
  describe('canvas-edit / applyInsertArtboard (I4)', () => {
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { describe, expect, test } from 'bun:test';
10
10
 
11
- import { clampScale, run } from '../../exporters/png.ts';
11
+ import { clampDpi, clampScale, resolveDeviceScale, run } from '../../exporters/png.ts';
12
12
 
13
13
  const CTX = {
14
14
  designRoot: '/tmp/.design',
@@ -53,3 +53,51 @@ describe('png clampScale — size presets (item 1)', () => {
53
53
  expect(clampScale(1.2)).toBe(1);
54
54
  });
55
55
  });
56
+
57
+ describe('png clampDpi — feature-2-print-artboards T4', () => {
58
+ test('undefined/null → undefined (falls back to scale)', () => {
59
+ expect(clampDpi(undefined)).toBeUndefined();
60
+ expect(clampDpi(null)).toBeUndefined();
61
+ });
62
+
63
+ test('non-numeric → undefined', () => {
64
+ expect(clampDpi('nonsense')).toBeUndefined();
65
+ });
66
+
67
+ test('exact presets pass through', () => {
68
+ expect(clampDpi(96)).toBe(96);
69
+ expect(clampDpi(150)).toBe(150);
70
+ expect(clampDpi(300)).toBe(300);
71
+ expect(clampDpi(600)).toBe(600);
72
+ });
73
+
74
+ test('snaps to the nearest preset', () => {
75
+ expect(clampDpi(200)).toBe(150);
76
+ expect(clampDpi(250)).toBe(300);
77
+ expect(clampDpi(1000)).toBe(600);
78
+ expect(clampDpi(0)).toBe(96);
79
+ });
80
+ });
81
+
82
+ describe('png resolveDeviceScale — dpi wins over scale', () => {
83
+ test('dpi=300 → 3.125× (300/96)', () => {
84
+ expect(resolveDeviceScale({ dpi: 300 })).toBeCloseTo(300 / 96, 10);
85
+ });
86
+
87
+ test('dpi=600 → 6.25× (300dpi ceiling raise, item T4)', () => {
88
+ expect(resolveDeviceScale({ dpi: 600 })).toBeCloseTo(600 / 96, 10);
89
+ });
90
+
91
+ test('dpi=96 → 1× (no-op)', () => {
92
+ expect(resolveDeviceScale({ dpi: 96 })).toBeCloseTo(1, 10);
93
+ });
94
+
95
+ test('no dpi → falls back to the legacy scale preset', () => {
96
+ expect(resolveDeviceScale({ scale: 3 })).toBe(3);
97
+ expect(resolveDeviceScale({})).toBe(2); // clampScale default
98
+ });
99
+
100
+ test('dpi present but not a number → falls back to scale', () => {
101
+ expect(resolveDeviceScale({ dpi: 'nope', scale: 1 })).toBe(1);
102
+ });
103
+ });
@@ -0,0 +1,272 @@
1
+ // feature-2-print-artboards T5 — the load-bearing golden gate: MediaBox ⊇
2
+ // BleedBox ⊇ TrimBox with exact expected pt values, plus marks presence.
3
+ // Exercises pdf-lib directly (no Playwright/Chromium needed — box-setting
4
+ // and mark-drawing are pure pdf-lib API calls); the page size mirrors what
5
+ // Chromium's page.pdf() would actually produce for an artboard authored via
6
+ // resolvePrintArtboard (96dpi CSS px → pt, same ratio as mm → pt).
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { PDFDocument } from 'pdf-lib';
10
+
11
+ import {
12
+ applyPageFit,
13
+ applyPrintBoxesAndMarks,
14
+ artboardIdFromCssPath,
15
+ type PdfPrintOptions,
16
+ parsePageFit,
17
+ parsePdfPrintOptions,
18
+ resolvePdfDeviceScale,
19
+ } from '../exporters/pdf.ts';
20
+ import { requiredSlugPt } from '../print/marks.ts';
21
+ import {
22
+ getPaperPreset,
23
+ mmToPt,
24
+ pxToPt,
25
+ resolvePrintArtboard,
26
+ trimSizeMm,
27
+ } from '../print/units.ts';
28
+
29
+ const NO_MARKS: PdfPrintOptions = {
30
+ marks: { crop: false, registration: false, colorBars: false, pageInfo: false },
31
+ };
32
+
33
+ describe('pdf.ts — artboardIdFromCssPath', () => {
34
+ test('extracts the id from the artboard-by-id selector shape', () => {
35
+ expect(artboardIdFromCssPath('[data-dc-screen="home"]')).toBe('home');
36
+ });
37
+
38
+ test('a descendant / :first-of-type selector yields null', () => {
39
+ expect(artboardIdFromCssPath('.some .descendant')).toBeNull();
40
+ expect(artboardIdFromCssPath('[data-dc-screen]:first-of-type')).toBeNull();
41
+ expect(artboardIdFromCssPath('[data-dc-screen]')).toBeNull();
42
+ });
43
+ });
44
+
45
+ describe('pdf.ts — parsePdfPrintOptions', () => {
46
+ test('absent/malformed → null (print post-pass off)', () => {
47
+ expect(parsePdfPrintOptions(undefined)).toBeNull();
48
+ expect(parsePdfPrintOptions(null)).toBeNull();
49
+ expect(parsePdfPrintOptions('nope')).toBeNull();
50
+ expect(parsePdfPrintOptions([1, 2])).toBeNull();
51
+ });
52
+
53
+ test('valid shape passes through with marks defaulting false', () => {
54
+ expect(parsePdfPrintOptions({})).toEqual({
55
+ includeBleed: undefined,
56
+ marks: { crop: false, registration: false, colorBars: false, pageInfo: false },
57
+ });
58
+ });
59
+
60
+ test('marks flags coerce to strict booleans (non-true → false)', () => {
61
+ expect(parsePdfPrintOptions({ marks: { crop: 'yes', registration: 1 } })).toEqual({
62
+ includeBleed: undefined,
63
+ marks: { crop: false, registration: false, colorBars: false, pageInfo: false },
64
+ });
65
+ expect(parsePdfPrintOptions({ marks: { crop: true, registration: true } })).toEqual({
66
+ includeBleed: undefined,
67
+ marks: { crop: true, registration: true, colorBars: false, pageInfo: false },
68
+ });
69
+ });
70
+
71
+ test('includeBleed passes through only when a strict boolean', () => {
72
+ expect(parsePdfPrintOptions({ includeBleed: false })?.includeBleed).toBe(false);
73
+ expect(parsePdfPrintOptions({ includeBleed: 'false' })?.includeBleed).toBeUndefined();
74
+ });
75
+ });
76
+
77
+ describe('pdf.ts — parsePageFit', () => {
78
+ test('a known paper id passes through', () => {
79
+ expect(parsePageFit('a4')).toBe('a4');
80
+ expect(parsePageFit('letter')).toBe('letter');
81
+ });
82
+
83
+ test('unknown id / non-string → null', () => {
84
+ expect(parsePageFit('not-a-paper')).toBeNull();
85
+ expect(parsePageFit(42)).toBeNull();
86
+ expect(parsePageFit(undefined)).toBeNull();
87
+ });
88
+ });
89
+
90
+ // Dogfood follow-up (2026-07-16) — a "vector PDF" still embeds raster
91
+ // content (dropped photos, large-format art) as a bitmap whose density is
92
+ // set by the capturing context's deviceScaleFactor; `dpi` controls that.
93
+ describe('pdf.ts — resolvePdfDeviceScale (raster-content density, dogfood follow-up)', () => {
94
+ test("no dpi → 1× (today's unchanged default — NOT png.ts's 2× default)", () => {
95
+ expect(resolvePdfDeviceScale({})).toBe(1);
96
+ });
97
+
98
+ test('dpi=300 → 3.125× (300/96)', () => {
99
+ expect(resolvePdfDeviceScale({ dpi: 300 })).toBeCloseTo(300 / 96, 10);
100
+ });
101
+
102
+ test('dpi=600 → 6.25×', () => {
103
+ expect(resolvePdfDeviceScale({ dpi: 600 })).toBeCloseTo(600 / 96, 10);
104
+ });
105
+
106
+ test("a bare `scale` option (PNG's legacy field) is ignored — PDF has no scale concept", () => {
107
+ expect(resolvePdfDeviceScale({ scale: 3 })).toBe(1);
108
+ });
109
+ });
110
+
111
+ describe('pdf-print-boxes — GOLDEN: A4 + 3mm bleed, no marks', () => {
112
+ test('MediaBox === BleedBox (full page); TrimBox insets by bleedPt exactly', async () => {
113
+ const resolved = resolvePrintArtboard({ paper: 'a4', bleedMm: 3 });
114
+ const pageWidthPt = pxToPt(resolved.widthPx);
115
+ const pageHeightPt = pxToPt(resolved.heightPx);
116
+
117
+ const doc = await PDFDocument.create();
118
+ const page = doc.addPage([pageWidthPt, pageHeightPt]);
119
+ applyPrintBoxesAndMarks(page, 3, NO_MARKS);
120
+
121
+ const bleedPt = mmToPt(3);
122
+ const media = page.getMediaBox();
123
+ const bleed = page.getBleedBox();
124
+ const trim = page.getTrimBox();
125
+
126
+ // No marks requested → no slug → MediaBox is exactly the bleed box.
127
+ expect(media.x).toBeCloseTo(0, 6);
128
+ expect(media.y).toBeCloseTo(0, 6);
129
+ expect(media.width).toBeCloseTo(pageWidthPt, 6);
130
+ expect(media.height).toBeCloseTo(pageHeightPt, 6);
131
+
132
+ expect(bleed.x).toBeCloseTo(0, 6);
133
+ expect(bleed.y).toBeCloseTo(0, 6);
134
+ expect(bleed.width).toBeCloseTo(pageWidthPt, 6);
135
+ expect(bleed.height).toBeCloseTo(pageHeightPt, 6);
136
+
137
+ expect(trim.x).toBeCloseTo(bleedPt, 6);
138
+ expect(trim.y).toBeCloseTo(bleedPt, 6);
139
+ expect(trim.width).toBeCloseTo(pageWidthPt - 2 * bleedPt, 6);
140
+ expect(trim.height).toBeCloseTo(pageHeightPt - 2 * bleedPt, 6);
141
+
142
+ // The load-bearing nesting invariant, independent of the exact numbers above.
143
+ expect(media.x).toBeLessThanOrEqual(bleed.x);
144
+ expect(media.y).toBeLessThanOrEqual(bleed.y);
145
+ expect(media.x + media.width).toBeGreaterThanOrEqual(bleed.x + bleed.width);
146
+ expect(bleed.x).toBeLessThanOrEqual(trim.x);
147
+ expect(bleed.y).toBeLessThanOrEqual(trim.y);
148
+ expect(bleed.x + bleed.width).toBeGreaterThanOrEqual(trim.x + trim.width);
149
+ });
150
+ });
151
+
152
+ describe('pdf-print-boxes — GOLDEN: A4 + 3mm bleed + crop + registration marks', () => {
153
+ test('MediaBox enlarges by the exact required slug, negative origin, content-box unmoved', async () => {
154
+ const resolved = resolvePrintArtboard({ paper: 'a4', bleedMm: 3 });
155
+ const pageWidthPt = pxToPt(resolved.widthPx);
156
+ const pageHeightPt = pxToPt(resolved.heightPx);
157
+
158
+ const doc = await PDFDocument.create();
159
+ const page = doc.addPage([pageWidthPt, pageHeightPt]);
160
+ const printOpts: PdfPrintOptions = {
161
+ marks: { crop: true, registration: true, colorBars: false, pageInfo: false },
162
+ };
163
+ applyPrintBoxesAndMarks(page, 3, printOpts);
164
+
165
+ const bleedPt = mmToPt(3);
166
+ const slugPt = requiredSlugPt({ crop: true, registration: true });
167
+ const media = page.getMediaBox();
168
+ const bleed = page.getBleedBox();
169
+ const trim = page.getTrimBox();
170
+
171
+ // Exact expected pt values — the golden assertion.
172
+ expect(media.x).toBeCloseTo(-slugPt, 6);
173
+ expect(media.y).toBeCloseTo(-slugPt, 6);
174
+ expect(media.width).toBeCloseTo(pageWidthPt + 2 * slugPt, 6);
175
+ expect(media.height).toBeCloseTo(pageHeightPt + 2 * slugPt, 6);
176
+ // MediaBox's negative origin is the marks slug ONLY — bleed box content
177
+ // coordinates are untouched (still start at the page's own (0,0)).
178
+ expect(bleed.x).toBeCloseTo(0, 6);
179
+ expect(bleed.y).toBeCloseTo(0, 6);
180
+ expect(bleed.width).toBeCloseTo(pageWidthPt, 6);
181
+ expect(bleed.height).toBeCloseTo(pageHeightPt, 6);
182
+ expect(trim.x).toBeCloseTo(bleedPt, 6);
183
+ expect(trim.y).toBeCloseTo(bleedPt, 6);
184
+ expect(trim.width).toBeCloseTo(pageWidthPt - 2 * bleedPt, 6);
185
+ expect(trim.height).toBeCloseTo(pageHeightPt - 2 * bleedPt, 6);
186
+
187
+ // MediaBox ⊇ BleedBox ⊇ TrimBox, strictly (marks slug > 0 here).
188
+ expect(media.x).toBeLessThan(bleed.x);
189
+ expect(media.width + media.x).toBeGreaterThan(bleed.width + bleed.x);
190
+ expect(bleed.x).toBeLessThan(trim.x);
191
+ expect(bleed.width + bleed.x).toBeGreaterThan(trim.width + trim.x);
192
+ });
193
+
194
+ test('marks present as vector ops — saved PDF is measurably larger with marks than without', async () => {
195
+ const resolved = resolvePrintArtboard({ paper: 'a4', bleedMm: 3 });
196
+ const pageWidthPt = pxToPt(resolved.widthPx);
197
+ const pageHeightPt = pxToPt(resolved.heightPx);
198
+
199
+ const withoutMarks = await PDFDocument.create();
200
+ applyPrintBoxesAndMarks(withoutMarks.addPage([pageWidthPt, pageHeightPt]), 3, NO_MARKS);
201
+ const withoutBytes = await withoutMarks.save();
202
+
203
+ const withMarks = await PDFDocument.create();
204
+ applyPrintBoxesAndMarks(withMarks.addPage([pageWidthPt, pageHeightPt]), 3, {
205
+ marks: { crop: true, registration: true, colorBars: false, pageInfo: false },
206
+ });
207
+ const withBytes = await withMarks.save();
208
+
209
+ // 8 crop-mark line segments + 4 registration circles (each an approximated
210
+ // bezier ellipse) + 8 crosshair segments is a non-trivial amount of vector
211
+ // content — the byte-size delta is a reliable proxy without depending on
212
+ // pdf-lib's internal (and version-specific) content-stream compression
213
+ // format for a literal operator-string grep.
214
+ expect(withBytes.byteLength).toBeGreaterThan(withoutBytes.byteLength);
215
+ });
216
+ });
217
+
218
+ describe('pdf-print-boxes — includeBleed:false crops the bleed strip out', () => {
219
+ test('MediaBox/BleedBox collapse onto TrimBox (degenerate but valid nesting)', async () => {
220
+ const resolved = resolvePrintArtboard({ paper: 'a4', bleedMm: 3 });
221
+ const pageWidthPt = pxToPt(resolved.widthPx);
222
+ const pageHeightPt = pxToPt(resolved.heightPx);
223
+ const bleedPt = mmToPt(3);
224
+
225
+ const doc = await PDFDocument.create();
226
+ const page = doc.addPage([pageWidthPt, pageHeightPt]);
227
+ applyPrintBoxesAndMarks(page, 3, { ...NO_MARKS, includeBleed: false });
228
+
229
+ const media = page.getMediaBox();
230
+ const bleed = page.getBleedBox();
231
+ const trim = page.getTrimBox();
232
+
233
+ expect(media.x).toBeCloseTo(bleedPt, 6);
234
+ expect(media.y).toBeCloseTo(bleedPt, 6);
235
+ expect(media.width).toBeCloseTo(pageWidthPt - 2 * bleedPt, 6);
236
+ expect(bleed.x).toBeCloseTo(trim.x, 6);
237
+ expect(bleed.width).toBeCloseTo(trim.width, 6);
238
+ });
239
+ });
240
+
241
+ describe('pdf-print-boxes — applyPageFit (non-print artboard scale-to-paper)', () => {
242
+ test('the resulting page is sized to the target paper, content preserved', async () => {
243
+ const doc = await PDFDocument.create();
244
+ // A wide, non-print-sized artboard (e.g. a 1440×1024 digital screen at 96dpi).
245
+ const origWidthPt = pxToPt(1440);
246
+ const origHeightPt = pxToPt(1024);
247
+ doc.addPage([origWidthPt, origHeightPt]);
248
+ expect(doc.getPageCount()).toBe(1);
249
+
250
+ await applyPageFit(doc, 0, 'a4');
251
+
252
+ expect(doc.getPageCount()).toBe(1); // swapped in place, not appended
253
+ const page = doc.getPage(0);
254
+ // Expected size mirrors applyPageFit's OWN derivation (mmToPt of the
255
+ // preset's raw mm dims via trimSizeMm) — NOT a px-rounded roundtrip via
256
+ // resolvePrintArtboard, which would introduce a spurious sub-pt mismatch.
257
+ const preset = getPaperPreset('a4');
258
+ if (!preset) throw new Error('a4 preset missing');
259
+ const { widthMm, heightMm } = trimSizeMm(preset, 'portrait');
260
+ const { width, height } = page.getSize();
261
+ expect(width).toBeCloseTo(mmToPt(widthMm), 6);
262
+ expect(height).toBeCloseTo(mmToPt(heightMm), 6);
263
+ });
264
+
265
+ test('unknown paper id is a no-op (never throws, never mutates)', async () => {
266
+ const doc = await PDFDocument.create();
267
+ doc.addPage([100, 100]);
268
+ await applyPageFit(doc, 0, 'not-a-paper');
269
+ expect(doc.getPageCount()).toBe(1);
270
+ expect(doc.getPage(0).getSize()).toEqual({ width: 100, height: 100 });
271
+ });
272
+ });