@agent-native/core 0.118.1 → 0.119.1
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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +19 -0
- package/corpus/core/docs/content/a2a-protocol.mdx +15 -0
- package/corpus/core/docs/content/template-chat.mdx +1 -1
- package/corpus/core/docs/content/template-plan.mdx +3 -3
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/activity.ts +371 -0
- package/corpus/core/src/a2a/client.ts +3 -1
- package/corpus/core/src/a2a/index.ts +21 -0
- package/corpus/core/src/a2a/types.ts +8 -0
- package/corpus/core/src/agent/model-config.ts +19 -23
- package/corpus/core/src/agent/production-agent.ts +167 -100
- package/corpus/core/src/agent/types.ts +16 -1
- package/corpus/core/src/client/chat/tool-call-display.tsx +190 -35
- package/corpus/core/src/client/chat-model-groups.ts +67 -27
- package/corpus/core/src/client/sharing/ShareButton.tsx +1 -27
- package/corpus/core/src/client/sharing/ShareDialog.tsx +0 -16
- package/corpus/core/src/client/sharing/useShareButtonController.ts +0 -7
- package/corpus/core/src/client/sharing/useShareDialogController.ts +0 -2
- package/corpus/core/src/client/sse-event-processor.ts +85 -1
- package/corpus/core/src/ingestion/figma-node-to-html.ts +20 -99
- package/corpus/core/src/ingestion/figma-paint-math.ts +371 -0
- package/corpus/core/src/ingestion/index.ts +20 -0
- package/corpus/core/src/localization/default-messages.ts +6 -0
- package/corpus/core/src/scripts/agent-engines/list-agent-engines.ts +1 -18
- package/corpus/core/src/scripts/call-agent.ts +49 -6
- package/corpus/core/src/server/agent-chat-plugin.ts +50 -10
- package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +16 -0
- package/corpus/templates/assets/app/routes/library.tsx +17 -9
- package/corpus/templates/clips/changelog/2026-07-23-fixed-recordings-failing-to-save-with-a-cancelled-or-cleanup.md +6 -0
- package/corpus/templates/clips/server/plugins/db.ts +9 -1
- package/corpus/templates/design/AGENTS.md +18 -5
- package/corpus/templates/design/FIGMA_INTEROPERABILITY.md +25 -12
- package/corpus/templates/design/actions/hydrate-figma-paste-images.ts +115 -0
- package/corpus/templates/design/actions/import-figma-clipboard.ts +83 -18
- package/corpus/templates/design/actions/import-figma-frame.ts +34 -21
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +28 -1
- package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +106 -11
- package/corpus/templates/design/app/components/design/FigmaHydrationDialog.tsx +273 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +7 -7
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +57 -9
- package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +10 -0
- package/corpus/templates/design/app/components/design/multi-screen/board-surface-html.ts +8 -1
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -0
- package/corpus/templates/design/app/i18n-data.ts +150 -2
- package/corpus/templates/design/app/lib/design-file-upload.ts +85 -0
- package/corpus/templates/design/app/lib/design-import.ts +19 -1
- package/corpus/templates/design/app/lib/figma-clipboard.ts +110 -10
- package/corpus/templates/design/changelog/2026-07-20-paste-figma-frames-directly-onto-the-canvas-no-token-needed.md +6 -0
- package/corpus/templates/design/changelog/2026-07-21-fig-upload-frame-url-and-rate-limit-errors.md +5 -0
- package/corpus/templates/design/changelog/2026-07-22-connecting-a-figma-token-now-actually-fills-in-a-pasted-desi.md +6 -0
- package/corpus/templates/design/changelog/2026-07-22-figma-line-arrow-vectors-and-stroked-icons-now-render-instea.md +6 -0
- package/corpus/templates/design/changelog/2026-07-22-fill-a-no-token-figma-paste-s-images-by-dropping-the-origina.md +6 -0
- package/corpus/templates/design/changelog/2026-07-22-imported-and-pasted-figma-frames-now-render-faithfully-fixed.md +6 -0
- package/corpus/templates/design/server/handlers/import-design-file.ts +50 -0
- package/corpus/templates/design/server/lib/fig-file-decoder.ts +90 -49
- package/corpus/templates/design/server/lib/fig-file-import.ts +19 -5
- package/corpus/templates/design/server/lib/fig-file-to-html.ts +583 -78
- package/corpus/templates/design/server/lib/figma-clipboard-local-decode.ts +238 -0
- package/corpus/templates/design/server/lib/figma-image-hydration.ts +389 -0
- package/corpus/templates/design/server/lib/figma-node-import.ts +136 -36
- package/corpus/templates/design/server/register-secrets.ts +14 -2
- package/dist/a2a/activity.d.ts +50 -0
- package/dist/a2a/activity.d.ts.map +1 -0
- package/dist/a2a/activity.js +236 -0
- package/dist/a2a/activity.js.map +1 -0
- package/dist/a2a/client.d.ts +2 -0
- package/dist/a2a/client.d.ts.map +1 -1
- package/dist/a2a/client.js +1 -1
- package/dist/a2a/client.js.map +1 -1
- package/dist/a2a/index.d.ts +2 -1
- package/dist/a2a/index.d.ts.map +1 -1
- package/dist/a2a/index.js +1 -0
- package/dist/a2a/index.js.map +1 -1
- package/dist/a2a/types.d.ts +1 -0
- package/dist/a2a/types.d.ts.map +1 -1
- package/dist/a2a/types.js.map +1 -1
- package/dist/agent/engine/anthropic-engine.d.ts +1 -1
- package/dist/agent/engine/builder-engine.d.ts +2 -2
- package/dist/agent/engine/builder-engine.d.ts.map +1 -1
- package/dist/agent/model-config.d.ts +16 -16
- package/dist/agent/model-config.d.ts.map +1 -1
- package/dist/agent/model-config.js +19 -23
- package/dist/agent/model-config.js.map +1 -1
- package/dist/agent/production-agent.d.ts +26 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +137 -80
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/types.d.ts +10 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/client/chat/tool-call-display.d.ts.map +1 -1
- package/dist/client/chat/tool-call-display.js +63 -14
- package/dist/client/chat/tool-call-display.js.map +1 -1
- package/dist/client/chat-model-groups.d.ts.map +1 -1
- package/dist/client/chat-model-groups.js +44 -21
- package/dist/client/chat-model-groups.js.map +1 -1
- package/dist/client/sharing/ShareButton.d.ts +1 -1
- package/dist/client/sharing/ShareButton.d.ts.map +1 -1
- package/dist/client/sharing/ShareButton.js +3 -4
- package/dist/client/sharing/ShareButton.js.map +1 -1
- package/dist/client/sharing/ShareDialog.d.ts.map +1 -1
- package/dist/client/sharing/ShareDialog.js +1 -2
- package/dist/client/sharing/ShareDialog.js.map +1 -1
- package/dist/client/sharing/useShareButtonController.d.ts +0 -1
- package/dist/client/sharing/useShareButtonController.d.ts.map +1 -1
- package/dist/client/sharing/useShareButtonController.js +0 -6
- package/dist/client/sharing/useShareButtonController.js.map +1 -1
- package/dist/client/sharing/useShareDialogController.d.ts +0 -1
- package/dist/client/sharing/useShareDialogController.d.ts.map +1 -1
- package/dist/client/sharing/useShareDialogController.js +0 -1
- package/dist/client/sharing/useShareDialogController.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts +12 -0
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +63 -1
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/ingestion/figma-node-to-html.d.ts.map +1 -1
- package/dist/ingestion/figma-node-to-html.js +14 -88
- package/dist/ingestion/figma-node-to-html.js.map +1 -1
- package/dist/ingestion/figma-paint-math.d.ts +157 -0
- package/dist/ingestion/figma-paint-math.d.ts.map +1 -0
- package/dist/ingestion/figma-paint-math.js +274 -0
- package/dist/ingestion/figma-paint-math.js.map +1 -0
- package/dist/ingestion/index.d.ts +1 -0
- package/dist/ingestion/index.d.ts.map +1 -1
- package/dist/ingestion/index.js +1 -0
- package/dist/ingestion/index.js.map +1 -1
- package/dist/localization/default-messages.d.ts +6 -0
- package/dist/localization/default-messages.d.ts.map +1 -1
- package/dist/localization/default-messages.js +6 -0
- package/dist/localization/default-messages.js.map +1 -1
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +7 -7
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/scripts/agent-engines/list-agent-engines.d.ts.map +1 -1
- package/dist/scripts/agent-engines/list-agent-engines.js +1 -18
- package/dist/scripts/agent-engines/list-agent-engines.js.map +1 -1
- package/dist/scripts/call-agent.d.ts.map +1 -1
- package/dist/scripts/call-agent.js +49 -6
- package/dist/scripts/call-agent.js.map +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +38 -10
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +16 -0
- package/docs/content/a2a-protocol.mdx +15 -0
- package/docs/content/template-chat.mdx +1 -1
- package/docs/content/template-plan.mdx +3 -3
- package/package.json +1 -1
- package/src/a2a/activity.ts +371 -0
- package/src/a2a/client.ts +3 -1
- package/src/a2a/index.ts +21 -0
- package/src/a2a/types.ts +8 -0
- package/src/agent/model-config.ts +19 -23
- package/src/agent/production-agent.ts +167 -100
- package/src/agent/types.ts +16 -1
- package/src/client/chat/tool-call-display.tsx +190 -35
- package/src/client/chat-model-groups.ts +67 -27
- package/src/client/sharing/ShareButton.tsx +1 -27
- package/src/client/sharing/ShareDialog.tsx +0 -16
- package/src/client/sharing/useShareButtonController.ts +0 -7
- package/src/client/sharing/useShareDialogController.ts +0 -2
- package/src/client/sse-event-processor.ts +85 -1
- package/src/ingestion/figma-node-to-html.ts +20 -99
- package/src/ingestion/figma-paint-math.ts +371 -0
- package/src/ingestion/index.ts +20 -0
- package/src/localization/default-messages.ts +6 -0
- package/src/scripts/agent-engines/list-agent-engines.ts +1 -18
- package/src/scripts/call-agent.ts +49 -6
- package/src/server/agent-chat-plugin.ts +50 -10
- package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +16 -0
- package/corpus/core/src/observability/hosted-model-experiment.ts +0 -118
- package/dist/observability/hosted-model-experiment.d.ts +0 -39
- package/dist/observability/hosted-model-experiment.d.ts.map +0 -1
- package/dist/observability/hosted-model-experiment.js +0 -90
- package/dist/observability/hosted-model-experiment.js.map +0 -1
- package/src/observability/hosted-model-experiment.ts +0 -118
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
|
|
14
|
+
import {
|
|
15
|
+
cssBlendMode,
|
|
16
|
+
gradientAngleDegreesFromHandles,
|
|
17
|
+
gradientGeometryFromTransform,
|
|
18
|
+
remapLinearStopPosition,
|
|
19
|
+
} from "./figma-node-to-html.js";
|
|
20
|
+
|
|
14
21
|
export interface Guid {
|
|
15
22
|
sessionID: number;
|
|
16
23
|
localID: number;
|
|
@@ -140,7 +147,17 @@ export interface FigNode {
|
|
|
140
147
|
lineHeight?: { value: number; units?: string };
|
|
141
148
|
textAlignHorizontal?: string;
|
|
142
149
|
textAlignVertical?: string;
|
|
143
|
-
textData?: {
|
|
150
|
+
textData?: {
|
|
151
|
+
characters?: string;
|
|
152
|
+
// Per-character style index (one entry per UTF-16 code unit); the index
|
|
153
|
+
// keys into `styleOverrideTable`. Absent/0 means the node's base style.
|
|
154
|
+
characterStyleIDs?: number[];
|
|
155
|
+
styleOverrideTable?: Array<{
|
|
156
|
+
styleID?: number;
|
|
157
|
+
fillPaints?: Paint[];
|
|
158
|
+
fontSize?: number;
|
|
159
|
+
}>;
|
|
160
|
+
};
|
|
144
161
|
textAutoResize?: string;
|
|
145
162
|
symbolData?: {
|
|
146
163
|
symbolID?: Guid;
|
|
@@ -609,6 +626,60 @@ function num(n: number | null | undefined): number | null {
|
|
|
609
626
|
return Math.round(n * 100) / 100;
|
|
610
627
|
}
|
|
611
628
|
|
|
629
|
+
interface TextRun {
|
|
630
|
+
text: string;
|
|
631
|
+
/** CSS color when a per-character override differs from the base fill. */
|
|
632
|
+
color?: string;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Split TEXT into color runs from `characterStyleIDs` + `styleOverrideTable`
|
|
637
|
+
* (how one node holds two colors). Overridden runs carry an explicit color;
|
|
638
|
+
* base-fill runs inherit the element's `color`. One plain run when unstyled.
|
|
639
|
+
*/
|
|
640
|
+
function textStyleRuns(node: FigNode): TextRun[] {
|
|
641
|
+
const chars = node.textData?.characters ?? "";
|
|
642
|
+
const ids = node.textData?.characterStyleIDs;
|
|
643
|
+
const table = node.textData?.styleOverrideTable;
|
|
644
|
+
if (!chars) return [];
|
|
645
|
+
if (!ids || ids.length === 0 || !table || table.length === 0) {
|
|
646
|
+
return [{ text: chars }];
|
|
647
|
+
}
|
|
648
|
+
const colorByStyle = new Map<number, string | undefined>();
|
|
649
|
+
for (const entry of table) {
|
|
650
|
+
if (entry?.styleID == null) continue;
|
|
651
|
+
// Topmost visible solid in the override's fill list.
|
|
652
|
+
let solid: Paint | undefined;
|
|
653
|
+
for (const p of entry.fillPaints ?? []) {
|
|
654
|
+
if (p.visible !== false && p.type === "SOLID") solid = p;
|
|
655
|
+
}
|
|
656
|
+
colorByStyle.set(
|
|
657
|
+
entry.styleID,
|
|
658
|
+
solid
|
|
659
|
+
? (colorToCss(solid.color, solid.opacity ?? 1) ?? undefined)
|
|
660
|
+
: undefined,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
const runs: TextRun[] = [];
|
|
664
|
+
let curText = "";
|
|
665
|
+
let curColor: string | undefined;
|
|
666
|
+
let started = false;
|
|
667
|
+
for (let i = 0; i < chars.length; i++) {
|
|
668
|
+
const color = colorByStyle.get(ids[i] ?? 0);
|
|
669
|
+
if (!started) {
|
|
670
|
+
curColor = color;
|
|
671
|
+
started = true;
|
|
672
|
+
} else if (color !== curColor) {
|
|
673
|
+
runs.push({ text: curText, color: curColor });
|
|
674
|
+
curText = "";
|
|
675
|
+
curColor = color;
|
|
676
|
+
}
|
|
677
|
+
curText += chars[i];
|
|
678
|
+
}
|
|
679
|
+
if (curText) runs.push({ text: curText, color: curColor });
|
|
680
|
+
return runs;
|
|
681
|
+
}
|
|
682
|
+
|
|
612
683
|
function tagFor(type: string | undefined): string {
|
|
613
684
|
// Everything renders as a real DOM tag rather than a synthetic component.
|
|
614
685
|
// TEXT becomes <span> so it inlines nicely; everything else is <div>.
|
|
@@ -683,7 +754,7 @@ function imageUrl(hashHex: string, ctx: Ctx): string {
|
|
|
683
754
|
const resolved = ctx.imageMap.get(hashHex);
|
|
684
755
|
if (!resolved && ctx.missingImageUrl) return ctx.missingImageUrl;
|
|
685
756
|
const filename = resolved ?? hashHex;
|
|
686
|
-
if (/^(?:https?:|blob:|about:)/i.test(filename)) return filename;
|
|
757
|
+
if (/^(?:https?:|blob:|about:|data:|file:)/i.test(filename)) return filename;
|
|
687
758
|
const base = ctx.imageRefBase ?? "images";
|
|
688
759
|
return `${base}/${filename}`;
|
|
689
760
|
}
|
|
@@ -737,24 +808,72 @@ function effectiveStrokePaints(node: FigNode, ctx: Ctx): Paint[] | undefined {
|
|
|
737
808
|
return node.strokePaints;
|
|
738
809
|
}
|
|
739
810
|
|
|
740
|
-
function paintToBackground(p: Paint,
|
|
811
|
+
function paintToBackground(p: Paint, node: FigNode, ctx: Ctx): string | null {
|
|
741
812
|
if (p.visible === false) return null;
|
|
742
|
-
if (p.type === "SOLID")
|
|
813
|
+
if (p.type === "SOLID") {
|
|
814
|
+
const color = colorToCss(p.color, p.opacity ?? 1);
|
|
815
|
+
return color ? `linear-gradient(${color}, ${color})` : null;
|
|
816
|
+
}
|
|
743
817
|
if (p.type?.startsWith("GRADIENT") && Array.isArray(p.stops)) {
|
|
818
|
+
const box = node.size ? { width: node.size.x, height: node.size.y } : null;
|
|
819
|
+
const kind = p.type.slice("GRADIENT_".length) as
|
|
820
|
+
| "LINEAR"
|
|
821
|
+
| "RADIAL"
|
|
822
|
+
| "ANGULAR"
|
|
823
|
+
| "DIAMOND";
|
|
824
|
+
const geometry =
|
|
825
|
+
p.transform && box
|
|
826
|
+
? gradientGeometryFromTransform(kind, p.transform, box)
|
|
827
|
+
: null;
|
|
828
|
+
const stopPosition =
|
|
829
|
+
geometry && kind === "LINEAR" && box
|
|
830
|
+
? remapLinearStopPosition(
|
|
831
|
+
geometry.handles,
|
|
832
|
+
box,
|
|
833
|
+
gradientAngleDegreesFromHandles(geometry.handles, box),
|
|
834
|
+
)
|
|
835
|
+
: (position: number) => position;
|
|
744
836
|
const stops = p.stops
|
|
745
837
|
.map(
|
|
746
838
|
(s) =>
|
|
747
|
-
`${colorToCss(s.color, p.opacity ?? 1)} ${num(s.position * 100)}%`,
|
|
839
|
+
`${colorToCss(s.color, p.opacity ?? 1)} ${num(stopPosition(s.position) * 100)}%`,
|
|
748
840
|
)
|
|
749
841
|
.join(", ");
|
|
750
|
-
if (p.type === "GRADIENT_LINEAR")
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
842
|
+
if (p.type === "GRADIENT_LINEAR") {
|
|
843
|
+
if (geometry && box) {
|
|
844
|
+
const angle = gradientAngleDegreesFromHandles(geometry.handles, box);
|
|
845
|
+
return `linear-gradient(${num(angle)}deg, ${stops})`;
|
|
846
|
+
}
|
|
847
|
+
return `linear-gradient(${stops})`;
|
|
848
|
+
}
|
|
849
|
+
if (p.type === "GRADIENT_RADIAL") {
|
|
850
|
+
if (geometry) {
|
|
851
|
+
return `radial-gradient(ellipse ${num(geometry.rx)}px ${num(geometry.ry)}px at ${num(geometry.center.x)}px ${num(geometry.center.y)}px, ${stops})`;
|
|
852
|
+
}
|
|
853
|
+
return `radial-gradient(${stops})`;
|
|
854
|
+
}
|
|
855
|
+
if (p.type === "GRADIENT_ANGULAR") {
|
|
856
|
+
if (geometry) {
|
|
857
|
+
return `conic-gradient(from ${num(geometry.fromDeg)}deg at ${num(geometry.center.x)}px ${num(geometry.center.y)}px, ${stops})`;
|
|
858
|
+
}
|
|
859
|
+
return `conic-gradient(${stops})`;
|
|
860
|
+
}
|
|
861
|
+
if (p.type === "GRADIENT_DIAMOND") {
|
|
862
|
+
ctx.approximatedNodes.push({
|
|
863
|
+
nodeId: guidKey(node.guid),
|
|
864
|
+
nodeName: node.name,
|
|
865
|
+
nodeType: node.type,
|
|
866
|
+
notes: ["GRADIENT_DIAMOND approximated as radial-gradient"],
|
|
867
|
+
});
|
|
868
|
+
return `radial-gradient(${stops})`;
|
|
869
|
+
}
|
|
754
870
|
}
|
|
755
871
|
if (p.type === "IMAGE") {
|
|
756
872
|
const hex = hashToHex(p.image?.hash);
|
|
757
|
-
if (hex)
|
|
873
|
+
if (hex) {
|
|
874
|
+
const u = imageUrl(hex, ctx);
|
|
875
|
+
return `url('${u.replace(/'/g, "%27")}')`;
|
|
876
|
+
}
|
|
758
877
|
}
|
|
759
878
|
return null;
|
|
760
879
|
}
|
|
@@ -768,6 +887,7 @@ function backgroundShorthand(
|
|
|
768
887
|
backgroundSize?: string;
|
|
769
888
|
backgroundPosition?: string;
|
|
770
889
|
backgroundRepeat?: string;
|
|
890
|
+
backgroundBlendMode?: string;
|
|
771
891
|
} {
|
|
772
892
|
const fills = (effectiveFillPaints(node, ctx) ?? []).filter(
|
|
773
893
|
(f) => f.visible !== false,
|
|
@@ -779,31 +899,55 @@ function backgroundShorthand(
|
|
|
779
899
|
backgroundSize?: string;
|
|
780
900
|
backgroundPosition?: string;
|
|
781
901
|
backgroundRepeat?: string;
|
|
902
|
+
backgroundBlendMode?: string;
|
|
782
903
|
} = {};
|
|
904
|
+
// Optimization: when there is exactly one fill and it's a plain SOLID at the
|
|
905
|
+
// bottom, emit it as `background-color` (cheaper CSS, same visual) and skip
|
|
906
|
+
// adding it to the bgImages layer list so we don't double-render it.
|
|
907
|
+
const isSingleSolidOnly = fills.length === 1 && fills[0]?.type === "SOLID";
|
|
908
|
+
if (isSingleSolidOnly) {
|
|
909
|
+
const color = colorToCss(fills[0]!.color, fills[0]!.opacity ?? 1);
|
|
910
|
+
if (color) result.backgroundColor = color;
|
|
911
|
+
return result;
|
|
912
|
+
}
|
|
783
913
|
const bgImages: string[] = [];
|
|
914
|
+
const bgSizes: string[] = [];
|
|
915
|
+
const bgPositions: string[] = [];
|
|
916
|
+
const bgRepeats: string[] = [];
|
|
917
|
+
const bgBlends: string[] = [];
|
|
784
918
|
for (const f of fills) {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
919
|
+
const image = paintToBackground(f, node, ctx);
|
|
920
|
+
if (!image) continue;
|
|
921
|
+
bgImages.push(image);
|
|
922
|
+
bgBlends.push(blendModeCss(f.blendMode) ?? "normal");
|
|
923
|
+
if (f.type !== "IMAGE") {
|
|
924
|
+
bgSizes.push("auto");
|
|
925
|
+
bgPositions.push("0% 0%");
|
|
926
|
+
bgRepeats.push("repeat");
|
|
788
927
|
continue;
|
|
789
928
|
}
|
|
790
|
-
const
|
|
791
|
-
if (
|
|
792
|
-
if (
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
929
|
+
const mode = f.imageScaleMode ?? "FILL";
|
|
930
|
+
if (mode === "FILL") bgSizes.push("cover");
|
|
931
|
+
else if (mode === "FIT") bgSizes.push("contain");
|
|
932
|
+
else if (mode === "STRETCH") bgSizes.push("100% 100%");
|
|
933
|
+
else bgSizes.push("auto");
|
|
934
|
+
bgPositions.push(mode === "TILE" ? "0% 0%" : "center");
|
|
935
|
+
bgRepeats.push(mode === "TILE" ? "repeat" : "no-repeat");
|
|
936
|
+
}
|
|
937
|
+
bgImages.reverse();
|
|
938
|
+
bgSizes.reverse();
|
|
939
|
+
bgPositions.reverse();
|
|
940
|
+
bgRepeats.reverse();
|
|
941
|
+
bgBlends.reverse();
|
|
942
|
+
if (bgImages.length > 0) {
|
|
943
|
+
result.backgroundImage = bgImages.join(", ");
|
|
944
|
+
result.backgroundSize = bgSizes.join(", ");
|
|
945
|
+
result.backgroundPosition = bgPositions.join(", ");
|
|
946
|
+
result.backgroundRepeat = bgRepeats.join(", ");
|
|
947
|
+
if (bgBlends.some((blend) => blend !== "normal")) {
|
|
948
|
+
result.backgroundBlendMode = bgBlends.join(", ");
|
|
804
949
|
}
|
|
805
950
|
}
|
|
806
|
-
if (bgImages.length > 0) result.backgroundImage = bgImages.join(", ");
|
|
807
951
|
return result;
|
|
808
952
|
}
|
|
809
953
|
|
|
@@ -813,7 +957,8 @@ function borderShorthand(node: FigNode, ctx: Ctx): Record<string, string> {
|
|
|
813
957
|
);
|
|
814
958
|
if (strokes.length === 0) return {};
|
|
815
959
|
const first = strokes[0]!;
|
|
816
|
-
const color = colorToCss(first.color, first.opacity ?? 1)
|
|
960
|
+
const color = colorToCss(first.color, first.opacity ?? 1);
|
|
961
|
+
if (!color) return {};
|
|
817
962
|
|
|
818
963
|
const hasPerSide =
|
|
819
964
|
node.strokeTopWeight !== undefined ||
|
|
@@ -940,9 +1085,9 @@ function effectStyles(
|
|
|
940
1085
|
`inset ${num(e.offset?.x ?? 0)}px ${num(e.offset?.y ?? 0)}px ${num(e.radius ?? 0)}px ${num(e.spread ?? 0)}px ${c}`,
|
|
941
1086
|
);
|
|
942
1087
|
} else if (e.type === "FOREGROUND_BLUR" || e.type === "LAYER_BLUR") {
|
|
943
|
-
filters.push(`blur(${num(e.radius ?? 0)}px)`);
|
|
1088
|
+
filters.push(`blur(${num((e.radius ?? 0) / 2)}px)`);
|
|
944
1089
|
} else if (e.type === "BACKGROUND_BLUR") {
|
|
945
|
-
backdropBlur = `blur(${num(e.radius ?? 0)}px)`;
|
|
1090
|
+
backdropBlur = `blur(${num((e.radius ?? 0) / 2)}px)`;
|
|
946
1091
|
}
|
|
947
1092
|
}
|
|
948
1093
|
const out: Record<string, string> = {};
|
|
@@ -958,8 +1103,22 @@ function transformStyle(node: FigNode): {
|
|
|
958
1103
|
} {
|
|
959
1104
|
const t = node.transform;
|
|
960
1105
|
if (!t) return {};
|
|
961
|
-
|
|
1106
|
+
const determinant = t.m00 * t.m11 - t.m01 * t.m10;
|
|
1107
|
+
const hasNonTrivialScale = Math.abs(Math.abs(determinant) - 1) > 0.01;
|
|
962
1108
|
const angle = Math.atan2(t.m10, t.m00);
|
|
1109
|
+
const isPureRotation =
|
|
1110
|
+
Math.abs(t.m00 - Math.cos(angle)) < 0.01 &&
|
|
1111
|
+
Math.abs(t.m01 + Math.sin(angle)) < 0.01 &&
|
|
1112
|
+
Math.abs(t.m10 - Math.sin(angle)) < 0.01 &&
|
|
1113
|
+
Math.abs(t.m11 - Math.cos(angle)) < 0.01;
|
|
1114
|
+
const hasSkew =
|
|
1115
|
+
(Math.abs(t.m01) > 0.0001 || Math.abs(t.m10) > 0.0001) && !isPureRotation;
|
|
1116
|
+
if (hasNonTrivialScale || hasSkew) {
|
|
1117
|
+
return {
|
|
1118
|
+
transform: `matrix(${num(t.m00)}, ${num(t.m10)}, ${num(t.m01)}, ${num(t.m11)}, 0, 0)`,
|
|
1119
|
+
transformOrigin: "0 0",
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
963
1122
|
const deg = (angle * 180) / Math.PI;
|
|
964
1123
|
if (Math.abs(deg) < 0.01) return {};
|
|
965
1124
|
return { transform: `rotate(${num(deg)}deg)`, transformOrigin: "top left" };
|
|
@@ -1011,9 +1170,26 @@ function textStyles(node: FigNode, ctx?: Ctx): Record<string, string | number> {
|
|
|
1011
1170
|
styleNode?.textAlignHorizontal ?? node.textAlignHorizontal;
|
|
1012
1171
|
|
|
1013
1172
|
if (fontName?.family) {
|
|
1014
|
-
// Quote families with spaces so the inline style stays valid.
|
|
1015
1173
|
const fam = fontName.family;
|
|
1016
|
-
|
|
1174
|
+
const quoted = /\s/.test(fam) ? `"${fam}"` : fam;
|
|
1175
|
+
// Append a metric-compatible fallback stack by classifying the family.
|
|
1176
|
+
// This prevents UA serif from appearing when a Google/system font is missing.
|
|
1177
|
+
const famLower = fam.toLowerCase();
|
|
1178
|
+
let fallback: string;
|
|
1179
|
+
if (
|
|
1180
|
+
/mono|courier|code|consol|menlo|fira code|source code/i.test(famLower)
|
|
1181
|
+
) {
|
|
1182
|
+
fallback =
|
|
1183
|
+
"ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace";
|
|
1184
|
+
} else if (
|
|
1185
|
+
/serif|georgia|garamond|didot|baskerville|palatino|times/i.test(famLower)
|
|
1186
|
+
) {
|
|
1187
|
+
fallback = "'Times New Roman', Georgia, Garamond, serif";
|
|
1188
|
+
} else {
|
|
1189
|
+
fallback =
|
|
1190
|
+
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
|
1191
|
+
}
|
|
1192
|
+
out.fontFamily = `${quoted}, ${fallback}`;
|
|
1017
1193
|
}
|
|
1018
1194
|
const weight = fontWeightFromStyle(fontName?.style);
|
|
1019
1195
|
if (weight !== null) out.fontWeight = weight;
|
|
@@ -1033,24 +1209,36 @@ function textStyles(node: FigNode, ctx?: Ctx): Record<string, string | number> {
|
|
|
1033
1209
|
if (ls !== null && ls !== undefined) out.letterSpacing = ls;
|
|
1034
1210
|
if (textAlignHorizontal)
|
|
1035
1211
|
out.textAlign = TEXT_ALIGN[textAlignHorizontal] ?? "left";
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
if (
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1212
|
+
const fills = (
|
|
1213
|
+
ctx ? effectiveFillPaints(node, ctx) : node.fillPaints
|
|
1214
|
+
)?.filter((fill) => fill.visible !== false);
|
|
1215
|
+
if (!fills?.length) {
|
|
1216
|
+
out.visibility = "hidden";
|
|
1217
|
+
return out;
|
|
1218
|
+
}
|
|
1219
|
+
const firstFill = fills[0]!;
|
|
1220
|
+
if (firstFill.type === "SOLID") {
|
|
1221
|
+
const color = colorToCss(firstFill.color, firstFill.opacity ?? 1);
|
|
1222
|
+
if (color) out.color = color;
|
|
1223
|
+
} else if (
|
|
1224
|
+
ctx &&
|
|
1225
|
+
(firstFill.type?.startsWith("GRADIENT_") || firstFill.type === "IMAGE")
|
|
1226
|
+
) {
|
|
1227
|
+
const background = paintToBackground(firstFill, node, ctx);
|
|
1228
|
+
if (background) {
|
|
1229
|
+
out.background = background;
|
|
1230
|
+
out.WebkitBackgroundClip = "text";
|
|
1231
|
+
out.backgroundClip = "text";
|
|
1232
|
+
out.color = "transparent";
|
|
1046
1233
|
}
|
|
1047
1234
|
}
|
|
1048
1235
|
return out;
|
|
1049
1236
|
}
|
|
1050
1237
|
|
|
1051
1238
|
function blendModeCss(mode: string | undefined): string | null {
|
|
1052
|
-
if (!mode
|
|
1053
|
-
|
|
1239
|
+
if (!mode) return null;
|
|
1240
|
+
const result = cssBlendMode(mode);
|
|
1241
|
+
return result?.cssMode ?? null;
|
|
1054
1242
|
}
|
|
1055
1243
|
|
|
1056
1244
|
function isAutolayout(parent: FigNode | null): boolean {
|
|
@@ -1138,13 +1326,11 @@ function layoutSizing(
|
|
|
1138
1326
|
}
|
|
1139
1327
|
}
|
|
1140
1328
|
|
|
1141
|
-
// 2) Non-autolayout frames
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1329
|
+
// 2) Non-autolayout frames (e.g. Figma groups) position their children
|
|
1330
|
+
// absolutely in this renderer, and CSS `width/height: auto` cannot hug
|
|
1331
|
+
// out-of-flow children — a hugged group would collapse to 0×0 and clip
|
|
1332
|
+
// everything inside it. The baked `node.size` already equals the group's
|
|
1333
|
+
// content bounds, so keep it FIXED rather than honoring `resizeToFit`.
|
|
1148
1334
|
|
|
1149
1335
|
// 3) TEXT auto-resize hugs along the indicated axis/axes.
|
|
1150
1336
|
if (node.type === "TEXT" && node.textAutoResize) {
|
|
@@ -1215,6 +1401,22 @@ function applyAxisConstraint(
|
|
|
1215
1401
|
return false;
|
|
1216
1402
|
}
|
|
1217
1403
|
|
|
1404
|
+
function positionRelativeToParent(
|
|
1405
|
+
node: FigNode,
|
|
1406
|
+
parent: FigNode | null,
|
|
1407
|
+
ctx: Ctx,
|
|
1408
|
+
): { x: number | null; y: number | null } {
|
|
1409
|
+
const transform = node.transform;
|
|
1410
|
+
if (!transform) return { x: null, y: null };
|
|
1411
|
+
// Figma/Kiwi node transforms are already expressed relative to the parent
|
|
1412
|
+
// (the node's `relativeTransform`), so the translation is the parent-local
|
|
1413
|
+
// offset at every depth — including direct children of a top-level frame,
|
|
1414
|
+
// whose own canvas position is dropped when the frame becomes a screen root.
|
|
1415
|
+
// Subtracting the parent's canvas translation here double-counts the frame
|
|
1416
|
+
// offset and flings direct children off-canvas.
|
|
1417
|
+
return { x: num(transform.m02), y: num(transform.m12) };
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1218
1420
|
function buildCss(
|
|
1219
1421
|
node: FigNode,
|
|
1220
1422
|
parent: FigNode | null,
|
|
@@ -1239,8 +1441,7 @@ function buildCss(
|
|
|
1239
1441
|
let suppressHeight = false;
|
|
1240
1442
|
if (isPositioned) {
|
|
1241
1443
|
css.position = "absolute";
|
|
1242
|
-
const x
|
|
1243
|
-
const y = node.transform ? num(node.transform.m12) : null;
|
|
1444
|
+
const { x, y } = positionRelativeToParent(node, parent, ctx);
|
|
1244
1445
|
const nodeW = node.size ? num(node.size.x) : null;
|
|
1245
1446
|
const nodeH = node.size ? num(node.size.y) : null;
|
|
1246
1447
|
const parentW = parent?.size ? num(parent.size.x) : null;
|
|
@@ -1288,6 +1489,16 @@ function buildCss(
|
|
|
1288
1489
|
if (h !== null && emitHeight && !suppressHeight) css.height = `${h}px`;
|
|
1289
1490
|
}
|
|
1290
1491
|
|
|
1492
|
+
// Line vectors (horizontal/vertical strokes) have a 0-size axis. A 0-size
|
|
1493
|
+
// <svg> viewport is dropped by the browser even with `overflow: visible`, so
|
|
1494
|
+
// give the degenerate axis a minimal non-zero size; the stroke, arrowheads,
|
|
1495
|
+
// and caps still paint at native coords because the SVG has no viewBox here.
|
|
1496
|
+
if (vectorLike) {
|
|
1497
|
+
const minAxis = Math.max(1, num(node.strokeWeight) ?? 1);
|
|
1498
|
+
if (css.width === "0px") css.width = `${minAxis}px`;
|
|
1499
|
+
if (css.height === "0px") css.height = `${minAxis}px`;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1291
1502
|
// Auto-layout min/max size constraints. Each axis is independent and may be
|
|
1292
1503
|
// unconstrained (null). Only emit positive values — a zero constraint is a
|
|
1293
1504
|
// no-op and a min-width keeps a hugging container (e.g. a badge) circular.
|
|
@@ -1315,15 +1526,24 @@ function buildCss(
|
|
|
1315
1526
|
}
|
|
1316
1527
|
}
|
|
1317
1528
|
|
|
1529
|
+
// A vector with no decodable geometry must not paint its bounding box as a
|
|
1530
|
+
// solid fill (that renders the shape as a block); render nothing instead.
|
|
1531
|
+
const geometrylessVector =
|
|
1532
|
+
!!node.type &&
|
|
1533
|
+
VECTOR_LIKE_TYPES.has(node.type) &&
|
|
1534
|
+
!vectorLike &&
|
|
1535
|
+
!node.fillPaints?.some((p) => p.visible !== false && p.type === "IMAGE");
|
|
1536
|
+
|
|
1318
1537
|
// Background (TEXT uses fillPaints for color, not background; vector
|
|
1319
1538
|
// nodes paint via <path fill> inside the <svg>).
|
|
1320
|
-
if (node.type !== "TEXT" && !vectorLike) {
|
|
1539
|
+
if (node.type !== "TEXT" && !vectorLike && !geometrylessVector) {
|
|
1321
1540
|
Object.assign(css, backgroundShorthand(node, ctx));
|
|
1322
1541
|
}
|
|
1323
1542
|
|
|
1324
1543
|
// Border / outline (skipped for vector nodes — strokes go on <path>).
|
|
1325
1544
|
// Merge box-shadows from border (e.g. INSIDE strokes) and effects so neither overwrites the other.
|
|
1326
|
-
const borderStyle =
|
|
1545
|
+
const borderStyle =
|
|
1546
|
+
!vectorLike && !geometrylessVector ? borderShorthand(node, ctx) : {};
|
|
1327
1547
|
const { boxShadow: borderBoxShadow, ...restBorderStyle } = borderStyle;
|
|
1328
1548
|
Object.assign(css, restBorderStyle);
|
|
1329
1549
|
// Radius
|
|
@@ -1346,14 +1566,39 @@ function buildCss(
|
|
|
1346
1566
|
// Opacity / blend mode / overflow / visibility
|
|
1347
1567
|
if (typeof node.opacity === "number" && node.opacity < 0.999)
|
|
1348
1568
|
css.opacity = node.opacity;
|
|
1349
|
-
|
|
1350
|
-
|
|
1569
|
+
if (node.blendMode) {
|
|
1570
|
+
const bmResult = cssBlendMode(node.blendMode);
|
|
1571
|
+
if (bmResult) {
|
|
1572
|
+
css.mixBlendMode = bmResult.cssMode;
|
|
1573
|
+
if (bmResult.verdict === "approximated") {
|
|
1574
|
+
ctx.approximatedNodes.push({
|
|
1575
|
+
nodeId: guidKey(node.guid),
|
|
1576
|
+
nodeName: node.name,
|
|
1577
|
+
nodeType: node.type,
|
|
1578
|
+
notes: [
|
|
1579
|
+
`blend mode ${node.blendMode} approximated as ${bmResult.cssMode}`,
|
|
1580
|
+
],
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1351
1585
|
if (
|
|
1352
1586
|
(node.type === "FRAME" || node.type === "INSTANCE") &&
|
|
1353
|
-
node.frameMaskDisabled !== true
|
|
1587
|
+
node.frameMaskDisabled !== true &&
|
|
1588
|
+
// `resizeToFit` marks a group/hug container that sizes to its children and
|
|
1589
|
+
// never clips in Figma — its children legitimately overflow the baked box,
|
|
1590
|
+
// so clipping here would crop content that should be visible.
|
|
1591
|
+
node.resizeToFit !== true
|
|
1354
1592
|
) {
|
|
1355
1593
|
css.overflow = "hidden";
|
|
1356
1594
|
}
|
|
1595
|
+
// Vector <svg> elements default to clipping content to their viewport. Figma
|
|
1596
|
+
// vector bounds are the path's *fill* box, but strokes, arrowheads, and line
|
|
1597
|
+
// caps extend beyond it (and horizontal/vertical lines have a 0-size box), so
|
|
1598
|
+
// clipping erases them. `overflow: visible` lets the full geometry paint.
|
|
1599
|
+
if (vectorLike) {
|
|
1600
|
+
css.overflow = "visible";
|
|
1601
|
+
}
|
|
1357
1602
|
// (Hidden nodes are dropped entirely in emitNode; no display:none needed.)
|
|
1358
1603
|
|
|
1359
1604
|
return css;
|
|
@@ -1381,6 +1626,10 @@ interface Ctx {
|
|
|
1381
1626
|
/** Hex hash -> on-disk filename (e.g. `<hash>` or `<hash>.png`). */
|
|
1382
1627
|
imageMap: Map<string, string>;
|
|
1383
1628
|
missingImageUrl?: string;
|
|
1629
|
+
/** When true, per-node IMAGE fills with no imageMap entry emit data-figma-image-ref. */
|
|
1630
|
+
trackUnresolvedImageRefs?: boolean;
|
|
1631
|
+
/** Populated by buildAttrs() when trackUnresolvedImageRefs is true. */
|
|
1632
|
+
unresolvedImageRefs?: Set<string>;
|
|
1384
1633
|
/**
|
|
1385
1634
|
* Set of `family|weight|italic` triples seen while emitting the current
|
|
1386
1635
|
* frame. We use it to build a Google Fonts <link> in <head> so the custom
|
|
@@ -1395,6 +1644,13 @@ interface Ctx {
|
|
|
1395
1644
|
maxFrameOutputBytes: number;
|
|
1396
1645
|
maxTotalOutputBytes: number;
|
|
1397
1646
|
totalOutputBytes: number;
|
|
1647
|
+
/** Collect fidelity verdicts for approximated nodes. */
|
|
1648
|
+
approximatedNodes: Array<{
|
|
1649
|
+
nodeId: string;
|
|
1650
|
+
nodeName?: string;
|
|
1651
|
+
nodeType?: string;
|
|
1652
|
+
notes: string[];
|
|
1653
|
+
}>;
|
|
1398
1654
|
}
|
|
1399
1655
|
|
|
1400
1656
|
/**
|
|
@@ -1451,11 +1707,129 @@ function decodePathCommands(bytes: Buffer | undefined): string {
|
|
|
1451
1707
|
return out.join(" ");
|
|
1452
1708
|
}
|
|
1453
1709
|
|
|
1454
|
-
/**
|
|
1710
|
+
/**
|
|
1711
|
+
* Decode a Figma vector-network blob into an SVG path. The clipboard ships this
|
|
1712
|
+
* editable `vectorData.vectorNetworkBlob` instead of a flattened `commandsBlob`,
|
|
1713
|
+
* so it's the only vector geometry a no-token paste has. Format (little-endian,
|
|
1714
|
+
* reverse-engineered from real `.fig` data):
|
|
1715
|
+
* header : u32 vertexCount, segmentCount, regionCount, _reserved
|
|
1716
|
+
* vertices: vertexCount × { f32 x, f32 y, u32 styleID } (12 B)
|
|
1717
|
+
* segments: segmentCount × { u32 startVtx, f32 tanStart{x,y},
|
|
1718
|
+
* u32 endVtx, f32 tanEnd{x,y}, u32 _ } (24 B / 28 stride)
|
|
1719
|
+
* Each segment is the cubic P0=vtx[start], P1=P0+tanStart, P2=vtx[end]+tanEnd,
|
|
1720
|
+
* P3=vtx[end] (zero tangents → a line); segments chain end→start into subpaths.
|
|
1721
|
+
*/
|
|
1722
|
+
interface DecodedVectorNetwork {
|
|
1723
|
+
d: string;
|
|
1724
|
+
/** End stroke-cap is an arrow/marker type (cap enum ≥ 3; 0/1/2 = none/round/square). */
|
|
1725
|
+
arrowEnd: boolean;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function decodeVectorNetwork(bytes: Buffer | undefined): DecodedVectorNetwork {
|
|
1729
|
+
const empty: DecodedVectorNetwork = { d: "", arrowEnd: false };
|
|
1730
|
+
if (!bytes || bytes.length < 16) return empty;
|
|
1731
|
+
const vertexCount = bytes.readUInt32LE(0);
|
|
1732
|
+
const segmentCount = bytes.readUInt32LE(4);
|
|
1733
|
+
if (vertexCount > 200_000 || segmentCount > 200_000) return empty;
|
|
1734
|
+
const arrowEnd = bytes.readUInt32LE(12) >= 3;
|
|
1735
|
+
|
|
1736
|
+
const verts: Array<{ x: number; y: number }> = [];
|
|
1737
|
+
for (let i = 0; i < vertexCount; i++) {
|
|
1738
|
+
const o = 16 + i * 12;
|
|
1739
|
+
if (o + 8 > bytes.length) break;
|
|
1740
|
+
verts.push({ x: bytes.readFloatLE(o), y: bytes.readFloatLE(o + 4) });
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
interface Seg {
|
|
1744
|
+
s: number;
|
|
1745
|
+
sx: number;
|
|
1746
|
+
sy: number;
|
|
1747
|
+
e: number;
|
|
1748
|
+
ex: number;
|
|
1749
|
+
ey: number;
|
|
1750
|
+
}
|
|
1751
|
+
const segStart = 16 + vertexCount * 12;
|
|
1752
|
+
const segs: Seg[] = [];
|
|
1753
|
+
for (let i = 0; i < segmentCount; i++) {
|
|
1754
|
+
const o = segStart + i * 28;
|
|
1755
|
+
if (o + 24 > bytes.length) break;
|
|
1756
|
+
segs.push({
|
|
1757
|
+
s: bytes.readUInt32LE(o),
|
|
1758
|
+
sx: bytes.readFloatLE(o + 4),
|
|
1759
|
+
sy: bytes.readFloatLE(o + 8),
|
|
1760
|
+
e: bytes.readUInt32LE(o + 12),
|
|
1761
|
+
ex: bytes.readFloatLE(o + 16),
|
|
1762
|
+
ey: bytes.readFloatLE(o + 20),
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
if (segs.length === 0) return empty;
|
|
1766
|
+
|
|
1767
|
+
const fmt = (n: number) => {
|
|
1768
|
+
if (!Number.isFinite(n)) return "0";
|
|
1769
|
+
const r = Math.round(n * 1000) / 1000;
|
|
1770
|
+
return Object.is(r, -0) ? "0" : String(r);
|
|
1771
|
+
};
|
|
1772
|
+
const out: string[] = [];
|
|
1773
|
+
const used = new Array<boolean>(segs.length).fill(false);
|
|
1774
|
+
|
|
1775
|
+
for (let start = 0; start < segs.length; start++) {
|
|
1776
|
+
if (used[start]) continue;
|
|
1777
|
+
// Trace a connected chain: each segment's end vertex feeds the next
|
|
1778
|
+
// segment's start vertex.
|
|
1779
|
+
const chain: Seg[] = [];
|
|
1780
|
+
let cur: number | null = start;
|
|
1781
|
+
while (cur !== null && !used[cur]) {
|
|
1782
|
+
used[cur] = true;
|
|
1783
|
+
chain.push(segs[cur]!);
|
|
1784
|
+
const endV = segs[cur]!.e;
|
|
1785
|
+
let next: number | null = null;
|
|
1786
|
+
for (let j = 0; j < segs.length; j++) {
|
|
1787
|
+
if (!used[j] && segs[j]!.s === endV) {
|
|
1788
|
+
next = j;
|
|
1789
|
+
break;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
cur = next;
|
|
1793
|
+
}
|
|
1794
|
+
const first = chain[0]!;
|
|
1795
|
+
const p0 = verts[first.s];
|
|
1796
|
+
if (!p0) continue;
|
|
1797
|
+
out.push(`M${fmt(p0.x)} ${fmt(p0.y)}`);
|
|
1798
|
+
for (const seg of chain) {
|
|
1799
|
+
const a = verts[seg.s];
|
|
1800
|
+
const b = verts[seg.e];
|
|
1801
|
+
if (!a || !b) continue;
|
|
1802
|
+
const straight =
|
|
1803
|
+
seg.sx === 0 && seg.sy === 0 && seg.ex === 0 && seg.ey === 0;
|
|
1804
|
+
if (straight) {
|
|
1805
|
+
out.push(`L${fmt(b.x)} ${fmt(b.y)}`);
|
|
1806
|
+
} else {
|
|
1807
|
+
out.push(
|
|
1808
|
+
`C${fmt(a.x + seg.sx)} ${fmt(a.y + seg.sy)} ${fmt(b.x + seg.ex)} ${fmt(b.y + seg.ey)} ${fmt(b.x)} ${fmt(b.y)}`,
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
if (chain.length > 0 && chain[chain.length - 1]!.e === first.s) {
|
|
1813
|
+
out.push("Z");
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
return { d: out.join(" "), arrowEnd };
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
/**
|
|
1820
|
+
* SVG paint attribute (fill / stroke) for the visible solid paint. Figma
|
|
1821
|
+
* composites a node's paint list bottom-to-top, so the LAST opaque solid is the
|
|
1822
|
+
* one actually seen — e.g. a stroke stacked `[cyan, pink]` renders pink. Pick
|
|
1823
|
+
* the topmost visible solid rather than the first.
|
|
1824
|
+
*/
|
|
1455
1825
|
function paintToSvgFill(
|
|
1456
1826
|
paints: Paint[] | undefined,
|
|
1457
1827
|
): { color: string; opacity?: number } | null {
|
|
1458
|
-
|
|
1828
|
+
let p: Paint | undefined;
|
|
1829
|
+
for (const candidate of paints ?? []) {
|
|
1830
|
+
if (candidate.visible !== false && candidate.type === "SOLID")
|
|
1831
|
+
p = candidate;
|
|
1832
|
+
}
|
|
1459
1833
|
if (!p || !p.color) return null;
|
|
1460
1834
|
const c = p.color;
|
|
1461
1835
|
const r = Math.round(c.r * 255);
|
|
@@ -1481,10 +1855,13 @@ const VECTOR_LIKE_TYPES = new Set([
|
|
|
1481
1855
|
|
|
1482
1856
|
function isVectorLike(node: FigNode): boolean {
|
|
1483
1857
|
if (!node.type || !VECTOR_LIKE_TYPES.has(node.type)) return false;
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1858
|
+
// Flattened geometry (saved .fig / REST) OR an editable vector network
|
|
1859
|
+
// (clipboard paste) — either lets us draw the real shape as <svg>.
|
|
1860
|
+
const hasFlatGeometry =
|
|
1861
|
+
(node.fillGeometry?.length ?? 0) > 0 ||
|
|
1862
|
+
(node.strokeGeometry?.length ?? 0) > 0;
|
|
1863
|
+
const hasNetwork = typeof node.vectorData?.vectorNetworkBlob === "number";
|
|
1864
|
+
if (!hasFlatGeometry && !hasNetwork) {
|
|
1488
1865
|
return false;
|
|
1489
1866
|
}
|
|
1490
1867
|
// Nodes with an IMAGE fill render better as a regular <div> with
|
|
@@ -1517,11 +1894,14 @@ function emitSvgBody(
|
|
|
1517
1894
|
const strokePaint = paintToSvgFill(effectiveStrokePaints(node, ctx));
|
|
1518
1895
|
const strokeWeight = node.strokeWeight ?? 0;
|
|
1519
1896
|
|
|
1897
|
+
let emittedFlat = false;
|
|
1898
|
+
|
|
1520
1899
|
// Fill paths
|
|
1521
1900
|
for (const g of node.fillGeometry ?? []) {
|
|
1522
1901
|
if (typeof g.commandsBlob !== "number") continue;
|
|
1523
1902
|
const d = decodePathCommands(ctx.blobs[g.commandsBlob]);
|
|
1524
1903
|
if (!d) continue;
|
|
1904
|
+
emittedFlat = true;
|
|
1525
1905
|
const attrs = [`d="${d}"`, `fill-rule="${fillRule}"`];
|
|
1526
1906
|
if (fillPaint) {
|
|
1527
1907
|
attrs.push(`fill="${fillPaint.color}"`);
|
|
@@ -1538,6 +1918,7 @@ function emitSvgBody(
|
|
|
1538
1918
|
if (typeof g.commandsBlob !== "number") continue;
|
|
1539
1919
|
const d = decodePathCommands(ctx.blobs[g.commandsBlob]);
|
|
1540
1920
|
if (!d) continue;
|
|
1921
|
+
emittedFlat = true;
|
|
1541
1922
|
const attrs = [
|
|
1542
1923
|
`d="${d}"`,
|
|
1543
1924
|
`fill="none"`,
|
|
@@ -1553,10 +1934,61 @@ function emitSvgBody(
|
|
|
1553
1934
|
lines.push(`${indent} <path ${attrs.join(" ")} />`);
|
|
1554
1935
|
}
|
|
1555
1936
|
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1937
|
+
|
|
1938
|
+
// Vector-network fallback (clipboard paste ships only the editable network,
|
|
1939
|
+
// not flattened geometry). Decode it to a path and paint it with the node's
|
|
1940
|
+
// fill/stroke. Network coords are in `normalizedSize` space, so scale into
|
|
1941
|
+
// the node's box (the SVG viewBox is 0 0 w h).
|
|
1942
|
+
if (!emittedFlat && typeof node.vectorData?.vectorNetworkBlob === "number") {
|
|
1943
|
+
const net = decodeVectorNetwork(
|
|
1944
|
+
ctx.blobs[node.vectorData.vectorNetworkBlob],
|
|
1945
|
+
);
|
|
1946
|
+
if (net.d) {
|
|
1947
|
+
const d = net.d;
|
|
1948
|
+
const ns = node.vectorData.normalizedSize;
|
|
1949
|
+
const sx = ns && ns.x ? (w || ns.x) / ns.x : 1;
|
|
1950
|
+
const sy = ns && ns.y ? (h || ns.y) / ns.y : 1;
|
|
1951
|
+
const scaled = Math.abs(sx - 1) > 1e-6 || Math.abs(sy - 1) > 1e-6;
|
|
1952
|
+
const inner = scaled ? `${indent} ` : indent;
|
|
1953
|
+
// Arrow end-cap → SVG `<marker>` (SVG strokes have no arrow linecap). It
|
|
1954
|
+
// scales with stroke width and auto-orients to the path's end direction.
|
|
1955
|
+
const arrowId =
|
|
1956
|
+
net.arrowEnd && strokePaint && strokeWeight > 0
|
|
1957
|
+
? `ah-${guidKey(node.guid).replace(/[^a-z0-9]/gi, "")}`
|
|
1958
|
+
: null;
|
|
1959
|
+
if (arrowId) {
|
|
1960
|
+
lines.push(
|
|
1961
|
+
`${inner} <marker id="${arrowId}" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse" markerUnits="strokeWidth"><path d="M0 0 L10 5 L0 10 z" fill="${strokePaint!.color}" /></marker>`,
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
if (scaled)
|
|
1965
|
+
lines.push(`${indent} <g transform="scale(${num(sx)} ${num(sy)})">`);
|
|
1966
|
+
if (fillPaint) {
|
|
1967
|
+
const a = [
|
|
1968
|
+
`d="${d}"`,
|
|
1969
|
+
`fill-rule="${fillRule}"`,
|
|
1970
|
+
`fill="${fillPaint.color}"`,
|
|
1971
|
+
];
|
|
1972
|
+
if (fillPaint.opacity !== undefined)
|
|
1973
|
+
a.push(`fill-opacity="${fillPaint.opacity}"`);
|
|
1974
|
+
lines.push(`${inner} <path ${a.join(" ")} />`);
|
|
1975
|
+
}
|
|
1976
|
+
if (strokePaint && strokeWeight > 0) {
|
|
1977
|
+
const a = [
|
|
1978
|
+
`d="${d}"`,
|
|
1979
|
+
`fill="none"`,
|
|
1980
|
+
`stroke="${strokePaint.color}"`,
|
|
1981
|
+
`stroke-width="${num(strokeWeight)}"`,
|
|
1982
|
+
`stroke-linejoin="${(node.strokeJoin ?? "ROUND").toLowerCase()}"`,
|
|
1983
|
+
`stroke-linecap="${(node.strokeCap ?? "ROUND").toLowerCase()}"`,
|
|
1984
|
+
];
|
|
1985
|
+
if (arrowId) a.push(`marker-start="url(#${arrowId})"`);
|
|
1986
|
+
if (strokePaint.opacity !== undefined)
|
|
1987
|
+
a.push(`stroke-opacity="${strokePaint.opacity}"`);
|
|
1988
|
+
lines.push(`${inner} <path ${a.join(" ")} />`);
|
|
1989
|
+
}
|
|
1990
|
+
if (scaled) lines.push(`${indent} </g>`);
|
|
1991
|
+
}
|
|
1560
1992
|
}
|
|
1561
1993
|
}
|
|
1562
1994
|
|
|
@@ -1665,6 +2097,23 @@ function buildAttrs(
|
|
|
1665
2097
|
if (Object.keys(css).length > 0) {
|
|
1666
2098
|
attrs.push(`style="${escapeHtmlAttr(formatStyleString(css))}"`);
|
|
1667
2099
|
}
|
|
2100
|
+
|
|
2101
|
+
// Stamp data-figma-image-ref on elements whose IMAGE fills have no imageMap
|
|
2102
|
+
// entry. The sentinel placeholder URLs in the style (written by imageUrl())
|
|
2103
|
+
// can then be swapped for real URLs by hydrate-figma-paste-images without a
|
|
2104
|
+
// full re-render. Only active when the caller opts in via trackUnresolvedImageRefs.
|
|
2105
|
+
if (ctx.trackUnresolvedImageRefs) {
|
|
2106
|
+
const unresolvedHashes = (effectiveFillPaints(node, ctx) ?? [])
|
|
2107
|
+
.filter((p) => p.visible !== false && p.type === "IMAGE")
|
|
2108
|
+
.map((p) => hashToHex(p.image?.hash))
|
|
2109
|
+
.filter((h): h is string => h !== null && !ctx.imageMap.has(h));
|
|
2110
|
+
if (unresolvedHashes.length > 0) {
|
|
2111
|
+
const joined = unresolvedHashes.join(" ");
|
|
2112
|
+
attrs.push(`data-figma-image-ref="${escapeHtmlAttr(joined)}"`);
|
|
2113
|
+
for (const h of unresolvedHashes) ctx.unresolvedImageRefs?.add(h);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
|
|
1668
2117
|
return attrs;
|
|
1669
2118
|
}
|
|
1670
2119
|
|
|
@@ -1925,9 +2374,18 @@ function emitNode(
|
|
|
1925
2374
|
// relative to it. Check the actually-rendered children (the inlined
|
|
1926
2375
|
// master's, for an INSTANCE).
|
|
1927
2376
|
const childSource = inlinedSymbol ?? node;
|
|
1928
|
-
const
|
|
1929
|
-
|
|
1930
|
-
|
|
2377
|
+
const childrenOfSource = ctx.childrenOf.get(guidKey(childSource.guid)) ?? [];
|
|
2378
|
+
const hasAbsoluteChild =
|
|
2379
|
+
childrenOfSource.some((c) => c.stackPositioning === "ABSOLUTE") ||
|
|
2380
|
+
// Non-autolayout frames/instances that are NOT themselves absolutely
|
|
2381
|
+
// positioned need position:relative so their absolutely-positioned children
|
|
2382
|
+
// (all children in non-flex mode) are offset relative to THIS element, not
|
|
2383
|
+
// the nearest positioned ancestor further up the tree (e.g. <html> for
|
|
2384
|
+
// top-level frames). Without this, children near the bottom of a frame
|
|
2385
|
+
// escape the frame's overflow:hidden box and appear at wrong viewport coords.
|
|
2386
|
+
(!isPositioned &&
|
|
2387
|
+
(!layoutNode.stackMode || layoutNode.stackMode === "NONE") &&
|
|
2388
|
+
childrenOfSource.length > 0);
|
|
1931
2389
|
// A container whose drop shadow must wrap an overflowing absolute child
|
|
1932
2390
|
// (e.g. a tooltip caret) needs `filter: drop-shadow()` rather than
|
|
1933
2391
|
// `box-shadow`, which would only trace the body's box. Children render from
|
|
@@ -1951,7 +2409,12 @@ function emitNode(
|
|
|
1951
2409
|
// the master.
|
|
1952
2410
|
const vw = vectorSourceNode.size?.x ?? node.size?.x ?? 0;
|
|
1953
2411
|
const vh = vectorSourceNode.size?.y ?? node.size?.y ?? 0;
|
|
1954
|
-
|
|
2412
|
+
// A viewBox with a 0 (or sub-pixel, rounds-to-0) dimension is degenerate —
|
|
2413
|
+
// the browser can't map coordinates and drops the whole SVG. Horizontal /
|
|
2414
|
+
// vertical line vectors have exactly this shape (one dimension ~0), so emit
|
|
2415
|
+
// a viewBox only when both dimensions survive rounding; otherwise the SVG
|
|
2416
|
+
// paints its geometry at native 1:1 coords under `overflow: visible`.
|
|
2417
|
+
if (num(vw)! > 0 && num(vh)! > 0) {
|
|
1955
2418
|
attrs.push(`viewBox="0 0 ${num(vw)} ${num(vh)}"`);
|
|
1956
2419
|
}
|
|
1957
2420
|
attrs.push(`xmlns="http://www.w3.org/2000/svg"`);
|
|
@@ -1994,8 +2457,22 @@ function emitNode(
|
|
|
1994
2457
|
emitOpenWithChildren(tag, attrs, indent, lines);
|
|
1995
2458
|
// Preserve newlines in the source by splitting into <br>-separated lines
|
|
1996
2459
|
// (HTML otherwise collapses whitespace).
|
|
1997
|
-
const
|
|
1998
|
-
|
|
2460
|
+
const runs = textStyleRuns(node);
|
|
2461
|
+
const toHtml = (s: string) => escapeHtmlText(s).replace(/\n/g, "<br>");
|
|
2462
|
+
if (runs.length <= 1) {
|
|
2463
|
+
lines.push(`${indent} ${toHtml(chars)}`);
|
|
2464
|
+
} else {
|
|
2465
|
+
// Per-character color runs → one <span> per run; base-color runs inherit
|
|
2466
|
+
// the element's `color`, overridden runs carry their own.
|
|
2467
|
+
const html = runs
|
|
2468
|
+
.map((r) =>
|
|
2469
|
+
r.color
|
|
2470
|
+
? `<span style="color: ${r.color}">${toHtml(r.text)}</span>`
|
|
2471
|
+
: toHtml(r.text),
|
|
2472
|
+
)
|
|
2473
|
+
.join("");
|
|
2474
|
+
lines.push(`${indent} ${html}`);
|
|
2475
|
+
}
|
|
1999
2476
|
lines.push(`${indent}</${tag}>`);
|
|
2000
2477
|
return;
|
|
2001
2478
|
}
|
|
@@ -2127,7 +2604,7 @@ function emitFrameTemplate(frame: FigNode, ctx: Ctx, pageName: string): string {
|
|
|
2127
2604
|
// Default CSS content-box would inflate every explicit width/height/min-size
|
|
2128
2605
|
// by the padding + border, so normalize to border-box.
|
|
2129
2606
|
lines.push(
|
|
2130
|
-
" <style>*, *::before, *::after { box-sizing: border-box; }</style>",
|
|
2607
|
+
" <style>*, *::before, *::after { box-sizing: border-box; } body { margin: 0; padding: 0; }</style>",
|
|
2131
2608
|
);
|
|
2132
2609
|
// Custom font families used by the frame -> request them from Google
|
|
2133
2610
|
// Fonts. (Smart-export does the same for design hand-off so the layout
|
|
@@ -2180,10 +2657,21 @@ export interface RenderedFrame {
|
|
|
2180
2657
|
height?: number;
|
|
2181
2658
|
}
|
|
2182
2659
|
|
|
2660
|
+
export interface RenderHtmlFidelityEntry {
|
|
2661
|
+
nodeId: string;
|
|
2662
|
+
nodeName?: string;
|
|
2663
|
+
nodeType?: string;
|
|
2664
|
+
notes: string[];
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2183
2667
|
export interface RenderHtmlResult {
|
|
2184
2668
|
pageCount: number;
|
|
2185
2669
|
frameCount: number;
|
|
2186
2670
|
frames: RenderedFrame[];
|
|
2671
|
+
/** Populated when `trackUnresolvedImageRefs` is true in RenderHtmlOptions. */
|
|
2672
|
+
unresolvedImageRefs?: Set<string>;
|
|
2673
|
+
/** Approximated nodes collected during rendering. */
|
|
2674
|
+
approximatedNodes: RenderHtmlFidelityEntry[];
|
|
2187
2675
|
}
|
|
2188
2676
|
|
|
2189
2677
|
export interface RenderHtmlOptions {
|
|
@@ -2193,6 +2681,13 @@ export interface RenderHtmlOptions {
|
|
|
2193
2681
|
imageMap?: Map<string, string>;
|
|
2194
2682
|
/** Safe URL used when an embedded image was omitted (for example no storage provider). */
|
|
2195
2683
|
missingImageUrl?: string;
|
|
2684
|
+
/**
|
|
2685
|
+
* When true, any node whose IMAGE fill hash is absent from `imageMap` gets a
|
|
2686
|
+
* `data-figma-image-ref="<hexHash>"` attribute stamped on its rendered element.
|
|
2687
|
+
* The collected set is returned as `unresolvedImageRefs` in the result so
|
|
2688
|
+
* callers know which hashes need retroactive resolution.
|
|
2689
|
+
*/
|
|
2690
|
+
trackUnresolvedImageRefs?: boolean;
|
|
2196
2691
|
/*** Optional set of page (CANVAS) and/or frame GUID keys */
|
|
2197
2692
|
selection?: Set<string>;
|
|
2198
2693
|
maxFrames?: number;
|
|
@@ -2485,8 +2980,13 @@ export function renderHtmlTemplates(
|
|
|
2485
2980
|
blobs,
|
|
2486
2981
|
imageMap: options.imageMap ?? new Map<string, string>(),
|
|
2487
2982
|
missingImageUrl: options.missingImageUrl,
|
|
2983
|
+
trackUnresolvedImageRefs: options.trackUnresolvedImageRefs,
|
|
2984
|
+
unresolvedImageRefs: options.trackUnresolvedImageRefs
|
|
2985
|
+
? new Set<string>()
|
|
2986
|
+
: undefined,
|
|
2488
2987
|
fontUsage: new Set(),
|
|
2489
2988
|
inliningStack: new Set(),
|
|
2989
|
+
approximatedNodes: [],
|
|
2490
2990
|
renderedNodeCount: 0,
|
|
2491
2991
|
maxRenderedNodes: options.maxRenderedNodes ?? DEFAULT_MAX_RENDERED_NODES,
|
|
2492
2992
|
maxTreeDepth: options.maxTreeDepth ?? DEFAULT_MAX_TREE_DEPTH,
|
|
@@ -2498,7 +2998,8 @@ export function renderHtmlTemplates(
|
|
|
2498
2998
|
};
|
|
2499
2999
|
|
|
2500
3000
|
const documentNode = nodes.find((n) => n.type === "DOCUMENT");
|
|
2501
|
-
if (!documentNode)
|
|
3001
|
+
if (!documentNode)
|
|
3002
|
+
return { pageCount: 0, frameCount: 0, frames: [], approximatedNodes: [] };
|
|
2502
3003
|
|
|
2503
3004
|
const allPages = (childrenOf.get(guidKey(documentNode.guid)) ?? []).filter(
|
|
2504
3005
|
(n) => n.type === "CANVAS" && !n.internalOnly,
|
|
@@ -2564,5 +3065,9 @@ export function renderHtmlTemplates(
|
|
|
2564
3065
|
pageCount: pages.length,
|
|
2565
3066
|
frameCount: frames.length,
|
|
2566
3067
|
frames,
|
|
3068
|
+
approximatedNodes: ctx.approximatedNodes,
|
|
3069
|
+
...(ctx.unresolvedImageRefs !== undefined
|
|
3070
|
+
? { unresolvedImageRefs: ctx.unresolvedImageRefs }
|
|
3071
|
+
: {}),
|
|
2567
3072
|
};
|
|
2568
3073
|
}
|