@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.
- package/apps/studio/acp/bridge.ts +628 -74
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +344 -14
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-edit.ts +302 -3
- package/apps/studio/canvas-lib.tsx +203 -11
- package/apps/studio/canvas-shell.tsx +167 -28
- package/apps/studio/client/app.jsx +678 -176
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +846 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +82 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +689 -11
- package/apps/studio/dist/client.bundle.js +1738 -1736
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +246 -9
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/canvas-meta-api.test.ts +167 -0
- package/apps/studio/test/element-structural-edit.test.ts +250 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +21 -2
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/whats-new.json +62 -0
- package/cli/commands/design.mjs +116 -3
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- package/package.json +11 -9
- 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 ≤
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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 [
|
|
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')
|
|
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
|
-
}, [
|
|
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">
|
|
588
|
+
<label htmlFor="dc-ed-png-scale">Resolution</label>
|
|
534
589
|
<select
|
|
535
590
|
id="dc-ed-png-scale"
|
|
536
|
-
value={
|
|
537
|
-
onChange={(e) =>
|
|
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
|
-
{
|
|
540
|
-
<option key={
|
|
541
|
-
{
|
|
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
|
-
|
|
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="print"</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
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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
|
-
|
|
102
|
-
|
|
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)
|
|
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 });
|