@leviosa-ai/detail-page-editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/cardnews/color-input.tsx +380 -0
- package/components/detail-page/ai-generate-panel.tsx +763 -0
- package/components/detail-page/annotation-canvas.tsx +860 -0
- package/components/detail-page/annotation-dialog.tsx +397 -0
- package/components/detail-page/bubble-tail-overlay.tsx +209 -0
- package/components/detail-page/canvas-context-menu.tsx +234 -0
- package/components/detail-page/canvas-observer.tsx +68 -0
- package/components/detail-page/canvas-overlay-host.tsx +44 -0
- package/components/detail-page/canvas-store-facade.ts +51 -0
- package/components/detail-page/chart-inspector.tsx +531 -0
- package/components/detail-page/design-reference-picker.tsx +383 -0
- package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
- package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
- package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
- package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
- package/components/detail-page/detail-page-charts-panel.tsx +104 -0
- package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
- package/components/detail-page/detail-page-download-dialog.tsx +524 -0
- package/components/detail-page/detail-page-editor.tsx +974 -0
- package/components/detail-page/detail-page-elements-panel.tsx +117 -0
- package/components/detail-page/detail-page-font-picker.tsx +231 -0
- package/components/detail-page/detail-page-history-buttons.tsx +87 -0
- package/components/detail-page/detail-page-host-context.tsx +465 -0
- package/components/detail-page/detail-page-icons-panel.tsx +251 -0
- package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
- package/components/detail-page/detail-page-layers-panel.tsx +618 -0
- package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
- package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
- package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
- package/components/detail-page/detail-page-pages-panel.tsx +299 -0
- package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
- package/components/detail-page/detail-page-photos-panel.tsx +257 -0
- package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
- package/components/detail-page/detail-page-qr-panel.tsx +137 -0
- package/components/detail-page/detail-page-references-panel.tsx +166 -0
- package/components/detail-page/detail-page-selection.ts +44 -0
- package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
- package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
- package/components/detail-page/detail-page-tables-panel.tsx +106 -0
- package/components/detail-page/detail-page-text-panel.tsx +179 -0
- package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
- package/components/detail-page/edit-quota-ui.tsx +150 -0
- package/components/detail-page/editor-history.ts +56 -0
- package/components/detail-page/editor-hotkeys.tsx +165 -0
- package/components/detail-page/element-recents-strip.tsx +105 -0
- package/components/detail-page/element-rects.ts +82 -0
- package/components/detail-page/fill-control.tsx +261 -0
- package/components/detail-page/find-replace-panel.tsx +252 -0
- package/components/detail-page/generation-progress-fill.tsx +57 -0
- package/components/detail-page/gif-animator.tsx +184 -0
- package/components/detail-page/gif-effect-picker.tsx +136 -0
- package/components/detail-page/group-drill-in.tsx +219 -0
- package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
- package/components/detail-page/hover-highlight-overlay.tsx +111 -0
- package/components/detail-page/hovered-layer.ts +34 -0
- package/components/detail-page/inspector-controls.tsx +175 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
- package/components/detail-page/nested-selection-overlay.tsx +364 -0
- package/components/detail-page/panel-search-input.tsx +49 -0
- package/components/detail-page/prompt-edit-panel.tsx +146 -0
- package/components/detail-page/section-height-handle.tsx +276 -0
- package/components/detail-page/section-reauthor-controller.tsx +210 -0
- package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
- package/components/detail-page/spec-resize-absorber.tsx +149 -0
- package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
- package/components/detail-page/table-canvas-overlay.tsx +707 -0
- package/components/detail-page/table-inspector.tsx +586 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/command.tsx +175 -0
- package/components/ui/dialog.tsx +167 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/select.tsx +190 -0
- package/components/ui/slider.tsx +63 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/tooltip.tsx +57 -0
- package/config/detail-page-bundle-font-tags.json +39 -0
- package/config/detail-page-fonts.json +373 -0
- package/index.ts +24 -0
- package/lib/cardnews/font-loader.ts +306 -0
- package/lib/detail-page/animation-sniff.ts +101 -0
- package/lib/detail-page/asset-bytes-url.ts +70 -0
- package/lib/detail-page/basic-shapes.ts +603 -0
- package/lib/detail-page/canvas-menu.ts +182 -0
- package/lib/detail-page/chart/defaults.ts +283 -0
- package/lib/detail-page/chart/format.ts +66 -0
- package/lib/detail-page/chart/normalize.ts +149 -0
- package/lib/detail-page/chart/palette.ts +85 -0
- package/lib/detail-page/chart/paste.ts +82 -0
- package/lib/detail-page/chart/preview.ts +78 -0
- package/lib/detail-page/chart/render.ts +188 -0
- package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
- package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
- package/lib/detail-page/chart/renderers/donut.ts +186 -0
- package/lib/detail-page/chart/renderers/gauge.ts +96 -0
- package/lib/detail-page/chart/renderers/line.ts +143 -0
- package/lib/detail-page/chart/renderers/shared.ts +263 -0
- package/lib/detail-page/chart/renderers/stack.ts +121 -0
- package/lib/detail-page/chart/sync.ts +92 -0
- package/lib/detail-page/chart/types.ts +145 -0
- package/lib/detail-page/css-color.ts +34 -0
- package/lib/detail-page/data-gif-payload.ts +167 -0
- package/lib/detail-page/design-reference.ts +244 -0
- package/lib/detail-page/distribute.ts +89 -0
- package/lib/detail-page/editor-routes.ts +10 -0
- package/lib/detail-page/element-recents.ts +167 -0
- package/lib/detail-page/find-replace.ts +120 -0
- package/lib/detail-page/format-painter.ts +159 -0
- package/lib/detail-page/gif-frames.ts +216 -0
- package/lib/detail-page/gif-timeline.ts +65 -0
- package/lib/detail-page/group-action.ts +62 -0
- package/lib/detail-page/hangul-roman.ts +58 -0
- package/lib/detail-page/icon-curated.ts +290 -0
- package/lib/detail-page/icon-fuzzy.ts +250 -0
- package/lib/detail-page/icon-keywords.generated.ts +3766 -0
- package/lib/detail-page/icon-keywords.ko.ts +524 -0
- package/lib/detail-page/icon-search.ts +257 -0
- package/lib/detail-page/icons.ts +67 -0
- package/lib/detail-page/image-credit.ts +121 -0
- package/lib/detail-page/image-data-uri.ts +29 -0
- package/lib/detail-page/insert-image.ts +75 -0
- package/lib/detail-page/insert-shape.ts +49 -0
- package/lib/detail-page/korean-dictionary.ts +187 -0
- package/lib/detail-page/layer-move.ts +257 -0
- package/lib/detail-page/new-product-draft-store.ts +105 -0
- package/lib/detail-page/new-product-draft.ts +283 -0
- package/lib/detail-page/personal-images-refresh.ts +30 -0
- package/lib/detail-page/product-source.ts +587 -0
- package/lib/detail-page/qr-code.ts +214 -0
- package/lib/detail-page/reference-image.ts +103 -0
- package/lib/detail-page/reference-upload.ts +181 -0
- package/lib/detail-page/replace-page.ts +35 -0
- package/lib/detail-page/replace-with-gif.ts +251 -0
- package/lib/detail-page/section-height.ts +131 -0
- package/lib/detail-page/section-reauthor-bus.ts +61 -0
- package/lib/detail-page/shape-to-image.ts +219 -0
- package/lib/detail-page/spec-group/clipboard.ts +35 -0
- package/lib/detail-page/spec-group/sync.ts +377 -0
- package/lib/detail-page/spec-group/text-metrics.ts +48 -0
- package/lib/detail-page/stock-photos.ts +88 -0
- package/lib/detail-page/svg-colors.ts +74 -0
- package/lib/detail-page/table/defaults.ts +344 -0
- package/lib/detail-page/table/edit.ts +312 -0
- package/lib/detail-page/table/harvest.ts +103 -0
- package/lib/detail-page/table/normalize.ts +125 -0
- package/lib/detail-page/table/paste.ts +49 -0
- package/lib/detail-page/table/preview.ts +66 -0
- package/lib/detail-page/table/render.ts +388 -0
- package/lib/detail-page/table/sync.ts +107 -0
- package/lib/detail-page/table/types.ts +102 -0
- package/lib/detail-page/text-gif-layout.ts +245 -0
- package/lib/detail-page/text-gif-payload.ts +67 -0
- package/lib/detail-page/text-presets.ts +230 -0
- package/lib/detail-page/use-fake-progress.ts +67 -0
- package/lib/detail-page/z-order.ts +82 -0
- package/lib/detail-page-canvas/bubble-path.ts +190 -0
- package/lib/detail-page-canvas/canvas-key.ts +22 -0
- package/lib/detail-page-canvas/clip-rect.ts +65 -0
- package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
- package/lib/detail-page-canvas/editor-fonts.ts +161 -0
- package/lib/detail-page-canvas/export/ai.ts +413 -0
- package/lib/detail-page-canvas/export/color.ts +35 -0
- package/lib/detail-page-canvas/export/document-model.ts +78 -0
- package/lib/detail-page-canvas/export/export-files.ts +290 -0
- package/lib/detail-page-canvas/export/font-subset.ts +264 -0
- package/lib/detail-page-canvas/export/gif-export.ts +310 -0
- package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
- package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
- package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
- package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
- package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
- package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
- package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
- package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
- package/lib/detail-page-canvas/export/psd.ts +354 -0
- package/lib/detail-page-canvas/export/raster.ts +255 -0
- package/lib/detail-page-canvas/export/svg.ts +479 -0
- package/lib/detail-page-canvas/export/text-layout.ts +142 -0
- package/lib/detail-page-canvas/font-catalog.ts +177 -0
- package/lib/detail-page-canvas/font-tags.ts +47 -0
- package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
- package/lib/detail-page-canvas/konva-fallback.ts +477 -0
- package/lib/detail-page-canvas/text-background-plan.ts +124 -0
- package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
- package/lib/utils.ts +10 -0
- package/package.json +68 -0
- package/types/commerce.ts +171 -0
- package/types/detail-page-api.ts +200 -0
- package/types/detail-page-canvas.ts +20 -0
- package/types/fontkit.d.ts +42 -0
- package/types/gifenc.d.ts +51 -0
|
@@ -0,0 +1,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
|
+
}
|