@leviosa-ai/detail-page-editor 0.1.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/components/cardnews/color-input.tsx +380 -0
- package/components/detail-page/ai-generate-panel.tsx +763 -0
- package/components/detail-page/annotation-canvas.tsx +860 -0
- package/components/detail-page/annotation-dialog.tsx +397 -0
- package/components/detail-page/bubble-tail-overlay.tsx +209 -0
- package/components/detail-page/canvas-context-menu.tsx +234 -0
- package/components/detail-page/canvas-observer.tsx +68 -0
- package/components/detail-page/canvas-overlay-host.tsx +44 -0
- package/components/detail-page/canvas-store-facade.ts +51 -0
- package/components/detail-page/chart-inspector.tsx +531 -0
- package/components/detail-page/design-reference-picker.tsx +383 -0
- package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
- package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
- package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
- package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
- package/components/detail-page/detail-page-charts-panel.tsx +104 -0
- package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
- package/components/detail-page/detail-page-download-dialog.tsx +524 -0
- package/components/detail-page/detail-page-editor.tsx +974 -0
- package/components/detail-page/detail-page-elements-panel.tsx +117 -0
- package/components/detail-page/detail-page-font-picker.tsx +231 -0
- package/components/detail-page/detail-page-history-buttons.tsx +87 -0
- package/components/detail-page/detail-page-host-context.tsx +465 -0
- package/components/detail-page/detail-page-icons-panel.tsx +251 -0
- package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
- package/components/detail-page/detail-page-layers-panel.tsx +618 -0
- package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
- package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
- package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
- package/components/detail-page/detail-page-pages-panel.tsx +299 -0
- package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
- package/components/detail-page/detail-page-photos-panel.tsx +257 -0
- package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
- package/components/detail-page/detail-page-qr-panel.tsx +137 -0
- package/components/detail-page/detail-page-references-panel.tsx +166 -0
- package/components/detail-page/detail-page-selection.ts +44 -0
- package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
- package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
- package/components/detail-page/detail-page-tables-panel.tsx +106 -0
- package/components/detail-page/detail-page-text-panel.tsx +179 -0
- package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
- package/components/detail-page/edit-quota-ui.tsx +150 -0
- package/components/detail-page/editor-history.ts +56 -0
- package/components/detail-page/editor-hotkeys.tsx +165 -0
- package/components/detail-page/element-recents-strip.tsx +105 -0
- package/components/detail-page/element-rects.ts +82 -0
- package/components/detail-page/fill-control.tsx +261 -0
- package/components/detail-page/find-replace-panel.tsx +252 -0
- package/components/detail-page/generation-progress-fill.tsx +57 -0
- package/components/detail-page/gif-animator.tsx +184 -0
- package/components/detail-page/gif-effect-picker.tsx +136 -0
- package/components/detail-page/group-drill-in.tsx +219 -0
- package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
- package/components/detail-page/hover-highlight-overlay.tsx +111 -0
- package/components/detail-page/hovered-layer.ts +34 -0
- package/components/detail-page/inspector-controls.tsx +175 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
- package/components/detail-page/nested-selection-overlay.tsx +364 -0
- package/components/detail-page/panel-search-input.tsx +49 -0
- package/components/detail-page/prompt-edit-panel.tsx +146 -0
- package/components/detail-page/section-height-handle.tsx +276 -0
- package/components/detail-page/section-reauthor-controller.tsx +210 -0
- package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
- package/components/detail-page/spec-resize-absorber.tsx +149 -0
- package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
- package/components/detail-page/table-canvas-overlay.tsx +707 -0
- package/components/detail-page/table-inspector.tsx +586 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/command.tsx +175 -0
- package/components/ui/dialog.tsx +167 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/select.tsx +190 -0
- package/components/ui/slider.tsx +63 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/tooltip.tsx +57 -0
- package/config/detail-page-bundle-font-tags.json +39 -0
- package/config/detail-page-fonts.json +373 -0
- package/index.ts +24 -0
- package/lib/cardnews/font-loader.ts +306 -0
- package/lib/detail-page/animation-sniff.ts +101 -0
- package/lib/detail-page/asset-bytes-url.ts +70 -0
- package/lib/detail-page/basic-shapes.ts +603 -0
- package/lib/detail-page/canvas-menu.ts +182 -0
- package/lib/detail-page/chart/defaults.ts +283 -0
- package/lib/detail-page/chart/format.ts +66 -0
- package/lib/detail-page/chart/normalize.ts +149 -0
- package/lib/detail-page/chart/palette.ts +85 -0
- package/lib/detail-page/chart/paste.ts +82 -0
- package/lib/detail-page/chart/preview.ts +78 -0
- package/lib/detail-page/chart/render.ts +188 -0
- package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
- package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
- package/lib/detail-page/chart/renderers/donut.ts +186 -0
- package/lib/detail-page/chart/renderers/gauge.ts +96 -0
- package/lib/detail-page/chart/renderers/line.ts +143 -0
- package/lib/detail-page/chart/renderers/shared.ts +263 -0
- package/lib/detail-page/chart/renderers/stack.ts +121 -0
- package/lib/detail-page/chart/sync.ts +92 -0
- package/lib/detail-page/chart/types.ts +145 -0
- package/lib/detail-page/css-color.ts +34 -0
- package/lib/detail-page/data-gif-payload.ts +167 -0
- package/lib/detail-page/design-reference.ts +244 -0
- package/lib/detail-page/distribute.ts +89 -0
- package/lib/detail-page/editor-routes.ts +10 -0
- package/lib/detail-page/element-recents.ts +167 -0
- package/lib/detail-page/find-replace.ts +120 -0
- package/lib/detail-page/format-painter.ts +159 -0
- package/lib/detail-page/gif-frames.ts +216 -0
- package/lib/detail-page/gif-timeline.ts +65 -0
- package/lib/detail-page/group-action.ts +62 -0
- package/lib/detail-page/hangul-roman.ts +58 -0
- package/lib/detail-page/icon-curated.ts +290 -0
- package/lib/detail-page/icon-fuzzy.ts +250 -0
- package/lib/detail-page/icon-keywords.generated.ts +3766 -0
- package/lib/detail-page/icon-keywords.ko.ts +524 -0
- package/lib/detail-page/icon-search.ts +257 -0
- package/lib/detail-page/icons.ts +67 -0
- package/lib/detail-page/image-credit.ts +121 -0
- package/lib/detail-page/image-data-uri.ts +29 -0
- package/lib/detail-page/insert-image.ts +75 -0
- package/lib/detail-page/insert-shape.ts +49 -0
- package/lib/detail-page/korean-dictionary.ts +187 -0
- package/lib/detail-page/layer-move.ts +257 -0
- package/lib/detail-page/new-product-draft-store.ts +105 -0
- package/lib/detail-page/new-product-draft.ts +283 -0
- package/lib/detail-page/personal-images-refresh.ts +30 -0
- package/lib/detail-page/product-source.ts +587 -0
- package/lib/detail-page/qr-code.ts +214 -0
- package/lib/detail-page/reference-image.ts +103 -0
- package/lib/detail-page/reference-upload.ts +181 -0
- package/lib/detail-page/replace-page.ts +35 -0
- package/lib/detail-page/replace-with-gif.ts +251 -0
- package/lib/detail-page/section-height.ts +131 -0
- package/lib/detail-page/section-reauthor-bus.ts +61 -0
- package/lib/detail-page/shape-to-image.ts +219 -0
- package/lib/detail-page/spec-group/clipboard.ts +35 -0
- package/lib/detail-page/spec-group/sync.ts +377 -0
- package/lib/detail-page/spec-group/text-metrics.ts +48 -0
- package/lib/detail-page/stock-photos.ts +88 -0
- package/lib/detail-page/svg-colors.ts +74 -0
- package/lib/detail-page/table/defaults.ts +344 -0
- package/lib/detail-page/table/edit.ts +312 -0
- package/lib/detail-page/table/harvest.ts +103 -0
- package/lib/detail-page/table/normalize.ts +125 -0
- package/lib/detail-page/table/paste.ts +49 -0
- package/lib/detail-page/table/preview.ts +66 -0
- package/lib/detail-page/table/render.ts +388 -0
- package/lib/detail-page/table/sync.ts +107 -0
- package/lib/detail-page/table/types.ts +102 -0
- package/lib/detail-page/text-gif-layout.ts +245 -0
- package/lib/detail-page/text-gif-payload.ts +67 -0
- package/lib/detail-page/text-presets.ts +230 -0
- package/lib/detail-page/use-fake-progress.ts +67 -0
- package/lib/detail-page/z-order.ts +82 -0
- package/lib/detail-page-canvas/bubble-path.ts +190 -0
- package/lib/detail-page-canvas/canvas-key.ts +22 -0
- package/lib/detail-page-canvas/clip-rect.ts +65 -0
- package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
- package/lib/detail-page-canvas/editor-fonts.ts +161 -0
- package/lib/detail-page-canvas/export/ai.ts +413 -0
- package/lib/detail-page-canvas/export/color.ts +35 -0
- package/lib/detail-page-canvas/export/document-model.ts +78 -0
- package/lib/detail-page-canvas/export/export-files.ts +290 -0
- package/lib/detail-page-canvas/export/font-subset.ts +264 -0
- package/lib/detail-page-canvas/export/gif-export.ts +310 -0
- package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
- package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
- package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
- package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
- package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
- package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
- package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
- package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
- package/lib/detail-page-canvas/export/psd.ts +354 -0
- package/lib/detail-page-canvas/export/raster.ts +255 -0
- package/lib/detail-page-canvas/export/svg.ts +479 -0
- package/lib/detail-page-canvas/export/text-layout.ts +142 -0
- package/lib/detail-page-canvas/font-catalog.ts +177 -0
- package/lib/detail-page-canvas/font-tags.ts +47 -0
- package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
- package/lib/detail-page-canvas/konva-fallback.ts +477 -0
- package/lib/detail-page-canvas/text-background-plan.ts +124 -0
- package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
- package/lib/utils.ts +10 -0
- package/package.json +68 -0
- package/types/commerce.ts +171 -0
- package/types/detail-page-api.ts +200 -0
- package/types/detail-page-canvas.ts +20 -0
- package/types/fontkit.d.ts +42 -0
- package/types/gifenc.d.ts +51 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import type { Layer, Psd } from "ag-psd";
|
|
2
|
+
|
|
3
|
+
import { parseColor } from "./color";
|
|
4
|
+
import { parseCssGradient } from "../konva-fallback";
|
|
5
|
+
import {
|
|
6
|
+
documentWidth,
|
|
7
|
+
pageHeight,
|
|
8
|
+
selectPages,
|
|
9
|
+
type ExportDocument,
|
|
10
|
+
type ExportElement,
|
|
11
|
+
} from "./document-model";
|
|
12
|
+
import {
|
|
13
|
+
drawBitmap,
|
|
14
|
+
drawFigure,
|
|
15
|
+
drawPlaceholder,
|
|
16
|
+
drawText,
|
|
17
|
+
normalizeFontWeight,
|
|
18
|
+
transformText,
|
|
19
|
+
type DrawableImage,
|
|
20
|
+
type Raster2D,
|
|
21
|
+
} from "./raster";
|
|
22
|
+
import { cssFont, layoutText, resolveLeading } from "./text-layout";
|
|
23
|
+
import {
|
|
24
|
+
pdfFontName,
|
|
25
|
+
type PdfFontSpec,
|
|
26
|
+
} from "./pdf/resources";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Maps a Canvas document JSON to an ag-psd document object: pages become
|
|
30
|
+
* collapsed layer groups stacked vertically, text elements become editable
|
|
31
|
+
* PSD text layers (with a raster cache so dumb viewers still render), and
|
|
32
|
+
* figure/svg/image elements become raster layers.
|
|
33
|
+
*
|
|
34
|
+
* This is the browser port of ``exporters/psd_exporter`` in
|
|
35
|
+
* leviosa-sourcing-server-cafe24 — keep the two in sync when the layer
|
|
36
|
+
* mapping changes.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// Photoshop stores files up to 30,000 px per side (PSB would go further, but
|
|
40
|
+
// ag-psd writes classic PSD).
|
|
41
|
+
export const PSD_MAX_DIMENSION = 30000;
|
|
42
|
+
|
|
43
|
+
const FONT_PS_NAMES: Array<[number, string]> = [
|
|
44
|
+
[450, "Pretendard-Regular"],
|
|
45
|
+
[650, "Pretendard-SemiBold"],
|
|
46
|
+
[750, "Pretendard-Bold"],
|
|
47
|
+
[Infinity, "Pretendard-ExtraBold"],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** PostScript name Photoshop should resolve for an editable type layer. */
|
|
51
|
+
export function fontPostScriptName(
|
|
52
|
+
family: string | undefined,
|
|
53
|
+
fontWeight: number | string | undefined,
|
|
54
|
+
italic = false,
|
|
55
|
+
catalogNames: Array<{ spec: PdfFontSpec; name: string }> = [],
|
|
56
|
+
): string {
|
|
57
|
+
const w = Number(fontWeight) || 400;
|
|
58
|
+
const requested = { family: family || "Pretendard", weight: w, italic };
|
|
59
|
+
const exact = catalogNames.find(
|
|
60
|
+
({ spec }) =>
|
|
61
|
+
spec.family === requested.family &&
|
|
62
|
+
spec.weight === requested.weight &&
|
|
63
|
+
spec.italic === requested.italic,
|
|
64
|
+
);
|
|
65
|
+
if (exact) return exact.name;
|
|
66
|
+
const familyNames = catalogNames.filter(
|
|
67
|
+
({ spec }) => spec.family === requested.family,
|
|
68
|
+
);
|
|
69
|
+
const sameStyle = familyNames.filter(
|
|
70
|
+
({ spec }) => spec.italic === requested.italic,
|
|
71
|
+
);
|
|
72
|
+
const nearest = (sameStyle.length ? sameStyle : familyNames)
|
|
73
|
+
.filter(
|
|
74
|
+
({ spec }) =>
|
|
75
|
+
spec.family === requested.family,
|
|
76
|
+
)
|
|
77
|
+
.sort(
|
|
78
|
+
(a, b) =>
|
|
79
|
+
Math.abs(a.spec.weight - w) - Math.abs(b.spec.weight - w),
|
|
80
|
+
)[0];
|
|
81
|
+
if (nearest) return nearest.name;
|
|
82
|
+
|
|
83
|
+
if (requested.family !== "Pretendard") return pdfFontName(requested);
|
|
84
|
+
const found = FONT_PS_NAMES.find(([max]) => w < max);
|
|
85
|
+
const name = found ? found[1] : "Pretendard-Regular";
|
|
86
|
+
return italic ? `${name}-Italic` : name;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function justification(align: string | undefined): "left" | "center" | "right" {
|
|
90
|
+
return align === "center" || align === "right" ? align : "left";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type ExportCanvas = {
|
|
94
|
+
width: number;
|
|
95
|
+
height: number;
|
|
96
|
+
getContext(kind: "2d"): Raster2D | null;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export type BuildPsdOptions = {
|
|
100
|
+
createCanvas: (width: number, height: number) => ExportCanvas;
|
|
101
|
+
/** Artifact slot_bindings; used for layer names. */
|
|
102
|
+
slotBindings?: Record<string, { element_id?: string } | undefined>;
|
|
103
|
+
/** Resolve image/svg src to a drawable; null renders a placeholder. */
|
|
104
|
+
loadBitmap?: (el: ExportElement) => Promise<DrawableImage | null>;
|
|
105
|
+
/** Export only these page ids (document order); default all pages. */
|
|
106
|
+
pageIds?: string[];
|
|
107
|
+
/** Real PostScript names parsed from catalog font programs. */
|
|
108
|
+
fontPostScriptNames?: Array<{ spec: PdfFontSpec; name: string }>;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type BuildEnv = {
|
|
112
|
+
createCanvas: BuildPsdOptions["createCanvas"];
|
|
113
|
+
compositeCtx: Raster2D;
|
|
114
|
+
slotNameByElementId: Record<string, string>;
|
|
115
|
+
loadBitmap: NonNullable<BuildPsdOptions["loadBitmap"]>;
|
|
116
|
+
fontPostScriptNames: NonNullable<BuildPsdOptions["fontPostScriptNames"]>;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function num(value: unknown, fallback = 0): number {
|
|
120
|
+
const n = Number(value);
|
|
121
|
+
return Number.isFinite(n) ? n : fallback;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Integer pixel bounds for an element, padded so strokes are not clipped. */
|
|
125
|
+
function elementBounds(el: ExportElement, dy: number, extraHeight = 0) {
|
|
126
|
+
const strokeWidth = num(el.strokeWidth);
|
|
127
|
+
const pad = strokeWidth > 0 ? Math.ceil(strokeWidth / 2) : 0;
|
|
128
|
+
const left = Math.floor(num(el.x) - pad);
|
|
129
|
+
const top = Math.floor(num(el.y) + dy - pad);
|
|
130
|
+
const right = Math.ceil(num(el.x) + num(el.width) + pad);
|
|
131
|
+
const bottom = Math.ceil(num(el.y) + dy + Math.max(num(el.height), extraHeight) + pad);
|
|
132
|
+
return {
|
|
133
|
+
left,
|
|
134
|
+
top,
|
|
135
|
+
width: Math.max(1, right - left),
|
|
136
|
+
height: Math.max(1, bottom - top),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Build the ag-psd document for a Canvas document (pages stacked). */
|
|
141
|
+
export async function buildPsd(doc: ExportDocument, opts: BuildPsdOptions): Promise<Psd> {
|
|
142
|
+
const {
|
|
143
|
+
createCanvas,
|
|
144
|
+
slotBindings = {},
|
|
145
|
+
loadBitmap = async () => null,
|
|
146
|
+
fontPostScriptNames = [],
|
|
147
|
+
} = opts;
|
|
148
|
+
const pages = selectPages(doc, opts.pageIds);
|
|
149
|
+
const width = documentWidth(doc);
|
|
150
|
+
const pageHeights = pages.map((p) => pageHeight(p, doc));
|
|
151
|
+
const totalHeight = pageHeights.reduce((a, b) => a + b, 0);
|
|
152
|
+
if (width > PSD_MAX_DIMENSION || totalHeight > PSD_MAX_DIMENSION) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`document ${width}x${totalHeight} exceeds the PSD limit of ${PSD_MAX_DIMENSION}px; export per page instead`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const slotNameByElementId: Record<string, string> = {};
|
|
159
|
+
for (const [slot, binding] of Object.entries(slotBindings)) {
|
|
160
|
+
if (binding?.element_id) slotNameByElementId[binding.element_id] = slot;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const composite = createCanvas(Math.max(1, width), Math.max(1, totalHeight));
|
|
164
|
+
const compositeCtx = composite.getContext("2d");
|
|
165
|
+
if (!compositeCtx) throw new Error("canvas 2d context unavailable");
|
|
166
|
+
const env: BuildEnv = {
|
|
167
|
+
createCanvas,
|
|
168
|
+
compositeCtx,
|
|
169
|
+
slotNameByElementId,
|
|
170
|
+
loadBitmap,
|
|
171
|
+
fontPostScriptNames,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const children: Layer[] = [];
|
|
175
|
+
let dy = 0;
|
|
176
|
+
for (let i = 0; i < pages.length; i++) {
|
|
177
|
+
const page = pages[i];
|
|
178
|
+
const currentPageHeight = pageHeights[i];
|
|
179
|
+
const pageChildren: Layer[] = [];
|
|
180
|
+
|
|
181
|
+
// Any non-transparent CSS background paints a layer: 'rgb(…)', named
|
|
182
|
+
// colors like the stock editor's default 'white', or a gradient string.
|
|
183
|
+
const bg = page.background;
|
|
184
|
+
if (typeof bg === "string" && bg && bg !== "transparent") {
|
|
185
|
+
const canvas = createCanvas(width, currentPageHeight);
|
|
186
|
+
const ctx = canvas.getContext("2d");
|
|
187
|
+
if (ctx) {
|
|
188
|
+
const gradient = parseCssGradient(bg);
|
|
189
|
+
if (gradient) {
|
|
190
|
+
drawFigure(ctx, {
|
|
191
|
+
type: "figure",
|
|
192
|
+
x: 0,
|
|
193
|
+
y: 0,
|
|
194
|
+
width,
|
|
195
|
+
height: currentPageHeight,
|
|
196
|
+
custom: { gradient: bg },
|
|
197
|
+
});
|
|
198
|
+
} else {
|
|
199
|
+
ctx.fillStyle = bg;
|
|
200
|
+
ctx.fillRect(0, 0, width, currentPageHeight);
|
|
201
|
+
}
|
|
202
|
+
compositeCtx.drawImage(canvas as unknown as CanvasImageSource, 0, dy);
|
|
203
|
+
pageChildren.push({
|
|
204
|
+
name: "background",
|
|
205
|
+
left: 0,
|
|
206
|
+
top: dy,
|
|
207
|
+
canvas: canvas as unknown as HTMLCanvasElement,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const el of page.children ?? []) {
|
|
213
|
+
const layer = await elementToLayer(el, dy, env, 1);
|
|
214
|
+
if (layer) pageChildren.push(layer);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
children.push({
|
|
218
|
+
name: `p${String(i + 1).padStart(2, "0")} ${page.name || page.id || ""}`.trim(),
|
|
219
|
+
opened: false,
|
|
220
|
+
children: pageChildren,
|
|
221
|
+
});
|
|
222
|
+
dy += currentPageHeight;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
width,
|
|
227
|
+
height: totalHeight,
|
|
228
|
+
children,
|
|
229
|
+
canvas: composite as unknown as HTMLCanvasElement,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function layerName(el: ExportElement, env: BuildEnv): string {
|
|
234
|
+
return (el.id && env.slotNameByElementId[el.id]) || el.name || el.id || el.type || "layer";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function elementToLayer(
|
|
238
|
+
el: ExportElement,
|
|
239
|
+
dy: number,
|
|
240
|
+
env: BuildEnv,
|
|
241
|
+
parentAlpha: number,
|
|
242
|
+
): Promise<Layer | null> {
|
|
243
|
+
if (el.visible === false) return null;
|
|
244
|
+
const opacity = num(el.opacity, 1);
|
|
245
|
+
const effectiveAlpha = parentAlpha * opacity;
|
|
246
|
+
|
|
247
|
+
if (el.type === "group") {
|
|
248
|
+
const children: Layer[] = [];
|
|
249
|
+
for (const child of el.children ?? []) {
|
|
250
|
+
const layer = await elementToLayer(child, dy, env, effectiveAlpha);
|
|
251
|
+
if (layer) children.push(layer);
|
|
252
|
+
}
|
|
253
|
+
return { name: layerName(el, env), opened: false, opacity, children };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (el.type === "text") return textLayer(el, dy, env, effectiveAlpha);
|
|
257
|
+
if (el.type === "figure") return rasterLayer(el, dy, env, effectiveAlpha, drawFigure);
|
|
258
|
+
if (el.type === "image" || el.type === "svg") {
|
|
259
|
+
const bitmap = await env.loadBitmap(el).catch(() => null);
|
|
260
|
+
const draw = bitmap
|
|
261
|
+
? (ctx: Raster2D, e: ExportElement, ox: number, oy: number) =>
|
|
262
|
+
drawBitmap(ctx, e, bitmap, ox, oy)
|
|
263
|
+
: drawPlaceholder;
|
|
264
|
+
return rasterLayer(el, dy, env, effectiveAlpha, draw);
|
|
265
|
+
}
|
|
266
|
+
return null; // unknown element type: skip, keep the export going
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Rasterize an element into its own tight canvas and blit it to the composite. */
|
|
270
|
+
function rasterLayer(
|
|
271
|
+
el: ExportElement,
|
|
272
|
+
dy: number,
|
|
273
|
+
env: BuildEnv,
|
|
274
|
+
effectiveAlpha: number,
|
|
275
|
+
draw: (ctx: Raster2D, el: ExportElement, ox: number, oy: number) => void,
|
|
276
|
+
extraHeight = 0,
|
|
277
|
+
): Layer | null {
|
|
278
|
+
const bounds = elementBounds(el, dy, extraHeight);
|
|
279
|
+
const canvas = env.createCanvas(bounds.width, bounds.height);
|
|
280
|
+
const ctx = canvas.getContext("2d");
|
|
281
|
+
if (!ctx) return null;
|
|
282
|
+
draw(ctx, el, -bounds.left, dy - bounds.top);
|
|
283
|
+
|
|
284
|
+
const composite = env.compositeCtx as Raster2D & { globalAlpha?: number };
|
|
285
|
+
composite.save();
|
|
286
|
+
composite.globalAlpha = effectiveAlpha;
|
|
287
|
+
composite.drawImage(canvas as unknown as CanvasImageSource, bounds.left, bounds.top);
|
|
288
|
+
composite.restore();
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
name: layerName(el, env),
|
|
292
|
+
left: bounds.left,
|
|
293
|
+
top: bounds.top,
|
|
294
|
+
opacity: num(el.opacity, 1),
|
|
295
|
+
canvas: canvas as unknown as HTMLCanvasElement,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Editable PSD text layer carrying the rendered pixels as its raster cache. */
|
|
300
|
+
function textLayer(
|
|
301
|
+
el: ExportElement,
|
|
302
|
+
dy: number,
|
|
303
|
+
env: BuildEnv,
|
|
304
|
+
effectiveAlpha: number,
|
|
305
|
+
): Layer | null {
|
|
306
|
+
env.compositeCtx.font = cssFont(el);
|
|
307
|
+
const { blockHeight, offsetY, scaleX } = layoutText(el, (s) =>
|
|
308
|
+
env.compositeCtx.measureText(s).width,
|
|
309
|
+
);
|
|
310
|
+
const layer = rasterLayer(el, dy, env, effectiveAlpha, drawText, offsetY + blockHeight);
|
|
311
|
+
if (!layer) return null;
|
|
312
|
+
const fill = parseColor(el.fill) ?? { r: 0, g: 0, b: 0, a: 1 };
|
|
313
|
+
const fontSize = num(el.fontSize, 16);
|
|
314
|
+
const letterSpacing = num(el.letterSpacing);
|
|
315
|
+
const italic = el.fontStyle === "italic";
|
|
316
|
+
const catalogFamilyNames = env.fontPostScriptNames.filter(
|
|
317
|
+
({ spec }) => spec.family === el.fontFamily,
|
|
318
|
+
);
|
|
319
|
+
const hasCatalogItalic = catalogFamilyNames.some(
|
|
320
|
+
({ spec }) => spec.italic,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
layer.text = {
|
|
324
|
+
text: transformText(el),
|
|
325
|
+
transform: [1, 0, 0, 1, num(el.x), num(el.y) + dy + offsetY],
|
|
326
|
+
shapeType: "box",
|
|
327
|
+
boxBounds: [0, 0, num(el.width), Math.max(num(el.height) - offsetY, blockHeight)],
|
|
328
|
+
antiAlias: "smooth",
|
|
329
|
+
style: {
|
|
330
|
+
font: {
|
|
331
|
+
name: fontPostScriptName(
|
|
332
|
+
el.fontFamily,
|
|
333
|
+
normalizeFontWeight(el),
|
|
334
|
+
italic,
|
|
335
|
+
env.fontPostScriptNames,
|
|
336
|
+
),
|
|
337
|
+
},
|
|
338
|
+
fontSize,
|
|
339
|
+
fillColor: { r: fill.r, g: fill.g, b: fill.b },
|
|
340
|
+
autoLeading: false,
|
|
341
|
+
leading: resolveLeading(el),
|
|
342
|
+
...(italic && catalogFamilyNames.length && !hasCatalogItalic
|
|
343
|
+
? { fauxItalic: true }
|
|
344
|
+
: {}),
|
|
345
|
+
...(scaleX < 1 ? { horizontalScale: scaleX } : {}),
|
|
346
|
+
// Photoshop tracking is in 1/1000 em; our letterSpacing is px.
|
|
347
|
+
...(letterSpacing ? { tracking: Math.round((letterSpacing / fontSize) * 1000) } : {}),
|
|
348
|
+
strikethrough: el.textDecoration === "line-through",
|
|
349
|
+
underline: el.textDecoration === "underline",
|
|
350
|
+
},
|
|
351
|
+
paragraphStyle: { justification: justification(el.align) },
|
|
352
|
+
};
|
|
353
|
+
return layer;
|
|
354
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import {
|
|
2
|
+
linearGradientKonvaProps,
|
|
3
|
+
parseCssGradient,
|
|
4
|
+
radialGradientKonvaProps,
|
|
5
|
+
} from "../konva-fallback";
|
|
6
|
+
import type { ExportElement } from "./document-model";
|
|
7
|
+
import { cssFont, layoutText, normalizeFontWeight, transformText } from "./text-layout";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Canvas rasterization for export layers (PSD layer pixels + composite). All
|
|
11
|
+
* geometry is in document pixels; the caller positions each element canvas.
|
|
12
|
+
*
|
|
13
|
+
* A structural 2D-context type keeps this testable in jsdom, where
|
|
14
|
+
* ``canvas.getContext('2d')`` returns null — tests inject a recording stub.
|
|
15
|
+
*/
|
|
16
|
+
export type Raster2D = Pick<
|
|
17
|
+
CanvasRenderingContext2D,
|
|
18
|
+
| "save"
|
|
19
|
+
| "restore"
|
|
20
|
+
| "beginPath"
|
|
21
|
+
| "closePath"
|
|
22
|
+
| "rect"
|
|
23
|
+
| "moveTo"
|
|
24
|
+
| "lineTo"
|
|
25
|
+
| "arcTo"
|
|
26
|
+
| "ellipse"
|
|
27
|
+
| "clip"
|
|
28
|
+
| "translate"
|
|
29
|
+
| "scale"
|
|
30
|
+
| "fill"
|
|
31
|
+
| "stroke"
|
|
32
|
+
| "fillRect"
|
|
33
|
+
| "fillText"
|
|
34
|
+
| "measureText"
|
|
35
|
+
| "createLinearGradient"
|
|
36
|
+
| "createRadialGradient"
|
|
37
|
+
| "drawImage"
|
|
38
|
+
> & {
|
|
39
|
+
font: string;
|
|
40
|
+
fillStyle: string | CanvasGradient | CanvasPattern;
|
|
41
|
+
strokeStyle: string | CanvasGradient | CanvasPattern;
|
|
42
|
+
lineWidth: number;
|
|
43
|
+
textAlign: CanvasTextAlign;
|
|
44
|
+
textBaseline: CanvasTextBaseline;
|
|
45
|
+
letterSpacing?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type DrawableImage = { width: number; height: number };
|
|
49
|
+
|
|
50
|
+
function num(value: unknown, fallback = 0): number {
|
|
51
|
+
const n = Number(value);
|
|
52
|
+
return Number.isFinite(n) ? n : fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Trace the element outline: rounded rect, or ellipse for circle subtypes. */
|
|
56
|
+
export function tracePath(
|
|
57
|
+
ctx: Raster2D,
|
|
58
|
+
el: ExportElement,
|
|
59
|
+
x: number,
|
|
60
|
+
y: number,
|
|
61
|
+
w: number,
|
|
62
|
+
h: number,
|
|
63
|
+
): void {
|
|
64
|
+
ctx.beginPath();
|
|
65
|
+
if (el.subType === "circle" || el.subType === "ellipse") {
|
|
66
|
+
ctx.ellipse(x + w / 2, y + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
|
|
67
|
+
ctx.closePath();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const r = Math.max(0, Math.min(num(el.cornerRadius), w / 2, h / 2));
|
|
71
|
+
if (r === 0) {
|
|
72
|
+
ctx.rect(x, y, w, h);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
ctx.moveTo(x + r, y);
|
|
76
|
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
77
|
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
78
|
+
ctx.arcTo(x, y + h, x, y, r);
|
|
79
|
+
ctx.arcTo(x, y, x + w, y, r);
|
|
80
|
+
ctx.closePath();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Gradient fill for an element, if it carries one. The custom-props adapter
|
|
85
|
+
* promotes ``custom.gradient`` into ``fill`` for the SDK editor, so both spots
|
|
86
|
+
* are checked.
|
|
87
|
+
*/
|
|
88
|
+
export function elementGradient(el: ExportElement) {
|
|
89
|
+
return parseCssGradient(el.fill) ?? parseCssGradient(el.custom?.gradient);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Canvas gradient matching CSS geometry for a w×h box at local (0,0). */
|
|
93
|
+
function makeCanvasGradient(
|
|
94
|
+
ctx: Raster2D,
|
|
95
|
+
gradient: NonNullable<ReturnType<typeof parseCssGradient>>,
|
|
96
|
+
w: number,
|
|
97
|
+
h: number,
|
|
98
|
+
): CanvasGradient {
|
|
99
|
+
if (gradient.type === "radial") {
|
|
100
|
+
const p = radialGradientKonvaProps(gradient, w, h);
|
|
101
|
+
const g = ctx.createRadialGradient(
|
|
102
|
+
p.fillRadialGradientStartPoint.x,
|
|
103
|
+
p.fillRadialGradientStartPoint.y,
|
|
104
|
+
p.fillRadialGradientStartRadius,
|
|
105
|
+
p.fillRadialGradientEndPoint.x,
|
|
106
|
+
p.fillRadialGradientEndPoint.y,
|
|
107
|
+
p.fillRadialGradientEndRadius,
|
|
108
|
+
);
|
|
109
|
+
for (const stop of gradient.stops) g.addColorStop(clamp01(stop.offset), stop.color);
|
|
110
|
+
return g;
|
|
111
|
+
}
|
|
112
|
+
const p = linearGradientKonvaProps(gradient, w, h);
|
|
113
|
+
const g = ctx.createLinearGradient(
|
|
114
|
+
p.fillLinearGradientStartPoint.x,
|
|
115
|
+
p.fillLinearGradientStartPoint.y,
|
|
116
|
+
p.fillLinearGradientEndPoint.x,
|
|
117
|
+
p.fillLinearGradientEndPoint.y,
|
|
118
|
+
);
|
|
119
|
+
for (const stop of gradient.stops) g.addColorStop(clamp01(stop.offset), stop.color);
|
|
120
|
+
return g;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function clamp01(n: number): number {
|
|
124
|
+
return Math.min(1, Math.max(0, n));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function applyLetterSpacing(ctx: Raster2D, el: ExportElement): void {
|
|
128
|
+
const spacing = num(el.letterSpacing);
|
|
129
|
+
if (spacing && "letterSpacing" in ctx) ctx.letterSpacing = `${spacing}px`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Draw a text element onto ctx at (el.x + ox, el.y + oy). */
|
|
133
|
+
export function drawText(ctx: Raster2D, el: ExportElement, ox = 0, oy = 0): void {
|
|
134
|
+
ctx.font = cssFont(el);
|
|
135
|
+
applyLetterSpacing(ctx, el);
|
|
136
|
+
const layout = layoutText(el, (s) => ctx.measureText(s).width);
|
|
137
|
+
const { lines, leading, offsetY, scaleX } = layout;
|
|
138
|
+
const fontSize = num(el.fontSize, 16);
|
|
139
|
+
ctx.save();
|
|
140
|
+
ctx.scale(scaleX, 1);
|
|
141
|
+
ctx.font = cssFont(el);
|
|
142
|
+
applyLetterSpacing(ctx, el);
|
|
143
|
+
ctx.fillStyle = el.fill || "#000";
|
|
144
|
+
ctx.textBaseline = "middle";
|
|
145
|
+
const align = el.align === "center" || el.align === "right" ? el.align : "left";
|
|
146
|
+
ctx.textAlign = align;
|
|
147
|
+
// Anchor in unscaled coordinates, mapped into the condensed space.
|
|
148
|
+
const ax = align === "center" ? num(el.width) / 2 : align === "right" ? num(el.width) : 0;
|
|
149
|
+
const anchorX = (num(el.x) + ox + ax) / scaleX;
|
|
150
|
+
lines.forEach((line, i) => {
|
|
151
|
+
const y = num(el.y) + oy + offsetY + i * leading + leading / 2;
|
|
152
|
+
ctx.fillText(line, anchorX, y);
|
|
153
|
+
if (el.textDecoration === "line-through" && line) {
|
|
154
|
+
const w = ctx.measureText(line).width;
|
|
155
|
+
const lx = anchorX - (align === "center" ? w / 2 : align === "right" ? w : 0);
|
|
156
|
+
ctx.save();
|
|
157
|
+
ctx.strokeStyle = el.fill || "#000";
|
|
158
|
+
ctx.lineWidth = Math.max(1, fontSize / 14);
|
|
159
|
+
ctx.beginPath();
|
|
160
|
+
ctx.moveTo(lx, y);
|
|
161
|
+
ctx.lineTo(lx + w, y);
|
|
162
|
+
ctx.stroke();
|
|
163
|
+
ctx.restore();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
ctx.restore();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Draw a figure (rect/ellipse) element onto ctx at (el.x + ox, el.y + oy). */
|
|
170
|
+
export function drawFigure(ctx: Raster2D, el: ExportElement, ox = 0, oy = 0): void {
|
|
171
|
+
const x = num(el.x) + ox;
|
|
172
|
+
const y = num(el.y) + oy;
|
|
173
|
+
const w = num(el.width);
|
|
174
|
+
const h = num(el.height);
|
|
175
|
+
ctx.save();
|
|
176
|
+
tracePath(ctx, el, x, y, w, h);
|
|
177
|
+
const gradient = elementGradient(el);
|
|
178
|
+
if (gradient) {
|
|
179
|
+
ctx.save();
|
|
180
|
+
ctx.clip();
|
|
181
|
+
ctx.translate(x, y);
|
|
182
|
+
ctx.fillStyle = makeCanvasGradient(ctx, gradient, w, h);
|
|
183
|
+
ctx.fillRect(0, 0, w, h);
|
|
184
|
+
ctx.restore();
|
|
185
|
+
} else if (el.fill && el.fill !== "transparent") {
|
|
186
|
+
ctx.fillStyle = el.fill;
|
|
187
|
+
ctx.fill();
|
|
188
|
+
}
|
|
189
|
+
if (el.stroke && num(el.strokeWidth) > 0) {
|
|
190
|
+
ctx.strokeStyle = el.stroke;
|
|
191
|
+
ctx.lineWidth = num(el.strokeWidth);
|
|
192
|
+
ctx.stroke();
|
|
193
|
+
}
|
|
194
|
+
ctx.restore();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Draw a pre-decoded image/svg bitmap into the element box (objectFit-aware). */
|
|
198
|
+
export function drawBitmap(
|
|
199
|
+
ctx: Raster2D,
|
|
200
|
+
el: ExportElement,
|
|
201
|
+
image: DrawableImage,
|
|
202
|
+
ox = 0,
|
|
203
|
+
oy = 0,
|
|
204
|
+
): void {
|
|
205
|
+
const x = num(el.x) + ox;
|
|
206
|
+
const y = num(el.y) + oy;
|
|
207
|
+
const w = num(el.width);
|
|
208
|
+
const h = num(el.height);
|
|
209
|
+
ctx.save();
|
|
210
|
+
tracePath(ctx, el, x, y, w, h);
|
|
211
|
+
ctx.clip();
|
|
212
|
+
const fit = el.custom?.objectFit || "cover";
|
|
213
|
+
const iw = image.width;
|
|
214
|
+
const ih = image.height;
|
|
215
|
+
if (!iw || !ih) {
|
|
216
|
+
ctx.restore();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const source = image as CanvasImageSource;
|
|
220
|
+
if (fit === "fill") {
|
|
221
|
+
ctx.drawImage(source, x, y, w, h);
|
|
222
|
+
ctx.restore();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const scale = fit === "contain" ? Math.min(w / iw, h / ih) : Math.max(w / iw, h / ih);
|
|
226
|
+
const dw = iw * scale;
|
|
227
|
+
const dh = ih * scale;
|
|
228
|
+
ctx.drawImage(source, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh);
|
|
229
|
+
ctx.restore();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Neutral gray placeholder for image slots that have no source yet. */
|
|
233
|
+
export function drawPlaceholder(ctx: Raster2D, el: ExportElement, ox = 0, oy = 0): void {
|
|
234
|
+
const x = num(el.x) + ox;
|
|
235
|
+
const y = num(el.y) + oy;
|
|
236
|
+
const w = num(el.width);
|
|
237
|
+
const h = num(el.height);
|
|
238
|
+
ctx.save();
|
|
239
|
+
tracePath(ctx, el, x, y, w, h);
|
|
240
|
+
ctx.clip();
|
|
241
|
+
ctx.fillStyle = String(el.custom?.placeholderBg ?? "#e5e7eb");
|
|
242
|
+
ctx.fillRect(x, y, w, h);
|
|
243
|
+
ctx.strokeStyle = "rgba(0, 0, 0, 0.12)";
|
|
244
|
+
ctx.lineWidth = 2;
|
|
245
|
+
ctx.beginPath();
|
|
246
|
+
ctx.moveTo(x, y);
|
|
247
|
+
ctx.lineTo(x + w, y + h);
|
|
248
|
+
ctx.moveTo(x + w, y);
|
|
249
|
+
ctx.lineTo(x, y + h);
|
|
250
|
+
ctx.stroke();
|
|
251
|
+
ctx.restore();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Re-export used by PSD text styling. */
|
|
255
|
+
export { normalizeFontWeight, transformText };
|