@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
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bun
2
+ // Mock ACP agent proving the MAX_PENDING_PERMISSIONS cap (SECURITY /
3
+ // ethical-hacker finding, retroactive review of Milestone B — a single agent
4
+ // turn can legitimately issue several tool calls back to back, so "one per
5
+ // tool call" is not a natural rate limit). Fires MORE than the cap
6
+ // concurrently, without waiting for each one, and reports which ones actually
7
+ // round-tripped to the client vs. were denied immediately by the bridge's
8
+ // own cap.
9
+
10
+ import { Readable, Writable } from 'node:stream';
11
+
12
+ import * as acp from '@agentclientprotocol/sdk';
13
+
14
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
15
+
16
+ const FLOOD_COUNT = 13; // > MAX_PENDING_PERMISSIONS (10) in acp/bridge.ts
17
+
18
+ let n = 0;
19
+
20
+ acp
21
+ .agent({ name: 'mock-acp-agent-permission-flood' })
22
+ .onRequest('initialize', () => ({
23
+ protocolVersion: acp.PROTOCOL_VERSION,
24
+ agentCapabilities: { loadSession: false },
25
+ }))
26
+ .onRequest('session/new', () => ({ sessionId: `mock-perm-flood-session-${++n}` }))
27
+ .onRequest('session/prompt', async (ctx) => {
28
+ const requests = Array.from({ length: FLOOD_COUNT }, (_, i) =>
29
+ ctx.client.request(acp.methods.client.session.requestPermission, {
30
+ sessionId: ctx.params.sessionId,
31
+ toolCall: { toolCallId: `tc${i}`, title: `Flood tool call ${i}`, kind: 'edit' },
32
+ options: [
33
+ { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
34
+ { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
35
+ ],
36
+ })
37
+ );
38
+ const outcomes = await Promise.all(requests);
39
+ await ctx.client.notify('session/update', {
40
+ sessionId: ctx.params.sessionId,
41
+ update: {
42
+ sessionUpdate: 'agent_message_chunk',
43
+ content: {
44
+ type: 'text',
45
+ text: `outcomes=${JSON.stringify(outcomes.map((o) => o.outcome))}`,
46
+ },
47
+ },
48
+ });
49
+ return { stopReason: 'end_turn' };
50
+ })
51
+ .connect(stream);
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bun
2
+ // Mock ACP agent exercising the permission approve/deny gate (Milestone B,
3
+ // retires DDR-125 F2) — session/prompt calls back INTO the client via
4
+ // `ctx.client.requestPermission(...)` (a real request, not a notification)
5
+ // before streaming its final reply, mirroring how claude-agent-acp asks for
6
+ // tool authorization mid-turn. No real `claude` needed.
7
+
8
+ import { Readable, Writable } from 'node:stream';
9
+
10
+ import * as acp from '@agentclientprotocol/sdk';
11
+
12
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
13
+
14
+ let n = 0;
15
+
16
+ acp
17
+ .agent({ name: 'mock-acp-agent-permission' })
18
+ .onRequest('initialize', () => ({
19
+ protocolVersion: acp.PROTOCOL_VERSION,
20
+ agentCapabilities: { loadSession: false },
21
+ }))
22
+ .onRequest('session/new', () => ({ sessionId: `mock-perm-session-${++n}` }))
23
+ .onRequest('session/prompt', async (ctx) => {
24
+ // `ctx.client` (AgentContext) only exposes the generic request()/notify() —
25
+ // no typed `.requestPermission()` convenience method — so call the raw ACP
26
+ // method via `acp.methods.client.session.requestPermission`.
27
+ const outcome = await ctx.client.request(acp.methods.client.session.requestPermission, {
28
+ sessionId: ctx.params.sessionId,
29
+ toolCall: {
30
+ toolCallId: 'tc1',
31
+ title: 'Write file',
32
+ kind: 'edit',
33
+ rawInput: { path: '/tmp/x' },
34
+ },
35
+ options: [
36
+ { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
37
+ { optionId: 'allow-always', name: 'Allow always', kind: 'allow_always' },
38
+ { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
39
+ ],
40
+ });
41
+ await ctx.client.notify('session/update', {
42
+ sessionId: ctx.params.sessionId,
43
+ update: {
44
+ sessionUpdate: 'agent_message_chunk',
45
+ content: { type: 'text', text: `outcome=${JSON.stringify(outcome.outcome)}` },
46
+ },
47
+ });
48
+ return { stopReason: 'end_turn' };
49
+ })
50
+ .connect(stream);
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bun
2
+ // Mock ACP agent emitting `usage_update` notifications (Milestone D) — one
3
+ // after the prompt result (context/cost), and one carrying the thin
4
+ // `_meta["_claude/rateLimit"]` rate-limit signal (mirrors the real adapter's
5
+ // `rate_limit_event` forwarding, acp-agent.js:1768-1776). No real `claude` needed.
6
+
7
+ import { Readable, Writable } from 'node:stream';
8
+
9
+ import * as acp from '@agentclientprotocol/sdk';
10
+
11
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
12
+
13
+ let n = 0;
14
+
15
+ acp
16
+ .agent({ name: 'mock-acp-agent-usage' })
17
+ .onRequest('initialize', () => ({
18
+ protocolVersion: acp.PROTOCOL_VERSION,
19
+ agentCapabilities: { loadSession: false },
20
+ }))
21
+ .onRequest('session/new', () => ({ sessionId: `mock-usage-session-${++n}` }))
22
+ .onRequest('session/prompt', async (ctx) => {
23
+ await ctx.client.notify('session/update', {
24
+ sessionId: ctx.params.sessionId,
25
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' } },
26
+ });
27
+ await ctx.client.notify('session/update', {
28
+ sessionId: ctx.params.sessionId,
29
+ update: {
30
+ sessionUpdate: 'usage_update',
31
+ used: 4200,
32
+ size: 200000,
33
+ cost: { amount: 0.0123, currency: 'USD' },
34
+ },
35
+ });
36
+ if (ctx.params.prompt?.[0]?.text === 'trigger-rate-limit') {
37
+ await ctx.client.notify('session/update', {
38
+ sessionId: ctx.params.sessionId,
39
+ update: {
40
+ sessionUpdate: 'usage_update',
41
+ used: 4200,
42
+ size: 200000,
43
+ _meta: {
44
+ '_claude/rateLimit': {
45
+ status: 'allowed_warning',
46
+ rateLimitType: 'five_hour',
47
+ utilization: 82,
48
+ resetsAt: 1234567890,
49
+ },
50
+ },
51
+ },
52
+ });
53
+ }
54
+ return { stopReason: 'end_turn' };
55
+ })
56
+ .connect(stream);
@@ -42,16 +42,44 @@ describe('svgPreParseReject', () => {
42
42
  expect(() => svgPreParseReject(huge)).toThrow(/cap/);
43
43
  });
44
44
 
45
- test('rejects DOCTYPE (XXE class)', () => {
45
+ test('rejects a DOCTYPE with an internal subset carrying an ENTITY (XXE class)', () => {
46
46
  const xxe =
47
47
  '<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>';
48
- expect(() => svgPreParseReject(xxe)).toThrow(/DOCTYPE/);
48
+ expect(() => svgPreParseReject(xxe)).toThrow(/entity-expansion/i);
49
49
  });
50
50
 
51
51
  test('rejects a bare ENTITY declaration even without DOCTYPE wrapping', () => {
52
52
  expect(() =>
53
53
  svgPreParseReject('<!ENTITY x "y"><svg xmlns="http://www.w3.org/2000/svg"/>')
54
- ).toThrow(/DOCTYPE\/ENTITY/);
54
+ ).toThrow(/ENTITY/);
55
+ });
56
+
57
+ test('rejects an internal DTD subset even with no ENTITY declared', () => {
58
+ expect(() =>
59
+ svgPreParseReject('<!DOCTYPE svg [ <!-- x --> ]><svg xmlns="http://www.w3.org/2000/svg"/>')
60
+ ).toThrow(/internal subset/i);
61
+ });
62
+
63
+ // ethical-hacker review: a `>` inside a quoted external-id literal must NOT let
64
+ // an internal subset slip past the detector (a naive non-greedy regex truncated
65
+ // there and missed the `[`).
66
+ test('rejects an internal subset hidden behind a `>` in a quoted SYSTEM literal', () => {
67
+ expect(() =>
68
+ svgPreParseReject(
69
+ '<!DOCTYPE svg SYSTEM "http://attacker/x>y" [ <!ELEMENT foo ANY> ]><svg xmlns="http://www.w3.org/2000/svg"/>'
70
+ )
71
+ ).toThrow(/internal subset/i);
72
+ });
73
+
74
+ // RCA G8 — a plain external-identifier DOCTYPE (no internal subset, no ENTITY)
75
+ // is what Illustrator/Serif/Inkscape emit; rejecting the whole class turned away
76
+ // real clean logos. It must be accepted (the sanitizer drops it from the output).
77
+ test('accepts a plain external-identifier DOCTYPE (Illustrator/Serif export)', () => {
78
+ const illustrator =
79
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
80
+ '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n' +
81
+ '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="#3B5BDB"/></svg>';
82
+ expect(() => svgPreParseReject(illustrator)).not.toThrow();
55
83
  });
56
84
 
57
85
  test('rejects a non-declaration processing instruction (xml-stylesheet)', () => {
@@ -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
+ });
@@ -0,0 +1,113 @@
1
+ // feature-2-print-artboards T5 — print/marks.ts pure geometry unit tests.
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { computeMarksGeometry, MARK_STROKE_PT, requiredSlugPt } from '../print/marks.ts';
6
+ import { mmToPt } from '../print/units.ts';
7
+
8
+ const PAGE_W = 600; // pt, arbitrary
9
+ const PAGE_H = 800;
10
+ const BLEED = mmToPt(3);
11
+
12
+ describe('print/marks — requiredSlugPt', () => {
13
+ test('neither crop nor registration → 0', () => {
14
+ expect(requiredSlugPt({})).toBe(0);
15
+ });
16
+
17
+ test('crop only → the mark length', () => {
18
+ expect(requiredSlugPt({ crop: true })).toBeCloseTo(mmToPt(3.5), 6);
19
+ });
20
+
21
+ test('registration reaches further than crop alone', () => {
22
+ const cropOnly = requiredSlugPt({ crop: true });
23
+ const both = requiredSlugPt({ crop: true, registration: true });
24
+ expect(both).toBeGreaterThan(cropOnly);
25
+ });
26
+ });
27
+
28
+ describe('print/marks — computeMarksGeometry', () => {
29
+ test('crop:false, registration:false → both arrays empty', () => {
30
+ const g = computeMarksGeometry({ pageWidthPt: PAGE_W, pageHeightPt: PAGE_H, bleedPt: BLEED });
31
+ expect(g.cropMarks).toEqual([]);
32
+ expect(g.registrationMarks).toEqual([]);
33
+ });
34
+
35
+ test('crop:true → exactly 8 segments (2 per corner × 4 corners)', () => {
36
+ const g = computeMarksGeometry({
37
+ pageWidthPt: PAGE_W,
38
+ pageHeightPt: PAGE_H,
39
+ bleedPt: BLEED,
40
+ crop: true,
41
+ });
42
+ expect(g.cropMarks.length).toBe(8);
43
+ });
44
+
45
+ test('every crop-mark segment lies OUTSIDE the bleed box (page) bounds', () => {
46
+ const g = computeMarksGeometry({
47
+ pageWidthPt: PAGE_W,
48
+ pageHeightPt: PAGE_H,
49
+ bleedPt: BLEED,
50
+ crop: true,
51
+ });
52
+ for (const seg of g.cropMarks) {
53
+ const outside = (x: number, y: number) => x < 0 || x > PAGE_W || y < 0 || y > PAGE_H;
54
+ // At least one endpoint of every mark segment must be outside the page —
55
+ // the whole point of a crop mark is to be visible in the bleed/slug area.
56
+ expect(outside(seg.x1, seg.y1) || outside(seg.x2, seg.y2)).toBe(true);
57
+ }
58
+ });
59
+
60
+ test('crop-mark segments align to the trim line (bleed inset)', () => {
61
+ const g = computeMarksGeometry({
62
+ pageWidthPt: PAGE_W,
63
+ pageHeightPt: PAGE_H,
64
+ bleedPt: BLEED,
65
+ crop: true,
66
+ });
67
+ const trimX0 = BLEED;
68
+ const trimX1 = PAGE_W - BLEED;
69
+ const trimY0 = BLEED;
70
+ const trimY1 = PAGE_H - BLEED;
71
+ const xs = g.cropMarks.filter((s) => s.x1 === s.x2).map((s) => s.x1);
72
+ const ys = g.cropMarks.filter((s) => s.y1 === s.y2).map((s) => s.y1);
73
+ expect(new Set(xs)).toEqual(new Set([trimX0, trimX1]));
74
+ expect(new Set(ys)).toEqual(new Set([trimY0, trimY1]));
75
+ });
76
+
77
+ test('registration:true → 4 marks, each a circle + 2-segment crosshair', () => {
78
+ const g = computeMarksGeometry({
79
+ pageWidthPt: PAGE_W,
80
+ pageHeightPt: PAGE_H,
81
+ bleedPt: BLEED,
82
+ registration: true,
83
+ });
84
+ expect(g.registrationMarks.length).toBe(4);
85
+ for (const m of g.registrationMarks) {
86
+ expect(m.crosshair.length).toBe(2);
87
+ expect(m.circle.r).toBeGreaterThan(0);
88
+ }
89
+ });
90
+
91
+ test('registration marks sit beyond the crop marks (no visual collision)', () => {
92
+ const g = computeMarksGeometry({
93
+ pageWidthPt: PAGE_W,
94
+ pageHeightPt: PAGE_H,
95
+ bleedPt: BLEED,
96
+ crop: true,
97
+ registration: true,
98
+ });
99
+ const topCenterReg = g.registrationMarks.find((m) => m.circle.cy > PAGE_H);
100
+ expect(topCenterReg).toBeDefined();
101
+ const topCropTip = Math.max(
102
+ ...g.cropMarks.filter((s) => s.y1 > PAGE_H || s.y2 > PAGE_H).map((s) => Math.max(s.y1, s.y2))
103
+ );
104
+ expect((topCenterReg?.circle.cy ?? 0) - (topCenterReg?.circle.r ?? 0)).toBeGreaterThan(
105
+ topCropTip
106
+ );
107
+ });
108
+ });
109
+
110
+ test('MARK_STROKE_PT is a small positive value (0.25mm)', () => {
111
+ expect(MARK_STROKE_PT).toBeCloseTo(mmToPt(0.25), 6);
112
+ expect(MARK_STROKE_PT).toBeGreaterThan(0);
113
+ });