@overtone-art/canvas-editor-core 0.6.4 → 0.7.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/dist/chunk-XNGPX7FG.mjs +641 -0
- package/dist/chunk-XNGPX7FG.mjs.map +1 -0
- package/dist/index.d.mts +99 -3
- package/dist/index.d.ts +99 -3
- package/dist/index.global.js +55 -54
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +426 -127
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +87 -138
- package/dist/index.mjs.map +1 -1
- package/dist/node.d.mts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +146 -0
- package/dist/node.js.map +1 -1
- package/dist/node.mjs +3 -1
- package/dist/node.mjs.map +1 -1
- package/dist/{types-DSu2jbiV.d.mts → types-DLrbzRj1.d.mts} +20 -1
- package/dist/{types-DSu2jbiV.d.ts → types-DLrbzRj1.d.ts} +20 -1
- package/package.json +1 -1
- package/dist/chunk-MCBRZQ4M.mjs +0 -261
- package/dist/chunk-MCBRZQ4M.mjs.map +0 -1
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
// src/text-fit.ts
|
|
2
|
+
import { Point } from "fabric";
|
|
3
|
+
var TEXT_FIT_SLACK = 0.5;
|
|
4
|
+
var MEASURE_WIDTH = 1e5;
|
|
5
|
+
var GROWTH_FACTOR = 1.1;
|
|
6
|
+
var GROWTH_TRIES = 8;
|
|
7
|
+
var SEARCH_TRIES = 24;
|
|
8
|
+
function unwrappedWidth(text) {
|
|
9
|
+
const authored = text.width;
|
|
10
|
+
try {
|
|
11
|
+
text.set({ width: MEASURE_WIDTH });
|
|
12
|
+
text.initDimensions?.();
|
|
13
|
+
const measured = text.calcTextWidth?.() ?? authored;
|
|
14
|
+
return Number.isFinite(measured) && measured > 0 ? measured : authored;
|
|
15
|
+
} finally {
|
|
16
|
+
text.set({ width: authored });
|
|
17
|
+
text.initDimensions?.();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function asText(object) {
|
|
21
|
+
if (!object) return null;
|
|
22
|
+
const text = object;
|
|
23
|
+
return typeof text.text === "string" && typeof text.calcTextWidth === "function" ? text : null;
|
|
24
|
+
}
|
|
25
|
+
function textInk(text) {
|
|
26
|
+
const boxWidth = Math.max(0, text.width ?? 0);
|
|
27
|
+
const measured = text.calcTextWidth?.() ?? boxWidth;
|
|
28
|
+
const width = Math.max(1, Math.min(boxWidth, Number.isFinite(measured) ? measured : boxWidth));
|
|
29
|
+
const slack = (boxWidth - width) / 2;
|
|
30
|
+
const align = text.textAlign ?? "left";
|
|
31
|
+
if (align.includes("center")) return { width, dx: 0 };
|
|
32
|
+
const flip = text.flipX ? -1 : 1;
|
|
33
|
+
return { width, dx: flip * (align.includes("right") ? slack : -slack) };
|
|
34
|
+
}
|
|
35
|
+
function wrapsAt(text, width) {
|
|
36
|
+
text.set({ width });
|
|
37
|
+
text.initDimensions?.();
|
|
38
|
+
const authored = text.text.split("\n").length;
|
|
39
|
+
return (text._textLines?.length ?? 0) > authored;
|
|
40
|
+
}
|
|
41
|
+
function widenPastSoftWrap(text, fitted) {
|
|
42
|
+
if (!wrapsAt(text, fitted)) return fitted;
|
|
43
|
+
let low = fitted;
|
|
44
|
+
let high = fitted;
|
|
45
|
+
let bracketed = false;
|
|
46
|
+
for (let tries = 0; tries < GROWTH_TRIES; tries += 1) {
|
|
47
|
+
low = high;
|
|
48
|
+
high = high * GROWTH_FACTOR + 1;
|
|
49
|
+
if (!wrapsAt(text, high)) {
|
|
50
|
+
bracketed = true;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!bracketed) return high;
|
|
55
|
+
for (let tries = 0; tries < SEARCH_TRIES && high - low > TEXT_FIT_SLACK; tries += 1) {
|
|
56
|
+
const mid = (low + high) / 2;
|
|
57
|
+
if (wrapsAt(text, mid)) low = mid;
|
|
58
|
+
else high = mid;
|
|
59
|
+
}
|
|
60
|
+
if (text.width !== high) wrapsAt(text, high);
|
|
61
|
+
return high;
|
|
62
|
+
}
|
|
63
|
+
function restoreWidth(text, width) {
|
|
64
|
+
try {
|
|
65
|
+
text.set({ width });
|
|
66
|
+
text.initDimensions?.();
|
|
67
|
+
} catch {
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function isSettled(text, width) {
|
|
71
|
+
if (wrapsAt(text, width)) return false;
|
|
72
|
+
const tight = wrapsAt(text, width - TEXT_FIT_SLACK);
|
|
73
|
+
wrapsAt(text, width);
|
|
74
|
+
return tight;
|
|
75
|
+
}
|
|
76
|
+
function fitTextWidth(object) {
|
|
77
|
+
const text = asText(object);
|
|
78
|
+
if (!text || text.path) return false;
|
|
79
|
+
const authored = text.width;
|
|
80
|
+
try {
|
|
81
|
+
const before = textInk(text);
|
|
82
|
+
const fitted = unwrappedWidth(text) + TEXT_FIT_SLACK;
|
|
83
|
+
if (Math.abs(fitted - authored) < TEXT_FIT_SLACK) return false;
|
|
84
|
+
const nearFit = authored > fitted && authored <= fitted * GROWTH_FACTOR + 1;
|
|
85
|
+
if (nearFit && isSettled(text, authored)) return false;
|
|
86
|
+
const centre = text.getCenterPoint();
|
|
87
|
+
const settled = widenPastSoftWrap(text, fitted);
|
|
88
|
+
const after = textInk(text);
|
|
89
|
+
const shift = (before.dx - after.dx) * (text.scaleX ?? 1);
|
|
90
|
+
const radians = (text.angle ?? 0) * Math.PI / 180;
|
|
91
|
+
const moved = new Point(
|
|
92
|
+
centre.x + shift * Math.cos(radians),
|
|
93
|
+
centre.y + shift * Math.sin(radians)
|
|
94
|
+
);
|
|
95
|
+
text.setPositionByOrigin(moved, "center", "center");
|
|
96
|
+
text.setCoords();
|
|
97
|
+
text.dirty = true;
|
|
98
|
+
return Math.abs(settled - authored) >= TEXT_FIT_SLACK;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
restoreWidth(text, authored);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/text-wrap-split.ts
|
|
106
|
+
var WHITESPACE = /[ \t\r]/;
|
|
107
|
+
function preWrapWordSplit(value) {
|
|
108
|
+
const tokens = [];
|
|
109
|
+
let index = 0;
|
|
110
|
+
let first = true;
|
|
111
|
+
while (index < value.length) {
|
|
112
|
+
let space = "";
|
|
113
|
+
while (index < value.length && WHITESPACE.test(value[index])) {
|
|
114
|
+
space += value[index];
|
|
115
|
+
index += 1;
|
|
116
|
+
}
|
|
117
|
+
let word = "";
|
|
118
|
+
while (index < value.length && !WHITESPACE.test(value[index])) {
|
|
119
|
+
word += value[index];
|
|
120
|
+
index += 1;
|
|
121
|
+
}
|
|
122
|
+
tokens.push((first ? space : space.slice(1)) + word);
|
|
123
|
+
first = false;
|
|
124
|
+
}
|
|
125
|
+
return tokens.length > 0 ? tokens : [""];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/masks/space.ts
|
|
129
|
+
import { util } from "fabric";
|
|
130
|
+
function matrixOf(object) {
|
|
131
|
+
return object.calcTransformMatrix();
|
|
132
|
+
}
|
|
133
|
+
function applyMatrix(object, matrix) {
|
|
134
|
+
const decomposed = util.qrDecompose(matrix);
|
|
135
|
+
object.set({
|
|
136
|
+
flipX: false,
|
|
137
|
+
flipY: false,
|
|
138
|
+
originX: "center",
|
|
139
|
+
originY: "center",
|
|
140
|
+
left: decomposed.translateX,
|
|
141
|
+
top: decomposed.translateY,
|
|
142
|
+
scaleX: decomposed.scaleX,
|
|
143
|
+
scaleY: decomposed.scaleY,
|
|
144
|
+
angle: decomposed.angle,
|
|
145
|
+
skewX: decomposed.skewX,
|
|
146
|
+
skewY: 0
|
|
147
|
+
});
|
|
148
|
+
object.setCoords();
|
|
149
|
+
}
|
|
150
|
+
function toCanvasSpace(object, host) {
|
|
151
|
+
applyMatrix(object, util.multiplyTransformMatrices(matrixOf(host), matrixOf(object)));
|
|
152
|
+
}
|
|
153
|
+
function toHostSpace(object, host) {
|
|
154
|
+
applyMatrix(
|
|
155
|
+
object,
|
|
156
|
+
util.multiplyTransformMatrices(util.invertTransform(matrixOf(host)), matrixOf(object))
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
function relativeMatrix(object, host) {
|
|
160
|
+
return util.multiplyTransformMatrices(util.invertTransform(matrixOf(host)), matrixOf(object));
|
|
161
|
+
}
|
|
162
|
+
function applyRelativeMatrix(object, host, rel) {
|
|
163
|
+
applyMatrix(object, util.multiplyTransformMatrices(matrixOf(host), rel));
|
|
164
|
+
}
|
|
165
|
+
function asObject(clip) {
|
|
166
|
+
return clip;
|
|
167
|
+
}
|
|
168
|
+
function toMatrix(values) {
|
|
169
|
+
if (!values || values.length !== 6 || values.some((value) => !Number.isFinite(value)))
|
|
170
|
+
return null;
|
|
171
|
+
return [values[0], values[1], values[2], values[3], values[4], values[5]];
|
|
172
|
+
}
|
|
173
|
+
function fitToBox(object, box, zoom = 1) {
|
|
174
|
+
const width = Math.max(1, box.width) * zoom;
|
|
175
|
+
const height = Math.max(1, box.height) * zoom;
|
|
176
|
+
object.set({
|
|
177
|
+
originX: "center",
|
|
178
|
+
originY: "center",
|
|
179
|
+
angle: 0,
|
|
180
|
+
skewX: 0,
|
|
181
|
+
skewY: 0,
|
|
182
|
+
left: box.left + box.width / 2,
|
|
183
|
+
top: box.top + box.height / 2,
|
|
184
|
+
scaleX: width / Math.max(1, object.width ?? 1),
|
|
185
|
+
scaleY: height / Math.max(1, object.height ?? 1)
|
|
186
|
+
});
|
|
187
|
+
object.setCoords();
|
|
188
|
+
}
|
|
189
|
+
function unwrapGroup(group) {
|
|
190
|
+
const children = group.removeAll();
|
|
191
|
+
for (const child of children) child.setCoords();
|
|
192
|
+
return children;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/text-wrap.ts
|
|
196
|
+
import { Rect } from "fabric";
|
|
197
|
+
function patchWordSplit(text) {
|
|
198
|
+
if (Object.prototype.hasOwnProperty.call(text, "wordSplit")) return;
|
|
199
|
+
Object.defineProperty(text, "wordSplit", {
|
|
200
|
+
value: preWrapWordSplit,
|
|
201
|
+
configurable: true,
|
|
202
|
+
writable: true
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function unpatchWordSplit(text) {
|
|
206
|
+
if (Object.prototype.hasOwnProperty.call(text, "wordSplit")) {
|
|
207
|
+
delete text.wordSplit;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function readTextWrap(meta) {
|
|
211
|
+
return { wrap: meta?.wrap ?? "none", overflow: meta?.overflow ?? "visible" };
|
|
212
|
+
}
|
|
213
|
+
function boxClip(text, makeRect) {
|
|
214
|
+
return makeRect({
|
|
215
|
+
width: Math.max(1, text.width ?? 1),
|
|
216
|
+
height: Math.max(1, text.height ?? 1),
|
|
217
|
+
originX: "center",
|
|
218
|
+
originY: "center",
|
|
219
|
+
left: 0,
|
|
220
|
+
top: 0,
|
|
221
|
+
objectCaching: false
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
function hasMask(meta) {
|
|
225
|
+
return !!meta.maskPreset || (meta.maskStack?.length ?? 0) > 0;
|
|
226
|
+
}
|
|
227
|
+
function applyTextWrapToObject(object, meta, makeRect = (options) => new Rect(options)) {
|
|
228
|
+
const text = asText(object);
|
|
229
|
+
if (!text) return;
|
|
230
|
+
const state = readTextWrap(meta);
|
|
231
|
+
if (!text.path) {
|
|
232
|
+
text.set({ splitByGrapheme: state.wrap === "break-all" });
|
|
233
|
+
if (state.wrap === "pre-wrap") patchWordSplit(text);
|
|
234
|
+
else unpatchWordSplit(text);
|
|
235
|
+
text.initDimensions?.();
|
|
236
|
+
}
|
|
237
|
+
const clip = state.overflow === "hidden" ? boxClip(text, makeRect) : void 0;
|
|
238
|
+
if (meta && hasMask(meta)) {
|
|
239
|
+
const host = text.clipPath;
|
|
240
|
+
if (host) {
|
|
241
|
+
if (clip) toHostSpace(clip, asObject(host));
|
|
242
|
+
host.clipPath = clip;
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
text.clipPath = clip;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
var TextWrapManager = class {
|
|
249
|
+
constructor(canvas, layers, history, events) {
|
|
250
|
+
this.canvas = canvas;
|
|
251
|
+
this.layers = layers;
|
|
252
|
+
this.history = history;
|
|
253
|
+
this.events = events;
|
|
254
|
+
this.canvas.on("text:changed", this.onTextChanged);
|
|
255
|
+
this.events.on("masks:changed", this.onMasksChanged);
|
|
256
|
+
}
|
|
257
|
+
canvas;
|
|
258
|
+
layers;
|
|
259
|
+
history;
|
|
260
|
+
events;
|
|
261
|
+
/**
|
|
262
|
+
* Typing changes the run the box was fitted to, so an auto-width layer has to
|
|
263
|
+
* re-fit. No history entry: fabric records the edit when editing exits, and a
|
|
264
|
+
* save per keystroke would bury every earlier step.
|
|
265
|
+
*/
|
|
266
|
+
onTextChanged = (event) => {
|
|
267
|
+
const layer = event.target ? this.layers.findByObject(event.target) : void 0;
|
|
268
|
+
if (!layer) return;
|
|
269
|
+
this.refresh(layer.id, false);
|
|
270
|
+
};
|
|
271
|
+
/**
|
|
272
|
+
* Both mask owners — the preset manager and every mask-stack mutation —
|
|
273
|
+
* install their clip straight onto `clipPath`, dropping whatever was there,
|
|
274
|
+
* and neither knows this layer had a box clip. Re-deriving on the one event
|
|
275
|
+
* they both announce puts it back where it now belongs: nested under the new
|
|
276
|
+
* mask, or at the top level when the last mask leaves. Waiting for the next
|
|
277
|
+
* keystroke instead would leave a layer that should clip inside its box
|
|
278
|
+
* serialized unclipped — which is what the print renderer reads.
|
|
279
|
+
*/
|
|
280
|
+
onMasksChanged = ({ target }) => {
|
|
281
|
+
this.refresh(target);
|
|
282
|
+
};
|
|
283
|
+
dispose() {
|
|
284
|
+
this.canvas.off("text:changed", this.onTextChanged);
|
|
285
|
+
this.events.off("masks:changed", this.onMasksChanged);
|
|
286
|
+
}
|
|
287
|
+
/** Both properties for a text layer, or null when it is not text. */
|
|
288
|
+
get(layerId) {
|
|
289
|
+
const layer = this.layers.get(layerId);
|
|
290
|
+
if (!layer || !asText(layer.fabricObject)) return null;
|
|
291
|
+
return readTextWrap(layer.meta);
|
|
292
|
+
}
|
|
293
|
+
apply(layerId, wrap, save = true) {
|
|
294
|
+
const layer = this.layers.get(layerId);
|
|
295
|
+
const text = layer ? asText(layer.fabricObject) : null;
|
|
296
|
+
if (!layer || !text) return false;
|
|
297
|
+
if (wrap === "none") {
|
|
298
|
+
const curved = !!text.path;
|
|
299
|
+
if (layer.meta.wrapWidth === void 0 && !curved) {
|
|
300
|
+
layer.meta.wrapWidth = text.width ?? 0;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
layer.meta.wrap = wrap;
|
|
304
|
+
return this.derive(layerId, save);
|
|
305
|
+
}
|
|
306
|
+
setOverflow(layerId, overflow, save = true) {
|
|
307
|
+
const layer = this.layers.get(layerId);
|
|
308
|
+
if (!layer || !asText(layer.fabricObject)) return false;
|
|
309
|
+
layer.meta.overflow = overflow;
|
|
310
|
+
return this.derive(layerId, save);
|
|
311
|
+
}
|
|
312
|
+
/** Back to the defaults: auto-width, unclipped. */
|
|
313
|
+
clear(layerId, save = true) {
|
|
314
|
+
const layer = this.layers.get(layerId);
|
|
315
|
+
if (!layer || !asText(layer.fabricObject)) return false;
|
|
316
|
+
delete layer.meta.overflow;
|
|
317
|
+
return this.apply(layerId, "none", save);
|
|
318
|
+
}
|
|
319
|
+
/** Re-derive from the stored mode — after a text, font or size change. */
|
|
320
|
+
refresh(layerId, save = false) {
|
|
321
|
+
const layer = this.layers.get(layerId);
|
|
322
|
+
if (!layer || !asText(layer.fabricObject)) return false;
|
|
323
|
+
return this.derive(layerId, save);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Re-derive every text layer — used after a state restore.
|
|
327
|
+
*
|
|
328
|
+
* Recursive because a template inserts as a group of real child layers, and
|
|
329
|
+
* text inside one would otherwise keep whatever box it was restored with.
|
|
330
|
+
*/
|
|
331
|
+
refreshAll() {
|
|
332
|
+
const visit = (layers) => {
|
|
333
|
+
for (const layer of layers) {
|
|
334
|
+
if (asText(layer.fabricObject)) this.refresh(layer.id);
|
|
335
|
+
if (layer.children.length > 0) visit(layer.children);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
visit(this.layers.getAll());
|
|
339
|
+
}
|
|
340
|
+
derive(layerId, save) {
|
|
341
|
+
const layer = this.layers.get(layerId);
|
|
342
|
+
const text = layer ? asText(layer.fabricObject) : null;
|
|
343
|
+
if (!layer || !text) return false;
|
|
344
|
+
const state = readTextWrap(layer.meta);
|
|
345
|
+
if (!text.path) {
|
|
346
|
+
if (state.wrap === "none") {
|
|
347
|
+
fitTextWidth(text);
|
|
348
|
+
} else if (layer.meta.wrapWidth !== void 0) {
|
|
349
|
+
text.set({ width: layer.meta.wrapWidth });
|
|
350
|
+
delete layer.meta.wrapWidth;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
applyTextWrapToObject(text, layer.meta);
|
|
354
|
+
text.dirty = true;
|
|
355
|
+
text.setCoords();
|
|
356
|
+
this.canvas.requestRenderAll();
|
|
357
|
+
this.events.emit("layer:modified", { layerId });
|
|
358
|
+
if (save) this.history.save();
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// src/displacement.ts
|
|
364
|
+
var CHANNEL_INDEX = {
|
|
365
|
+
red: 0,
|
|
366
|
+
green: 1,
|
|
367
|
+
blue: 2,
|
|
368
|
+
alpha: 3
|
|
369
|
+
};
|
|
370
|
+
function finiteScale(value, fallback, label) {
|
|
371
|
+
const resolved = value ?? fallback;
|
|
372
|
+
if (!Number.isFinite(resolved)) throw new Error(`${label} must be finite`);
|
|
373
|
+
return resolved;
|
|
374
|
+
}
|
|
375
|
+
function sample(source, width, height, x, y, channel) {
|
|
376
|
+
const clampedX = Math.max(0, Math.min(width - 1, x));
|
|
377
|
+
const clampedY = Math.max(0, Math.min(height - 1, y));
|
|
378
|
+
const x0 = Math.floor(clampedX);
|
|
379
|
+
const y0 = Math.floor(clampedY);
|
|
380
|
+
const x1 = Math.min(width - 1, x0 + 1);
|
|
381
|
+
const y1 = Math.min(height - 1, y0 + 1);
|
|
382
|
+
const tx = clampedX - x0;
|
|
383
|
+
const ty = clampedY - y0;
|
|
384
|
+
const top = source[(y0 * width + x0) * 4 + channel] * (1 - tx) + source[(y0 * width + x1) * 4 + channel] * tx;
|
|
385
|
+
const bottom = source[(y1 * width + x0) * 4 + channel] * (1 - tx) + source[(y1 * width + x1) * 4 + channel] * tx;
|
|
386
|
+
return top * (1 - ty) + bottom * ty;
|
|
387
|
+
}
|
|
388
|
+
function displaceRgba(source, map, width, height, options) {
|
|
389
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
|
390
|
+
throw new Error("Displacement dimensions must be positive integers");
|
|
391
|
+
}
|
|
392
|
+
const expectedLength = width * height * 4;
|
|
393
|
+
if (source.length !== expectedLength || map.length !== expectedLength) {
|
|
394
|
+
throw new Error("Displacement source and map must match the requested dimensions");
|
|
395
|
+
}
|
|
396
|
+
const scaleX = finiteScale(options.scaleX, 10, "Displacement scaleX");
|
|
397
|
+
const scaleY = finiteScale(options.scaleY, 10, "Displacement scaleY");
|
|
398
|
+
const channelX = CHANNEL_INDEX[options.channelX ?? "red"];
|
|
399
|
+
const channelY = CHANNEL_INDEX[options.channelY ?? "green"];
|
|
400
|
+
const output = new Uint8ClampedArray(expectedLength);
|
|
401
|
+
for (let y = 0; y < height; y += 1) {
|
|
402
|
+
for (let x = 0; x < width; x += 1) {
|
|
403
|
+
const offset = (y * width + x) * 4;
|
|
404
|
+
const sourceX = x + (map[offset + channelX] - 128) / 127 * scaleX;
|
|
405
|
+
const sourceY = y + (map[offset + channelY] - 128) / 127 * scaleY;
|
|
406
|
+
for (let channel = 0; channel < 4; channel += 1) {
|
|
407
|
+
output[offset + channel] = Math.round(
|
|
408
|
+
sample(source, width, height, sourceX, sourceY, channel)
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return output;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// src/export.ts
|
|
417
|
+
import { StaticCanvas } from "fabric";
|
|
418
|
+
function computePrintAreaClip(area, scaleX, scaleY, targetWidth, targetHeight) {
|
|
419
|
+
const left = Math.max(0, Math.min(targetWidth, area.left * scaleX));
|
|
420
|
+
const top = Math.max(0, Math.min(targetHeight, area.top * scaleY));
|
|
421
|
+
const right = Math.max(left, Math.min(targetWidth, (area.left + area.width) * scaleX));
|
|
422
|
+
const bottom = Math.max(top, Math.min(targetHeight, (area.top + area.height) * scaleY));
|
|
423
|
+
return { left, top, width: right - left, height: bottom - top };
|
|
424
|
+
}
|
|
425
|
+
function computeCoverPlacement(sourceWidth, sourceHeight, targetWidth, targetHeight) {
|
|
426
|
+
if (sourceWidth <= 0 || sourceHeight <= 0 || targetWidth <= 0 || targetHeight <= 0) {
|
|
427
|
+
throw new Error("Cover dimensions must be positive");
|
|
428
|
+
}
|
|
429
|
+
const scale = Math.max(targetWidth / sourceWidth, targetHeight / sourceHeight);
|
|
430
|
+
const width = sourceWidth * scale;
|
|
431
|
+
const height = sourceHeight * scale;
|
|
432
|
+
return {
|
|
433
|
+
left: (targetWidth - width) / 2,
|
|
434
|
+
top: (targetHeight - height) / 2,
|
|
435
|
+
width,
|
|
436
|
+
height
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function canvasElementToBlob(output, format, quality) {
|
|
440
|
+
const mime = format === "jpeg" ? "image/jpeg" : `image/${format}`;
|
|
441
|
+
return new Promise((resolve, reject) => {
|
|
442
|
+
output.toBlob(
|
|
443
|
+
(blob) => blob ? resolve(blob) : reject(new Error(`Failed to export ${format}`)),
|
|
444
|
+
mime,
|
|
445
|
+
quality
|
|
446
|
+
);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
async function exportPNG(canvas, options = {}) {
|
|
450
|
+
const { multiplier = 1, format = "png", quality = 1 } = options;
|
|
451
|
+
const output = canvas.toCanvasElement(multiplier);
|
|
452
|
+
return canvasElementToBlob(output, format, quality);
|
|
453
|
+
}
|
|
454
|
+
async function exportIsolatedPNG(source, objects, options = {}) {
|
|
455
|
+
const element = source.lowerCanvasEl.ownerDocument.createElement("canvas");
|
|
456
|
+
const canvas = new StaticCanvas(element, {
|
|
457
|
+
width: options.width ?? source.getWidth(),
|
|
458
|
+
height: options.height ?? source.getHeight(),
|
|
459
|
+
backgroundColor: options.backgroundColor || void 0
|
|
460
|
+
});
|
|
461
|
+
try {
|
|
462
|
+
const clones = options.cloneObjects === false ? objects : await Promise.all(objects.map((object) => object.clone()));
|
|
463
|
+
if (clones.length) canvas.add(...clones);
|
|
464
|
+
if (options.backgroundImage) canvas.backgroundImage = await options.backgroundImage.clone();
|
|
465
|
+
canvas.requestRenderAll();
|
|
466
|
+
return await exportPNG(canvas, options);
|
|
467
|
+
} finally {
|
|
468
|
+
canvas.dispose();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async function exportPrintArea(source, area, options = {}) {
|
|
472
|
+
const { multiplier = 1, format = "png", quality = 1 } = options;
|
|
473
|
+
const width = source.getWidth();
|
|
474
|
+
const height = source.getHeight();
|
|
475
|
+
const clip = computePrintAreaClip(
|
|
476
|
+
area,
|
|
477
|
+
multiplier,
|
|
478
|
+
multiplier,
|
|
479
|
+
width * multiplier,
|
|
480
|
+
height * multiplier
|
|
481
|
+
);
|
|
482
|
+
if (clip.width <= 0 || clip.height <= 0) {
|
|
483
|
+
throw new Error("Print area does not overlap the canvas");
|
|
484
|
+
}
|
|
485
|
+
const element = source.lowerCanvasEl.ownerDocument.createElement("canvas");
|
|
486
|
+
const canvas = new StaticCanvas(element, { width, height });
|
|
487
|
+
try {
|
|
488
|
+
const clones = await Promise.all(source.getObjects().map((object) => object.clone()));
|
|
489
|
+
if (clones.length) canvas.add(...clones);
|
|
490
|
+
canvas.requestRenderAll();
|
|
491
|
+
const rendered = canvas.toCanvasElement(multiplier);
|
|
492
|
+
const output = rendered.ownerDocument.createElement("canvas");
|
|
493
|
+
output.width = Math.max(1, Math.round(clip.width));
|
|
494
|
+
output.height = Math.max(1, Math.round(clip.height));
|
|
495
|
+
const context = output.getContext("2d");
|
|
496
|
+
if (!context) throw new Error("2D canvas context is unavailable");
|
|
497
|
+
context.drawImage(rendered, -clip.left, -clip.top);
|
|
498
|
+
return await canvasElementToBlob(output, format, quality);
|
|
499
|
+
} finally {
|
|
500
|
+
canvas.dispose();
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async function exportMockup(canvas, mockup, options = {}) {
|
|
504
|
+
const { multiplier = 1, format = "png", quality = 1 } = options;
|
|
505
|
+
const design = canvas.toCanvasElement(multiplier);
|
|
506
|
+
const output = design.ownerDocument.createElement("canvas");
|
|
507
|
+
output.width = design.width;
|
|
508
|
+
output.height = design.height;
|
|
509
|
+
const context = output.getContext("2d");
|
|
510
|
+
if (!context) throw new Error("2D canvas context is unavailable");
|
|
511
|
+
const loadImage = (url) => new Promise((resolve, reject) => {
|
|
512
|
+
const element = new Image();
|
|
513
|
+
element.crossOrigin = "anonymous";
|
|
514
|
+
element.onload = () => resolve(element);
|
|
515
|
+
element.onerror = () => reject(new Error(`Failed to load mockup image: ${url}`));
|
|
516
|
+
element.src = url;
|
|
517
|
+
});
|
|
518
|
+
const drawCover = (image, targetContext = context) => {
|
|
519
|
+
const placement = computeCoverPlacement(
|
|
520
|
+
image.naturalWidth || image.width,
|
|
521
|
+
image.naturalHeight || image.height,
|
|
522
|
+
output.width,
|
|
523
|
+
output.height
|
|
524
|
+
);
|
|
525
|
+
targetContext.drawImage(
|
|
526
|
+
image,
|
|
527
|
+
placement.left,
|
|
528
|
+
placement.top,
|
|
529
|
+
placement.width,
|
|
530
|
+
placement.height
|
|
531
|
+
);
|
|
532
|
+
};
|
|
533
|
+
const scratch = [design];
|
|
534
|
+
try {
|
|
535
|
+
drawCover(await loadImage(mockup.image));
|
|
536
|
+
let compositedDesign = design;
|
|
537
|
+
if (mockup.displacement) {
|
|
538
|
+
const sourceContext = design.getContext("2d");
|
|
539
|
+
if (!sourceContext) throw new Error("2D design context is unavailable");
|
|
540
|
+
const mapCanvas = design.ownerDocument.createElement("canvas");
|
|
541
|
+
scratch.push(mapCanvas);
|
|
542
|
+
mapCanvas.width = design.width;
|
|
543
|
+
mapCanvas.height = design.height;
|
|
544
|
+
const mapContext = mapCanvas.getContext("2d");
|
|
545
|
+
if (!mapContext) throw new Error("2D displacement-map context is unavailable");
|
|
546
|
+
drawCover(await loadImage(mockup.displacement.image), mapContext);
|
|
547
|
+
const warped = design.ownerDocument.createElement("canvas");
|
|
548
|
+
scratch.push(warped);
|
|
549
|
+
warped.width = design.width;
|
|
550
|
+
warped.height = design.height;
|
|
551
|
+
const warpedContext = warped.getContext("2d");
|
|
552
|
+
if (!warpedContext) throw new Error("2D displaced-design context is unavailable");
|
|
553
|
+
let sourcePixels;
|
|
554
|
+
let mapPixels;
|
|
555
|
+
try {
|
|
556
|
+
sourcePixels = sourceContext.getImageData(0, 0, design.width, design.height).data;
|
|
557
|
+
mapPixels = mapContext.getImageData(0, 0, design.width, design.height).data;
|
|
558
|
+
} catch (error) {
|
|
559
|
+
throw new Error("Failed to apply mockup displacement map; verify image CORS access", {
|
|
560
|
+
cause: error
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
const pixels = displaceRgba(sourcePixels, mapPixels, design.width, design.height, {
|
|
564
|
+
...mockup.displacement,
|
|
565
|
+
scaleX: (mockup.displacement.scaleX ?? 10) * multiplier,
|
|
566
|
+
scaleY: (mockup.displacement.scaleY ?? 10) * multiplier
|
|
567
|
+
});
|
|
568
|
+
const imageData = warpedContext.createImageData(design.width, design.height);
|
|
569
|
+
imageData.data.set(pixels);
|
|
570
|
+
warpedContext.putImageData(imageData, 0, 0);
|
|
571
|
+
compositedDesign = warped;
|
|
572
|
+
}
|
|
573
|
+
context.save();
|
|
574
|
+
if (mockup.printArea && mockup.clipToPrintArea !== false) {
|
|
575
|
+
const clip = computePrintAreaClip(
|
|
576
|
+
mockup.printArea,
|
|
577
|
+
output.width / canvas.getWidth(),
|
|
578
|
+
output.height / canvas.getHeight(),
|
|
579
|
+
output.width,
|
|
580
|
+
output.height
|
|
581
|
+
);
|
|
582
|
+
context.beginPath();
|
|
583
|
+
context.rect(clip.left, clip.top, clip.width, clip.height);
|
|
584
|
+
context.clip();
|
|
585
|
+
}
|
|
586
|
+
context.globalAlpha = Math.max(0, Math.min(1, mockup.designOpacity ?? 1));
|
|
587
|
+
context.globalCompositeOperation = !mockup.designBlendMode || mockup.designBlendMode === "normal" ? "source-over" : mockup.designBlendMode;
|
|
588
|
+
context.drawImage(compositedDesign, 0, 0);
|
|
589
|
+
context.restore();
|
|
590
|
+
if (mockup.overlay) {
|
|
591
|
+
context.save();
|
|
592
|
+
context.globalAlpha = Math.max(0, Math.min(1, mockup.overlay.opacity ?? 1));
|
|
593
|
+
context.globalCompositeOperation = mockup.overlay.blendMode === "normal" ? "source-over" : mockup.overlay.blendMode ?? "multiply";
|
|
594
|
+
drawCover(await loadImage(mockup.overlay.image));
|
|
595
|
+
context.restore();
|
|
596
|
+
}
|
|
597
|
+
return await canvasElementToBlob(output, format, quality);
|
|
598
|
+
} finally {
|
|
599
|
+
for (const element of scratch) {
|
|
600
|
+
element.width = 0;
|
|
601
|
+
element.height = 0;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
function exportSVG(canvas) {
|
|
606
|
+
return canvas.toSVG();
|
|
607
|
+
}
|
|
608
|
+
function exportDataURL(canvas, format = "png", multiplier = 1) {
|
|
609
|
+
return canvas.toDataURL({ format, multiplier });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export {
|
|
613
|
+
TEXT_FIT_SLACK,
|
|
614
|
+
asText,
|
|
615
|
+
textInk,
|
|
616
|
+
fitTextWidth,
|
|
617
|
+
preWrapWordSplit,
|
|
618
|
+
matrixOf,
|
|
619
|
+
applyMatrix,
|
|
620
|
+
toCanvasSpace,
|
|
621
|
+
toHostSpace,
|
|
622
|
+
relativeMatrix,
|
|
623
|
+
applyRelativeMatrix,
|
|
624
|
+
asObject,
|
|
625
|
+
toMatrix,
|
|
626
|
+
fitToBox,
|
|
627
|
+
unwrapGroup,
|
|
628
|
+
readTextWrap,
|
|
629
|
+
applyTextWrapToObject,
|
|
630
|
+
TextWrapManager,
|
|
631
|
+
displaceRgba,
|
|
632
|
+
computePrintAreaClip,
|
|
633
|
+
computeCoverPlacement,
|
|
634
|
+
exportPNG,
|
|
635
|
+
exportIsolatedPNG,
|
|
636
|
+
exportPrintArea,
|
|
637
|
+
exportMockup,
|
|
638
|
+
exportSVG,
|
|
639
|
+
exportDataURL
|
|
640
|
+
};
|
|
641
|
+
//# sourceMappingURL=chunk-XNGPX7FG.mjs.map
|