@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,290 @@
1
+ import { writePsdBuffer } from "ag-psd";
2
+
3
+ import { buildAiPdf } from "./ai";
4
+ import type { ExportDocument, ExportElement, ExportPage } from "./document-model";
5
+ import {
6
+ buildCatalogFontSubsets,
7
+ loadCatalogPostScriptNames,
8
+ } from "./font-subset";
9
+ import type { AiBitmap } from "./pdf/resources";
10
+ import { buildPsd } from "./psd";
11
+ import { drawBitmap, drawPlaceholder, type Raster2D } from "./raster";
12
+ import { buildSvgDocument, buildSvgPages } from "./svg";
13
+ import { cssFont } from "./text-layout";
14
+
15
+ /**
16
+ * Browser glue for the PSD / Figma-SVG / Illustrator exporters: waits for
17
+ * webfonts, decodes bitmaps CORS-safely, and returns downloadable Blobs.
18
+ * Everything document-shaped stays in psd.ts / svg.ts / ai.ts so it can be
19
+ * unit-tested without a DOM.
20
+ *
21
+ * Loaded lazily from the download dialog (dynamic import) so ag-psd never
22
+ * lands in the editor's main bundle.
23
+ */
24
+
25
+ export type PsdExportOptions = {
26
+ slotBindings?: Record<string, { element_id?: string } | undefined>;
27
+ pageIds?: string[];
28
+ };
29
+
30
+ export type SvgExportOptions = {
31
+ pageIds?: string[];
32
+ /** true: one stacked SVG; false: one Blob per page. */
33
+ merged: boolean;
34
+ };
35
+
36
+ export type AiExportOptions = {
37
+ pageIds?: string[];
38
+ /** true: one tall artboard; false: one artboard per page. */
39
+ merged: boolean;
40
+ };
41
+
42
+ function walkElements(pages: ExportPage[], visit: (el: ExportElement) => void): void {
43
+ const walk = (elements?: ExportElement[]) => {
44
+ for (const el of elements ?? []) {
45
+ visit(el);
46
+ walk(el.children);
47
+ }
48
+ };
49
+ for (const page of pages) walk(page.children);
50
+ }
51
+
52
+ /**
53
+ * Ask the browser to have every (family, weight, style) combination the
54
+ * document uses ready before canvas rasterization; a font that falls back
55
+ * silently would rasterize and measure with the wrong metrics.
56
+ */
57
+ async function ensureFontsLoaded(doc: ExportDocument): Promise<void> {
58
+ if (typeof document === "undefined" || !document.fonts) return;
59
+ const wanted = new Set<string>();
60
+ walkElements(doc.pages ?? [], (el) => {
61
+ if (el.type === "text") wanted.add(cssFont({ ...el, fontSize: 16 }));
62
+ });
63
+ await Promise.allSettled([...wanted].map((font) => document.fonts.load(font)));
64
+ await document.fonts.ready.catch(() => undefined);
65
+ }
66
+
67
+ /** Decode an image CORS-safely; null lets the caller draw a placeholder. */
68
+ function loadImageElement(src: string): Promise<HTMLImageElement | null> {
69
+ return new Promise((resolve) => {
70
+ const img = new Image();
71
+ // Cross-origin pixels would taint the canvas and make ag-psd's
72
+ // getImageData throw; our sources are same-origin or CORS-enabled S3.
73
+ if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
74
+ img.onload = () => resolve(img.naturalWidth > 0 ? img : null);
75
+ img.onerror = () => resolve(null);
76
+ img.src = src;
77
+ });
78
+ }
79
+
80
+ /** Build a layered PSD Blob for the document (selected pages stacked). */
81
+ export async function exportPsdBlob(
82
+ doc: ExportDocument,
83
+ opts: PsdExportOptions = {},
84
+ ): Promise<Blob> {
85
+ await ensureFontsLoaded(doc);
86
+ const fontPostScriptNames = await loadCatalogPostScriptNames(
87
+ doc,
88
+ opts.pageIds,
89
+ );
90
+ const psd = await buildPsd(doc, {
91
+ createCanvas: (w, h) => {
92
+ const canvas = document.createElement("canvas");
93
+ canvas.width = Math.max(1, Math.round(w));
94
+ canvas.height = Math.max(1, Math.round(h));
95
+ return canvas;
96
+ },
97
+ slotBindings: opts.slotBindings,
98
+ pageIds: opts.pageIds,
99
+ fontPostScriptNames,
100
+ loadBitmap: async (el) => {
101
+ const src = String(el.src ?? "");
102
+ if (!src) return null;
103
+ return loadImageElement(src);
104
+ },
105
+ });
106
+ const buffer = writePsdBuffer(psd);
107
+ // ag-psd 타입은 Node Buffer지만 브라우저 런타임에선 Uint8Array다.
108
+ return new Blob([buffer as unknown as Uint8Array<ArrayBuffer>], {
109
+ type: "image/vnd.adobe.photoshop",
110
+ });
111
+ }
112
+
113
+ function fetchAsDataUri(src: string): Promise<string | null> {
114
+ return fetch(src)
115
+ .then((res) => (res.ok ? res.blob() : null))
116
+ .then(
117
+ (blob) =>
118
+ blob &&
119
+ new Promise<string | null>((resolve) => {
120
+ const reader = new FileReader();
121
+ reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null);
122
+ reader.onerror = () => resolve(null);
123
+ reader.readAsDataURL(blob);
124
+ }),
125
+ )
126
+ .catch(() => null);
127
+ }
128
+
129
+ /**
130
+ * Embed every remote image as a data URI so the SVG file is self-contained —
131
+ * Figma cannot fetch our (auth-guarded) URLs when the file is dropped in.
132
+ * Sources that fail to fetch keep their original URL rather than breaking
133
+ * the whole export.
134
+ */
135
+ async function buildHrefMap(doc: ExportDocument, pageIds?: string[]): Promise<Map<string, string>> {
136
+ const pages = pageIds?.length
137
+ ? (doc.pages ?? []).filter((p) => p.id && pageIds.includes(p.id))
138
+ : (doc.pages ?? []);
139
+ const remote = new Set<string>();
140
+ walkElements(pages, (el) => {
141
+ const src = String(el.src ?? "");
142
+ if ((el.type === "image" || el.type === "svg") && src && !src.startsWith("data:")) {
143
+ remote.add(src);
144
+ }
145
+ });
146
+ const entries = await Promise.all(
147
+ [...remote].map(async (src) => [src, await fetchAsDataUri(src)] as const),
148
+ );
149
+ const map = new Map<string, string>();
150
+ for (const [src, dataUri] of entries) if (dataUri) map.set(src, dataUri);
151
+ return map;
152
+ }
153
+
154
+ /** Measure text with a shared canvas context in the element's font. */
155
+ function makeCanvasMeasure(): (el: ExportElement, text: string) => number {
156
+ const ctx = document.createElement("canvas").getContext("2d");
157
+ if (!ctx) return () => 0;
158
+ return (el, text) => {
159
+ ctx.font = cssFont(el);
160
+ // 스톡 편집기의 letterSpacing은 em이다 (Konva에는 letterSpacing * fontSize로 넘어간다).
161
+ // px로 재면 자간이 실제보다 넓게 잡혀, 딱 맞는 텍스트가 '넘친다'고 오판된다.
162
+ const em = Number(el.letterSpacing);
163
+ const spacing = Number.isFinite(em) ? em * (Number(el.fontSize) || 16) : 0;
164
+ const withSpacing = ctx as CanvasRenderingContext2D & { letterSpacing?: string };
165
+ if ("letterSpacing" in withSpacing) {
166
+ withSpacing.letterSpacing = spacing ? `${spacing}px` : "0px";
167
+ }
168
+ return ctx.measureText(text).width;
169
+ };
170
+ }
171
+
172
+ /** Build Figma-compatible SVG Blob(s): one merged file or one per page. */
173
+ export async function exportSvgBlobs(
174
+ doc: ExportDocument,
175
+ opts: SvgExportOptions,
176
+ ): Promise<Blob[]> {
177
+ await ensureFontsLoaded(doc);
178
+ const hrefMap = await buildHrefMap(doc, opts.pageIds);
179
+ const buildOpts = {
180
+ measure: makeCanvasMeasure(),
181
+ hrefFor: (src: string) => hrefMap.get(src) ?? src,
182
+ pageIds: opts.pageIds,
183
+ };
184
+ const documents = opts.merged
185
+ ? [buildSvgDocument(doc, buildOpts)]
186
+ : buildSvgPages(doc, buildOpts);
187
+ return documents.map((svg) => new Blob([svg], { type: "image/svg+xml" }));
188
+ }
189
+
190
+ /**
191
+ * zlib deflate for PDF ``/FlateDecode`` streams. ``CompressionStream("deflate")``
192
+ * emits the zlib wrapper PDF expects (``deflate-raw`` would not).
193
+ */
194
+ function deflateBytes(data: Uint8Array): Promise<Uint8Array> {
195
+ const stream = new Blob([data as Uint8Array<ArrayBuffer>])
196
+ .stream()
197
+ .pipeThrough(new CompressionStream("deflate"));
198
+ return new Response(stream).arrayBuffer().then((buffer) => new Uint8Array(buffer));
199
+ }
200
+
201
+ async function canvasToJpeg(canvas: HTMLCanvasElement): Promise<Uint8Array | null> {
202
+ const blob = await new Promise<Blob | null>((resolve) => {
203
+ canvas.toBlob(resolve, "image/jpeg", 0.95);
204
+ });
205
+ if (!blob) return null;
206
+ return new Uint8Array(await blob.arrayBuffer());
207
+ }
208
+
209
+ /**
210
+ * Bake an image/svg element into an element-sized bitmap, applying the same
211
+ * objectFit and corner rounding the editor paints — so the PDF places it as a
212
+ * plain rectangle and the framing cannot drift from the canvas.
213
+ *
214
+ * Opaque artwork ships as JPEG (DCTDecode passthrough, small); anything with
215
+ * transparency ships as raw RGB plus an alpha soft mask, because flattening a
216
+ * cut-out product shot onto white is exactly the bug this avoids.
217
+ */
218
+ async function bakeBitmap(el: ExportElement): Promise<AiBitmap | null> {
219
+ const src = String(el.src ?? "");
220
+ if (!src) return null;
221
+ const width = Math.max(1, Math.round(Number(el.width) || 0));
222
+ const height = Math.max(1, Math.round(Number(el.height) || 0));
223
+ const canvas = document.createElement("canvas");
224
+ canvas.width = width;
225
+ canvas.height = height;
226
+ const ctx = canvas.getContext("2d") as Raster2D | null;
227
+ if (!ctx) return null;
228
+
229
+ const image = await loadImageElement(src);
230
+ const ox = -Number(el.x ?? 0);
231
+ const oy = -Number(el.y ?? 0);
232
+ if (image) drawBitmap(ctx, el, image, ox, oy);
233
+ else drawPlaceholder(ctx, el, ox, oy);
234
+
235
+ const pixels = (ctx as unknown as CanvasRenderingContext2D).getImageData(
236
+ 0,
237
+ 0,
238
+ width,
239
+ height,
240
+ ).data;
241
+ const rgb = new Uint8Array(width * height * 3);
242
+ const alpha = new Uint8Array(width * height);
243
+ let opaque = true;
244
+ for (let i = 0; i < width * height; i++) {
245
+ rgb[i * 3] = pixels[i * 4];
246
+ rgb[i * 3 + 1] = pixels[i * 4 + 1];
247
+ rgb[i * 3 + 2] = pixels[i * 4 + 2];
248
+ alpha[i] = pixels[i * 4 + 3];
249
+ if (alpha[i] < 255) opaque = false;
250
+ }
251
+
252
+ if (opaque) {
253
+ const jpeg = await canvasToJpeg(canvas);
254
+ if (jpeg) return { width, height, format: "jpeg", data: jpeg };
255
+ }
256
+ return { width, height, format: "rgb", data: rgb, ...(opaque ? {} : { alpha }) };
257
+ }
258
+
259
+ /**
260
+ * Build an Adobe Illustrator (.ai) Blob. The file is a PDF — that is what an
261
+ * .ai has been since Illustrator 9 — with editable text and vector artwork.
262
+ */
263
+ export async function exportAiBlob(
264
+ doc: ExportDocument,
265
+ opts: AiExportOptions,
266
+ ): Promise<Blob> {
267
+ await ensureFontsLoaded(doc);
268
+ const embeddedFonts = await buildCatalogFontSubsets(doc, opts.pageIds);
269
+ const bytes = await buildAiPdf(doc, {
270
+ measure: makeCanvasMeasure(),
271
+ loadBitmap: bakeBitmap,
272
+ deflate: typeof CompressionStream === "undefined" ? undefined : deflateBytes,
273
+ pageIds: opts.pageIds,
274
+ merged: opts.merged,
275
+ embeddedFonts,
276
+ });
277
+ return new Blob([bytes as Uint8Array<ArrayBuffer>], { type: "application/pdf" });
278
+ }
279
+
280
+ /** Trigger a browser download for a Blob. */
281
+ export function downloadBlob(blob: Blob, fileName: string): void {
282
+ const url = URL.createObjectURL(blob);
283
+ const link = document.createElement("a");
284
+ link.href = url;
285
+ link.download = fileName;
286
+ document.body.appendChild(link);
287
+ link.click();
288
+ link.remove();
289
+ URL.revokeObjectURL(url);
290
+ }
@@ -0,0 +1,264 @@
1
+ import {
2
+ getDetailPageFont,
3
+ resolveDetailPageFontStyle,
4
+ type DetailPageFont,
5
+ type DetailPageFontStyle,
6
+ } from "../font-catalog";
7
+
8
+ import {
9
+ selectPages,
10
+ type ExportDocument,
11
+ type ExportElement,
12
+ } from "./document-model";
13
+ import {
14
+ pdfFontKey,
15
+ type PdfEmbeddedFont,
16
+ type PdfFontSpec,
17
+ } from "./pdf/resources";
18
+ import {
19
+ isItalic,
20
+ normalizeFontWeight,
21
+ transformText,
22
+ } from "./text-layout";
23
+
24
+ type CatalogFontUsage = {
25
+ font: DetailPageFont;
26
+ source: DetailPageFontStyle;
27
+ spec: PdfFontSpec;
28
+ chars: Set<string>;
29
+ };
30
+
31
+ export type CatalogFontPostScriptName = {
32
+ spec: PdfFontSpec;
33
+ name: string;
34
+ };
35
+
36
+ type FontkitModule = typeof import("fontkit");
37
+ type FontkitFont = ReturnType<FontkitModule["create"]>;
38
+
39
+ export class FontEmbeddingError extends Error {
40
+ readonly code = "FONT_EMBEDDING_FAILED";
41
+
42
+ constructor(
43
+ readonly family: string,
44
+ message: string,
45
+ ) {
46
+ super(message);
47
+ this.name = "FontEmbeddingError";
48
+ }
49
+ }
50
+
51
+ const fontFilePromises = new Map<string, Promise<Uint8Array>>();
52
+ let fontkitPromise: Promise<FontkitModule> | null = null;
53
+
54
+ function loadFontkit(): Promise<FontkitModule> {
55
+ fontkitPromise ??= import("fontkit");
56
+ return fontkitPromise;
57
+ }
58
+
59
+ function fetchFontFile(url: string): Promise<Uint8Array> {
60
+ const cached = fontFilePromises.get(url);
61
+ if (cached) return cached;
62
+ const pending = fetch(url, { mode: "cors", credentials: "omit" })
63
+ .then(async (response) => {
64
+ if (!response.ok) {
65
+ throw new Error(`font request failed (${response.status})`);
66
+ }
67
+ return new Uint8Array(await response.arrayBuffer());
68
+ })
69
+ .catch((error) => {
70
+ fontFilePromises.delete(url);
71
+ throw error;
72
+ });
73
+ fontFilePromises.set(url, pending);
74
+ return pending;
75
+ }
76
+
77
+ async function decodeWebFont(
78
+ bytes: Uint8Array,
79
+ format: DetailPageFontStyle["format"],
80
+ ): Promise<Uint8Array> {
81
+ if (format === "woff2") {
82
+ const { default: decompress } = await import("woff2-encoder/decompress");
83
+ return decompress(bytes);
84
+ }
85
+ return bytes;
86
+ }
87
+
88
+ function normalizeColorFont(font: FontkitFont): FontkitFont {
89
+ const tables = font.directory?.tables;
90
+ if (!tables?.COLR && !tables?.CPAL) return font;
91
+ // PDF text fill supplies the colour. Removing COLR/CPAL makes Fontkit expose
92
+ // Mona12's underlying monochrome TrueType outlines instead of a COLR wrapper
93
+ // that its subset encoder cannot serialize.
94
+ delete tables.COLR;
95
+ delete tables.CPAL;
96
+ font._glyphs = [];
97
+ return font;
98
+ }
99
+
100
+ function collectCatalogFontUsage(
101
+ doc: ExportDocument,
102
+ pageIds?: string[],
103
+ ): CatalogFontUsage[] {
104
+ const usage = new Map<string, CatalogFontUsage>();
105
+ const walk = (elements?: ExportElement[]) => {
106
+ for (const element of elements ?? []) {
107
+ if (element.visible === false) continue;
108
+ if (element.type === "text") {
109
+ const family = String(element.fontFamily || "");
110
+ const font = getDetailPageFont(family);
111
+ if (font) {
112
+ const italic = isItalic(element);
113
+ const source = resolveDetailPageFontStyle(
114
+ font,
115
+ normalizeFontWeight(element),
116
+ italic ? "italic" : "normal",
117
+ );
118
+ const spec: PdfFontSpec = {
119
+ family,
120
+ weight: source.weight,
121
+ italic: source.style === "italic",
122
+ };
123
+ const key = pdfFontKey(spec);
124
+ const entry = usage.get(key) ?? {
125
+ font,
126
+ source,
127
+ spec,
128
+ chars: new Set<string>(),
129
+ };
130
+ for (const char of Array.from(transformText(element))) {
131
+ entry.chars.add(char);
132
+ }
133
+ usage.set(key, entry);
134
+ }
135
+ }
136
+ walk(element.children);
137
+ }
138
+ };
139
+ for (const page of selectPages(doc, pageIds)) walk(page.children);
140
+ return [...usage.values()];
141
+ }
142
+
143
+ async function openCatalogFont(usage: CatalogFontUsage): Promise<FontkitFont> {
144
+ try {
145
+ const [fontkit, sourceBytes] = await Promise.all([
146
+ loadFontkit(),
147
+ fetchFontFile(usage.source.url),
148
+ ]);
149
+ return normalizeColorFont(
150
+ fontkit.create(
151
+ await decodeWebFont(sourceBytes, usage.source.format),
152
+ ),
153
+ );
154
+ } catch (error) {
155
+ throw new FontEmbeddingError(
156
+ usage.font.label,
157
+ error instanceof Error ? error.message : String(error),
158
+ );
159
+ }
160
+ }
161
+
162
+ function assertEmbeddingAllowed(usage: CatalogFontUsage, font: FontkitFont) {
163
+ const rights = font["OS/2"]?.fsType;
164
+ if (rights?.noEmbedding || rights?.bitmapOnly) {
165
+ throw new FontEmbeddingError(
166
+ usage.font.label,
167
+ "font metadata prohibits embedding",
168
+ );
169
+ }
170
+ if (rights?.noSubsetting) {
171
+ throw new FontEmbeddingError(
172
+ usage.font.label,
173
+ "font metadata prohibits subsetting",
174
+ );
175
+ }
176
+ }
177
+
178
+ async function subsetCatalogFont(
179
+ usage: CatalogFontUsage,
180
+ ): Promise<PdfEmbeddedFont> {
181
+ const font = await openCatalogFont(usage);
182
+ assertEmbeddingAllowed(usage, font);
183
+
184
+ try {
185
+ const subset = font.createSubset();
186
+ const glyphs: PdfEmbeddedFont["glyphs"] = [];
187
+ for (const char of usage.chars) {
188
+ const codePoint = char.codePointAt(0);
189
+ if (codePoint == null) continue;
190
+ const glyph = font.glyphForCodePoint(codePoint);
191
+ const id = subset.includeGlyph(glyph);
192
+ glyphs.push({
193
+ char,
194
+ unicode: char,
195
+ id,
196
+ width: Math.round(
197
+ (glyph.advanceWidth / Math.max(1, font.unitsPerEm)) * 1000,
198
+ ),
199
+ });
200
+ }
201
+
202
+ return {
203
+ spec: usage.spec,
204
+ data: subset.encode(),
205
+ format: font["CFF "] ? "cff" : "truetype",
206
+ postscriptName:
207
+ String(font.postscriptName || "").trim() ||
208
+ `${usage.font.family}-${usage.source.weight}`,
209
+ unitsPerEm: font.unitsPerEm,
210
+ ascent: font.ascent,
211
+ descent: font.descent,
212
+ capHeight: font.capHeight || font.ascent,
213
+ italicAngle: font.italicAngle || 0,
214
+ bbox: [
215
+ font.bbox.minX,
216
+ font.bbox.minY,
217
+ font.bbox.maxX,
218
+ font.bbox.maxY,
219
+ ],
220
+ glyphs,
221
+ };
222
+ } catch (error) {
223
+ if (error instanceof FontEmbeddingError) throw error;
224
+ throw new FontEmbeddingError(
225
+ usage.font.label,
226
+ error instanceof Error ? error.message : String(error),
227
+ );
228
+ }
229
+ }
230
+
231
+ /** Generate one PDF-ready subset for every catalog face used by the export. */
232
+ export async function buildCatalogFontSubsets(
233
+ doc: ExportDocument,
234
+ pageIds?: string[],
235
+ ): Promise<PdfEmbeddedFont[]> {
236
+ return Promise.all(
237
+ collectCatalogFontUsage(doc, pageIds).map(subsetCatalogFont),
238
+ );
239
+ }
240
+
241
+ /**
242
+ * PSD cannot contain a font program, but the type layer can reference the
243
+ * source font's real PostScript name while its raster cache preserves pixels.
244
+ */
245
+ export async function loadCatalogPostScriptNames(
246
+ doc: ExportDocument,
247
+ pageIds?: string[],
248
+ ): Promise<CatalogFontPostScriptName[]> {
249
+ const usages = collectCatalogFontUsage(doc, pageIds);
250
+ const names = await Promise.all(
251
+ usages.map(async (usage) => {
252
+ try {
253
+ const font = await openCatalogFont(usage);
254
+ const name = String(font.postscriptName || "").trim();
255
+ return name ? { spec: usage.spec, name } : null;
256
+ } catch {
257
+ return null;
258
+ }
259
+ }),
260
+ );
261
+ return names.filter(
262
+ (entry): entry is CatalogFontPostScriptName => entry !== null,
263
+ );
264
+ }