@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.
Files changed (190) hide show
  1. package/components/cardnews/color-input.tsx +380 -0
  2. package/components/detail-page/ai-generate-panel.tsx +763 -0
  3. package/components/detail-page/annotation-canvas.tsx +860 -0
  4. package/components/detail-page/annotation-dialog.tsx +397 -0
  5. package/components/detail-page/bubble-tail-overlay.tsx +209 -0
  6. package/components/detail-page/canvas-context-menu.tsx +234 -0
  7. package/components/detail-page/canvas-observer.tsx +68 -0
  8. package/components/detail-page/canvas-overlay-host.tsx +44 -0
  9. package/components/detail-page/canvas-store-facade.ts +51 -0
  10. package/components/detail-page/chart-inspector.tsx +531 -0
  11. package/components/detail-page/design-reference-picker.tsx +383 -0
  12. package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
  13. package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
  14. package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
  15. package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
  16. package/components/detail-page/detail-page-charts-panel.tsx +104 -0
  17. package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
  18. package/components/detail-page/detail-page-download-dialog.tsx +524 -0
  19. package/components/detail-page/detail-page-editor.tsx +974 -0
  20. package/components/detail-page/detail-page-elements-panel.tsx +117 -0
  21. package/components/detail-page/detail-page-font-picker.tsx +231 -0
  22. package/components/detail-page/detail-page-history-buttons.tsx +87 -0
  23. package/components/detail-page/detail-page-host-context.tsx +465 -0
  24. package/components/detail-page/detail-page-icons-panel.tsx +251 -0
  25. package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
  26. package/components/detail-page/detail-page-layers-panel.tsx +618 -0
  27. package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
  28. package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
  29. package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
  30. package/components/detail-page/detail-page-pages-panel.tsx +299 -0
  31. package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
  32. package/components/detail-page/detail-page-photos-panel.tsx +257 -0
  33. package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
  34. package/components/detail-page/detail-page-qr-panel.tsx +137 -0
  35. package/components/detail-page/detail-page-references-panel.tsx +166 -0
  36. package/components/detail-page/detail-page-selection.ts +44 -0
  37. package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
  38. package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
  39. package/components/detail-page/detail-page-tables-panel.tsx +106 -0
  40. package/components/detail-page/detail-page-text-panel.tsx +179 -0
  41. package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
  42. package/components/detail-page/edit-quota-ui.tsx +150 -0
  43. package/components/detail-page/editor-history.ts +56 -0
  44. package/components/detail-page/editor-hotkeys.tsx +165 -0
  45. package/components/detail-page/element-recents-strip.tsx +105 -0
  46. package/components/detail-page/element-rects.ts +82 -0
  47. package/components/detail-page/fill-control.tsx +261 -0
  48. package/components/detail-page/find-replace-panel.tsx +252 -0
  49. package/components/detail-page/generation-progress-fill.tsx +57 -0
  50. package/components/detail-page/gif-animator.tsx +184 -0
  51. package/components/detail-page/gif-effect-picker.tsx +136 -0
  52. package/components/detail-page/group-drill-in.tsx +219 -0
  53. package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
  54. package/components/detail-page/hover-highlight-overlay.tsx +111 -0
  55. package/components/detail-page/hovered-layer.ts +34 -0
  56. package/components/detail-page/inspector-controls.tsx +175 -0
  57. package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
  58. package/components/detail-page/nested-selection-overlay.tsx +364 -0
  59. package/components/detail-page/panel-search-input.tsx +49 -0
  60. package/components/detail-page/prompt-edit-panel.tsx +146 -0
  61. package/components/detail-page/section-height-handle.tsx +276 -0
  62. package/components/detail-page/section-reauthor-controller.tsx +210 -0
  63. package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
  64. package/components/detail-page/spec-resize-absorber.tsx +149 -0
  65. package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
  66. package/components/detail-page/table-canvas-overlay.tsx +707 -0
  67. package/components/detail-page/table-inspector.tsx +586 -0
  68. package/components/ui/button.tsx +64 -0
  69. package/components/ui/command.tsx +175 -0
  70. package/components/ui/dialog.tsx +167 -0
  71. package/components/ui/label.tsx +24 -0
  72. package/components/ui/select.tsx +190 -0
  73. package/components/ui/slider.tsx +63 -0
  74. package/components/ui/switch.tsx +35 -0
  75. package/components/ui/tooltip.tsx +57 -0
  76. package/config/detail-page-bundle-font-tags.json +39 -0
  77. package/config/detail-page-fonts.json +373 -0
  78. package/index.ts +24 -0
  79. package/lib/cardnews/font-loader.ts +306 -0
  80. package/lib/detail-page/animation-sniff.ts +101 -0
  81. package/lib/detail-page/asset-bytes-url.ts +70 -0
  82. package/lib/detail-page/basic-shapes.ts +603 -0
  83. package/lib/detail-page/canvas-menu.ts +182 -0
  84. package/lib/detail-page/chart/defaults.ts +283 -0
  85. package/lib/detail-page/chart/format.ts +66 -0
  86. package/lib/detail-page/chart/normalize.ts +149 -0
  87. package/lib/detail-page/chart/palette.ts +85 -0
  88. package/lib/detail-page/chart/paste.ts +82 -0
  89. package/lib/detail-page/chart/preview.ts +78 -0
  90. package/lib/detail-page/chart/render.ts +188 -0
  91. package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
  92. package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
  93. package/lib/detail-page/chart/renderers/donut.ts +186 -0
  94. package/lib/detail-page/chart/renderers/gauge.ts +96 -0
  95. package/lib/detail-page/chart/renderers/line.ts +143 -0
  96. package/lib/detail-page/chart/renderers/shared.ts +263 -0
  97. package/lib/detail-page/chart/renderers/stack.ts +121 -0
  98. package/lib/detail-page/chart/sync.ts +92 -0
  99. package/lib/detail-page/chart/types.ts +145 -0
  100. package/lib/detail-page/css-color.ts +34 -0
  101. package/lib/detail-page/data-gif-payload.ts +167 -0
  102. package/lib/detail-page/design-reference.ts +244 -0
  103. package/lib/detail-page/distribute.ts +89 -0
  104. package/lib/detail-page/editor-routes.ts +10 -0
  105. package/lib/detail-page/element-recents.ts +167 -0
  106. package/lib/detail-page/find-replace.ts +120 -0
  107. package/lib/detail-page/format-painter.ts +159 -0
  108. package/lib/detail-page/gif-frames.ts +216 -0
  109. package/lib/detail-page/gif-timeline.ts +65 -0
  110. package/lib/detail-page/group-action.ts +62 -0
  111. package/lib/detail-page/hangul-roman.ts +58 -0
  112. package/lib/detail-page/icon-curated.ts +290 -0
  113. package/lib/detail-page/icon-fuzzy.ts +250 -0
  114. package/lib/detail-page/icon-keywords.generated.ts +3766 -0
  115. package/lib/detail-page/icon-keywords.ko.ts +524 -0
  116. package/lib/detail-page/icon-search.ts +257 -0
  117. package/lib/detail-page/icons.ts +67 -0
  118. package/lib/detail-page/image-credit.ts +121 -0
  119. package/lib/detail-page/image-data-uri.ts +29 -0
  120. package/lib/detail-page/insert-image.ts +75 -0
  121. package/lib/detail-page/insert-shape.ts +49 -0
  122. package/lib/detail-page/korean-dictionary.ts +187 -0
  123. package/lib/detail-page/layer-move.ts +257 -0
  124. package/lib/detail-page/new-product-draft-store.ts +105 -0
  125. package/lib/detail-page/new-product-draft.ts +283 -0
  126. package/lib/detail-page/personal-images-refresh.ts +30 -0
  127. package/lib/detail-page/product-source.ts +587 -0
  128. package/lib/detail-page/qr-code.ts +214 -0
  129. package/lib/detail-page/reference-image.ts +103 -0
  130. package/lib/detail-page/reference-upload.ts +181 -0
  131. package/lib/detail-page/replace-page.ts +35 -0
  132. package/lib/detail-page/replace-with-gif.ts +251 -0
  133. package/lib/detail-page/section-height.ts +131 -0
  134. package/lib/detail-page/section-reauthor-bus.ts +61 -0
  135. package/lib/detail-page/shape-to-image.ts +219 -0
  136. package/lib/detail-page/spec-group/clipboard.ts +35 -0
  137. package/lib/detail-page/spec-group/sync.ts +377 -0
  138. package/lib/detail-page/spec-group/text-metrics.ts +48 -0
  139. package/lib/detail-page/stock-photos.ts +88 -0
  140. package/lib/detail-page/svg-colors.ts +74 -0
  141. package/lib/detail-page/table/defaults.ts +344 -0
  142. package/lib/detail-page/table/edit.ts +312 -0
  143. package/lib/detail-page/table/harvest.ts +103 -0
  144. package/lib/detail-page/table/normalize.ts +125 -0
  145. package/lib/detail-page/table/paste.ts +49 -0
  146. package/lib/detail-page/table/preview.ts +66 -0
  147. package/lib/detail-page/table/render.ts +388 -0
  148. package/lib/detail-page/table/sync.ts +107 -0
  149. package/lib/detail-page/table/types.ts +102 -0
  150. package/lib/detail-page/text-gif-layout.ts +245 -0
  151. package/lib/detail-page/text-gif-payload.ts +67 -0
  152. package/lib/detail-page/text-presets.ts +230 -0
  153. package/lib/detail-page/use-fake-progress.ts +67 -0
  154. package/lib/detail-page/z-order.ts +82 -0
  155. package/lib/detail-page-canvas/bubble-path.ts +190 -0
  156. package/lib/detail-page-canvas/canvas-key.ts +22 -0
  157. package/lib/detail-page-canvas/clip-rect.ts +65 -0
  158. package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
  159. package/lib/detail-page-canvas/editor-fonts.ts +161 -0
  160. package/lib/detail-page-canvas/export/ai.ts +413 -0
  161. package/lib/detail-page-canvas/export/color.ts +35 -0
  162. package/lib/detail-page-canvas/export/document-model.ts +78 -0
  163. package/lib/detail-page-canvas/export/export-files.ts +290 -0
  164. package/lib/detail-page-canvas/export/font-subset.ts +264 -0
  165. package/lib/detail-page-canvas/export/gif-export.ts +310 -0
  166. package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
  167. package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
  168. package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
  169. package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
  170. package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
  171. package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
  172. package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
  173. package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
  174. package/lib/detail-page-canvas/export/psd.ts +354 -0
  175. package/lib/detail-page-canvas/export/raster.ts +255 -0
  176. package/lib/detail-page-canvas/export/svg.ts +479 -0
  177. package/lib/detail-page-canvas/export/text-layout.ts +142 -0
  178. package/lib/detail-page-canvas/font-catalog.ts +177 -0
  179. package/lib/detail-page-canvas/font-tags.ts +47 -0
  180. package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
  181. package/lib/detail-page-canvas/konva-fallback.ts +477 -0
  182. package/lib/detail-page-canvas/text-background-plan.ts +124 -0
  183. package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
  184. package/lib/utils.ts +10 -0
  185. package/package.json +68 -0
  186. package/types/commerce.ts +171 -0
  187. package/types/detail-page-api.ts +200 -0
  188. package/types/detail-page-canvas.ts +20 -0
  189. package/types/fontkit.d.ts +42 -0
  190. package/types/gifenc.d.ts +51 -0
@@ -0,0 +1,479 @@
1
+ import {
2
+ linearGradientKonvaProps,
3
+ parseCssGradient,
4
+ radialGradientKonvaProps,
5
+ } from "../konva-fallback";
6
+ import {
7
+ documentWidth,
8
+ pageHeight,
9
+ selectPages,
10
+ type ExportDocument,
11
+ type ExportElement,
12
+ type ExportPage,
13
+ } from "./document-model";
14
+ import { isItalic, layoutText, normalizeFontWeight, transformText } from "./text-layout";
15
+
16
+ /**
17
+ * Serializes a Canvas document to Figma-compatible SVG. Figma's SVG importer
18
+ * keeps ``<text>`` as editable text layers, ``<g>`` as groups and vector
19
+ * shapes as vector layers, so this is the practical "Figma export": the .fig
20
+ * format itself is a proprietary binary no third-party tool can write.
21
+ *
22
+ * Import-fidelity choices:
23
+ * - Text is laid out into explicit per-line ``<tspan>``s using the same
24
+ * layout as the PSD/canvas exporters, so line breaks match the editor.
25
+ * - Gradients use ``userSpaceOnUse`` with the exact geometry Konva paints.
26
+ * - ``svg``-type elements (decomposer icons carried as data URIs) are inlined
27
+ * as nested ``<svg>`` so they stay vector in Figma instead of rasterizing.
28
+ */
29
+
30
+ export type BuildSvgOptions = {
31
+ /** Measures ``text`` in the element's font (canvas in browser, stub in tests). */
32
+ measure: (el: ExportElement, text: string) => number;
33
+ /** Maps an image src to an embeddable href (data URI); default: identity. */
34
+ hrefFor?: (src: string) => string;
35
+ /** Export only these page ids; default all pages. */
36
+ pageIds?: string[];
37
+ };
38
+
39
+ type SvgEnv = {
40
+ measure: BuildSvgOptions["measure"];
41
+ hrefFor: (src: string) => string;
42
+ defs: string[];
43
+ nextId: () => string;
44
+ };
45
+
46
+ function num(value: unknown, fallback = 0): number {
47
+ const n = Number(value);
48
+ return Number.isFinite(n) ? n : fallback;
49
+ }
50
+
51
+ /** Compact fixed-point formatting keeps 16-page files small. */
52
+ function fmt(n: number): string {
53
+ const rounded = Math.round(n * 100) / 100;
54
+ return Object.is(rounded, -0) ? "0" : String(rounded);
55
+ }
56
+
57
+ export function escapeXml(value: string): string {
58
+ return value
59
+ .replaceAll("&", "&amp;")
60
+ .replaceAll("<", "&lt;")
61
+ .replaceAll(">", "&gt;")
62
+ .replaceAll('"', "&quot;");
63
+ }
64
+
65
+ function attrs(pairs: Record<string, string | number | undefined>): string {
66
+ return Object.entries(pairs)
67
+ .filter(([, v]) => v !== undefined && v !== "")
68
+ .map(([k, v]) => `${k}="${typeof v === "number" ? fmt(v) : escapeXml(String(v))}"`)
69
+ .join(" ");
70
+ }
71
+
72
+ function makeEnv(opts: BuildSvgOptions): SvgEnv {
73
+ let counter = 0;
74
+ return {
75
+ measure: opts.measure,
76
+ hrefFor: opts.hrefFor ?? ((src) => src),
77
+ defs: [],
78
+ nextId: () => `lv${counter++}`,
79
+ };
80
+ }
81
+
82
+ function wrapSvg(width: number, height: number, env: SvgEnv, body: string): string {
83
+ const defs = env.defs.length ? `\n<defs>\n${env.defs.join("\n")}\n</defs>` : "";
84
+ return (
85
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${fmt(width)}" height="${fmt(height)}" ` +
86
+ `viewBox="0 0 ${fmt(width)} ${fmt(height)}">${defs}\n${body}\n</svg>`
87
+ );
88
+ }
89
+
90
+ /** One standalone SVG per selected page, in document order. */
91
+ export function buildSvgPages(doc: ExportDocument, opts: BuildSvgOptions): string[] {
92
+ const width = documentWidth(doc);
93
+ return selectPages(doc, opts.pageIds).map((page, i) => {
94
+ const env = makeEnv(opts);
95
+ const body = pageMarkup(page, doc, env, 0, i + 1);
96
+ return wrapSvg(width, pageHeight(page, doc), env, body);
97
+ });
98
+ }
99
+
100
+ /** All selected pages stacked vertically in a single SVG. */
101
+ export function buildSvgDocument(doc: ExportDocument, opts: BuildSvgOptions): string {
102
+ const pages = selectPages(doc, opts.pageIds);
103
+ const width = documentWidth(doc);
104
+ const totalHeight = pages.reduce((acc, p) => acc + pageHeight(p, doc), 0);
105
+ const env = makeEnv(opts);
106
+ const parts: string[] = [];
107
+ let dy = 0;
108
+ pages.forEach((page, i) => {
109
+ parts.push(pageMarkup(page, doc, env, dy, i + 1));
110
+ dy += pageHeight(page, doc);
111
+ });
112
+ return wrapSvg(width, totalHeight, env, parts.join("\n"));
113
+ }
114
+
115
+ function pageMarkup(
116
+ page: ExportPage,
117
+ doc: ExportDocument,
118
+ env: SvgEnv,
119
+ dy: number,
120
+ index: number,
121
+ ): string {
122
+ const width = documentWidth(doc);
123
+ const height = pageHeight(page, doc);
124
+ const parts: string[] = [];
125
+
126
+ const bg = page.background;
127
+ if (typeof bg === "string" && bg && bg !== "transparent") {
128
+ const gradient = parseCssGradient(bg);
129
+ const fill = gradient ? gradientRef(gradient, 0, 0, width, height, env) : bg;
130
+ parts.push(`<rect ${attrs({ width, height, fill })}/>`);
131
+ }
132
+ parts.push(...childrenMarkup(page.children ?? [], env));
133
+
134
+ const name = `p${String(index).padStart(2, "0")} ${page.name || page.id || ""}`.trim();
135
+ const transform = dy ? ` transform="translate(0 ${fmt(dy)})"` : "";
136
+ return `<g id="${escapeXml(name)}"${transform}>\n${parts.join("\n")}\n</g>`;
137
+ }
138
+
139
+ function elementMarkup(el: ExportElement, env: SvgEnv): string | null {
140
+ if (el.visible === false) return null;
141
+ if (el.type === "group") {
142
+ // 체크리스트 행처럼 런들이 그룹 안에 들어 있는 경우가 많다 — 여기서도 줄로 묶는다.
143
+ const inner = childrenMarkup(
144
+ (el.children ?? []).filter((child) => child.visible !== false),
145
+ env,
146
+ ).join("\n");
147
+ if (!inner) return null;
148
+ return `<g ${attrs({ id: el.name || el.id, opacity: opacityAttr(el) })}>\n${inner}\n</g>`;
149
+ }
150
+ if (el.type === "text") return textMarkup(el, env);
151
+ if (el.type === "figure") return figureMarkup(el, env);
152
+ if (el.type === "svg") return inlineSvgMarkup(el) ?? imageMarkup(el, env);
153
+ if (el.type === "image") return imageMarkup(el, env);
154
+ return null;
155
+ }
156
+
157
+ function opacityAttr(el: ExportElement): number | undefined {
158
+ const opacity = num(el.opacity, 1);
159
+ return opacity < 1 ? opacity : undefined;
160
+ }
161
+
162
+ /**
163
+ * Gradient fill for a box at absolute (x, y): registers a def and returns the
164
+ * ``url(#id)`` reference. userSpaceOnUse keeps the CSS/Konva geometry exact.
165
+ */
166
+ function gradientRef(
167
+ gradient: NonNullable<ReturnType<typeof parseCssGradient>>,
168
+ x: number,
169
+ y: number,
170
+ w: number,
171
+ h: number,
172
+ env: SvgEnv,
173
+ ): string {
174
+ const id = env.nextId();
175
+ const stops = gradient.stops
176
+ .map(
177
+ (s) =>
178
+ `<stop offset="${fmt(Math.min(1, Math.max(0, s.offset)))}" stop-color="${escapeXml(s.color)}"/>`,
179
+ )
180
+ .join("");
181
+ if (gradient.type === "radial") {
182
+ const p = radialGradientKonvaProps(gradient, w, h);
183
+ env.defs.push(
184
+ `<radialGradient id="${id}" gradientUnits="userSpaceOnUse" ` +
185
+ `cx="${fmt(x + p.fillRadialGradientEndPoint.x)}" cy="${fmt(y + p.fillRadialGradientEndPoint.y)}" ` +
186
+ `r="${fmt(p.fillRadialGradientEndRadius)}">${stops}</radialGradient>`,
187
+ );
188
+ } else {
189
+ const p = linearGradientKonvaProps(gradient, w, h);
190
+ env.defs.push(
191
+ `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" ` +
192
+ `x1="${fmt(x + p.fillLinearGradientStartPoint.x)}" y1="${fmt(y + p.fillLinearGradientStartPoint.y)}" ` +
193
+ `x2="${fmt(x + p.fillLinearGradientEndPoint.x)}" y2="${fmt(y + p.fillLinearGradientEndPoint.y)}">` +
194
+ `${stops}</linearGradient>`,
195
+ );
196
+ }
197
+ return `url(#${id})`;
198
+ }
199
+
200
+ function figureMarkup(el: ExportElement, env: SvgEnv): string {
201
+ const x = num(el.x);
202
+ const y = num(el.y);
203
+ const w = num(el.width);
204
+ const h = num(el.height);
205
+ const gradient = parseCssGradient(el.fill) ?? parseCssGradient(el.custom?.gradient);
206
+ const fill = gradient
207
+ ? gradientRef(gradient, x, y, w, h, env)
208
+ : el.fill && el.fill !== "transparent"
209
+ ? el.fill
210
+ : "none";
211
+ const strokeWidth = num(el.strokeWidth);
212
+ const common = {
213
+ id: el.name || el.id,
214
+ fill,
215
+ opacity: opacityAttr(el),
216
+ ...(el.stroke && strokeWidth > 0
217
+ ? { stroke: el.stroke, "stroke-width": strokeWidth }
218
+ : {}),
219
+ };
220
+ if (el.subType === "circle" || el.subType === "ellipse") {
221
+ return `<ellipse ${attrs({ cx: x + w / 2, cy: y + h / 2, rx: w / 2, ry: h / 2, ...common })}/>`;
222
+ }
223
+ const r = Math.max(0, Math.min(num(el.cornerRadius), w / 2, h / 2));
224
+ return `<rect ${attrs({ x, y, width: w, height: h, ...(r ? { rx: r } : {}), ...common })}/>`;
225
+ }
226
+
227
+ function textMarkup(el: ExportElement, env: SvgEnv): string {
228
+ const x = num(el.x);
229
+ const y = num(el.y);
230
+ const w = num(el.width);
231
+ const fontSize = num(el.fontSize, 16);
232
+ const layout = layoutText(el, (s) => env.measure(el, s));
233
+ const align = el.align === "center" || el.align === "right" ? el.align : "left";
234
+ const anchor = align === "center" ? "middle" : align === "right" ? "end" : "start";
235
+ const ax = align === "center" ? x + w / 2 : align === "right" ? x + w : x;
236
+
237
+ // Match canvas textBaseline='middle': the baseline sits ~0.35em below the
238
+ // vertical center of the line box (kept renderer-independent on purpose —
239
+ // Figma's importer ignores dominant-baseline).
240
+ const tspans = layout.lines
241
+ .map((line, i) => {
242
+ const lineY = y + layout.offsetY + i * layout.leading + layout.leading / 2 + fontSize * 0.35;
243
+ return `<tspan x="${fmt(ax / layout.scaleX)}" y="${fmt(lineY)}">${escapeXml(line) || " "}</tspan>`;
244
+ })
245
+ .join("");
246
+
247
+ // 스톡 편집기의 letterSpacing은 em이다(Konva에는 letterSpacing * fontSize로 넘어간다).
248
+ // 숫자를 그대로 쓰면 SVG는 px로 읽어 자간이 사실상 0이 되고, 글자가 편집기보다 넓어진다.
249
+ const letterSpacing = num(el.letterSpacing) * fontSize;
250
+ return `<text ${attrs({
251
+ id: el.name || el.id,
252
+ "font-family": el.fontFamily || "Pretendard",
253
+ "font-size": fontSize,
254
+ "font-weight": normalizeFontWeight(el),
255
+ ...(isItalic(el) ? { "font-style": "italic" } : {}),
256
+ fill: el.fill || "#000",
257
+ opacity: opacityAttr(el),
258
+ ...(letterSpacing ? { "letter-spacing": letterSpacing } : {}),
259
+ ...(el.textDecoration === "line-through" || el.textDecoration === "underline"
260
+ ? { "text-decoration": el.textDecoration }
261
+ : {}),
262
+ "text-anchor": anchor,
263
+ ...(layout.scaleX < 1 ? { transform: `scale(${fmt(layout.scaleX)} 1)` } : {}),
264
+ })}>${tspans}</text>`;
265
+ }
266
+
267
+ /**
268
+ * 한 줄을 이루는 텍스트 런들을 하나의 ``<text>``로 흘려보낸다.
269
+ *
270
+ * 디컴포저는 "흡수가 **빠른** 콜로이드 미네랄"처럼 굵기·색이 섞인 한 줄을 런마다
271
+ * 별개 요소로 쪼개고, 런 사이의 공백은 **절대 x 좌표의 여백**으로만 남긴다. 편집기(Konva)는
272
+ * 우리가 재둔 폰트로 그리니 그 여백이 정확히 맞지만, 여유는 5~6px뿐이다. SVG를 다른 도구가
273
+ * 열면(피그마가 폰트를 대체하거나 letter-spacing을 무시하면) 글자가 그만큼만 넓어져도
274
+ * 여백이 통째로 먹히고 런끼리 붙어 버린다 — 절대 좌표로 붙여 놓은 줄은 폰트가 바뀌는 순간
275
+ * 반드시 깨진다.
276
+ *
277
+ * 그래서 한 줄은 한 ``<text>``로 만들고, 런은 ``<tspan>``으로 **흘린다**. 간격은 좌표가 아니라
278
+ * 진짜 공백 문자가 만든다 → 어떤 폰트로 열어도 간격이 살아 있고, 피그마에는 굵기가 섞인
279
+ * 텍스트 레이어 하나로 들어온다(원래 그렇게 편집돼야 하는 물건이다).
280
+ */
281
+ const RUN_GAP_SPACE = 0.2; // em. 이 이상 벌어져 있으면 원래 공백이 있던 자리다.
282
+ const RUN_GAP_MAX = 1.6; // em. 이보다 멀면 같은 줄의 이웃 런이 아니라 딴 물건이다.
283
+
284
+ /** 글자가 실제로 앉는 선. 크기가 다른 런(각주 "[*고장성 온천]")도 이 선이 같으면 한 줄이다. */
285
+ function baselineOf(el: ExportElement, env: SvgEnv): number {
286
+ const fontSize = num(el.fontSize, 16);
287
+ const layout = layoutText(el, (s) => env.measure(el, s));
288
+ return num(el.y) + layout.offsetY + layout.leading / 2 + fontSize * 0.35;
289
+ }
290
+
291
+ function sameLineRun(a: ExportElement, b: ExportElement, env: SvgEnv): boolean {
292
+ if (a.type !== "text" || b.type !== "text") return false;
293
+ if (b.visible === false || a.visible === false) return false;
294
+ const fs = num(a.fontSize, 16);
295
+ // 같은 줄인지는 박스 y가 아니라 '글자가 앉는 선'으로 본다. 그래야 작은 각주 런도 같이
296
+ // 흐르고, 위로 띄운 첨자(sup)는 선이 다르니 제 자리에 남는다.
297
+ if (Math.abs(baselineOf(a, env) - baselineOf(b, env)) > 0.3 * fs) return false;
298
+ if ((a.fontFamily || "") !== (b.fontFamily || "")) return false;
299
+ if (num(a.rotation) || num(b.rotation)) return false;
300
+ if (num(a.opacity, 1) !== num(b.opacity, 1)) return false;
301
+ // 흐르게 하려면 둘 다 왼쪽 기준이어야 한다(가운데/오른쪽 정렬 런은 앵커가 다르다).
302
+ const leftAligned = (el: ExportElement) => el.align !== "center" && el.align !== "right";
303
+ if (!leftAligned(a) || !leftAligned(b)) return false;
304
+ if (String(a.text ?? "").includes("\n") || String(b.text ?? "").includes("\n")) return false;
305
+ const gap = runGap(a, b, env);
306
+ // 박스가 잉크보다 넓어 gap이 살짝 음수로 나오기도 한다(-0.25em까지는 이웃으로 본다).
307
+ return gap > -0.25 * fs && gap < RUN_GAP_MAX * fs;
308
+ }
309
+
310
+ /**
311
+ * 두 런 사이의 실제 빈 공간.
312
+ *
313
+ * 요소의 width(박스)가 아니라 **글자 폭**으로 잰다. 디컴포저의 박스는 잉크보다 몇 px 넓어서
314
+ * (예: "흡수가" 잉크 68.5px, 박스 72px) 박스로 재면 원래 있던 공백 한 칸(≈6.5px)이 3px로
315
+ * 줄어들어 '공백 없음'으로 오판된다 — 그러면 합쳐진 줄이 "흡수가빠른"으로 붙어 버린다.
316
+ */
317
+ function runGap(a: ExportElement, b: ExportElement, env: SvgEnv): number {
318
+ const inkWidth = env.measure(a, transformText(a));
319
+ return num(b.x) - (num(a.x) + inkWidth);
320
+ }
321
+
322
+ function textRunLineMarkup(runs: ExportElement[], env: SvgEnv): string {
323
+ const first = runs[0];
324
+ const fontSize = num(first.fontSize, 16);
325
+ const layout = layoutText(first, (s) => env.measure(first, s));
326
+ const baseline =
327
+ num(first.y) + layout.offsetY + layout.leading / 2 + fontSize * 0.35;
328
+
329
+ const tspans = runs
330
+ .map((run, i) => {
331
+ const gap = i === 0 ? 0 : runGap(runs[i - 1], run, env);
332
+ // 공백 한 칸의 폭은 작은 쪽 글자 기준이다 — 큰 글자 기준으로 재면 각주 앞의
333
+ // 공백("함량 [*고장성 온천]")이 '공백 없음'으로 잘린다.
334
+ const scale = i === 0 ? fontSize : Math.min(fontSize, num(run.fontSize, 16));
335
+ const lead = i > 0 && gap >= RUN_GAP_SPACE * scale ? " " : "";
336
+ const weight = normalizeFontWeight(run);
337
+ const fill = run.fill || "#000";
338
+ const size = num(run.fontSize, 16);
339
+ return `<tspan ${attrs({
340
+ ...(weight !== normalizeFontWeight(first) ? { "font-weight": weight } : {}),
341
+ ...(fill !== (first.fill || "#000") ? { fill } : {}),
342
+ ...(Math.abs(size - fontSize) > 0.5 ? { "font-size": size } : {}),
343
+ ...(isItalic(run) && !isItalic(first) ? { "font-style": "italic" } : {}),
344
+ })}>${escapeXml(lead + transformText(run))}</tspan>`;
345
+ })
346
+ .join("");
347
+
348
+ const letterSpacing = num(first.letterSpacing) * fontSize; // 스톡 편집기의 letterSpacing은 em
349
+ return `<text ${attrs({
350
+ id: first.name || first.id,
351
+ "font-family": first.fontFamily || "Pretendard",
352
+ "font-size": fontSize,
353
+ "font-weight": normalizeFontWeight(first),
354
+ ...(isItalic(first) ? { "font-style": "italic" } : {}),
355
+ fill: first.fill || "#000",
356
+ opacity: opacityAttr(first),
357
+ ...(letterSpacing ? { "letter-spacing": letterSpacing } : {}),
358
+ "text-anchor": "start",
359
+ x: num(first.x),
360
+ y: baseline,
361
+ // 런 사이의 공백은 이 줄의 유일한 간격 장치다 — 기본 규칙대로면 공백이 접히거나 잘린다.
362
+ "xml:space": "preserve",
363
+ })}>${tspans}</text>`;
364
+ }
365
+
366
+ /** 자식들을 순회하되, 한 줄을 이루는 텍스트 런들은 하나의 <text>로 묶어 낸다. */
367
+ function childrenMarkup(children: ExportElement[], env: SvgEnv): string[] {
368
+ const parts: string[] = [];
369
+ for (let i = 0; i < children.length; i++) {
370
+ const run: ExportElement[] = [children[i]];
371
+ while (i + 1 < children.length && sameLineRun(children[i], children[i + 1], env)) {
372
+ run.push(children[++i]);
373
+ }
374
+ if (run.length > 1) {
375
+ parts.push(textRunLineMarkup(run, env));
376
+ continue;
377
+ }
378
+ const markup = elementMarkup(children[i], env);
379
+ if (markup) parts.push(markup);
380
+ }
381
+ return parts;
382
+ }
383
+
384
+ const FIT_TO_PRESERVE: Record<string, string> = {
385
+ cover: "xMidYMid slice",
386
+ contain: "xMidYMid meet",
387
+ fill: "none",
388
+ };
389
+
390
+ function imageMarkup(el: ExportElement, env: SvgEnv): string | null {
391
+ const src = String(el.src ?? "");
392
+ if (!src) return null;
393
+ const x = num(el.x);
394
+ const y = num(el.y);
395
+ const w = num(el.width);
396
+ const h = num(el.height);
397
+ const r = Math.max(0, Math.min(num(el.cornerRadius), w / 2, h / 2));
398
+ let clip: string | undefined;
399
+ if (r > 0) {
400
+ const id = env.nextId();
401
+ env.defs.push(
402
+ `<clipPath id="${id}"><rect ${attrs({ x, y, width: w, height: h, rx: r })}/></clipPath>`,
403
+ );
404
+ clip = `url(#${id})`;
405
+ }
406
+ const fit = String(el.custom?.objectFit ?? "cover");
407
+ return `<image ${attrs({
408
+ id: el.name || el.id,
409
+ href: env.hrefFor(src),
410
+ x,
411
+ y,
412
+ width: w,
413
+ height: h,
414
+ preserveAspectRatio: FIT_TO_PRESERVE[fit] ?? FIT_TO_PRESERVE.cover,
415
+ opacity: opacityAttr(el),
416
+ ...(clip ? { "clip-path": clip } : {}),
417
+ })}/>`;
418
+ }
419
+
420
+ /**
421
+ * Encode SVG markup into a ``data:image/svg+xml;base64`` URI (UTF-8 safe).
422
+ * Canvas ``svg``-type elements carry their markup in ``src`` this way; this is
423
+ * the inverse of {@link decodeSvgDataUri} and matches the bubble-tail encoder.
424
+ */
425
+ export function encodeSvgDataUri(markup: string): string {
426
+ return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(markup)))}`;
427
+ }
428
+
429
+ /** Decode a data:image/svg+xml URI into its markup, or null. */
430
+ export function decodeSvgDataUri(src: string): string | null {
431
+ const m = src.match(/^data:image\/svg\+xml(;charset=[^;,]+)?(;base64)?,([\s\S]*)$/);
432
+ if (!m) return null;
433
+ try {
434
+ return m[2] ? atob(m[3]) : decodeURIComponent(m[3]);
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Inline an ``svg``-type element as a nested ``<svg>`` positioned in the page.
442
+ * Returns null when the source is not decodable inline SVG — the caller then
443
+ * falls back to an ``<image>`` reference.
444
+ */
445
+ function inlineSvgMarkup(el: ExportElement): string | null {
446
+ const markup = decodeSvgDataUri(String(el.src ?? ""));
447
+ if (!markup) return null;
448
+ const rootMatch = markup.match(/<svg\b[^>]*>/i);
449
+ if (!rootMatch) return null;
450
+
451
+ const rootTag = rootMatch[0];
452
+ const readAttr = (name: string): string | null => {
453
+ const am = rootTag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, "i"));
454
+ return am ? am[1] : null;
455
+ };
456
+ let viewBox = readAttr("viewBox");
457
+ if (!viewBox) {
458
+ const iw = parseFloat(readAttr("width") ?? "");
459
+ const ih = parseFloat(readAttr("height") ?? "");
460
+ if (!Number.isFinite(iw) || !Number.isFinite(ih) || iw <= 0 || ih <= 0) return null;
461
+ viewBox = `0 0 ${iw} ${ih}`;
462
+ }
463
+
464
+ const inner = markup.slice(markup.indexOf(rootTag) + rootTag.length);
465
+ const closing = inner.lastIndexOf("</svg>");
466
+ if (closing < 0) return null;
467
+ const fit = String(el.custom?.objectFit ?? "contain");
468
+ const open = `<svg ${attrs({
469
+ id: el.name || el.id,
470
+ x: num(el.x),
471
+ y: num(el.y),
472
+ width: num(el.width),
473
+ height: num(el.height),
474
+ viewBox,
475
+ preserveAspectRatio: FIT_TO_PRESERVE[fit] ?? FIT_TO_PRESERVE.contain,
476
+ opacity: opacityAttr(el),
477
+ })}>`;
478
+ return `${open}${inner.slice(0, closing)}</svg>`;
479
+ }
@@ -0,0 +1,142 @@
1
+ import type { ExportElement } from "./document-model";
2
+
3
+ /**
4
+ * Text layout shared by the PSD rasterizer and the Figma-compatible SVG
5
+ * builder. Measurement is injected (canvas ``measureText`` in the browser, a
6
+ * deterministic stub in tests) so the layout itself stays pure.
7
+ */
8
+
9
+ export type MeasureText = (text: string) => number;
10
+
11
+ export type TextLayout = {
12
+ lines: string[];
13
+ /** Line pitch in px. */
14
+ leading: number;
15
+ blockHeight: number;
16
+ /** Horizontal condensation applied when wrapping would overflow the box. */
17
+ scaleX: number;
18
+ /** Vertical offset of the first line produced by verticalAlign. */
19
+ offsetY: number;
20
+ };
21
+
22
+ /** Normalize Canvas/decomposer font weights ('bold', '700', 700) to a number. */
23
+ export function normalizeFontWeight(el: ExportElement): number {
24
+ const raw = el.fontWeight ?? el.fontStyle;
25
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
26
+ if (typeof raw === "string") {
27
+ const n = parseInt(raw, 10);
28
+ if (Number.isFinite(n)) return n;
29
+ if (/bold/i.test(raw)) return 700;
30
+ }
31
+ return 400;
32
+ }
33
+
34
+ export function isItalic(el: ExportElement): boolean {
35
+ const style = `${el.fontStyle ?? ""} ${String(el.custom?.fontStyle ?? "")}`;
36
+ return /italic/i.test(style);
37
+ }
38
+
39
+ /** CSS font shorthand for canvas rendering of a text element. */
40
+ export function cssFont(el: ExportElement): string {
41
+ const italic = isItalic(el) ? "italic " : "";
42
+ const size = Number(el.fontSize) || 16;
43
+ return `${italic}${normalizeFontWeight(el)} ${size}px ${el.fontFamily || "Pretendard"}`;
44
+ }
45
+
46
+ /** Apply custom.textTransform to the exported string. */
47
+ export function transformText(el: ExportElement): string {
48
+ let text = String(el.text ?? "");
49
+ const tt = el.custom?.textTransform;
50
+ if (tt === "uppercase") text = text.toUpperCase();
51
+ else if (tt === "lowercase") text = text.toLowerCase();
52
+ return text;
53
+ }
54
+
55
+ /**
56
+ * Resolve lineHeight to a pixel leading. Canvas uses a multiplier, but
57
+ * decomposer artifacts may carry CSS values: 'normal', '32px', '1.4'.
58
+ * Never returns NaN — a NaN here once leaked into PSD engineData.
59
+ */
60
+ export function resolveLeading(el: ExportElement): number {
61
+ const lh = el.lineHeight;
62
+ const fontSize = Number(el.fontSize) || 16;
63
+ if (typeof lh === "number" && Number.isFinite(lh)) return lh * fontSize;
64
+ if (typeof lh === "string") {
65
+ const v = lh.trim();
66
+ if (v.endsWith("px")) {
67
+ const px = parseFloat(v);
68
+ if (Number.isFinite(px)) return px;
69
+ } else if (v !== "normal") {
70
+ const mult = parseFloat(v);
71
+ if (Number.isFinite(mult)) return mult * fontSize;
72
+ }
73
+ }
74
+ return 1.2 * fontSize; // CSS 'normal'
75
+ }
76
+
77
+ /**
78
+ * Word-wrap text to fit ``maxWidth``, breaking long words (incl. CJK) by
79
+ * character like Konva does.
80
+ */
81
+ export function wrapText(text: string, maxWidth: number, measure: MeasureText): string[] {
82
+ const lines: string[] = [];
83
+ const breakByChar = (word: string): string => {
84
+ let chunk = "";
85
+ for (const ch of word) {
86
+ if (measure(chunk + ch) > maxWidth && chunk) {
87
+ lines.push(chunk);
88
+ chunk = ch;
89
+ } else chunk += ch;
90
+ }
91
+ return chunk;
92
+ };
93
+ for (const paragraph of String(text).split("\n")) {
94
+ if (paragraph === "") {
95
+ lines.push("");
96
+ continue;
97
+ }
98
+ let line = "";
99
+ for (const word of paragraph.split(/(\s+)/)) {
100
+ if (!word) continue;
101
+ if (measure(line + word) <= maxWidth || line === "") {
102
+ if (line === "" && measure(word) > maxWidth) line = breakByChar(word);
103
+ else line += word;
104
+ } else {
105
+ lines.push(line.trimEnd());
106
+ line = word.trimStart();
107
+ if (line && measure(line) > maxWidth) line = breakByChar(line);
108
+ }
109
+ }
110
+ lines.push(line.trimEnd());
111
+ }
112
+ return lines;
113
+ }
114
+
115
+ /**
116
+ * Lay out a text element: wrapped lines, per-line leading, and the vertical
117
+ * offset produced by verticalAlign within the element box.
118
+ *
119
+ * The decomposer sizes text boxes from DOM measurements, which can be a few
120
+ * percent narrower than canvas metrics. The element height caps the line
121
+ * count (round(h / leading)); when wrapping would exceed it, the text is
122
+ * horizontally condensed (scaleX) until it fits, instead of overflowing onto
123
+ * extra lines that overlap following elements.
124
+ */
125
+ export function layoutText(el: ExportElement, measure: MeasureText): TextLayout {
126
+ const width = Number(el.width) || 0;
127
+ const height = Number(el.height) || 0;
128
+ const text = transformText(el);
129
+ const leading = resolveLeading(el);
130
+ const maxLines = Math.max(1, Math.round(height / leading) || 1);
131
+ let scaleX = 1;
132
+ let lines = wrapText(text, width, measure);
133
+ while (lines.length > maxLines && scaleX > 0.6) {
134
+ scaleX = Math.round((scaleX - 0.02) * 100) / 100;
135
+ lines = wrapText(text, width / scaleX, measure);
136
+ }
137
+ const blockHeight = lines.length * leading;
138
+ let offsetY = 0;
139
+ if (el.verticalAlign === "middle") offsetY = (height - blockHeight) / 2;
140
+ else if (el.verticalAlign === "bottom") offsetY = height - blockHeight;
141
+ return { lines, leading, blockHeight, scaleX, offsetY: Math.max(0, offsetY) };
142
+ }