@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,413 @@
1
+ import { parseCssGradient, linearGradientKonvaProps, radialGradientKonvaProps } from "../konva-fallback";
2
+ import {
3
+ documentWidth,
4
+ pageHeight,
5
+ selectPages,
6
+ type ExportDocument,
7
+ type ExportElement,
8
+ type ExportPage,
9
+ } from "./document-model";
10
+ import { ellipsePath, rectPath } from "./pdf/geometry";
11
+ import { inlineSvgOps } from "./pdf/inline-svg";
12
+ import {
13
+ parsePaint,
14
+ ResourcePool,
15
+ rgbOps,
16
+ type AiBitmap,
17
+ type PdfEmbeddedFont,
18
+ type RgbColor,
19
+ type ShadingSpec,
20
+ } from "./pdf/resources";
21
+ import { showTextOps } from "./pdf/text-ops";
22
+ import { fmt, PdfBuilder } from "./pdf/writer";
23
+ import { decodeSvgDataUri } from "./svg";
24
+ import { isItalic, layoutText, normalizeFontWeight, transformText } from "./text-layout";
25
+
26
+ /**
27
+ * Serializes a Canvas document to an Adobe Illustrator (.ai) file.
28
+ *
29
+ * An .ai file is a PDF underneath (that is what "PDF compatibility" has meant
30
+ * since Illustrator 9), so this builds a PDF: pages become artboards, figures
31
+ * and inline SVG icons become editable vector paths, and text stays text.
32
+ *
33
+ * Catalog fonts are embedded as document-specific subsets. Older/canonical
34
+ * fonts that have no single embeddable source keep the previous non-embedded
35
+ * Adobe-Korea1 fallback, with explicit browser-measured width corrections.
36
+ */
37
+
38
+ /** Illustrator refuses artboards larger than this (in points). */
39
+ export const AI_MAX_DIMENSION = 16383;
40
+
41
+ export type BuildAiOptions = {
42
+ /** Advance of ``text`` in the element's font (canvas in browser, stub in tests). */
43
+ measure: (el: ExportElement, text: string) => number;
44
+ /** Element-sized bitmap for an image/svg element; null draws nothing. */
45
+ loadBitmap: (el: ExportElement) => Promise<AiBitmap | null>;
46
+ /** zlib deflate; omitted, raw bitmaps are stored uncompressed. */
47
+ deflate?: (data: Uint8Array) => Promise<Uint8Array>;
48
+ /** Font programs generated from only the catalog faces/glyphs this export uses. */
49
+ embeddedFonts?: PdfEmbeddedFont[];
50
+ pageIds?: string[];
51
+ /** true: one tall artboard; false: one artboard per page. */
52
+ merged: boolean;
53
+ };
54
+
55
+ type AiEnv = {
56
+ pool: ResourcePool;
57
+ measure: BuildAiOptions["measure"];
58
+ bitmaps: Map<ExportElement, AiBitmap>;
59
+ };
60
+
61
+ function num(value: unknown, fallback = 0): number {
62
+ const n = Number(value);
63
+ return Number.isFinite(n) ? n : fallback;
64
+ }
65
+
66
+ const rgb = (value: unknown): RgbColor | null => parsePaint(value)?.color ?? null;
67
+
68
+ /** Gradient stops as PDF shading coordinates, in the page's y-down space. */
69
+ function shadingSpec(
70
+ gradient: NonNullable<ReturnType<typeof parseCssGradient>>,
71
+ x: number,
72
+ y: number,
73
+ w: number,
74
+ h: number,
75
+ ): ShadingSpec | null {
76
+ const stops = gradient.stops
77
+ .map((s) => ({ offset: s.offset, color: rgb(s.color) }))
78
+ .filter((s): s is { offset: number; color: RgbColor } => s.color !== null);
79
+ if (!stops.length) return null;
80
+ if (gradient.type === "radial") {
81
+ const p = radialGradientKonvaProps(gradient, w, h);
82
+ return {
83
+ kind: "radial",
84
+ cx: x + p.fillRadialGradientEndPoint.x,
85
+ cy: y + p.fillRadialGradientEndPoint.y,
86
+ r: p.fillRadialGradientEndRadius,
87
+ stops,
88
+ };
89
+ }
90
+ const p = linearGradientKonvaProps(gradient, w, h);
91
+ return {
92
+ kind: "axial",
93
+ x0: x + p.fillLinearGradientStartPoint.x,
94
+ y0: y + p.fillLinearGradientStartPoint.y,
95
+ x1: x + p.fillLinearGradientEndPoint.x,
96
+ y1: y + p.fillLinearGradientEndPoint.y,
97
+ stops,
98
+ };
99
+ }
100
+
101
+ /** Rotate around the element's own origin, the way Konva does. */
102
+ function rotationOps(el: ExportElement): { open: string[]; close: string[] } {
103
+ const degrees = num(el.rotation);
104
+ if (!degrees) return { open: [], close: [] };
105
+ const rad = (degrees * Math.PI) / 180;
106
+ const cos = Math.cos(rad);
107
+ const sin = Math.sin(rad);
108
+ const x = num(el.x);
109
+ const y = num(el.y);
110
+ return {
111
+ open: [
112
+ "q",
113
+ `${fmt(cos)} ${fmt(sin)} ${fmt(-sin)} ${fmt(cos)} ` +
114
+ `${fmt(x - x * cos + y * sin)} ${fmt(y - x * sin - y * cos)} cm`,
115
+ ],
116
+ close: ["Q"],
117
+ };
118
+ }
119
+
120
+ function figureOps(el: ExportElement, env: AiEnv): string[] {
121
+ const x = num(el.x);
122
+ const y = num(el.y);
123
+ const w = num(el.width);
124
+ const h = num(el.height);
125
+ if (w <= 0 || h <= 0) return [];
126
+
127
+ const path =
128
+ el.subType === "circle" || el.subType === "ellipse"
129
+ ? ellipsePath(x + w / 2, y + h / 2, w / 2, h / 2)
130
+ : rectPath(x, y, w, h, num(el.cornerRadius));
131
+
132
+ const gradient = parseCssGradient(el.fill) ?? parseCssGradient(el.custom?.gradient);
133
+ const shading = gradient ? shadingSpec(gradient, x, y, w, h) : null;
134
+ // `rgba(0, 0, 0, 0)` is how the decomposer writes "no fill" — a common shape
135
+ // is a bordered card with a fully transparent interior.
136
+ const fill = shading ? null : parsePaint(el.fill);
137
+ const strokeWidth = num(el.strokeWidth);
138
+ const stroke = strokeWidth > 0 ? parsePaint(el.stroke) : null;
139
+ if (!shading && !fill && !stroke) return [];
140
+
141
+ const opacity = num(el.opacity, 1);
142
+ const ops: string[] = ["q"];
143
+ const fillAlpha = opacity * (fill?.alpha ?? 1);
144
+ const strokeAlpha = opacity * (stroke?.alpha ?? 1);
145
+ if (fillAlpha < 1 || strokeAlpha < 1) {
146
+ ops.push(`/${env.pool.alpha(fillAlpha, strokeAlpha)} gs`);
147
+ }
148
+
149
+ if (shading) {
150
+ // A shading paints the clip region, so the shape becomes the clip.
151
+ ops.push("q", ...path, "W n", `/${env.pool.shading(shading)} sh`, "Q");
152
+ }
153
+ if (fill || stroke) {
154
+ if (fill) ops.push(`${rgbOps(fill.color)} rg`);
155
+ if (stroke) ops.push(`${rgbOps(stroke.color)} RG`, `${fmt(strokeWidth)} w`);
156
+ ops.push(...path, fill && stroke ? "B" : fill ? "f" : "S");
157
+ }
158
+ ops.push("Q");
159
+ return ops;
160
+ }
161
+
162
+ function imageOps(el: ExportElement, env: AiEnv): string[] {
163
+ const bitmap = env.bitmaps.get(el);
164
+ if (!bitmap) return [];
165
+ const x = num(el.x);
166
+ const y = num(el.y);
167
+ const w = num(el.width);
168
+ const h = num(el.height);
169
+ if (w <= 0 || h <= 0) return [];
170
+
171
+ const ops: string[] = ["q"];
172
+ const opacity = num(el.opacity, 1);
173
+ if (opacity < 1) ops.push(`/${env.pool.alpha(opacity)} gs`);
174
+ // Image space runs bottom-up; under the page's y-down CTM the height flips
175
+ // back, so the bitmap's top row lands on the element's top edge.
176
+ ops.push(
177
+ `${fmt(w)} 0 0 ${fmt(-h)} ${fmt(x)} ${fmt(y + h)} cm`,
178
+ `/${env.pool.image(bitmap)} Do`,
179
+ "Q",
180
+ );
181
+ return ops;
182
+ }
183
+
184
+ function svgOps(el: ExportElement, env: AiEnv): string[] {
185
+ const markup = decodeSvgDataUri(String(el.src ?? ""));
186
+ if (markup) {
187
+ const ops = inlineSvgOps(
188
+ markup,
189
+ { x: num(el.x), y: num(el.y), width: num(el.width), height: num(el.height) },
190
+ String(el.custom?.objectFit ?? "contain"),
191
+ env,
192
+ );
193
+ if (ops) {
194
+ const opacity = num(el.opacity, 1);
195
+ return opacity < 1 ? ["q", `/${env.pool.alpha(opacity)} gs`, ...ops, "Q"] : ops;
196
+ }
197
+ }
198
+ return imageOps(el, env); // remote or unparseable: embedded as a bitmap
199
+ }
200
+
201
+ function decorationOps(
202
+ el: ExportElement,
203
+ x: number,
204
+ baseline: number,
205
+ width: number,
206
+ color: RgbColor,
207
+ ): string[] {
208
+ const decoration = el.textDecoration;
209
+ if (decoration !== "underline" && decoration !== "line-through") return [];
210
+ const fontSize = num(el.fontSize, 16);
211
+ const y = decoration === "underline" ? baseline + fontSize * 0.12 : baseline - fontSize * 0.28;
212
+ const thickness = Math.max(1, fontSize * 0.06);
213
+ return [
214
+ "q",
215
+ `${rgbOps(color)} rg`,
216
+ `${fmt(x)} ${fmt(y)} ${fmt(width)} ${fmt(thickness)} re`,
217
+ "f",
218
+ "Q",
219
+ ];
220
+ }
221
+
222
+ function textOps(el: ExportElement, env: AiEnv): string[] {
223
+ const text = transformText(el);
224
+ if (!text.trim()) return [];
225
+ const x = num(el.x);
226
+ const y = num(el.y);
227
+ const width = num(el.width);
228
+ const fontSize = num(el.fontSize, 16);
229
+ const layout = layoutText(el, (s) => env.measure(el, s));
230
+
231
+ // A gradient-filled text run would need a pattern colour space; the first
232
+ // stop keeps it a solid, editable text object instead of a rasterized one.
233
+ const gradient = parseCssGradient(el.fill);
234
+ const paint = parsePaint(gradient ? gradient.stops[0]?.color : el.fill);
235
+ const color = paint?.color ?? ([0, 0, 0] as RgbColor);
236
+
237
+ const fontRes = env.pool.font({
238
+ family: el.fontFamily || "Pretendard",
239
+ weight: normalizeFontWeight(el),
240
+ italic: isItalic(el),
241
+ });
242
+
243
+ const ops: string[] = ["q"];
244
+ const alpha = num(el.opacity, 1) * (paint?.alpha ?? 1);
245
+ if (alpha < 1) ops.push(`/${env.pool.alpha(alpha)} gs`);
246
+ ops.push(`${rgbOps(color)} rg`);
247
+
248
+ layout.lines.forEach((line, i) => {
249
+ if (!line) return;
250
+ const lineWidth = env.measure(el, line) * layout.scaleX;
251
+ const anchor =
252
+ el.align === "center"
253
+ ? x + (width - lineWidth) / 2
254
+ : el.align === "right"
255
+ ? x + width - lineWidth
256
+ : x;
257
+ // Same baseline the SVG exporter uses: canvas' textBaseline='middle' sits
258
+ // ~0.35em below the centre of the line box.
259
+ const baseline =
260
+ y + layout.offsetY + i * layout.leading + layout.leading / 2 + fontSize * 0.35;
261
+ ops.push(
262
+ ...showTextOps({
263
+ fontRes,
264
+ fontSize,
265
+ x: anchor,
266
+ baseline,
267
+ text: line,
268
+ measure: (t) => env.measure(el, t),
269
+ scaleX: layout.scaleX,
270
+ glyph: (char) => env.pool.textGlyph(fontRes, char),
271
+ skewX: env.pool.syntheticItalic(fontRes) ? Math.tan(Math.PI / 15) : 0,
272
+ }),
273
+ ...decorationOps(el, anchor, baseline, lineWidth, color),
274
+ );
275
+ });
276
+ ops.push("Q");
277
+ return ops;
278
+ }
279
+
280
+ function elementOps(el: ExportElement, env: AiEnv): string[] {
281
+ if (el.visible === false) return [];
282
+ const { open, close } = rotationOps(el);
283
+ let body: string[] = [];
284
+ if (el.type === "group") {
285
+ const opacity = num(el.opacity, 1);
286
+ const children = (el.children ?? []).flatMap((child) => elementOps(child, env));
287
+ if (!children.length) return [];
288
+ // Group children carry absolute page coordinates (see document-model).
289
+ body = opacity < 1 ? ["q", `/${env.pool.alpha(opacity)} gs`, ...children, "Q"] : children;
290
+ } else if (el.type === "text") body = textOps(el, env);
291
+ else if (el.type === "figure") body = figureOps(el, env);
292
+ else if (el.type === "svg") body = svgOps(el, env);
293
+ else if (el.type === "image") body = imageOps(el, env);
294
+ if (!body.length) return [];
295
+ return [...open, ...body, ...close];
296
+ }
297
+
298
+ function pageOps(page: ExportPage, doc: ExportDocument, env: AiEnv, dy: number): string[] {
299
+ const width = documentWidth(doc);
300
+ const height = pageHeight(page, doc);
301
+ const ops: string[] = ["q"];
302
+ if (dy) ops.push(`1 0 0 1 0 ${fmt(dy)} cm`);
303
+
304
+ const background = page.background;
305
+ if (typeof background === "string" && background && background !== "transparent") {
306
+ const gradient = parseCssGradient(background);
307
+ const shading = gradient ? shadingSpec(gradient, 0, 0, width, height) : null;
308
+ if (shading) {
309
+ ops.push("q", `0 0 ${fmt(width)} ${fmt(height)} re`, "W n", `/${env.pool.shading(shading)} sh`, "Q");
310
+ } else {
311
+ const paint = parsePaint(background);
312
+ if (paint) {
313
+ ops.push("q");
314
+ if (paint.alpha < 1) ops.push(`/${env.pool.alpha(paint.alpha)} gs`);
315
+ ops.push(`${rgbOps(paint.color)} rg`, `0 0 ${fmt(width)} ${fmt(height)} re`, "f", "Q");
316
+ }
317
+ }
318
+ }
319
+ for (const el of page.children ?? []) ops.push(...elementOps(el, env));
320
+ ops.push("Q");
321
+ return ops;
322
+ }
323
+
324
+ /** Every element that needs a bitmap, in document order. */
325
+ function bitmapElements(pages: ExportPage[]): ExportElement[] {
326
+ const found: ExportElement[] = [];
327
+ const walk = (elements?: ExportElement[]) => {
328
+ for (const el of elements ?? []) {
329
+ if (el.visible === false) continue;
330
+ const src = String(el.src ?? "");
331
+ if (el.type === "image" && src) found.push(el);
332
+ // An inline SVG is drawn as vectors; only a remote one needs a bitmap.
333
+ if (el.type === "svg" && src && !decodeSvgDataUri(src)) found.push(el);
334
+ walk(el.children);
335
+ }
336
+ };
337
+ walk(pages.flatMap((page) => page.children ?? []));
338
+ return found;
339
+ }
340
+
341
+ /** Build the .ai (PDF) bytes for the selected pages. */
342
+ export async function buildAiPdf(
343
+ doc: ExportDocument,
344
+ opts: BuildAiOptions,
345
+ ): Promise<Uint8Array> {
346
+ const pages = selectPages(doc, opts.pageIds);
347
+ const width = documentWidth(doc);
348
+ if (!pages.length || width <= 0) throw new Error("빈 문서는 내보낼 수 없습니다");
349
+
350
+ const bitmaps = new Map<ExportElement, AiBitmap>();
351
+ for (const el of bitmapElements(pages)) {
352
+ const bitmap = await opts.loadBitmap(el);
353
+ if (bitmap) bitmaps.set(el, bitmap);
354
+ }
355
+
356
+ const env: AiEnv = {
357
+ pool: new ResourcePool(opts.embeddedFonts),
358
+ measure: opts.measure,
359
+ bitmaps,
360
+ };
361
+ const builder = new PdfBuilder();
362
+
363
+ // The PDF origin is bottom-left; flipping the CTM lets every element keep the
364
+ // top-left, y-down coordinates the document already uses.
365
+ const artboards: Array<{ height: number; ops: string[] }> = [];
366
+ if (opts.merged) {
367
+ const height = pages.reduce((acc, page) => acc + pageHeight(page, doc), 0);
368
+ const ops: string[] = [`1 0 0 -1 0 ${fmt(height)} cm`];
369
+ let dy = 0;
370
+ for (const page of pages) {
371
+ ops.push(...pageOps(page, doc, env, dy));
372
+ dy += pageHeight(page, doc);
373
+ }
374
+ artboards.push({ height, ops });
375
+ } else {
376
+ for (const page of pages) {
377
+ const height = pageHeight(page, doc);
378
+ artboards.push({
379
+ height,
380
+ ops: [`1 0 0 -1 0 ${fmt(height)} cm`, ...pageOps(page, doc, env, 0)],
381
+ });
382
+ }
383
+ }
384
+
385
+ const pagesRef = builder.alloc();
386
+ const encoder = new TextEncoder();
387
+ const pageRefs = await Promise.all(
388
+ artboards.map(async (artboard) => {
389
+ const content = builder.addStream(
390
+ opts.deflate ? "/Filter /FlateDecode" : "",
391
+ opts.deflate
392
+ ? await opts.deflate(encoder.encode(artboard.ops.join("\n")))
393
+ : encoder.encode(artboard.ops.join("\n")),
394
+ );
395
+ return { content, height: artboard.height };
396
+ }),
397
+ );
398
+
399
+ // Resources are written last: the content streams above are what filled them.
400
+ const resources = await env.pool.write(builder, opts.deflate);
401
+ const kids = pageRefs.map(({ content, height }) =>
402
+ builder.add(
403
+ `<< /Type /Page /Parent ${pagesRef} 0 R /MediaBox [0 0 ${fmt(width)} ${fmt(height)}] ` +
404
+ `/Resources ${resources} /Contents ${content} 0 R >>`,
405
+ ),
406
+ );
407
+ builder.set(
408
+ pagesRef,
409
+ `<< /Type /Pages /Kids [${kids.map((r) => `${r} 0 R`).join(" ")}] /Count ${kids.length} >>`,
410
+ );
411
+ const catalog = builder.add(`<< /Type /Catalog /Pages ${pagesRef} 0 R >>`);
412
+ return builder.build(catalog);
413
+ }
@@ -0,0 +1,35 @@
1
+ /** RGBA color parsed from the CSS strings our documents carry. */
2
+ export type ParsedColor = { r: number; g: number; b: number; a: number };
3
+
4
+ /** Parse 'rgb(…)', 'rgba(…)', '#rrggbb(aa)', '#rgb' into {r,g,b,a}. */
5
+ export function parseColor(value: unknown): ParsedColor | null {
6
+ if (!value || typeof value !== "string") return null;
7
+ const v = value.trim();
8
+ let m = v.match(
9
+ /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i,
10
+ );
11
+ if (m) {
12
+ return {
13
+ r: Math.round(+m[1]),
14
+ g: Math.round(+m[2]),
15
+ b: Math.round(+m[3]),
16
+ a: m[4] === undefined ? 1 : +m[4],
17
+ };
18
+ }
19
+ m = v.match(/^#([0-9a-f]{6})([0-9a-f]{2})?$/i);
20
+ if (m) {
21
+ const n = parseInt(m[1], 16);
22
+ return {
23
+ r: (n >> 16) & 255,
24
+ g: (n >> 8) & 255,
25
+ b: n & 255,
26
+ a: m[2] ? parseInt(m[2], 16) / 255 : 1,
27
+ };
28
+ }
29
+ m = v.match(/^#([0-9a-f]{3})$/i);
30
+ if (m) {
31
+ const [r, g, b] = m[1].split("").map((c) => parseInt(c + c, 16));
32
+ return { r, g, b, a: 1 };
33
+ }
34
+ return null;
35
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Loose structural types for walking a Canvas ``store.toJSON()`` document at
3
+ * export time. Every field is optional because documents arrive from several
4
+ * producers (decomposer bridge, live editor, older templates); exporters read
5
+ * defensively and fall back element-by-element instead of failing the file.
6
+ *
7
+ * Group children use ABSOLUTE page coordinates (both the decomposer bridge and
8
+ * Canvas itself serialize them that way), so exporters never accumulate a
9
+ * group offset.
10
+ */
11
+
12
+ export type ExportElement = {
13
+ id?: string;
14
+ name?: string;
15
+ type?: string;
16
+ subType?: string;
17
+ x?: number;
18
+ y?: number;
19
+ width?: number;
20
+ height?: number;
21
+ rotation?: number;
22
+ opacity?: number;
23
+ visible?: boolean;
24
+ fill?: string;
25
+ stroke?: string;
26
+ strokeWidth?: number;
27
+ cornerRadius?: number;
28
+ text?: string;
29
+ fontSize?: number;
30
+ fontFamily?: string;
31
+ fontWeight?: number | string;
32
+ fontStyle?: string;
33
+ lineHeight?: number | string;
34
+ letterSpacing?: number;
35
+ align?: string;
36
+ verticalAlign?: string;
37
+ textDecoration?: string;
38
+ src?: string;
39
+ children?: ExportElement[];
40
+ custom?: Record<string, unknown> | null;
41
+ };
42
+
43
+ export type ExportPage = {
44
+ id?: string;
45
+ name?: string;
46
+ background?: string;
47
+ width?: number | string;
48
+ height?: number | string;
49
+ children?: ExportElement[];
50
+ };
51
+
52
+ export type ExportDocument = {
53
+ width?: number;
54
+ height?: number;
55
+ pages?: ExportPage[];
56
+ };
57
+
58
+ /** Numeric page height; Canvas may store "auto" strings on legacy docs. */
59
+ export function pageHeight(page: ExportPage, doc: ExportDocument): number {
60
+ const h = Number(page.height);
61
+ if (Number.isFinite(h) && h > 0) return Math.round(h);
62
+ return Math.round(Number(doc.height) || 0);
63
+ }
64
+
65
+ export function documentWidth(doc: ExportDocument): number {
66
+ const w = Number(doc.width);
67
+ if (Number.isFinite(w) && w > 0) return Math.round(w);
68
+ const first = Number(doc.pages?.[0]?.width);
69
+ return Number.isFinite(first) && first > 0 ? Math.round(first) : 0;
70
+ }
71
+
72
+ /** Pages to export, preserving document order; no ids = all pages. */
73
+ export function selectPages(doc: ExportDocument, pageIds?: string[]): ExportPage[] {
74
+ const pages = doc.pages ?? [];
75
+ if (!pageIds?.length) return pages;
76
+ const wanted = new Set(pageIds);
77
+ return pages.filter((p) => p.id && wanted.has(p.id));
78
+ }