@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
@@ -393,6 +393,26 @@ function styleMapsFor(el: Element | null): {
393
393
  if (ATTR_SKIP.test(a.name)) continue;
394
394
  attrs[a.name] = a.value;
395
395
  }
396
+ // Dogfood follow-up (print artboards) — ATTR_SKIP deliberately hides the
397
+ // engine's own `data-dc-*` plumbing from the generic "custom HTML
398
+ // attributes" panel, but for a whole-ARTBOARD selection those attributes
399
+ // ARE the payload: DCArtboard's readBackAttrs (canvas-lib.tsx) stamps
400
+ // data-dc-kind / data-dc-print / data-dc-fixed / data-dc-bg / … on the
401
+ // <article> precisely so the Inspector's ArtboardKnobs can pre-fill its
402
+ // Kind picker, paper preset, Hug/Fixed toggle, and Style fields. With
403
+ // the blanket skip, every one of those readbacks silently fell back to
404
+ // its default ('digital', screen presets, Hug, empty Bg) no matter what
405
+ // the artboard actually was — the root cause of the "Kind shows Digital
406
+ // on a print artboard" family of bugs. Allow data-dc-* through for the
407
+ // artboard element itself; ordinary elements keep the filter (their
408
+ // panel is CssKnobs, which never reads these).
409
+ if ((el as HTMLElement).hasAttribute('data-dc-screen')) {
410
+ for (const a of Array.from((el as HTMLElement).attributes)) {
411
+ if (a.name.startsWith('data-dc-') && a.name !== 'data-dcid') {
412
+ attrs[a.name] = a.value;
413
+ }
414
+ }
415
+ }
396
416
  // Stage M — parent's layout context (for the Fixed/Hug/Fill sizing control +
397
417
  // flex-child row gating). Read here because the shell can't reach the
398
418
  // cross-origin iframe to compute it after selection.
@@ -101,14 +101,38 @@ export type Scope = 'selection' | 'artboard' | 'canvas-as-separate' | 'project-r
101
101
  // ─── PNG size presets (item 1) ───────────────────────────────────────────────
102
102
  // Resolution multiplier applied as Chromium `deviceScaleFactor`. The native
103
103
  // artboard is 1440×900; 2× → 2880×1800. Default 2× because a single-scale PNG
104
- // was uselessly small. The shim clamps deviceScaleFactor ≤ 4.
105
- type PngScale = 1 | 2 | 3;
106
- const PNG_SCALES: ReadonlyArray<{ value: PngScale; label: string }> = [
107
- { value: 1, label: '1× (native)' },
108
- { value: 2, label: '2× (retina)' },
109
- { value: 3, label: '3× (max)' },
104
+ // was uselessly small. The shim clamps deviceScaleFactor ≤ 8.
105
+ // feature-2-print-artboards T4/T6 — mirrors app.jsx's PNG_RESOLUTIONS (the
106
+ // two dialogs must stay in sync — see this file's own header comment + the
107
+ // plan's T6 gotcha). `kind:'scale'` sends `options.scale`, `kind:'dpi'`
108
+ // sends `options.dpi` (exporters/png.ts resolveDeviceScale). Replaces the
109
+ // old bare 1×/2×/3× PngScale picker — those three values are still
110
+ // reachable, folded into this one richer list.
111
+ type PngResolution = { id: string; label: string; kind: 'scale' | 'dpi'; value: number };
112
+ const PNG_RESOLUTIONS: readonly PngResolution[] = [
113
+ { id: '1x', label: '1× (native)', kind: 'scale', value: 1 },
114
+ { id: '2x', label: '2× (retina)', kind: 'scale', value: 2 },
115
+ { id: '3x', label: '3× (max)', kind: 'scale', value: 3 },
116
+ { id: 'dpi150', label: '150 dpi', kind: 'dpi', value: 150 },
117
+ { id: 'dpi300', label: '300 dpi (print)', kind: 'dpi', value: 300 },
118
+ { id: 'dpi600', label: '600 dpi (high-res print)', kind: 'dpi', value: 600 },
110
119
  ];
111
- const DEFAULT_PNG_SCALE: PngScale = 2;
120
+ const PNG_RESOLUTION_DEFAULT = '2x';
121
+
122
+ // feature-2-print-artboards T5/T6 (dogfood follow-up) — mirrors app.jsx's
123
+ // PDF_DPI_OPTIONS. The PDF page itself is always vector; this only sets the
124
+ // capture density for RASTER content ON the artboard (a dropped photo, a
125
+ // large-format piece authored at a fraction of its real physical size).
126
+ // Default "Auto (1×)" is today's unchanged behavior — unlike PNG_RESOLUTIONS,
127
+ // PDF has no legacy scale concept, so this is DPI-only.
128
+ type PdfDpiOption = { id: string; label: string; value: number | undefined };
129
+ const PDF_DPI_OPTIONS: readonly PdfDpiOption[] = [
130
+ { id: 'auto', label: 'Auto (1×)', value: undefined },
131
+ { id: 'dpi150', label: '150 dpi', value: 150 },
132
+ { id: 'dpi300', label: '300 dpi (print)', value: 300 },
133
+ { id: 'dpi600', label: '600 dpi (high-res print)', value: 600 },
134
+ ];
135
+ const PDF_DPI_DEFAULT = 'auto';
112
136
 
113
137
  /**
114
138
  * The artboard under the viewport centre — the export dialog's notion of "the
@@ -456,20 +480,51 @@ const DialogShell = (() => {
456
480
  const { ref, openState, onClose, onSubmit, history, submitting, status } = props;
457
481
  const [format, setFormat] = useState<Format>('png');
458
482
  const [scope, setScope] = useState<Scope>('artboard');
459
- const [pngScale, setPngScale] = useState<PngScale>(DEFAULT_PNG_SCALE);
483
+ const [pngResId, setPngResId] = useState<string>(PNG_RESOLUTION_DEFAULT);
484
+ // feature-2-print-artboards T5/T6 — PDF print options. Sent unconditionally
485
+ // on every PDF export; the server no-ops them for a non-print artboard
486
+ // (mirrors app.jsx's own ExportDialog — see that file's T6 comment).
487
+ const [pdfIncludeBleed, setPdfIncludeBleed] = useState(true);
488
+ const [pdfMarksOpen, setPdfMarksOpen] = useState(false);
489
+ const [pdfMarksCrop, setPdfMarksCrop] = useState(false);
490
+ const [pdfMarksRegistration, setPdfMarksRegistration] = useState(false);
491
+ const [pdfDpiId, setPdfDpiId] = useState<string>(PDF_DPI_DEFAULT);
460
492
  // Optional — the dialog can be mounted in tests without a provider.
461
493
  const selSet = useSelectionSetOptional();
462
494
 
463
495
  // Build the options bag at submit time: snapshot the live selection /
464
- // active artboard (items 3 & 5) plus the PNG size (item 1).
496
+ // active artboard (items 3 & 5) plus the PNG size (item 1) / PDF print
497
+ // options (feature-2-print-artboards T5/T6).
465
498
  const handleSubmit = useCallback(() => {
466
499
  const options: Record<string, unknown> = {};
467
500
  const hints = captureScopeHints(selSet);
468
501
  if (hints.selection) options.selection = hints.selection;
469
502
  if (hints.artboardId) options.artboardId = hints.artboardId;
470
- if (format === 'png') options.scale = pngScale;
503
+ if (format === 'png') {
504
+ const res = PNG_RESOLUTIONS.find((r) => r.id === pngResId) ?? PNG_RESOLUTIONS[1];
505
+ if (res.kind === 'dpi') options.dpi = res.value;
506
+ else options.scale = res.value;
507
+ }
508
+ if (format === 'pdf') {
509
+ options.pdfPrint = {
510
+ includeBleed: pdfIncludeBleed,
511
+ marks: { crop: pdfMarksCrop, registration: pdfMarksRegistration },
512
+ };
513
+ const pdfDpi = PDF_DPI_OPTIONS.find((d) => d.id === pdfDpiId)?.value;
514
+ if (pdfDpi !== undefined) options.dpi = pdfDpi;
515
+ }
471
516
  onSubmit(format, scope, options);
472
- }, [selSet, format, scope, pngScale, onSubmit]);
517
+ }, [
518
+ selSet,
519
+ format,
520
+ scope,
521
+ pngResId,
522
+ pdfIncludeBleed,
523
+ pdfMarksCrop,
524
+ pdfMarksRegistration,
525
+ pdfDpiId,
526
+ onSubmit,
527
+ ]);
473
528
 
474
529
  useEffect(() => {
475
530
  if (!openState) return;
@@ -530,20 +585,85 @@ const DialogShell = (() => {
530
585
  </div>
531
586
  {format === 'png' && (
532
587
  <div>
533
- <label htmlFor="dc-ed-png-scale">Size</label>
588
+ <label htmlFor="dc-ed-png-scale">Resolution</label>
534
589
  <select
535
590
  id="dc-ed-png-scale"
536
- value={pngScale}
537
- onChange={(e) => setPngScale(Number(e.target.value) as PngScale)}
591
+ value={pngResId}
592
+ onChange={(e) => setPngResId(e.target.value)}
593
+ >
594
+ {PNG_RESOLUTIONS.map((r) => (
595
+ <option key={r.id} value={r.id}>
596
+ {r.label}
597
+ </option>
598
+ ))}
599
+ </select>
600
+ <p className="dc-ed-desc">
601
+ Resolution multiplier or physical DPI for a print export. 2× ≈ 2880×1800 for a
602
+ 1440×900 artboard.
603
+ </p>
604
+ </div>
605
+ )}
606
+ {format === 'pdf' && (
607
+ <div>
608
+ <label htmlFor="dc-ed-pdf-dpi">Image quality</label>
609
+ <select
610
+ id="dc-ed-pdf-dpi"
611
+ value={pdfDpiId}
612
+ onChange={(e) => setPdfDpiId(e.target.value)}
538
613
  >
539
- {PNG_SCALES.map((s) => (
540
- <option key={s.value} value={s.value}>
541
- {s.label}
614
+ {PDF_DPI_OPTIONS.map((d) => (
615
+ <option key={d.id} value={d.id}>
616
+ {d.label}
542
617
  </option>
543
618
  ))}
544
619
  </select>
545
620
  <p className="dc-ed-desc">
546
- Resolution multiplier. 2× ≈ 2880×1800 for a 1440×900 artboard.
621
+ The page stays vector; this only sets the capture density for raster content on it
622
+ (dropped photos, large-format art).
623
+ </p>
624
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
625
+ <input
626
+ type="checkbox"
627
+ checked={pdfIncludeBleed}
628
+ onChange={(e) => setPdfIncludeBleed(e.target.checked)}
629
+ />
630
+ Include bleed
631
+ </label>
632
+ <button
633
+ type="button"
634
+ style={{ background: 'none', border: 0, padding: 0, cursor: 'pointer' }}
635
+ onClick={() => setPdfMarksOpen((v) => !v)}
636
+ aria-expanded={pdfMarksOpen}
637
+ >
638
+ {pdfMarksOpen ? '▾' : '▸'} Marks
639
+ </button>
640
+ {pdfMarksOpen && (
641
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
642
+ <label
643
+ style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
644
+ >
645
+ <input
646
+ type="checkbox"
647
+ checked={pdfMarksCrop}
648
+ onChange={(e) => setPdfMarksCrop(e.target.checked)}
649
+ />
650
+ Crop marks
651
+ </label>
652
+ <label
653
+ style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
654
+ >
655
+ <input
656
+ type="checkbox"
657
+ checked={pdfMarksRegistration}
658
+ onChange={(e) => setPdfMarksRegistration(e.target.checked)}
659
+ />
660
+ Registration marks
661
+ </label>
662
+ </div>
663
+ )}
664
+ <p className="dc-ed-desc">
665
+ Bleed and marks apply to print (<code>kind=&quot;print&quot;</code>) artboards only.
666
+ {scope === 'canvas-as-separate' ? ' One PDF page per artboard.' : ''}
547
667
  </p>
548
668
  </div>
549
669
  )}
@@ -7,12 +7,40 @@
7
7
  //
8
8
  // Multi-target rendering: one PDF per target via the playwright shim, then
9
9
  // pdf-lib concatenates pages into a single document.
10
+ //
11
+ // feature-2-print-artboards T5 — print-ready post-pass. When the caller asks
12
+ // for it (`options.pdfPrint` and/or `options.pageFit`), every page gets
13
+ // loaded into pdf-lib (single-page path now goes through pdf-lib too, not
14
+ // just multi) and, per page:
15
+ // - a `kind="print"` artboard's rendered page gets its MediaBox enlarged
16
+ // (negative origin — content coordinates never move), BleedBox set to
17
+ // the full rendered page, TrimBox inset by the artboard's own bleed (read
18
+ // off its `print` JSX prop — never re-passed via options, so the
19
+ // exported boxes can never drift from what the artboard's OWN prop says),
20
+ // and vector crop/registration marks drawn outside TrimBox.
21
+ // - a non-print artboard honors `pageFit` (scale-to-paper via
22
+ // embedPage/drawPage) — finally implementing the long-dead
23
+ // `--option pageFit=a4` documented in export.md.
24
+ // RGB PDF + correct boxes + vector marks + metadata — CMYK/PDF-X is
25
+ // explicitly out of scope (Design Decision 4); this never claims PDF/X
26
+ // compliance.
27
+ //
28
+ // `options.dpi` (dogfood follow-up to T4/T5) — the page itself is always
29
+ // vector (text/shapes are never rasterized), but any raster content ON the
30
+ // artboard (a dropped photo, a large-format piece authored at a fraction of
31
+ // its real physical size — e.g. a billboard at 1:10 scale) embeds as a
32
+ // bitmap whose pixel density is set by the CAPTURING context's
33
+ // deviceScaleFactor. `resolveDeviceScale` (exporters/png.ts) is reused here
34
+ // unchanged so `dpi` means the same physical resolution in both exporters.
10
35
 
11
36
  import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
12
37
  import { tmpdir } from 'node:os';
13
38
  import path from 'node:path';
14
39
 
15
- import { PDFDocument } from 'pdf-lib';
40
+ import { PDFDocument, type PDFPage, rgb } from 'pdf-lib';
41
+ import { readArtboardPrintProp } from '../canvas-edit.ts';
42
+ import { computeMarksGeometry, MARK_STROKE_PT, requiredSlugPt } from '../print/marks.ts';
43
+ import { CSS_DPI, getPaperPreset, mmToPt, resolveBleedMm, trimSizeMm } from '../print/units.ts';
16
44
  import { exportShimPath, runShim } from './_runtime.ts';
17
45
  import {
18
46
  canvasShellUrl,
@@ -21,16 +49,42 @@ import {
21
49
  type ExportOptions,
22
50
  type ExportResult,
23
51
  } from './index.ts';
52
+ import { clampDpi } from './png.ts';
24
53
  import type { Target } from './scope.ts';
25
54
 
26
55
  // DDR-045: resolve via DEV_SERVER_ROOT, never `import.meta.dir`. See _runtime.ts.
27
56
  const PDF_PLAYWRIGHT = exportShimPath('_pdf-playwright.mjs');
28
57
 
58
+ /** `[data-dc-screen="<id>"]` → `<id>` — the exact cssPath shape scope.ts's
59
+ * 'artboard' resolver produces when it knows the artboard id (the common
60
+ * case — see scope.ts hints.artboardId). Any other selector shape (a
61
+ * descendant-widen fallback, `:first-of-type`) yields null — the post-pass
62
+ * then simply skips print geometry for that page rather than guessing. */
63
+ export function artboardIdFromCssPath(cssPath: string): string | null {
64
+ const m = /^\[data-dc-screen="([^"]+)"\]$/.exec(cssPath);
65
+ return m ? (m[1] as string) : null;
66
+ }
67
+
68
+ /** Resolve a client-supplied `sourceFile` (from `options.selection.file`, a
69
+ * main-origin but caller-controlled string — see scope.ts's `readHints`)
70
+ * against `repoRoot`, rejecting anything that escapes it — mirrors
71
+ * `resolveCanvasAbs`'s containment check (api.ts) so this new read-path
72
+ * doesn't skip the same-origin path-traversal guard the rest of the
73
+ * codebase applies to disk reads. Returns null (skip print geometry for
74
+ * that page) rather than throwing — a bad path here must not fail the export. */
75
+ function resolveSourceFileUnderRoot(repoRoot: string, sourceFile: string): string | null {
76
+ const abs = path.resolve(repoRoot, sourceFile);
77
+ const resolvedRoot = path.resolve(repoRoot);
78
+ if (abs !== resolvedRoot && !abs.startsWith(`${resolvedRoot}${path.sep}`)) return null;
79
+ return abs;
80
+ }
81
+
29
82
  async function capturePdf(
30
83
  target: Extract<Target, { kind: 'element' }>,
31
84
  ctx: ExportContext,
32
85
  outDir: string,
33
86
  timeoutSec: number,
87
+ deviceScale: number,
34
88
  hooks?: ExportHooks
35
89
  ): Promise<string[]> {
36
90
  const args = [
@@ -41,6 +95,8 @@ async function capturePdf(
41
95
  target.cssPath,
42
96
  '--timeout',
43
97
  String(timeoutSec),
98
+ '--scale',
99
+ String(deviceScale),
44
100
  ];
45
101
  if (target.multi) args.push('--multi', '1', '--out-dir', outDir);
46
102
  else {
@@ -58,6 +114,188 @@ async function capturePdf(
58
114
  });
59
115
  }
60
116
 
117
+ export interface MarksOptions {
118
+ crop: boolean;
119
+ registration: boolean;
120
+ colorBars: boolean;
121
+ pageInfo: boolean;
122
+ }
123
+
124
+ export interface PdfPrintOptions {
125
+ /** Default true when the artboard's own bleedMm > 0 (Design Decision 5). */
126
+ includeBleed?: boolean;
127
+ marks: MarksOptions;
128
+ }
129
+
130
+ /**
131
+ * Validate + clamp `options.pdfPrint` — per exporters/index.ts's own
132
+ * contract, new print params enter through the free-form options bag and
133
+ * MUST be clamped/validated in-adapter (no schema gate exists upstream).
134
+ * Unknown/malformed shapes degrade to "print post-pass off" rather than
135
+ * throwing — a bad options bag should never break a plain export.
136
+ */
137
+ export function parsePdfPrintOptions(raw: unknown): PdfPrintOptions | null {
138
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
139
+ const o = raw as Record<string, unknown>;
140
+ const marksRaw =
141
+ o.marks && typeof o.marks === 'object' && !Array.isArray(o.marks)
142
+ ? (o.marks as Record<string, unknown>)
143
+ : {};
144
+ return {
145
+ includeBleed: typeof o.includeBleed === 'boolean' ? o.includeBleed : undefined,
146
+ marks: {
147
+ crop: marksRaw.crop === true,
148
+ registration: marksRaw.registration === true,
149
+ colorBars: marksRaw.colorBars === true,
150
+ pageInfo: marksRaw.pageInfo === true,
151
+ },
152
+ };
153
+ }
154
+
155
+ /** `options.pageFit` — a paper preset id for non-print artboards, or null. */
156
+ export function parsePageFit(raw: unknown): string | null {
157
+ if (typeof raw !== 'string') return null;
158
+ return getPaperPreset(raw) ? raw : null;
159
+ }
160
+
161
+ /**
162
+ * PDF's own device-scale resolver — deliberately NOT `png.ts`'s
163
+ * `resolveDeviceScale`, which defaults an absent `dpi` to `clampScale(undefined)`
164
+ * = 2× (PNG's own "a 1× export was uselessly small" UX default). A vector PDF's
165
+ * text/shapes are already crisp at 1×; only embedded raster content benefits
166
+ * from a higher capture density, and only when the caller actually asks for
167
+ * it via `dpi` — defaulting every PDF export to 2× would silently double
168
+ * embedded-image weight/render time for the common "just export a PDF" case.
169
+ */
170
+ export function resolvePdfDeviceScale(options: ExportOptions): number {
171
+ const dpi = clampDpi(options.dpi);
172
+ return dpi !== undefined ? dpi / CSS_DPI : 1;
173
+ }
174
+
175
+ /**
176
+ * Apply the print-ready post-pass to one page: enlarge MediaBox (negative
177
+ * origin, content untouched), set BleedBox/TrimBox, draw marks. `bleedMm`
178
+ * comes from the artboard's OWN `print` prop (read by the caller) — never a
179
+ * value re-derived here, so on-canvas guides (T3) and this export can never
180
+ * disagree about where the trim line is.
181
+ */
182
+ export function applyPrintBoxesAndMarks(
183
+ page: PDFPage,
184
+ bleedMm: number,
185
+ printOpts: PdfPrintOptions
186
+ ): void {
187
+ const { width: pageWidthPt, height: pageHeightPt } = page.getSize();
188
+ const bleedPt = mmToPt(bleedMm);
189
+ const includeBleed = printOpts.includeBleed ?? bleedMm > 0;
190
+
191
+ // Bleed box IS the full rendered page (Design Decision 1 — the artboard
192
+ // authored its own width/height as trim + 2×bleed). Trim box insets by
193
+ // bleedPt on every side. When the caller opts OUT of bleed, both boxes
194
+ // collapse onto the trim rect (still a valid MediaBox ⊇ BleedBox ⊇ TrimBox
195
+ // nesting — degenerate but not violated) and the MediaBox itself starts
196
+ // there too, effectively cropping the bleed strip out of the visible area
197
+ // (PDF viewers/printers clip to MediaBox) without moving any content.
198
+ const trimX = bleedPt;
199
+ const trimY = bleedPt;
200
+ const trimW = Math.max(0, pageWidthPt - 2 * bleedPt);
201
+ const trimH = Math.max(0, pageHeightPt - 2 * bleedPt);
202
+ const baseBox = includeBleed
203
+ ? { x: 0, y: 0, w: pageWidthPt, h: pageHeightPt }
204
+ : { x: trimX, y: trimY, w: trimW, h: trimH };
205
+
206
+ const wantsMarks = printOpts.marks.crop || printOpts.marks.registration;
207
+ const slugPt = wantsMarks
208
+ ? requiredSlugPt({ crop: printOpts.marks.crop, registration: printOpts.marks.registration })
209
+ : 0;
210
+
211
+ page.setMediaBox(
212
+ baseBox.x - slugPt,
213
+ baseBox.y - slugPt,
214
+ baseBox.w + 2 * slugPt,
215
+ baseBox.h + 2 * slugPt
216
+ );
217
+ page.setBleedBox(
218
+ includeBleed ? 0 : trimX,
219
+ includeBleed ? 0 : trimY,
220
+ includeBleed ? pageWidthPt : trimW,
221
+ includeBleed ? pageHeightPt : trimH
222
+ );
223
+ page.setTrimBox(trimX, trimY, trimW, trimH);
224
+
225
+ if (wantsMarks) {
226
+ // Marks always reference the FULL render (bleed-box) coordinate frame —
227
+ // they show where the trim line is regardless of whether bleed itself is
228
+ // included in the visible MediaBox.
229
+ const geo = computeMarksGeometry({
230
+ pageWidthPt,
231
+ pageHeightPt,
232
+ bleedPt,
233
+ crop: printOpts.marks.crop,
234
+ registration: printOpts.marks.registration,
235
+ });
236
+ const black = rgb(0, 0, 0);
237
+ for (const seg of geo.cropMarks) {
238
+ page.drawLine({
239
+ start: { x: seg.x1, y: seg.y1 },
240
+ end: { x: seg.x2, y: seg.y2 },
241
+ thickness: MARK_STROKE_PT,
242
+ color: black,
243
+ });
244
+ }
245
+ for (const mark of geo.registrationMarks) {
246
+ page.drawCircle({
247
+ x: mark.circle.cx,
248
+ y: mark.circle.cy,
249
+ size: mark.circle.r,
250
+ borderColor: black,
251
+ borderWidth: MARK_STROKE_PT,
252
+ });
253
+ for (const seg of mark.crosshair) {
254
+ page.drawLine({
255
+ start: { x: seg.x1, y: seg.y1 },
256
+ end: { x: seg.x2, y: seg.y2 },
257
+ thickness: MARK_STROKE_PT,
258
+ color: black,
259
+ });
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ /**
266
+ * `pageFit` — scale-to-paper for a NON-print artboard (Design Decision 6's
267
+ * counterpart for the export.md `--option pageFit=a4` surface: PDF is
268
+ * always vector, so "DPI" never applies here). Embeds the rendered page as
269
+ * an XObject on a fresh page sized to the target paper, uniformly scaled +
270
+ * centered, and swaps it in at the same index.
271
+ */
272
+ export async function applyPageFit(
273
+ doc: PDFDocument,
274
+ pageIndex: number,
275
+ paperId: string
276
+ ): Promise<void> {
277
+ const preset = getPaperPreset(paperId);
278
+ if (!preset) return;
279
+ const { widthMm, heightMm } = trimSizeMm(preset, 'portrait');
280
+ const widthPt = mmToPt(widthMm);
281
+ const heightPt = mmToPt(heightMm);
282
+ const original = doc.getPage(pageIndex);
283
+ const { width: origW, height: origH } = original.getSize();
284
+ if (origW <= 0 || origH <= 0) return;
285
+ const embedded = await doc.embedPage(original);
286
+ const scale = Math.min(widthPt / origW, heightPt / origH);
287
+ const drawW = origW * scale;
288
+ const drawH = origH * scale;
289
+ const newPage = doc.insertPage(pageIndex, [widthPt, heightPt]);
290
+ newPage.drawPage(embedded, {
291
+ x: (widthPt - drawW) / 2,
292
+ y: (heightPt - drawH) / 2,
293
+ width: drawW,
294
+ height: drawH,
295
+ });
296
+ doc.removePage(pageIndex + 1); // the original, now shifted one slot later
297
+ }
298
+
61
299
  export async function run(
62
300
  targets: Target[],
63
301
  options: ExportOptions,
@@ -74,37 +312,113 @@ export async function run(
74
312
  throw new Error('pdf adapter requires element targets (got file-tree)');
75
313
  }
76
314
  const timeoutSec = (options.timeoutSec as number | undefined) ?? 12;
315
+ const printOpts = parsePdfPrintOptions(options.pdfPrint);
316
+ const pageFit = parsePageFit(options.pageFit);
317
+ // Dogfood follow-up — raster CONTENT on the artboard (a dropped photo, a
318
+ // large-format piece authored at a fraction of its physical size) needs a
319
+ // real deviceScaleFactor to embed at print density; the page itself stays
320
+ // vector regardless (see the file header comment). Default 1× (today's
321
+ // behavior) when `dpi` is absent.
322
+ const deviceScale = resolvePdfDeviceScale(options);
77
323
  const tmp = mkdtempSync(path.join(tmpdir(), 'maude-pdf-'));
78
324
  try {
79
- const written: string[] = [];
325
+ // written[i] = { path, sourceFile (repo-relative canvas), artboardId }.
326
+ // artboardId is per-PAGE — a `--multi` capture writes one file per
327
+ // artboard, so this is populated per file, not per top-level Target.
328
+ const written: Array<{ path: string; sourceFile: string; artboardId: string | null }> = [];
80
329
  for (let i = 0; i < elementTargets.length; i += 1) {
81
- const paths = await capturePdf(elementTargets[i], ctx, tmp, timeoutSec, hooks);
82
- written.push(...paths);
83
- // Outer-loop tick (N separate top-level Targets — rare). The granular
84
- // "K of M artboards" signal for canvas-as-separate comes from the
85
- // shim's own MAUDE_PROGRESS lines via spawnShim's onProgress inside
86
- // capturePdf, above — both funnel into the same hooks.onProgress.
330
+ const target = elementTargets[i] as Extract<Target, { kind: 'element' }>;
331
+ const paths = await capturePdf(target, ctx, tmp, timeoutSec, deviceScale, hooks);
332
+ if (target.multi) {
333
+ // _pdf-playwright.mjs names multi output `${data-dc-screen}.pdf`.
334
+ for (const p of paths) {
335
+ written.push({
336
+ path: p,
337
+ sourceFile: target.file,
338
+ artboardId: path.basename(p, '.pdf'),
339
+ });
340
+ }
341
+ } else {
342
+ for (const p of paths) {
343
+ written.push({
344
+ path: p,
345
+ sourceFile: target.file,
346
+ artboardId: artboardIdFromCssPath(target.cssPath),
347
+ });
348
+ }
349
+ }
87
350
  hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
88
351
  }
89
352
  if (!written.length) {
90
353
  return { filename: 'export.pdf', contentType: 'application/pdf', body: new Uint8Array(0) };
91
354
  }
92
- if (written.length === 1) {
93
- const bytes = new Uint8Array(readFileSync(written[0]));
94
- const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
355
+
356
+ const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
357
+ const needsPostPass = !!printOpts || !!pageFit;
358
+
359
+ // Fast path — no print/pageFit options given: pass Chromium's vector PDF
360
+ // straight through, exactly as before this feature (zero pdf-lib
361
+ // round-trip overhead for the common "just export a PDF" case).
362
+ if (!needsPostPass) {
363
+ const [only] = written;
364
+ if (written.length === 1 && only) {
365
+ const bytes = new Uint8Array(readFileSync(only.path));
366
+ return { filename: `${baseSlug}.pdf`, contentType: 'application/pdf', body: bytes };
367
+ }
368
+ const out = await PDFDocument.create();
369
+ for (const w of written) {
370
+ const src = await PDFDocument.load(new Uint8Array(readFileSync(w.path)));
371
+ const pages = await out.copyPages(src, src.getPageIndices());
372
+ for (const page of pages) out.addPage(page);
373
+ }
374
+ const bytes = await out.save();
95
375
  return { filename: `${baseSlug}.pdf`, contentType: 'application/pdf', body: bytes };
96
376
  }
97
- // Multi-target → concatenate via pdf-lib `copyPages`. The vector
98
- // primitives produced by Chromium copy through losslessly — we never
99
- // re-render or rasterize.
377
+
378
+ // Post-pass path — every page goes through pdf-lib (single-page case now
379
+ // included, per T5's "extend the existing pdf-lib load to single-page
380
+ // too"), so boxes/marks/pageFit apply uniformly regardless of scope.
100
381
  const out = await PDFDocument.create();
101
- for (const p of written) {
102
- const src = await PDFDocument.load(new Uint8Array(readFileSync(p)));
382
+ // Cache canvas source text per file — a canvas-as-separate export reads
383
+ // the SAME source repeatedly (once per artboard) otherwise.
384
+ const sourceCache = new Map<string, string>();
385
+ for (const w of written) {
386
+ const src = await PDFDocument.load(new Uint8Array(readFileSync(w.path)));
103
387
  const pages = await out.copyPages(src, src.getPageIndices());
104
- for (const page of pages) out.addPage(page);
388
+ for (const page of pages) {
389
+ const pageIndex = out.getPageCount();
390
+ out.addPage(page);
391
+ let printProp: Record<string, unknown> | null = null;
392
+ if (w.artboardId) {
393
+ const abs = resolveSourceFileUnderRoot(ctx.repoRoot, w.sourceFile);
394
+ if (abs) {
395
+ let text = sourceCache.get(abs);
396
+ if (text === undefined) {
397
+ try {
398
+ text = readFileSync(abs, 'utf8');
399
+ } catch {
400
+ text = '';
401
+ }
402
+ sourceCache.set(abs, text);
403
+ }
404
+ if (text) printProp = readArtboardPrintProp(abs, text, w.artboardId);
405
+ }
406
+ }
407
+ if (printProp && printOpts) {
408
+ const paper = typeof printProp.paper === 'string' ? printProp.paper : 'a4';
409
+ const bleedMmRaw = printProp.bleedMm;
410
+ const bleedMm = resolveBleedMm({
411
+ paper,
412
+ bleedMm: typeof bleedMmRaw === 'number' ? bleedMmRaw : undefined,
413
+ });
414
+ applyPrintBoxesAndMarks(page, bleedMm, printOpts);
415
+ } else if (!printProp && pageFit) {
416
+ // Non-print artboard + pageFit requested — scale-to-paper.
417
+ await applyPageFit(out, pageIndex, pageFit);
418
+ }
419
+ }
105
420
  }
106
421
  const bytes = await out.save();
107
- const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
108
422
  return { filename: `${baseSlug}.pdf`, contentType: 'application/pdf', body: bytes };
109
423
  } finally {
110
424
  rmSync(tmp, { recursive: true, force: true });