embroidery-qc-image 1.0.6 → 1.0.7

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/index.js CHANGED
@@ -33,73 +33,191 @@ function styleInject(css, ref) {
33
33
  var css_248z = ".render-embroidery {\r\n display: inline-block;\r\n position: relative;\r\n width: 100%;\r\n max-width: 100%;\r\n}\r\n\r\n.render-embroidery-canvas {\r\n display: block;\r\n width: 100%;\r\n height: auto;\r\n image-rendering: high-quality;\r\n background: transparent;\r\n}\r\n";
34
34
  styleInject(css_248z);
35
35
 
36
- // Color mapping
36
+ // ============================================================================
37
+ // CONSTANTS
38
+ // ============================================================================
37
39
  const COLOR_MAP = {
38
- "Army (1394)": "#4B5320",
39
- Army: "#4B5320",
40
- "Black (8)": "#000000",
41
- Black: "#000000",
42
- "Bubblegum (1309)": "#FFC1CC",
43
- Bubblegum: "#FFC1CC",
44
- "Carolina Blue (1274)": "#7BAFD4",
45
- "Carolina Blue": "#7BAFD4",
46
- "Celadon (1098)": "#ACE1AF",
47
- Celadon: "#ACE1AF",
48
- "Coffee Bean (1145)": "#6F4E37",
49
- "Coffee Bean": "#6F4E37",
50
- "Daffodil (1180)": "#FFFF31",
51
- Daffodil: "#FFFF31",
52
- "Dark Gray (1131)": "#A9A9A9",
53
- "Dark Gray": "#A9A9A9",
54
- "Doe Skin Beige (1344)": "#F5E6D3",
55
- "Doe Skin Beige": "#F5E6D3",
56
- "Dusty Blue (1373)": "#6699CC",
57
- "Dusty Blue": "#6699CC",
58
- "Forest Green (1397)": "#228B22",
59
- "Forest Green": "#228B22",
60
- "Gold (1425)": "#FFD700",
61
- Gold: "#FFD700",
62
- "Gray (1118)": "#808080",
63
- Gray: "#808080",
64
- "Ivory (1072)": "#FFFFF0",
65
- Ivory: "#FFFFF0",
66
- "Lavender (1032)": "#E6E6FA",
67
- Lavender: "#E6E6FA",
68
- "Light Denim (1133)": "#B0C4DE",
69
- "Light Denim": "#B0C4DE",
70
- "Light Salmon (1018)": "#FFA07A",
71
- "Light Salmon": "#FFA07A",
72
- "Maroon (1374)": "#800000",
73
- Maroon: "#800000",
74
- "Navy Blue (1044)": "#000080",
75
- "Navy Blue": "#000080",
76
- "Olive Green (1157)": "#556B2F",
77
- "Olive Green": "#556B2F",
78
- "Orange (1278)": "#FFA500",
79
- Orange: "#FFA500",
80
- "Peach Blush (1053)": "#FFCCCB",
81
- "Peach Blush": "#FFCCCB",
82
- "Pink (1148)": "#FFC0CB",
83
- Pink: "#FFC0CB",
84
- "Purple (1412)": "#800080",
85
- Purple: "#800080",
86
- "Red (1037)": "#FF0000",
87
- Red: "#FF0000",
88
- "Silver Sage (1396)": "#A8A8A8",
89
- "Silver Sage": "#A8A8A8",
90
- "Summer Sky (1432)": "#87CEEB",
91
- "Summer Sky": "#87CEEB",
92
- "Terra Cotta (1477)": "#E2725B",
93
- "Terra Cotta": "#E2725B",
94
- "Sand (1055)": "#F4A460",
95
- Sand: "#F4A460",
96
- "White (9)": "#FFFFFF",
97
- White: "#FFFFFF",
40
+ "Army (1394)": "#4B5320", Army: "#4B5320",
41
+ "Black (8)": "#000000", Black: "#000000",
42
+ "Bubblegum (1309)": "#FFC1CC", Bubblegum: "#FFC1CC",
43
+ "Carolina Blue (1274)": "#7BAFD4", "Carolina Blue": "#7BAFD4",
44
+ "Celadon (1098)": "#ACE1AF", Celadon: "#ACE1AF",
45
+ "Coffee Bean (1145)": "#6F4E37", "Coffee Bean": "#6F4E37",
46
+ "Daffodil (1180)": "#FFFF31", Daffodil: "#FFFF31",
47
+ "Dark Gray (1131)": "#A9A9A9", "Dark Gray": "#A9A9A9",
48
+ "Doe Skin Beige (1344)": "#F5E6D3", "Doe Skin Beige": "#F5E6D3",
49
+ "Dusty Blue (1373)": "#6699CC", "Dusty Blue": "#6699CC",
50
+ "Forest Green (1397)": "#228B22", "Forest Green": "#228B22",
51
+ "Gold (1425)": "#FFD700", Gold: "#FFD700",
52
+ "Gray (1118)": "#808080", Gray: "#808080",
53
+ "Ivory (1072)": "#FFFFF0", Ivory: "#FFFFF0",
54
+ "Lavender (1032)": "#E6E6FA", Lavender: "#E6E6FA",
55
+ "Light Denim (1133)": "#B0C4DE", "Light Denim": "#B0C4DE",
56
+ "Light Salmon (1018)": "#FFA07A", "Light Salmon": "#FFA07A",
57
+ "Maroon (1374)": "#800000", Maroon: "#800000",
58
+ "Navy Blue (1044)": "#000080", "Navy Blue": "#000080",
59
+ "Olive Green (1157)": "#556B2F", "Olive Green": "#556B2F",
60
+ "Orange (1278)": "#FFA500", Orange: "#FFA500",
61
+ "Peach Blush (1053)": "#FFCCCB", "Peach Blush": "#FFCCCB",
62
+ "Pink (1148)": "#FFC0CB", Pink: "#FFC0CB",
63
+ "Purple (1412)": "#800080", Purple: "#800080",
64
+ "Red (1037)": "#FF0000", Red: "#FF0000",
65
+ "Silver Sage (1396)": "#A8A8A8", "Silver Sage": "#A8A8A8",
66
+ "Summer Sky (1432)": "#87CEEB", "Summer Sky": "#87CEEB",
67
+ "Terra Cotta (1477)": "#E2725B", "Terra Cotta": "#E2725B",
68
+ "Sand (1055)": "#F4A460", Sand: "#F4A460",
69
+ "White (9)": "#FFFFFF", White: "#FFFFFF",
98
70
  };
99
- const FONT_BASE_URL = "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/fonts";
100
- const ICON_BASE_URL = "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/icons";
101
- const FLORAL_BASE_URL = "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/florals";
102
- const THREAD_COLOR_BASE_URL = "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/thread-colors";
71
+ const BASE_URLS = {
72
+ FONT: "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/fonts",
73
+ ICON: "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/icons",
74
+ FLORAL: "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/florals",
75
+ THREAD_COLOR: "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/thread-colors",
76
+ };
77
+ const LAYOUT = {
78
+ // Font families
79
+ HEADER_FONT_FAMILY: "Times New Roman",
80
+ FONT_FAMILY: "Arial",
81
+ // Font sizes (base values, will be multiplied by scaleFactor)
82
+ HEADER_FONT_SIZE: 220,
83
+ TEXT_FONT_SIZE: 200,
84
+ OTHER_FONT_SIZE: 160,
85
+ // Colors
86
+ HEADER_COLOR: "#000000",
87
+ LABEL_COLOR: "#444444",
88
+ BACKGROUND_COLOR: "#FFFFFF",
89
+ // Text alignment
90
+ TEXT_ALIGN: "left",
91
+ TEXT_BASELINE: "top",
92
+ // Spacing
93
+ LINE_GAP: 40,
94
+ PADDING: 40,
95
+ SECTION_SPACING: 60,
96
+ ELEMENT_SPACING: 100,
97
+ SWATCH_SPACING: 25,
98
+ FLORAL_SPACING: 300,
99
+ // Visual styling
100
+ SWATCH_HEIGHT_RATIO: 2.025,
101
+ UNDERLINE_POSITION: 0.9,
102
+ UNDERLINE_WIDTH: 10,
103
+ // Swatch reserved space
104
+ SWATCH_RESERVED_SPACE: 1000,
105
+ MIN_TEXT_WIDTH: 400,
106
+ };
107
+ // ============================================================================
108
+ // HELPER FUNCTIONS
109
+ // ============================================================================
110
+ const loadFont = (fontName) => {
111
+ return new Promise((resolve) => {
112
+ const fontUrl = `${BASE_URLS.FONT}/${encodeURIComponent(fontName)}.woff2`;
113
+ const fontFace = new FontFace(fontName, `url(${fontUrl})`);
114
+ fontFace
115
+ .load()
116
+ .then((loadedFont) => {
117
+ document.fonts.add(loadedFont);
118
+ resolve();
119
+ })
120
+ .catch(() => {
121
+ console.warn(`Could not load font ${fontName} from CDN`);
122
+ resolve();
123
+ });
124
+ });
125
+ };
126
+ const getImageUrl = (type, value) => {
127
+ if (type === "icon")
128
+ return `${BASE_URLS.ICON}/Icon ${value}.png`;
129
+ if (type === "floral")
130
+ return `${BASE_URLS.FLORAL}/${value}.png`;
131
+ return `${BASE_URLS.THREAD_COLOR}/${value}.png`;
132
+ };
133
+ const loadImage = (url, imageRefs, onLoad) => {
134
+ if (imageRefs.current.has(url))
135
+ return;
136
+ const img = new Image();
137
+ img.crossOrigin = "anonymous";
138
+ img.src = url;
139
+ img.onload = onLoad;
140
+ imageRefs.current.set(url, img);
141
+ };
142
+ const wrapText = (ctx, text, x, y, maxWidth, lineHeight) => {
143
+ const words = text.split(" ");
144
+ const lines = [];
145
+ let currentLine = words[0];
146
+ for (let i = 1; i < words.length; i++) {
147
+ const testLine = currentLine + " " + words[i];
148
+ if (ctx.measureText(testLine).width > maxWidth && currentLine.length > 0) {
149
+ lines.push(currentLine);
150
+ currentLine = words[i];
151
+ }
152
+ else {
153
+ currentLine = testLine;
154
+ }
155
+ }
156
+ lines.push(currentLine);
157
+ let currentY = y;
158
+ lines.forEach((line) => {
159
+ ctx.fillText(line, x, currentY);
160
+ currentY += lineHeight;
161
+ });
162
+ return {
163
+ height: lines.length * lineHeight,
164
+ lastLineWidth: ctx.measureText(lines[lines.length - 1]).width,
165
+ lastLineY: y + (lines.length - 1) * lineHeight,
166
+ };
167
+ };
168
+ const wrapTextMultiColor = (ctx, text, colors, x, y, maxWidth, lineHeight) => {
169
+ const words = text.split(" ");
170
+ const lines = [];
171
+ const lineStartIndices = [0];
172
+ let currentLine = words[0];
173
+ let currentCharIndex = words[0].length;
174
+ for (let i = 1; i < words.length; i++) {
175
+ const testLine = currentLine + " " + words[i];
176
+ if (ctx.measureText(testLine).width > maxWidth && currentLine.length > 0) {
177
+ lines.push(currentLine);
178
+ lineStartIndices.push(currentCharIndex + 1);
179
+ currentLine = words[i];
180
+ currentCharIndex += words[i].length + 1;
181
+ }
182
+ else {
183
+ currentLine = testLine;
184
+ currentCharIndex += words[i].length + 1;
185
+ }
186
+ }
187
+ lines.push(currentLine);
188
+ let currentY = y;
189
+ lines.forEach((line, lineIdx) => {
190
+ let currentX = x;
191
+ const startCharIdx = lineIdx > 0 ? lineStartIndices[lineIdx] : 0;
192
+ for (let i = 0; i < line.length; i++) {
193
+ const char = line[i];
194
+ const globalCharIdx = startCharIdx + i;
195
+ const colorIndex = globalCharIdx % colors.length;
196
+ const color = colors[colorIndex];
197
+ ctx.fillStyle = COLOR_MAP[color] || "#000000";
198
+ ctx.fillText(char, currentX, currentY);
199
+ currentX += ctx.measureText(char).width;
200
+ }
201
+ currentY += lineHeight;
202
+ });
203
+ return lines.length * lineHeight;
204
+ };
205
+ const drawSwatches = (ctx, colors, startX, startY, swatchHeight, scaleFactor, imageRefs) => {
206
+ let swatchX = startX;
207
+ colors.forEach((color) => {
208
+ const url = getImageUrl("threadColor", color);
209
+ const img = imageRefs.current.get(url);
210
+ if (img && img.complete && img.naturalHeight > 0) {
211
+ const ratio = img.naturalWidth / img.naturalHeight;
212
+ const swatchW = Math.max(1, Math.floor(swatchHeight * ratio));
213
+ ctx.drawImage(img, swatchX, startY, swatchW, swatchHeight);
214
+ swatchX += swatchW + LAYOUT.SWATCH_SPACING * scaleFactor;
215
+ }
216
+ });
217
+ };
218
+ // ============================================================================
219
+ // MAIN COMPONENT
220
+ // ============================================================================
103
221
  const EmbroideryQCImage = ({ config, className = "", style = {}, }) => {
104
222
  const [canvasSize] = react.useState({ width: 4200, height: 4800 });
105
223
  const [loadedFonts, setLoadedFonts] = react.useState(new Set());
@@ -109,7 +227,7 @@ const EmbroideryQCImage = ({ config, className = "", style = {}, }) => {
109
227
  // Load fonts
110
228
  react.useEffect(() => {
111
229
  const loadFonts = async () => {
112
- if (!config.sides || config.sides.length === 0)
230
+ if (!config.sides?.length)
113
231
  return;
114
232
  const fontsToLoad = new Set();
115
233
  config.sides.forEach((side) => {
@@ -120,14 +238,14 @@ const EmbroideryQCImage = ({ config, className = "", style = {}, }) => {
120
238
  });
121
239
  });
122
240
  for (const fontName of fontsToLoad) {
123
- if (loadedFonts.has(fontName))
124
- continue;
125
- try {
126
- await loadFont(fontName);
127
- setLoadedFonts((prev) => new Set(prev).add(fontName));
128
- }
129
- catch (error) {
130
- console.warn(`Could not load font ${fontName}:`, error);
241
+ if (!loadedFonts.has(fontName)) {
242
+ try {
243
+ await loadFont(fontName);
244
+ setLoadedFonts((prev) => new Set(prev).add(fontName));
245
+ }
246
+ catch (error) {
247
+ console.warn(`Could not load font ${fontName}:`, error);
248
+ }
131
249
  }
132
250
  }
133
251
  };
@@ -135,649 +253,410 @@ const EmbroideryQCImage = ({ config, className = "", style = {}, }) => {
135
253
  }, [config.sides, loadedFonts]);
136
254
  // Load images
137
255
  react.useEffect(() => {
138
- const loadImages = async () => {
139
- if (!config.sides || config.sides.length === 0)
140
- return;
141
- // Load mockup image (not background). It will be drawn at bottom-right.
142
- if (config.image_url) {
143
- // Try with CORS first. If it fails (no CORS headers), retry without CORS
144
- const loadMockup = (useCors) => {
145
- const img = new Image();
146
- if (useCors)
147
- img.crossOrigin = "anonymous";
148
- img.onload = () => {
149
- imageRefs.current.set("mockup", img);
150
- setImagesLoaded((prev) => prev + 1);
151
- };
152
- img.onerror = () => {
153
- if (useCors) {
154
- // Retry without CORS; canvas may become tainted on export
155
- loadMockup(false);
156
- }
157
- };
158
- img.src = config.image_url;
256
+ if (!config.sides?.length)
257
+ return;
258
+ const incrementCounter = () => setImagesLoaded((prev) => prev + 1);
259
+ // Load mockup
260
+ if (config.image_url) {
261
+ const loadMockup = (useCors) => {
262
+ const img = new Image();
263
+ if (useCors)
264
+ img.crossOrigin = "anonymous";
265
+ img.onload = () => {
266
+ imageRefs.current.set("mockup", img);
267
+ incrementCounter();
159
268
  };
160
- loadMockup(true);
161
- }
162
- // Load icons
163
- config.sides.forEach((side) => {
164
- side.positions.forEach((position) => {
165
- if (position.type === "ICON" && position.icon !== 0) {
166
- const iconUrl = `${ICON_BASE_URL}/Icon ${position.icon}.png`;
167
- if (!imageRefs.current.has(iconUrl)) {
168
- const img = new Image();
169
- img.crossOrigin = "anonymous";
170
- img.src = iconUrl;
171
- img.onload = () => setImagesLoaded((prev) => prev + 1);
172
- imageRefs.current.set(iconUrl, img);
173
- }
174
- }
175
- if (position.type === "TEXT" && position.floral_pattern) {
176
- const floralUrl = `${FLORAL_BASE_URL}/${position.floral_pattern}.png`;
177
- if (!imageRefs.current.has(floralUrl)) {
178
- const img = new Image();
179
- img.crossOrigin = "anonymous";
180
- img.src = floralUrl;
181
- img.onload = () => setImagesLoaded((prev) => prev + 1);
182
- imageRefs.current.set(floralUrl, img);
183
- }
269
+ img.onerror = () => useCors && loadMockup(false);
270
+ img.src = config.image_url;
271
+ };
272
+ loadMockup(true);
273
+ }
274
+ // Load all other images
275
+ config.sides.forEach((side) => {
276
+ side.positions.forEach((position) => {
277
+ if (position.type === "ICON") {
278
+ if (position.icon !== 0) {
279
+ loadImage(getImageUrl("icon", position.icon), imageRefs, incrementCounter);
184
280
  }
185
- // Load thread color images for TEXT positions
186
- if (position.type === "TEXT") {
187
- // Load color image if position has color
188
- if (position.color) {
189
- const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${position.color}.png`;
190
- if (!imageRefs.current.has(threadColorUrl)) {
191
- const img = new Image();
192
- img.crossOrigin = "anonymous";
193
- img.src = threadColorUrl;
194
- img.onload = () => setImagesLoaded((prev) => prev + 1);
195
- imageRefs.current.set(threadColorUrl, img);
196
- }
197
- }
198
- // Load character color images
199
- if (position.character_colors &&
200
- position.character_colors.length > 0) {
201
- position.character_colors.forEach((color) => {
202
- const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${color}.png`;
203
- if (!imageRefs.current.has(threadColorUrl)) {
204
- const img = new Image();
205
- img.crossOrigin = "anonymous";
206
- img.src = threadColorUrl;
207
- img.onload = () => setImagesLoaded((prev) => prev + 1);
208
- imageRefs.current.set(threadColorUrl, img);
209
- }
210
- });
211
- }
281
+ position.layer_colors?.forEach((color) => {
282
+ loadImage(getImageUrl("threadColor", color), imageRefs, incrementCounter);
283
+ });
284
+ }
285
+ if (position.type === "TEXT") {
286
+ if (position.floral_pattern) {
287
+ loadImage(getImageUrl("floral", position.floral_pattern), imageRefs, incrementCounter);
212
288
  }
213
- // Load thread color images for ICON positions
214
- if (position.type === "ICON" && position.layer_colors) {
215
- position.layer_colors.forEach((color) => {
216
- const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${color}.png`;
217
- if (!imageRefs.current.has(threadColorUrl)) {
218
- const img = new Image();
219
- img.crossOrigin = "anonymous";
220
- img.src = threadColorUrl;
221
- img.onload = () => setImagesLoaded((prev) => prev + 1);
222
- imageRefs.current.set(threadColorUrl, img);
223
- }
224
- });
289
+ if (position.color) {
290
+ loadImage(getImageUrl("threadColor", position.color), imageRefs, incrementCounter);
225
291
  }
226
- });
292
+ position.character_colors?.forEach((color) => {
293
+ loadImage(getImageUrl("threadColor", color), imageRefs, incrementCounter);
294
+ });
295
+ }
227
296
  });
228
- };
229
- loadImages();
297
+ });
230
298
  }, [config]);
231
299
  // Render canvas
232
300
  react.useEffect(() => {
233
301
  const renderCanvas = () => {
234
- if (!canvasRef.current || !config.sides || config.sides.length === 0) {
302
+ if (!canvasRef.current || !config.sides?.length)
235
303
  return;
236
- }
237
304
  const canvas = canvasRef.current;
238
305
  const ctx = canvas.getContext("2d");
239
306
  if (!ctx)
240
307
  return;
241
308
  canvas.width = canvasSize.width;
242
309
  canvas.height = canvasSize.height;
243
- // Clear with white background
244
- ctx.fillStyle = "#FFFFFF";
310
+ // Set text alignment once
311
+ ctx.textAlign = LAYOUT.TEXT_ALIGN;
312
+ ctx.textBaseline = LAYOUT.TEXT_BASELINE;
313
+ // Clear background
314
+ ctx.fillStyle = LAYOUT.BACKGROUND_COLOR;
245
315
  ctx.fillRect(0, 0, canvas.width, canvas.height);
246
- // Collect floral images (for later drawing)
316
+ // Collect floral assets
247
317
  const floralAssets = [];
248
318
  const seenFlorals = new Set();
249
- if (config.sides) {
250
- config.sides.forEach((side) => {
251
- side.positions.forEach((position) => {
252
- if (position.type === "TEXT" && position.floral_pattern) {
253
- const floralUrl = `${FLORAL_BASE_URL}/${position.floral_pattern}.png`;
254
- if (!seenFlorals.has(floralUrl)) {
255
- const img = imageRefs.current.get(floralUrl);
256
- if (img && img.complete && img.naturalWidth > 0) {
257
- floralAssets.push(img);
258
- seenFlorals.add(floralUrl);
259
- }
319
+ config.sides.forEach((side) => {
320
+ side.positions.forEach((position) => {
321
+ if (position.type === "TEXT" && position.floral_pattern) {
322
+ const url = getImageUrl("floral", position.floral_pattern);
323
+ if (!seenFlorals.has(url)) {
324
+ const img = imageRefs.current.get(url);
325
+ if (img?.complete && img.naturalWidth > 0) {
326
+ floralAssets.push(img);
327
+ seenFlorals.add(url);
260
328
  }
261
329
  }
262
- });
263
- });
264
- }
265
- // Helper function to draw mockup and florals
266
- const drawMockupAndFlorals = () => {
267
- const mockupImg = imageRefs.current.get("mockup");
268
- const margin = 40; // small padding
269
- let mockupBox = null;
270
- if (mockupImg && mockupImg.complete && mockupImg.naturalWidth > 0) {
271
- const maxTargetWidth = Math.min(1800, canvas.width * 0.375);
272
- const maxTargetHeight = canvas.height * 0.375;
273
- const scale = Math.min(maxTargetWidth / mockupImg.naturalWidth, maxTargetHeight / mockupImg.naturalHeight);
274
- const targetWidth = Math.max(1, Math.floor(mockupImg.naturalWidth * scale));
275
- const targetHeight = Math.max(1, Math.floor(mockupImg.naturalHeight * scale));
276
- const targetX = canvas.width - margin - targetWidth;
277
- const targetY = canvas.height - margin - targetHeight;
278
- mockupBox = {
279
- x: targetX,
280
- y: targetY,
281
- w: targetWidth,
282
- h: targetHeight,
283
- };
284
- ctx.drawImage(mockupImg, targetX, targetY, targetWidth, targetHeight);
285
- }
286
- // Draw florals to the left of mockup
287
- if (mockupBox && floralAssets.length > 0) {
288
- const spacing = 300;
289
- const targetHeight = mockupBox.h;
290
- const floralFixedH = Math.min(900, targetHeight);
291
- let currentX = mockupBox.x - spacing;
292
- for (let i = floralAssets.length - 1; i >= 0; i--) {
293
- const img = floralAssets[i];
294
- const ratio = img.naturalWidth / Math.max(1, img.naturalHeight);
295
- const h = floralFixedH;
296
- const w = Math.max(1, Math.floor(h * ratio));
297
- currentX -= w;
298
- const y = mockupBox.y + (targetHeight - h);
299
- ctx.drawImage(img, currentX, y, w, h);
300
- currentX -= spacing;
301
330
  }
302
- }
303
- };
304
- // New approach: Draw images first (bottom layer), then text on top
305
- // This allows text to overlay images when needed
306
- // Pass 1: Measure actual height with original size (use offscreen canvas for measurement)
331
+ });
332
+ });
333
+ // Calculate scale factor
307
334
  const measureCanvas = document.createElement("canvas");
308
335
  measureCanvas.width = canvas.width;
309
336
  measureCanvas.height = canvas.height;
310
337
  const measureCtx = measureCanvas.getContext("2d");
311
338
  if (!measureCtx)
312
339
  return;
313
- // Set up measurement context
314
- measureCtx.font = ctx.font;
315
- measureCtx.textAlign = ctx.textAlign;
316
- measureCtx.textBaseline = ctx.textBaseline;
317
- let measureY = 40;
318
- const measureSpacing = 100;
340
+ measureCtx.textAlign = LAYOUT.TEXT_ALIGN;
341
+ measureCtx.textBaseline = LAYOUT.TEXT_BASELINE;
342
+ let measureY = LAYOUT.PADDING;
343
+ const measureSpacing = LAYOUT.ELEMENT_SPACING;
319
344
  config.sides.forEach((side) => {
320
- const sideHeight = renderSide(measureCtx, side, measureY, canvas.width, 1);
345
+ const sideHeight = renderSide(measureCtx, side, measureY, canvas.width, 1, imageRefs);
321
346
  measureY += sideHeight + measureSpacing;
322
347
  });
323
- const totalMeasuredHeight = measureY; // Total height used
324
- // Calculate scale factor - only scale down when necessary
325
- // Keep original font sizes (no scale up) - font size is the maximum
326
- const topPadding = 40;
327
- // No bottom padding - content can go to bottom, mockup will overlay
328
- const targetContentHeight = canvas.height - topPadding;
329
- // Only scale down if content exceeds canvas height
330
- // Never scale up - preserve original font sizes
331
- let scaleFactor = 1;
332
- if (totalMeasuredHeight > targetContentHeight) {
333
- // Scale down to fit exactly
334
- scaleFactor = targetContentHeight / totalMeasuredHeight;
335
- scaleFactor = Math.max(0.5, scaleFactor); // Minimum scale to prevent tiny fonts
336
- }
337
- // If content fits, keep scaleFactor = 1 (original font sizes)
338
- // Draw mockup and florals first (bottom layer)
339
- drawMockupAndFlorals();
340
- // Draw content on top (top layer) - text will overlay images if needed
341
- let currentY = topPadding * scaleFactor;
348
+ const scaleFactor = Math.max(0.5, Math.min(1, (canvas.height - LAYOUT.PADDING) / measureY));
349
+ // Draw mockup and florals
350
+ drawMockupAndFlorals(ctx, canvas, floralAssets, imageRefs);
351
+ // Draw content
352
+ let currentY = LAYOUT.PADDING * scaleFactor;
342
353
  config.sides.forEach((side) => {
343
- const sideHeight = renderSide(ctx, side, currentY, canvas.width, scaleFactor);
354
+ const sideHeight = renderSide(ctx, side, currentY, canvas.width, scaleFactor, imageRefs);
344
355
  currentY += sideHeight + measureSpacing * scaleFactor;
345
356
  });
346
357
  };
347
- // Delay rendering to ensure fonts and images are loaded
348
358
  const timer = setTimeout(renderCanvas, 100);
349
359
  return () => clearTimeout(timer);
350
360
  }, [config, canvasSize, loadedFonts, imagesLoaded]);
351
- // Helper function to wrap and draw text with word wrapping
352
- // Returns: { height: number, lastLineWidth: number, lastLineY: number }
353
- const fillTextWrapped = (ctx, text, x, y, maxWidth, lineHeight) => {
354
- const words = text.split(" ");
355
- const lines = [];
356
- let currentLine = words[0];
357
- for (let i = 1; i < words.length; i++) {
358
- const word = words[i];
359
- const testLine = currentLine + " " + word;
360
- const metrics = ctx.measureText(testLine);
361
- if (metrics.width > maxWidth && currentLine.length > 0) {
362
- lines.push(currentLine);
363
- currentLine = word;
364
- }
365
- else {
366
- currentLine = testLine;
367
- }
368
- }
369
- lines.push(currentLine);
370
- let currentY = y;
371
- lines.forEach((line) => {
372
- ctx.fillText(line, x, currentY);
373
- currentY += lineHeight;
374
- });
375
- const lastLineWidth = ctx.measureText(lines[lines.length - 1]).width;
376
- const lastLineY = y + (lines.length - 1) * lineHeight;
377
- return {
378
- height: lines.length * lineHeight,
379
- lastLineWidth,
380
- lastLineY,
381
- };
382
- };
383
- // Helper to wrap and draw multi-color text (for character_colors)
384
- const fillTextWrappedMultiColor = (ctx, text, colors, x, y, maxWidth, lineHeight) => {
385
- const words = text.split(" ");
386
- const lines = [];
387
- const lineStartIndices = [0];
388
- let currentLine = words[0];
389
- let currentCharIndex = words[0].length;
390
- for (let i = 1; i < words.length; i++) {
391
- const word = words[i];
392
- const testLine = currentLine + " " + word;
393
- const metrics = ctx.measureText(testLine);
394
- if (metrics.width > maxWidth && currentLine.length > 0) {
395
- lines.push(currentLine);
396
- lineStartIndices.push(currentCharIndex + 1); // +1 for space
397
- currentLine = word;
398
- currentCharIndex += word.length + 1;
399
- }
400
- else {
401
- currentLine = testLine;
402
- currentCharIndex += word.length + 1;
403
- }
361
+ return (jsxRuntime.jsx("div", { className: `render-embroidery${className ? ` ${className}` : ""}`, style: style, children: jsxRuntime.jsx("canvas", { ref: canvasRef, className: "render-embroidery-canvas" }) }));
362
+ };
363
+ // ============================================================================
364
+ // RENDERING FUNCTIONS
365
+ // ============================================================================
366
+ const drawMockupAndFlorals = (ctx, canvas, floralAssets, imageRefs) => {
367
+ const mockupImg = imageRefs.current.get("mockup");
368
+ if (!mockupImg?.complete || !mockupImg.naturalWidth)
369
+ return;
370
+ const margin = LAYOUT.PADDING;
371
+ const maxWidth = Math.min(1800, canvas.width * 0.375);
372
+ const maxHeight = canvas.height * 0.375;
373
+ const scale = Math.min(maxWidth / mockupImg.naturalWidth, maxHeight / mockupImg.naturalHeight);
374
+ const width = Math.max(1, Math.floor(mockupImg.naturalWidth * scale));
375
+ const height = Math.max(1, Math.floor(mockupImg.naturalHeight * scale));
376
+ const x = canvas.width - margin - width;
377
+ const y = canvas.height - margin - height;
378
+ ctx.drawImage(mockupImg, x, y, width, height);
379
+ // Draw florals
380
+ if (floralAssets.length > 0) {
381
+ const floralH = Math.min(900, height);
382
+ let currentX = x - LAYOUT.FLORAL_SPACING;
383
+ for (let i = floralAssets.length - 1; i >= 0; i--) {
384
+ const img = floralAssets[i];
385
+ const ratio = img.naturalWidth / Math.max(1, img.naturalHeight);
386
+ const w = Math.max(1, Math.floor(floralH * ratio));
387
+ currentX -= w;
388
+ ctx.drawImage(img, currentX, y + height - floralH, w, floralH);
389
+ currentX -= LAYOUT.FLORAL_SPACING;
404
390
  }
405
- lines.push(currentLine);
406
- let currentY = y;
407
- lines.forEach((line, lineIdx) => {
408
- let currentX = x;
409
- const startCharIdx = lineIdx > 0 ? lineStartIndices[lineIdx] : 0;
410
- for (let i = 0; i < line.length; i++) {
411
- const char = line[i];
412
- const globalCharIdx = startCharIdx + i;
413
- const colorIndex = globalCharIdx % colors.length;
414
- const color = colors[colorIndex];
415
- ctx.fillStyle = COLOR_MAP[color] || "#000000";
416
- ctx.fillText(char, currentX, currentY);
417
- currentX += ctx.measureText(char).width;
391
+ }
392
+ };
393
+ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
394
+ let currentY = startY;
395
+ const padding = LAYOUT.PADDING * scaleFactor;
396
+ const sideWidth = width - 2 * padding;
397
+ // Draw header
398
+ ctx.save();
399
+ const headerFontSize = LAYOUT.HEADER_FONT_SIZE * scaleFactor;
400
+ ctx.font = `bold ${headerFontSize}px ${LAYOUT.HEADER_FONT_FAMILY}`;
401
+ ctx.fillStyle = LAYOUT.HEADER_COLOR;
402
+ const headerResult = wrapText(ctx, side.print_side.toUpperCase(), padding, currentY, sideWidth, headerFontSize);
403
+ // Draw underline
404
+ const underlineY = headerResult.lastLineY + headerFontSize * LAYOUT.UNDERLINE_POSITION;
405
+ ctx.strokeStyle = LAYOUT.HEADER_COLOR;
406
+ ctx.lineWidth = LAYOUT.UNDERLINE_WIDTH * scaleFactor;
407
+ ctx.beginPath();
408
+ ctx.moveTo(padding, underlineY);
409
+ ctx.lineTo(padding + headerResult.lastLineWidth, underlineY);
410
+ ctx.stroke();
411
+ currentY += headerResult.height + LAYOUT.SECTION_SPACING * scaleFactor;
412
+ ctx.restore();
413
+ // Compute uniform properties
414
+ const textPositions = side.positions.filter((p) => p.type === "TEXT");
415
+ const uniformProps = computeUniformProperties(textPositions);
416
+ // Render uniform labels (only if more than 1 TEXT position)
417
+ if (textPositions.length > 1) {
418
+ currentY += renderUniformLabels(ctx, uniformProps, padding, currentY, sideWidth, scaleFactor, imageRefs);
419
+ }
420
+ // Group text positions by common properties
421
+ const textGroups = groupTextPositions(textPositions);
422
+ // Render text positions (with proper spacing between groups)
423
+ let textCounter = 1;
424
+ textGroups.forEach((group, groupIndex) => {
425
+ group.forEach((position, index) => {
426
+ // Add extra spacing between different groups
427
+ if (index === 0 && groupIndex !== 0) {
428
+ currentY += LAYOUT.SECTION_SPACING * scaleFactor;
418
429
  }
419
- currentY += lineHeight;
420
- });
421
- return lines.length * lineHeight;
422
- };
423
- const renderSide = (ctx, side, startY, width, scaleFactor = 1) => {
424
- let currentY = startY;
425
- const padding = 40 * scaleFactor;
426
- const sideWidth = width - 2 * padding;
427
- const sectionHeight = 200 * scaleFactor;
428
- // No background section anymore - just white background
429
- // Group positions by common properties for optimization
430
- const textGroups = [];
431
- let currentGroup = null;
432
- let currentProps = null;
433
- side.positions.forEach((position) => {
434
- if (position.type === "TEXT") {
435
- if (!currentGroup ||
436
- currentProps.font !== position.font ||
437
- currentProps.text_shape !== position.text_shape ||
438
- currentProps.color !== position.color ||
439
- currentProps.character_colors?.join(",") !==
440
- position.character_colors?.join(",")) {
441
- // Start new group
442
- if (currentGroup) {
443
- textGroups.push({
444
- positions: currentGroup,
445
- properties: currentProps,
446
- });
447
- }
448
- currentGroup = [position];
449
- currentProps = {
450
- font: position.font,
451
- text_shape: position.text_shape,
452
- color: position.color,
453
- character_colors: position.character_colors,
454
- };
455
- }
456
- else {
457
- currentGroup.push(position);
458
- }
430
+ // If only 1 TEXT position, show all labels (no uniform labels rendered)
431
+ const showLabels = textPositions.length === 1
432
+ ? { font: true, shape: true, floral: true, color: true }
433
+ : {
434
+ font: !uniformProps.isUniform.font,
435
+ shape: !uniformProps.isUniform.shape,
436
+ floral: !uniformProps.isUniform.floral,
437
+ color: !uniformProps.isUniform.color,
438
+ };
439
+ const height = renderTextPosition(ctx, position, padding, currentY, sideWidth, textCounter, showLabels, scaleFactor, imageRefs);
440
+ if (height > 0) {
441
+ currentY += height + LAYOUT.PADDING * scaleFactor;
442
+ textCounter++;
459
443
  }
460
444
  });
461
- if (currentGroup) {
462
- textGroups.push({ positions: currentGroup, properties: currentProps });
445
+ });
446
+ // Render icon positions
447
+ currentY += LAYOUT.LINE_GAP * scaleFactor;
448
+ side.positions.forEach((position) => {
449
+ if (position.type === "ICON") {
450
+ currentY += renderIconPosition(ctx, position, padding, currentY, sideWidth, scaleFactor, imageRefs);
451
+ currentY += LAYOUT.LINE_GAP / 3 * scaleFactor;
463
452
  }
464
- // Draw side header
465
- ctx.save();
466
- const headerFontSize = 200 * scaleFactor;
467
- ctx.font = `bold ${headerFontSize}px Times New Roman`;
468
- ctx.fillStyle = "#000000";
469
- ctx.textAlign = "left";
470
- ctx.textBaseline = "top";
471
- const headerResult = fillTextWrapped(ctx, side.print_side.toUpperCase(), padding, currentY, sideWidth, headerFontSize);
472
- currentY += headerResult.height + 50 * scaleFactor;
473
- ctx.restore();
474
- // Compute side-level uniform properties across all TEXT positions
475
- const allTextPositions = [];
476
- side.positions.forEach((position) => {
477
- if (position.type === "TEXT")
478
- allTextPositions.push(position);
479
- });
480
- const sideFonts = new Set(allTextPositions.map((p) => p.font));
481
- const sideShapes = new Set(allTextPositions.map((p) => p.text_shape));
482
- const sideFlorals = new Set(allTextPositions.map((p) => p.floral_pattern ?? "None"));
483
- const colorKeyOf = (p) => p.character_colors && p.character_colors.length > 0
484
- ? p.character_colors.join(",")
485
- : p.color ?? "None";
486
- const sideColors = new Set(allTextPositions.map((p) => colorKeyOf(p)));
487
- const sideUniform = {
488
- font: sideFonts.size === 1,
489
- shape: sideShapes.size === 1,
490
- floral: sideFlorals.size === 1,
491
- color: sideColors.size === 1,
453
+ });
454
+ return currentY - startY;
455
+ };
456
+ const groupTextPositions = (textPositions) => {
457
+ const groups = [];
458
+ let currentGroup = null;
459
+ let currentProps = null;
460
+ textPositions.forEach((position) => {
461
+ const posProps = {
462
+ font: position.font,
463
+ text_shape: position.text_shape,
464
+ color: position.color,
465
+ character_colors: position.character_colors?.join(","),
492
466
  };
493
- // Render side-level labels once for uniform properties
494
- currentY += renderSideUniformLabels(ctx, {
495
- font: sideUniform.font ? [...sideFonts][0] : null,
496
- shape: sideUniform.shape ? [...sideShapes][0] : null,
497
- floral: sideUniform.floral ? [...sideFlorals][0] : null,
498
- color: sideUniform.color ? [...sideColors][0] : null,
499
- }, padding, currentY, sideWidth, scaleFactor);
500
- // Render text groups first
501
- let sideTextCounter = 1;
502
- textGroups.forEach((group, groupIndex) => {
503
- group.positions.forEach((position, index) => {
504
- if (index === 0 && groupIndex !== 0)
505
- currentY += 50 * scaleFactor;
506
- const drawnHeight = renderText(ctx, position, padding, currentY, sideWidth, sideTextCounter, {
507
- font: !sideUniform.font,
508
- shape: !sideUniform.shape,
509
- floral: !sideUniform.floral,
510
- color: !sideUniform.color,
511
- }, scaleFactor);
512
- sideTextCounter += 1;
513
- // add padding only if something was actually drawn
514
- if (drawnHeight > 0) {
515
- currentY += drawnHeight + 40 * scaleFactor;
516
- }
517
- });
518
- });
519
- // Render ICON titles/values (no images here)
520
- currentY += 30 * scaleFactor; // minimal spacing before icon labels
521
- side.positions.forEach((position) => {
522
- if (position.type === "ICON") {
523
- currentY += renderIconLabels(ctx, position, padding, currentY, sideWidth, scaleFactor);
524
- currentY += 10 * scaleFactor;
467
+ if (!currentGroup ||
468
+ currentProps.font !== posProps.font ||
469
+ currentProps.text_shape !== posProps.text_shape ||
470
+ currentProps.color !== posProps.color ||
471
+ currentProps.character_colors !== posProps.character_colors) {
472
+ if (currentGroup) {
473
+ groups.push(currentGroup);
525
474
  }
526
- });
527
- return Math.max(currentY - startY, sectionHeight);
528
- };
529
- const renderSideUniformLabels = (ctx, values, x, y, maxWidth, scaleFactor = 1) => {
530
- const labelFontFamily = "Arial";
531
- const fontSize = 180 * scaleFactor;
532
- const lineGap = 20 * scaleFactor;
533
- ctx.save();
534
- ctx.font = `${fontSize}px ${labelFontFamily}`;
535
- ctx.textAlign = "left";
536
- ctx.textBaseline = "top";
537
- ctx.fillStyle = "#444444";
538
- let cursorY = y;
539
- let rendered = 0;
540
- if (values.font) {
541
- const fontText = `Font: ${values.font}`;
542
- const result = fillTextWrapped(ctx, fontText, x, cursorY, maxWidth, fontSize + lineGap);
543
- cursorY += result.height;
544
- rendered++;
545
- }
546
- if (values.shape && values.shape !== "None") {
547
- const shapeText = `Kiểu chữ: ${values.shape}`;
548
- const result = fillTextWrapped(ctx, shapeText, x, cursorY, maxWidth, fontSize + lineGap);
549
- cursorY += result.height;
550
- rendered++;
551
- }
552
- if (values.color && values.color !== "None") {
553
- const colorText = `Màu chỉ: ${values.color}`;
554
- // Reserve space for swatches (estimate: max 5 swatches × 200px each = 1000px)
555
- const swatchReserved = 1000 * scaleFactor;
556
- const textMaxWidth = Math.max(400 * scaleFactor, maxWidth - swatchReserved);
557
- const result = fillTextWrapped(ctx, colorText, x, cursorY, textMaxWidth, fontSize + lineGap);
558
- // Draw swatches inline for side-level color, preserving aspect ratio; 75% of previous size
559
- // Position swatches after the last line of wrapped text
560
- const swatchH = Math.floor(fontSize * 2.025);
561
- let swatchX = x + Math.ceil(result.lastLineWidth) + 100 * scaleFactor;
562
- const swatchY = result.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
563
- const colorTokens = values.color.includes(",")
564
- ? values.color.split(",").map((s) => s.trim())
565
- : [values.color];
566
- colorTokens.forEach((color) => {
567
- const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${color}.png`;
568
- const img = imageRefs.current.get(threadColorUrl);
569
- if (img && img.complete && img.naturalHeight > 0) {
570
- const ratio = img.naturalWidth / img.naturalHeight;
571
- const swatchW = Math.max(1, Math.floor(swatchH * ratio));
572
- ctx.drawImage(img, swatchX, swatchY, swatchW, swatchH);
573
- swatchX += swatchW + 25 * scaleFactor;
574
- }
575
- });
576
- cursorY += result.height;
577
- rendered++;
578
- }
579
- if (values.floral && values.floral !== "None") {
580
- const floralText = `Mẫu hoa: ${values.floral}`;
581
- const result = fillTextWrapped(ctx, floralText, x, cursorY, maxWidth, fontSize + lineGap);
582
- cursorY += result.height;
583
- rendered++;
584
- }
585
- if (rendered > 0)
586
- cursorY += 50 * scaleFactor; // extra gap before first text line
587
- ctx.restore();
588
- return cursorY - y;
589
- };
590
- const renderText = (ctx, position, x, y, maxWidth, displayIndex, showLabels, scaleFactor = 1) => {
591
- ctx.save();
592
- // Info labels
593
- // Unified font sizing for labels and content (side title uses its own larger size)
594
- const infoLineGap = 30 * scaleFactor;
595
- const labelFontFamily = "Arial";
596
- // Use a unified content font size for both labels and text content
597
- const fontSize = 180 * scaleFactor;
598
- const infoFontSize = fontSize;
599
- ctx.font = `${infoFontSize}px ${labelFontFamily}`;
600
- ctx.textAlign = "left";
601
- ctx.textBaseline = "top";
602
- ctx.fillStyle = "#444444";
603
- let currentYCursor = y;
604
- let drawnHeight = 0; // accumulate only when something is actually drawn
605
- // Text value with unified font size
606
- const displayText = position.text;
607
- ctx.textAlign = "left";
608
- ctx.textBaseline = "top";
609
- // Label for text line
610
- const textLabel = `Text ${displayIndex}: `;
611
- ctx.font = `bold ${fontSize}px ${labelFontFamily}`;
612
- const labelWidth = ctx.measureText(textLabel).width;
613
- ctx.fillStyle = "#444444";
614
- ctx.fillText(textLabel, x, currentYCursor);
615
- // Calculate available width for text content
616
- const textMaxWidth = maxWidth - labelWidth;
617
- // Handle character_colors (alternating colors)
618
- if (position.character_colors && position.character_colors.length > 0) {
619
- // Switch to content font
620
- ctx.font = `${fontSize}px ${position.font}`;
621
- const textHeight = fillTextWrappedMultiColor(ctx, displayText, position.character_colors, x + labelWidth, currentYCursor, textMaxWidth, fontSize);
622
- currentYCursor += textHeight;
623
- drawnHeight += textHeight;
475
+ currentGroup = [position];
476
+ currentProps = posProps;
624
477
  }
625
478
  else {
626
- // No color specified
627
- // Draw text in content font, black (non-bold)
628
- ctx.font = `${fontSize}px ${position.font}`;
629
- ctx.fillStyle = COLOR_MAP[position.color ?? "None"] || "#000000";
630
- const textResult = fillTextWrapped(ctx, displayText, x + labelWidth, currentYCursor, textMaxWidth, fontSize);
631
- currentYCursor += textResult.height;
632
- drawnHeight += textResult.height;
633
- }
634
- // After text, print Kiểu chữ (when not uniform), then Font and Color as needed
635
- currentYCursor += infoLineGap;
636
- ctx.font = `${infoFontSize}px ${labelFontFamily}`;
637
- ctx.fillStyle = "#444444";
638
- if (showLabels.shape && position.text_shape) {
639
- const shapeLabelAfter = `Kiểu chữ: ${position.text_shape}`;
640
- const result = fillTextWrapped(ctx, shapeLabelAfter, x, currentYCursor, maxWidth, infoFontSize + infoLineGap);
641
- currentYCursor += result.height;
642
- drawnHeight += result.height;
643
- }
644
- if (showLabels.font && position.font) {
645
- const fontLabel = `Font: ${position.font}`;
646
- const result = fillTextWrapped(ctx, fontLabel, x, currentYCursor, maxWidth, infoFontSize + infoLineGap);
647
- currentYCursor += result.height;
648
- drawnHeight += result.height;
649
- }
650
- if (showLabels.color) {
651
- let colorLabelValue = "None";
652
- if (position.character_colors && position.character_colors.length > 0) {
653
- colorLabelValue = position.character_colors.join(", ");
654
- }
655
- else if (position.color) {
656
- colorLabelValue = position.color;
657
- }
658
- if (colorLabelValue !== "None") {
659
- const colorLabel = `Màu chỉ: ${colorLabelValue}`;
660
- // Reserve space for swatches
661
- const swatchReserved = 1000 * scaleFactor;
662
- const textMaxWidth = Math.max(400 * scaleFactor, maxWidth - swatchReserved);
663
- const result = fillTextWrapped(ctx, colorLabel, x, currentYCursor, textMaxWidth, infoFontSize + infoLineGap);
664
- // Draw color swatch images inline with Color label for TEXT, preserve aspect ratio; 75% of previous size
665
- // Position swatches after the last line of wrapped text
666
- const swatchH = Math.floor(infoFontSize * 2.025);
667
- let swatchX = x + Math.ceil(result.lastLineWidth) + 100 * scaleFactor; // spacing after text
668
- const swatchY = result.lastLineY + Math.floor(infoFontSize / 2 - swatchH / 2);
669
- if (position.character_colors && position.character_colors.length > 0) {
670
- position.character_colors.forEach((color) => {
671
- const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${color}.png`;
672
- const img = imageRefs.current.get(threadColorUrl);
673
- if (img && img.complete && img.naturalHeight > 0) {
674
- const ratio = img.naturalWidth / img.naturalHeight;
675
- const swatchW = Math.max(1, Math.floor(swatchH * ratio));
676
- ctx.drawImage(img, swatchX, swatchY, swatchW, swatchH);
677
- swatchX += swatchW + 25 * scaleFactor;
678
- }
679
- });
680
- }
681
- else if (position.color) {
682
- const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${position.color}.png`;
683
- const img = imageRefs.current.get(threadColorUrl);
684
- if (img && img.complete && img.naturalHeight > 0) {
685
- const ratio = img.naturalWidth / img.naturalHeight;
686
- const swatchW = Math.max(1, Math.floor(swatchH * ratio));
687
- ctx.drawImage(img, swatchX, swatchY, swatchW, swatchH);
688
- }
689
- }
690
- currentYCursor += result.height;
691
- drawnHeight += result.height;
692
- }
479
+ currentGroup.push(position);
693
480
  }
694
- // Show floral label after color block when not uniform at side level
695
- if (showLabels.floral && position.floral_pattern) {
696
- const floralText = `Mẫu hoa: ${position.floral_pattern}`;
697
- const result = fillTextWrapped(ctx, floralText, x, currentYCursor, maxWidth, infoFontSize + infoLineGap);
698
- currentYCursor += result.height;
699
- drawnHeight += result.height;
700
- }
701
- // (Floral per-position label is printed above the text when needed; avoid duplicate after text)
702
- ctx.restore();
703
- return drawnHeight;
481
+ });
482
+ if (currentGroup) {
483
+ groups.push(currentGroup);
484
+ }
485
+ return groups;
486
+ };
487
+ const computeUniformProperties = (textPositions) => {
488
+ if (textPositions.length === 0) {
489
+ return {
490
+ values: { font: null, shape: null, floral: null, color: null },
491
+ isUniform: { font: false, shape: false, floral: false, color: false },
492
+ };
493
+ }
494
+ const fonts = new Set(textPositions.map((p) => p.font));
495
+ const shapes = new Set(textPositions.map((p) => p.text_shape));
496
+ const florals = new Set(textPositions.map((p) => p.floral_pattern ?? "None"));
497
+ const colors = new Set(textPositions.map((p) => p.character_colors?.length ? p.character_colors.join(",") : p.color ?? "None"));
498
+ return {
499
+ values: {
500
+ font: fonts.size === 1 ? [...fonts][0] : null,
501
+ shape: shapes.size === 1 ? [...shapes][0] : null,
502
+ floral: florals.size === 1 ? [...florals][0] : null,
503
+ color: colors.size === 1 ? [...colors][0] : null,
504
+ },
505
+ isUniform: {
506
+ font: fonts.size === 1,
507
+ shape: shapes.size === 1,
508
+ floral: florals.size === 1,
509
+ color: colors.size === 1,
510
+ },
704
511
  };
705
- const renderIconLabels = (ctx, position, x, y, maxWidth, scaleFactor = 1) => {
706
- const labelFontFamily = "Arial";
707
- const fontSize = 180 * scaleFactor;
708
- const lineGap = 30 * scaleFactor;
709
- ctx.save();
710
- ctx.font = `${fontSize}px ${labelFontFamily}`;
711
- ctx.textAlign = "left";
712
- ctx.textBaseline = "top";
713
- ctx.fillStyle = "#444444";
714
- let cursorY = y;
715
- const iconText = position.icon === 0
716
- ? `Icon: icon mặc định theo file thêu`
717
- : `Icon: ${position.icon}`;
718
- const iconResult = fillTextWrapped(ctx, iconText, x, cursorY, maxWidth, fontSize + lineGap);
719
- // draw icon image inline with text, preserve aspect ratio; match line height
720
- if (position.icon !== 0) {
721
- const iconUrl = `${ICON_BASE_URL}/Icon ${position.icon}.png`;
722
- const img = imageRefs.current.get(iconUrl);
723
- if (img && img.complete && img.naturalHeight > 0) {
724
- const swatchH = fontSize;
725
- const ratio = img.naturalWidth / img.naturalHeight;
726
- const swatchW = Math.max(1, Math.floor(swatchH * ratio));
727
- // Put icon on last line of wrapped text
728
- const iconX = x + Math.ceil(iconResult.lastLineWidth) + 100 * scaleFactor;
729
- const iconY = iconResult.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
730
- ctx.drawImage(img, iconX, iconY, swatchW, swatchH);
731
- }
512
+ };
513
+ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, imageRefs) => {
514
+ const { values } = uniformProps;
515
+ const fontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
516
+ const lineGap = LAYOUT.LINE_GAP * scaleFactor;
517
+ ctx.save();
518
+ ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
519
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
520
+ let cursorY = y;
521
+ let rendered = 0;
522
+ if (values.font) {
523
+ const result = wrapText(ctx, `Font: ${values.font}`, x, cursorY, maxWidth, fontSize + lineGap);
524
+ cursorY += result.height;
525
+ rendered++;
526
+ }
527
+ if (values.shape && values.shape !== "None") {
528
+ const result = wrapText(ctx, `Kiểu chữ: ${values.shape}`, x, cursorY, maxWidth, fontSize + lineGap);
529
+ cursorY += result.height;
530
+ rendered++;
531
+ }
532
+ if (values.color && values.color !== "None") {
533
+ const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
534
+ const result = wrapText(ctx, `Màu chỉ: ${values.color}`, x, cursorY, textMaxWidth, fontSize + lineGap);
535
+ const swatchH = Math.floor(fontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
536
+ const swatchX = x + Math.ceil(result.lastLineWidth) + LAYOUT.ELEMENT_SPACING * scaleFactor;
537
+ const swatchY = result.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
538
+ const colors = values.color.includes(",") ? values.color.split(",").map((s) => s.trim()) : [values.color];
539
+ drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
540
+ cursorY += result.height;
541
+ rendered++;
542
+ }
543
+ if (values.floral && values.floral !== "None") {
544
+ const result = wrapText(ctx, `Mẫu hoa: ${values.floral}`, x, cursorY, maxWidth, fontSize + lineGap);
545
+ cursorY += result.height;
546
+ rendered++;
547
+ }
548
+ if (rendered > 0)
549
+ cursorY += LAYOUT.SECTION_SPACING * scaleFactor;
550
+ ctx.restore();
551
+ return cursorY - y;
552
+ };
553
+ const renderTextPosition = (ctx, position, x, y, maxWidth, displayIndex, showLabels, scaleFactor, imageRefs) => {
554
+ ctx.save();
555
+ const textFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
556
+ const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
557
+ const lineGap = LAYOUT.LINE_GAP * scaleFactor;
558
+ let currentY = y;
559
+ let drawnHeight = 0;
560
+ // Draw label
561
+ const textLabel = `Text ${displayIndex}: `;
562
+ ctx.font = `bold ${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
563
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
564
+ const labelWidth = ctx.measureText(textLabel).width;
565
+ ctx.fillText(textLabel, x, currentY);
566
+ const textMaxWidth = maxWidth - labelWidth;
567
+ // Get display text (handle empty/null/undefined)
568
+ const isEmptyText = !position.text || position.text.trim() === "";
569
+ // Draw text content
570
+ if (isEmptyText) {
571
+ ctx.font = `${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
572
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
573
+ const textResult = wrapText(ctx, "không thay đổi", x + labelWidth, currentY, textMaxWidth, textFontSize);
574
+ currentY += textResult.height;
575
+ drawnHeight += textResult.height;
576
+ }
577
+ else if (position.character_colors?.length) {
578
+ ctx.font = `${textFontSize}px ${position.font}`;
579
+ const textHeight = wrapTextMultiColor(ctx, position.text, position.character_colors, x + labelWidth, currentY, textMaxWidth, textFontSize);
580
+ currentY += textHeight;
581
+ drawnHeight += textHeight;
582
+ }
583
+ else {
584
+ ctx.font = `${textFontSize}px ${position.font}`;
585
+ ctx.fillStyle = COLOR_MAP[position.color ?? "None"] || "#000000";
586
+ const textResult = wrapText(ctx, position.text, x + labelWidth, currentY, textMaxWidth, textFontSize);
587
+ currentY += textResult.height;
588
+ drawnHeight += textResult.height;
589
+ }
590
+ // Draw additional labels
591
+ currentY += lineGap;
592
+ ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
593
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
594
+ if (showLabels.shape && position.text_shape) {
595
+ const result = wrapText(ctx, `Kiểu chữ: ${position.text_shape}`, x, currentY, maxWidth, otherFontSize + lineGap);
596
+ currentY += result.height;
597
+ drawnHeight += result.height;
598
+ }
599
+ if (showLabels.font && position.font) {
600
+ const result = wrapText(ctx, `Font: ${position.font}`, x, currentY, maxWidth, otherFontSize + lineGap);
601
+ currentY += result.height;
602
+ drawnHeight += result.height;
603
+ }
604
+ if (showLabels.color) {
605
+ const colorValue = position.character_colors?.join(", ") || position.color;
606
+ if (colorValue) {
607
+ const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
608
+ const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, textMaxWidth, otherFontSize + lineGap);
609
+ const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
610
+ const swatchX = x + Math.ceil(result.lastLineWidth) + LAYOUT.ELEMENT_SPACING * scaleFactor;
611
+ const swatchY = result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
612
+ const colors = position.character_colors || [position.color];
613
+ drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
614
+ currentY += result.height;
615
+ drawnHeight += result.height;
732
616
  }
733
- cursorY += iconResult.height;
734
- // Draw color line only when there are layer colors
735
- if (position.layer_colors && position.layer_colors.length > 0) {
736
- const colorLabelValue = position.layer_colors.join(", ");
737
- const colorText = `Màu chỉ: ${colorLabelValue}`;
738
- // Reserve space for swatches
739
- const swatchReserved = 1000 * scaleFactor;
740
- const textMaxWidth = Math.max(400 * scaleFactor, maxWidth - swatchReserved);
741
- const colorResult = fillTextWrapped(ctx, colorText, x, cursorY, textMaxWidth, fontSize + lineGap);
742
- // Draw color swatch images (only for icon)
743
- // Position swatches after the last line of wrapped text
744
- const swatchH = Math.floor(fontSize * 2.025); // 75% of previous size
745
- let swatchX = x + Math.ceil(colorResult.lastLineWidth) + 100 * scaleFactor; // spacing after text
746
- const swatchY = colorResult.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
747
- position.layer_colors.forEach((color) => {
748
- const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${color}.png`;
749
- const img = imageRefs.current.get(threadColorUrl);
750
- if (img && img.complete && img.naturalHeight > 0) {
751
- const ratio = img.naturalWidth / img.naturalHeight;
752
- const swatchW = Math.max(1, Math.floor(swatchH * ratio));
753
- ctx.drawImage(img, swatchX, swatchY, swatchW, swatchH);
754
- swatchX += swatchW + 25 * scaleFactor; // spacing between swatches
755
- }
756
- });
757
- cursorY += colorResult.height;
617
+ }
618
+ if (showLabels.floral && position.floral_pattern) {
619
+ const result = wrapText(ctx, `Mẫu hoa: ${position.floral_pattern}`, x, currentY, maxWidth, otherFontSize + lineGap);
620
+ currentY += result.height;
621
+ drawnHeight += result.height;
622
+ }
623
+ ctx.restore();
624
+ return drawnHeight;
625
+ };
626
+ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRefs) => {
627
+ const iconFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
628
+ const lineGap = LAYOUT.LINE_GAP * scaleFactor;
629
+ ctx.save();
630
+ ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
631
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
632
+ let cursorY = y;
633
+ const iconText = position.icon === 0 ? `Icon: icon mặc định theo file thêu` : `Icon: ${position.icon}`;
634
+ const iconResult = wrapText(ctx, iconText, x, cursorY, maxWidth, iconFontSize + lineGap);
635
+ // Draw icon image
636
+ if (position.icon !== 0) {
637
+ const url = getImageUrl("icon", position.icon);
638
+ const img = imageRefs.current.get(url);
639
+ if (img?.complete && img.naturalHeight > 0) {
640
+ const ratio = img.naturalWidth / img.naturalHeight;
641
+ const swatchW = Math.max(1, Math.floor(iconFontSize * ratio));
642
+ const iconX = x + Math.ceil(iconResult.lastLineWidth) + LAYOUT.ELEMENT_SPACING * scaleFactor;
643
+ const iconY = iconResult.lastLineY + Math.floor(iconFontSize / 2 - iconFontSize / 2);
644
+ ctx.drawImage(img, iconX, iconY, swatchW, iconFontSize);
758
645
  }
759
- ctx.restore();
760
- return cursorY - y;
761
- };
762
- const loadFont = (fontName) => {
763
- return new Promise((resolve, reject) => {
764
- // Try to load from CDN
765
- const fontUrl = `${FONT_BASE_URL}/${encodeURIComponent(fontName)}.woff2`;
766
- const fontFace = new FontFace(fontName, `url(${fontUrl})`);
767
- fontFace
768
- .load()
769
- .then((loadedFont) => {
770
- document.fonts.add(loadedFont);
771
- resolve();
772
- })
773
- .catch(() => {
774
- // Font loading failed, will use fallback
775
- console.warn(`Could not load font ${fontName} from CDN`);
776
- resolve(); // Still resolve to not block rendering
777
- });
778
- });
779
- };
780
- return (jsxRuntime.jsx("div", { className: `render-embroidery${className ? ` ${className}` : ""}`, style: style, children: jsxRuntime.jsx("canvas", { ref: canvasRef, className: "render-embroidery-canvas" }) }));
646
+ }
647
+ cursorY += iconResult.height;
648
+ // Draw color swatches
649
+ if (position.layer_colors?.length) {
650
+ const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
651
+ const colorResult = wrapText(ctx, `Màu chỉ: ${position.layer_colors.join(", ")}`, x, cursorY, textMaxWidth, iconFontSize + lineGap);
652
+ const swatchH = Math.floor(iconFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
653
+ const swatchX = x + Math.ceil(colorResult.lastLineWidth) + LAYOUT.ELEMENT_SPACING * scaleFactor;
654
+ const swatchY = colorResult.lastLineY + Math.floor(iconFontSize / 2 - swatchH / 2);
655
+ drawSwatches(ctx, position.layer_colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
656
+ cursorY += colorResult.height;
657
+ }
658
+ ctx.restore();
659
+ return cursorY - y;
781
660
  };
782
661
 
783
662
  exports.EmbroideryQCImage = EmbroideryQCImage;