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