Codemaru 1.0.0.0
QR 코드, 모바일 랜딩 페이지, vCard 연락처 저장과 명함 디자인을 한 화면에서 제공하는 디지털 명함 서비스입니다.
로딩중...
검색중...
일치하는것 없음
LandingPageExporter.cs
이 파일의 문서화 페이지로 가기
2using System.Net;
3using System.Text;
4using System.Xml.Linq;
5
6namespace Codemaru.Services;
7
16public sealed class LandingPageExporter
17{
58 public string BuildCardSideSvg(CardProfile profile, string qrSvg, bool front)
59 {
60 if (front && !string.IsNullOrWhiteSpace(profile.ImportedFrontImageDataUrl))
61 {
62 return BuildImportedImageCardSvg(profile, profile.ImportedFrontImageDataUrl);
63 }
64
65 if (!front && !string.IsNullOrWhiteSpace(profile.ImportedBackImageDataUrl))
66 {
67 return BuildImportedImageCardSvg(profile, profile.ImportedBackImageDataUrl);
68 }
69
70 return front ? BuildFrontCardSvg(profile, qrSvg) : BuildBackCardSvg(profile);
71 }
72
113 public string BuildCardSideHtml(CardProfile profile, string qrSvg, bool front)
114 {
115 var card = front
116 ? BuildFrontCard(profile, qrSvg)
117 : BuildBackCard(profile);
118 var title = front ? "Business Card Front" : "Business Card Back";
119
120 var html = new StringBuilder();
121 html.AppendLine("<!doctype html>");
122 html.AppendLine("<html lang=\"ko\">");
123 html.AppendLine("<head>");
124 html.AppendLine(" <meta charset=\"utf-8\" />");
125 html.AppendLine(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />");
126 html.AppendLine($" <title>{title}</title>");
127 html.AppendLine(" <style>");
128 html.AppendLine($" {BuildCardCss(profile)}");
129 html.AppendLine(" body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f2f4f0; padding: 24px; }");
130 html.AppendLine(" </style>");
131 html.AppendLine("</head>");
132 html.AppendLine("<body>");
133 html.AppendLine(card);
134 html.AppendLine("</body>");
135 html.AppendLine("</html>");
136 return html.ToString();
137 }
138
171 public string BuildHtml(CardProfile profile, string qrPayload)
172 {
173 var palette = GetThemePalette(profile.LandingTheme);
174 var logo = string.IsNullOrWhiteSpace(profile.LogoImageDataUrl)
175 ? $"<div class=\"brand-mark\" style=\"background:{Html(profile.AccentColor)}\"></div>"
176 : $"<img class=\"brand-logo\" src=\"{Html(profile.LogoImageDataUrl)}\" alt=\"{Html(profile.BackBrand)} logo\" />";
177
178 var html = new StringBuilder();
179 html.AppendLine("<!doctype html>");
180 html.AppendLine("<html lang=\"ko\">");
181 html.AppendLine("<head>");
182 html.AppendLine(" <meta charset=\"utf-8\" />");
183 html.AppendLine(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />");
184 html.AppendLine($" <title>{Html(profile.Name)} - {Html(profile.BackBrand)}</title>");
185 html.AppendLine($" <meta name=\"description\" content=\"{Html(profile.LandingDescription)}\" />");
186 html.AppendLine(" <style>");
187 html.AppendLine($" :root {{ --bg: {palette.Background}; --panel: {palette.Panel}; --text: {palette.Text}; --muted: {palette.Muted}; --accent: {Html(profile.AccentColor)}; --line: rgba(120,140,120,.24); }}");
188 html.AppendLine(" * { box-sizing: border-box; }");
189 html.AppendLine(" body { margin: 0; min-height: 100vh; font-family: \"Segoe UI\", \"Noto Sans KR\", \"Malgun Gothic\", sans-serif; background: var(--bg); color: var(--text); }");
190 html.AppendLine(" body::before { content: \"\"; position: fixed; inset: 0; pointer-events: none; background-image: linear-gradient(rgba(120,140,120,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(120,140,120,.08) 1px, transparent 1px); background-size: 32px 32px; mask-image: linear-gradient(to bottom, rgba(0,0,0,.72), transparent 70%); }");
191 html.AppendLine(" main { position: relative; width: min(1040px, calc(100% - 32px)); margin: 0 auto; padding: 42px 0; }");
192 html.AppendLine(" .shell { display: grid; grid-template-columns: minmax(0, .95fr) minmax(320px, 1.05fr); gap: 18px; align-items: stretch; }");
193 html.AppendLine(" .hero, .details { background: color-mix(in srgb, var(--panel) 94%, transparent); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 20px 58px rgba(0,0,0,.12); }");
194 html.AppendLine(" .hero { min-height: 560px; display: grid; align-content: space-between; gap: 28px; padding: 34px; }");
195 html.AppendLine(" .brand-row { display: flex; align-items: center; gap: 14px; }");
196 html.AppendLine(" .brand-frame { width: 92px; aspect-ratio: 8 / 5; display: grid; place-items: center; border: 2px solid var(--accent); border-radius: 8px; padding: 8px; overflow: hidden; background: rgba(255,255,255,.5); }");
197 html.AppendLine(" .brand-logo { display: block; width: 100%; height: 100%; object-fit: contain; }");
198 html.AppendLine(" .brand-mark { width: 34px; height: 34px; border-radius: 999px; }");
199 html.AppendLine(" .eyebrow { margin: 0; color: var(--muted); font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 0; }");
200 html.AppendLine(" h1 { margin: 18px 0 10px; font-size: clamp(44px, 8vw, 86px); line-height: .92; letter-spacing: 0; }");
201 html.AppendLine(" .role { margin: 0; color: var(--text); font-size: clamp(18px, 3vw, 28px); font-weight: 650; }");
202 html.AppendLine(" .tagline { margin: 8px 0 0; color: var(--muted); font-size: 17px; line-height: 1.55; }");
203 html.AppendLine(" .cta-row { display: flex; flex-wrap: wrap; gap: 10px; }");
204 html.AppendLine(" .cta { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0 16px; border-radius: 6px; text-decoration: none; font-weight: 800; }");
205 html.AppendLine(" .cta.primary { background: var(--accent); color: #fff; }");
206 html.AppendLine(" .cta.secondary { color: var(--text); border: 1px solid var(--line); }");
207 html.AppendLine(" .details { padding: 28px; display: grid; gap: 24px; }");
208 html.AppendLine(" .section { display: grid; gap: 12px; }");
209 html.AppendLine(" h2 { margin: 0; font-size: 17px; }");
210 html.AppendLine(" p { margin: 0; color: var(--muted); line-height: 1.62; }");
211 html.AppendLine(" dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin: 0; }");
212 html.AppendLine(" dt { color: var(--muted); font-size: 12px; font-weight: 900; text-transform: uppercase; }");
213 html.AppendLine(" dd { margin: 4px 0 0; overflow-wrap: anywhere; }");
214 html.AppendLine(" a { color: var(--accent); overflow-wrap: anywhere; }");
215 html.AppendLine(" .wide { grid-column: 1 / -1; }");
216 html.AppendLine(" .footer { margin-top: 18px; color: var(--muted); font-size: 12px; text-align: center; }");
217 html.AppendLine(" @media (max-width: 820px) { main { padding: 16px 0; } .shell { grid-template-columns: 1fr; } .hero { min-height: auto; padding: 24px; } .details { padding: 22px; } dl { grid-template-columns: 1fr; } }");
218 html.AppendLine(" </style>");
219 html.AppendLine("</head>");
220 html.AppendLine("<body>");
221 html.AppendLine(" <main>");
222 html.AppendLine(" <div class=\"shell\">");
223 html.AppendLine(" <section class=\"hero\">");
224 html.AppendLine(" <div class=\"brand-row\">");
225 html.AppendLine($" <div class=\"brand-frame\">{logo}</div>");
226 html.AppendLine(" <div>");
227 html.AppendLine($" <p class=\"eyebrow\">{Html(profile.BackBrand)}</p>");
228 html.AppendLine($" <p class=\"tagline\">{Html(profile.BackTagline)}</p>");
229 html.AppendLine(" </div>");
230 html.AppendLine(" </div>");
231 html.AppendLine(" <div>");
232 html.AppendLine($" <h1>{Html(profile.Name)}</h1>");
233 html.AppendLine($" <p class=\"role\">{Html(profile.Role)}</p>");
234 html.AppendLine(" </div>");
235 html.AppendLine(" <div class=\"cta-row\">");
236 html.AppendLine($" <a class=\"cta primary\" href=\"mailto:{Html(profile.Email)}\">메일 보내기</a>");
237 if (!string.IsNullOrWhiteSpace(profile.Phone))
238 {
239 html.AppendLine($" <a class=\"cta secondary\" href=\"tel:{Html(profile.Phone)}\">전화하기</a>");
240 }
241 html.AppendLine($" <a class=\"cta secondary\" href=\"{Html(qrPayload)}\">명함 링크</a>");
242 html.AppendLine(" </div>");
243 html.AppendLine(" </section>");
244 html.AppendLine(" <section class=\"details\">");
245 html.AppendLine(" <div class=\"section\">");
246 html.AppendLine(" <h2>About</h2>");
247 html.AppendLine($" <p>{Html(profile.LandingDescription)}</p>");
248 html.AppendLine(" </div>");
249 html.AppendLine(" <div class=\"section\">");
250 html.AppendLine(" <h2>Contact</h2>");
251 html.AppendLine(" <dl>");
252 html.AppendLine($" <div><dt>Email</dt><dd>{Html(profile.Email)}</dd></div>");
253 html.AppendLine($" <div><dt>Phone</dt><dd>{Html(DisplayValue(profile.Phone))}</dd></div>");
254 html.AppendLine($" <div><dt>Company</dt><dd>{Html(profile.BackBrand)}</dd></div>");
255 html.AppendLine($" <div><dt>Address</dt><dd>{Html(DisplayValue(profile.Address))}</dd></div>");
256 html.AppendLine($" <div class=\"wide\"><dt>Card URL</dt><dd><a href=\"{Html(qrPayload)}\">{Html(qrPayload)}</a></dd></div>");
257 html.AppendLine(" </dl>");
258 html.AppendLine(" </div>");
259 html.AppendLine(" </section>");
260 html.AppendLine(" </div>");
261 html.AppendLine(" <p class=\"footer\">Generated by CodeMaru CardHybrid · Powered by Dreamine</p>");
262 html.AppendLine(" </main>");
263 html.AppendLine("</body>");
264 html.AppendLine("</html>");
265 return html.ToString();
266 }
267
300 private static string BuildFrontCard(CardProfile profile, string qrSvg)
301 {
302 return $"""
303 <div class="business-card front">
304 <div class="front-copy">
305 <h2>{Html(profile.Brand)}</h2>
306 <p>{Html(profile.Tagline)}</p>
307 <strong>{Html(profile.Category)}</strong>
308 <span>{Html(profile.Email)}</span>
309 </div>
310 <div class="qr-stack">
311 <div class="qr-box">{qrSvg}</div>
312 </div>
313 </div>
314 """;
315 }
316
341 private static string BuildBackCard(CardProfile profile)
342 {
343 var logo = string.IsNullOrWhiteSpace(profile.LogoImageDataUrl)
344 ? $"<span style=\"background:{Html(profile.AccentColor)}\"></span>"
345 : $"<img src=\"{Html(profile.LogoImageDataUrl)}\" alt=\"{Html(profile.BackBrand)} logo\" />";
346
347 return $"""
348 <div class="business-card back">
349 <div class="back-logo-slot">{logo}</div>
350 <div class="back-copy">
351 <h3>{Html(profile.BackBrand)}</h3>
352 <p>{Html(profile.BackTagline)}</p>
353 <strong>{Html(profile.Name)}</strong>
354 <span>{Html(profile.Role)}</span>
355 </div>
356 </div>
357 """;
358 }
359
384 private static string BuildCardCss(CardProfile profile)
385 {
386 var width = Math.Clamp(profile.CardWidthMm, 40, 140);
387 var height = Math.Clamp(profile.CardHeightMm, 25, 90);
388 return string.Join(Environment.NewLine,
389 "* { box-sizing: border-box; }",
390 $".business-card {{ width: {width * 5:0.##}px; aspect-ratio: {width:0.##} / {height:0.##}; border: 1px solid #cdd5cb; border-radius: 7px; background: #fbfaf7; box-shadow: 0 12px 28px rgba(38,49,39,.08); overflow: hidden; }}",
391 $".business-card.front {{ display: grid; grid-template-columns: minmax(0, 1fr) minmax(92px, 30%); gap: 20px; align-items: center; padding: 30px; font-family: '{Css(profile.FrontFontFamily)}', 'Malgun Gothic', sans-serif; }}",
392 $".business-card.front h2 {{ margin: 0 0 6px; color: {Html(profile.AccentColor)}; font-size: {profile.FrontBrandFontSize:0.##}px; }}",
393 $".business-card.front p {{ margin: 0 0 14px; color: #3f4841; font-size: {profile.FrontTaglineFontSize:0.##}px; }}",
394 $".business-card.front strong {{ display: block; margin-bottom: 14px; color: #222a24; font-size: {profile.FrontCategoryFontSize:0.##}px; white-space: pre-line; }}",
395 $".business-card.front span {{ color: #4d574f; font-size: {profile.FrontEmailFontSize:0.##}px; }}",
396 ".qr-stack { display: grid; justify-items: center; min-width: 0; }",
397 ".qr-box { width: min(132px, 100%); aspect-ratio: 1; padding: 10px; border: 1px solid #202520; border-radius: 999px; background: #fff; }",
398 ".qr-box svg { display: block; width: 100%; height: 100%; }",
399 $".business-card.back {{ display: grid; grid-template-rows: minmax(92px, .9fr) auto; align-items: center; justify-items: center; text-align: center; gap: 10px; padding: 24px; font-family: '{Css(profile.BackFontFamily)}', 'Malgun Gothic', sans-serif; }}",
400 $".back-logo-slot {{ width: 154px; aspect-ratio: 8 / 5; display: grid; place-items: center; border: 2px solid {Html(profile.AccentColor)}; border-radius: 20px; padding: 8px; overflow: hidden; }}",
401 ".back-logo-slot span { width: 52%; height: 52%; border-radius: 50%; }",
402 ".back-logo-slot img { display: block; width: 100%; height: 100%; object-fit: contain; border-radius: 5px; }",
403 ".back-copy { display: grid; justify-items: center; gap: 5px; }",
404 $".business-card.back h3 {{ margin: 0; font-size: {profile.BackBrandFontSize:0.##}px; line-height: 1.12; }}",
405 $".business-card.back p {{ margin: 0; color: #576158; font-size: {profile.BackTaglineFontSize:0.##}px; }}",
406 $".business-card.back strong {{ display: block; margin-top: 8px; font-size: {profile.BackNameFontSize:0.##}px; line-height: 1.15; }}",
407 $".business-card.back span {{ color: #4d574f; font-size: {profile.BackRoleFontSize:0.##}px; }}");
408 }
409
434 public string BuildVCard(CardProfile profile)
435 {
436 var name = profile.Name.Trim();
437
438 var lines = new List<string>
439 {
440 "BEGIN:VCARD",
441 "VERSION:3.0",
442 "PRODID:-//CodeMaru//Codemaru//KO",
443 $"N;CHARSET=UTF-8:{EscapeVCard(name)};;;;",
444 $"FN;CHARSET=UTF-8:{EscapeVCard(name)}"
445 };
446
447 AddUtf8Value(lines, "ORG", profile.BackBrand);
448 AddUtf8Value(lines, "TITLE", profile.Role);
449 AddPlainValues(lines, "EMAIL;TYPE=INTERNET", profile.Email);
450
451 if (profile.IncludePhoneInVCard)
452 {
453 AddPlainValues(lines, "TEL;TYPE=CELL,VOICE", profile.Phone);
454 }
455
456 if (profile.IncludeAddressInVCard && !string.IsNullOrWhiteSpace(profile.Address))
457 {
458 lines.Add($"ADR;TYPE=WORK;CHARSET=UTF-8:;;{EscapeVCard(profile.Address)};;;;");
459 }
460
461 AddPlainValue(lines, "URL", GetVCardContactUrl(profile));
462 AddUtf8Value(lines, "NOTE", profile.VCardNote);
463 AddPhoto(lines, profile.LogoImageDataUrl);
464 lines.Add("END:VCARD");
465
466 return string.Join("\r\n", lines.Select(FoldVCardLineByUtf8Bytes)) + "\r\n";
467 }
468
493 public string BuildWindowsVCard(CardProfile profile)
494 {
495 var name = profile.Name.Trim();
496
497 var lines = new List<string>
498 {
499 "BEGIN:VCARD",
500 "VERSION:2.1",
501 $"N;CHARSET=ks_c_5601-1987;ENCODING=QUOTED-PRINTABLE:{ToKoreanQuotedPrintable(name)};;;;",
502 $"FN;CHARSET=ks_c_5601-1987;ENCODING=QUOTED-PRINTABLE:{ToKoreanQuotedPrintable(name)}"
503 };
504
505 AddKoreanQuotedPrintableValue(lines, "ORG", profile.BackBrand);
506 AddKoreanQuotedPrintableValue(lines, "TITLE", profile.Role);
507 AddPlainValues(lines, "EMAIL;INTERNET", profile.Email);
508
509 if (profile.IncludePhoneInVCard)
510 {
511 AddPlainValues(lines, "TEL;CELL;VOICE", profile.Phone);
512 }
513
514 if (profile.IncludeAddressInVCard && !string.IsNullOrWhiteSpace(profile.Address))
515 {
516 lines.Add($"ADR;WORK;CHARSET=ks_c_5601-1987;ENCODING=QUOTED-PRINTABLE:;;{ToKoreanQuotedPrintable(profile.Address)};;;;");
517 }
518
519 AddPlainValue(lines, "URL;WORK", GetVCardContactUrl(profile));
520 AddKoreanQuotedPrintableValue(lines, "NOTE", profile.VCardNote);
521 lines.Add("END:VCARD");
522
523 return string.Join("\r\n", lines.Select(FoldQuotedPrintableVCardLine)) + "\r\n";
524 }
525
558 private static string BuildFrontCardSvg(CardProfile profile, string qrSvg)
559 {
560 const double scale = 10;
561 var width = Math.Clamp(profile.CardWidthMm, 40, 140) * scale;
562 var height = Math.Clamp(profile.CardHeightMm, 25, 90) * scale;
563 var fontScale = scale / 5;
564
565 // QR 원: 카드 크기에 비례하여 안전 영역 안에 배치
566 // 카드 높이의 40%를 반지름 최대값으로 (작은 명함에서도 원이 잘리지 않음)
567 // 우측·상하 stroke 여백 6px 확보
568 var maxRadiusByHeight = (height / 2) - 18;
569 var qrCircleRadius = Math.Min(maxRadiusByHeight, 112);
570 var qrCircleCx = width - qrCircleRadius - 24; // 우측 여백 = stroke 여유 + rx(14) 회피
571 var qrCircleCy = height / 2;
572 // QR 정사각형이 원 안에 완전히 들어가려면 대각선 ≤ 원의 지름
573 // 한 변 ≤ r × √2 ≈ r × 1.414. 안전 마진 두고 r × 1.3 사용
574 var qrSize = qrCircleRadius * 1.3;
575 var qrX = qrCircleCx - qrSize / 2;
576 var qrY = qrCircleCy - qrSize / 2;
577 var qr = EmbedSvg(qrSvg, qrX, qrY, qrSize, qrSize);
578
579 var categoryLines = SplitLines(profile.Category);
580 var categoryText = SvgTextLines(categoryLines, 64, 305, profile.FrontCategoryFontSize * fontScale, 1.25, "font-weight=\"700\"");
581
582 // rect는 stroke가 line center에 그려지므로 1px 안쪽으로 들여서 viewBox 잘림 방지
583 return $"""
584 <svg xmlns="http://www.w3.org/2000/svg" width="{profile.CardWidthMm:0.##}mm" height="{profile.CardHeightMm:0.##}mm" viewBox="0 0 {width:0.##} {height:0.##}" preserveAspectRatio="xMidYMid meet">
585 <rect x="1" y="1" width="{width - 2:0.##}" height="{height - 2:0.##}" rx="14" fill="#fbfaf7" stroke="#cdd5cb" stroke-width="1"/>
586 <g font-family="{Svg(profile.FrontFontFamily)}, Malgun Gothic, sans-serif">
587 <text x="64" y="150" fill="{Svg(profile.AccentColor)}" font-size="{profile.FrontBrandFontSize * fontScale:0.##}" font-weight="700">{Svg(profile.Brand)}</text>
588 <text x="64" y="215" fill="#3f4841" font-size="{profile.FrontTaglineFontSize * fontScale:0.##}">{Svg(profile.Tagline)}</text>
589 {categoryText}
590 <text x="64" y="{height - 70:0.##}" fill="#4d574f" font-size="{profile.FrontEmailFontSize * fontScale:0.##}">{Svg(profile.Email)}</text>
591 </g>
592 <circle cx="{qrCircleCx:0.##}" cy="{qrCircleCy:0.##}" r="{qrCircleRadius:0.##}" fill="#ffffff" stroke="#202520" stroke-width="2"/>
593 {qr}
594 </svg>
595 """;
596 }
597
630 private static string BuildImportedImageCardSvg(CardProfile profile, string imageDataUrl)
631 {
632 const double scale = 10;
633 var width = Math.Clamp(profile.CardWidthMm, 40, 140) * scale;
634 var height = Math.Clamp(profile.CardHeightMm, 25, 90) * scale;
635
636 return $"""
637 <svg xmlns="http://www.w3.org/2000/svg" width="{profile.CardWidthMm:0.##}mm" height="{profile.CardHeightMm:0.##}mm" viewBox="0 0 {width:0.##} {height:0.##}" preserveAspectRatio="xMidYMid meet">
638 <rect x="1" y="1" width="{width - 2:0.##}" height="{height - 2:0.##}" rx="14" fill="#fbfaf7" stroke="#cdd5cb" stroke-width="1"/>
639 <image href="{Svg(imageDataUrl)}" x="0" y="0" width="{width:0.##}" height="{height:0.##}" preserveAspectRatio="xMidYMid meet"/>
640 </svg>
641 """;
642 }
643
668 private static string BuildBackCardSvg(CardProfile profile)
669 {
670 const double scale = 10;
671 var width = Math.Clamp(profile.CardWidthMm, 40, 140) * scale;
672 var height = Math.Clamp(profile.CardHeightMm, 25, 90) * scale;
673 var fontScale = scale / 5;
674 var logo = string.IsNullOrWhiteSpace(profile.LogoImageDataUrl)
675 ? $"""<ellipse cx="{width / 2:0.##}" cy="155" rx="60" ry="34" fill="{Svg(profile.AccentColor)}"/>"""
676 : $"""<image href="{Svg(profile.LogoImageDataUrl)}" x="{width / 2 - 108:0.##}" y="92" width="216" height="135" preserveAspectRatio="xMidYMid meet"/>""";
677
678 return $"""
679 <svg xmlns="http://www.w3.org/2000/svg" width="{profile.CardWidthMm:0.##}mm" height="{profile.CardHeightMm:0.##}mm" viewBox="0 0 {width:0.##} {height:0.##}">
680 <rect width="100%" height="100%" rx="14" fill="#fbfaf7" stroke="#cdd5cb"/>
681 <rect x="{width / 2 - 126:0.##}" y="74" width="252" height="158" rx="34" fill="none" stroke="{Svg(profile.AccentColor)}" stroke-width="4"/>
682 {logo}
683 <g font-family="{Svg(profile.BackFontFamily)}, Malgun Gothic, sans-serif" text-anchor="middle">
684 <text x="{width / 2:0.##}" y="290" fill="#172119" font-size="{profile.BackBrandFontSize * fontScale:0.##}" font-weight="700">{Svg(profile.BackBrand)}</text>
685 <text x="{width / 2:0.##}" y="335" fill="#576158" font-size="{profile.BackTaglineFontSize * fontScale:0.##}">{Svg(profile.BackTagline)}</text>
686 <text x="{width / 2:0.##}" y="405" fill="#172119" font-size="{profile.BackNameFontSize * fontScale:0.##}" font-weight="700">{Svg(profile.Name)}</text>
687 <text x="{width / 2:0.##}" y="452" fill="#4d574f" font-size="{profile.BackRoleFontSize * fontScale:0.##}">{Svg(profile.Role)}</text>
688 </g>
689 </svg>
690 """;
691 }
692
717 private static (string Background, string Panel, string Text, string Muted) GetThemePalette(string theme)
718 {
719 return theme switch
720 {
721 "dark" => ("#101412", "#171d1a", "#f4f7f2", "#aeb9ae"),
722 "blue" => ("#eef5fb", "#ffffff", "#122436", "#5a6c7d"),
723 "green" => ("#eef5ee", "#ffffff", "#18261c", "#607064"),
724 _ => ("#f7f8f5", "#ffffff", "#172119", "#5d685f")
725 };
726 }
727
752 private static string DisplayValue(string value)
753 {
754 return string.IsNullOrWhiteSpace(value) ? "-" : value;
755 }
756
789 private static void AddUtf8Value(ICollection<string> lines, string key, string value)
790 {
791 if (string.IsNullOrWhiteSpace(value))
792 {
793 return;
794 }
795
796 lines.Add($"{key};CHARSET=UTF-8:{EscapeVCard(value)}");
797 }
798
831 private static void AddPlainValue(ICollection<string> lines, string key, string value)
832 {
833 if (string.IsNullOrWhiteSpace(value))
834 {
835 return;
836 }
837
838 lines.Add($"{key}:{EscapeVCard(value)}");
839 }
840
873 private static void AddPlainValues(ICollection<string> lines, string key, string value)
874 {
875 foreach (var item in SplitMultiValue(value))
876 {
877 AddPlainValue(lines, key, item);
878 }
879 }
880
913 private static void AddKoreanQuotedPrintableValue(ICollection<string> lines, string key, string value)
914 {
915 if (string.IsNullOrWhiteSpace(value))
916 {
917 return;
918 }
919
920 lines.Add($"{key};CHARSET=ks_c_5601-1987;ENCODING=QUOTED-PRINTABLE:{ToKoreanQuotedPrintable(value)}");
921 }
922
947 private static string GetVCardContactUrl(CardProfile profile)
948 {
949 return string.IsNullOrWhiteSpace(profile.Website)
950 ? string.Empty
951 : profile.Website.Trim().TrimEnd('/');
952 }
953
978 private static string ToKoreanQuotedPrintable(string value)
979 {
980 return ToQuotedPrintable(value, GetKoreanEncoding());
981 }
982
999 private static Encoding GetKoreanEncoding()
1000 {
1001 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
1002 return Encoding.GetEncoding(949);
1003 }
1004
1037 private static string ToQuotedPrintable(string value, Encoding encoding)
1038 {
1039 if (string.IsNullOrEmpty(value))
1040 {
1041 return string.Empty;
1042 }
1043
1044 var normalized = value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\r", "\n", StringComparison.Ordinal);
1045 var bytes = encoding.GetBytes(normalized);
1046 var builder = new StringBuilder(bytes.Length * 3);
1047
1048 foreach (var b in bytes)
1049 {
1050 if (b == 0x0A)
1051 {
1052 builder.Append("=0A");
1053 }
1054 else if ((b >= 33 && b <= 60) || (b >= 62 && b <= 126))
1055 {
1056 builder.Append((char)b);
1057 }
1058 else
1059 {
1060 builder.Append('=').Append(b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture));
1061 }
1062 }
1063
1064 return builder.ToString();
1065 }
1066
1091 private static void AddPhoto(ICollection<string> lines, string? dataUrl)
1092 {
1093 if (string.IsNullOrWhiteSpace(dataUrl) || !TryReadDataUrl(dataUrl, out var contentType, out var base64))
1094 {
1095 return;
1096 }
1097
1098 var type = contentType.Contains("jpeg", StringComparison.OrdinalIgnoreCase) ||
1099 contentType.Contains("jpg", StringComparison.OrdinalIgnoreCase)
1100 ? "JPEG"
1101 : "PNG";
1102
1103 // base64 데이터에 공백/줄바꿈이 섞여 있으면 Windows People이 디코딩 중 크래시 발생.
1104 // 안전을 위해 모든 whitespace를 제거하고 단일 라인으로 출력한다.
1105 var cleanBase64 = new string(base64.Where(static c => !char.IsWhiteSpace(c)).ToArray());
1106
1107 // PHOTO 라인 호환성 규칙:
1108 // - ENCODING=BASE64 (vCard 2.1/3.0 양쪽 호환, Windows People이 인식하는 형식)
1109 // ENCODING=b 는 RFC 2426 표준이지만 Windows People에서 인식 실패 → 앱 종료 원인
1110 // - TYPE 먼저, ENCODING 나중 (Windows People 선호 순서)
1111 // - line folding 적용하지 않음
1112 // Windows의 일부 vCard 파서는 fold된 base64를 잘못 unfold하여 디코딩 실패함
1113 lines.Add($"PHOTO;TYPE={type};ENCODING=BASE64:{cleanBase64}");
1114 }
1115
1156 private static bool TryReadDataUrl(string dataUrl, out string contentType, out string base64)
1157 {
1158 contentType = string.Empty;
1159 base64 = string.Empty;
1160
1161 const string marker = ";base64,";
1162 var markerIndex = dataUrl.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
1163 if (!dataUrl.StartsWith("data:", StringComparison.OrdinalIgnoreCase) || markerIndex < 0)
1164 {
1165 return false;
1166 }
1167
1168 contentType = dataUrl[5..markerIndex];
1169 base64 = dataUrl[(markerIndex + marker.Length)..];
1170 return base64.Length > 0;
1171 }
1172
1197 private static string FoldVCardLineByUtf8Bytes(string line)
1198 {
1199 const int maxBytes = 75;
1200 var bytes = Encoding.UTF8.GetBytes(line);
1201 if (bytes.Length <= maxBytes)
1202 {
1203 return line;
1204 }
1205
1206 var builder = new StringBuilder(line.Length + 16);
1207 var currentBytes = 0;
1208
1209 foreach (var rune in line.EnumerateRunes())
1210 {
1211 var runeText = rune.ToString();
1212 var runeBytes = Encoding.UTF8.GetByteCount(runeText);
1213 if (currentBytes > 0 && currentBytes + runeBytes > maxBytes)
1214 {
1215 builder.Append("\r\n ");
1216 currentBytes = 1;
1217 }
1218
1219 builder.Append(runeText);
1220 currentBytes += runeBytes;
1221 }
1222
1223 return builder.ToString();
1224 }
1225
1250 private static string FoldQuotedPrintableVCardLine(string line)
1251 {
1252 const int maxLength = 73;
1253 if (line.Length <= maxLength)
1254 {
1255 return line;
1256 }
1257
1258 var builder = new StringBuilder(line.Length + 32);
1259 var index = 0;
1260
1261 while (index < line.Length)
1262 {
1263 var take = Math.Min(maxLength, line.Length - index);
1264 while (take > 1 && line[index + take - 1] == '=')
1265 {
1266 take--;
1267 }
1268
1269 if (take > 2 && line[index + take - 2] == '=')
1270 {
1271 take -= 2;
1272 }
1273
1274 if (take <= 0)
1275 {
1276 take = Math.Min(maxLength, line.Length - index);
1277 }
1278
1279 if (index > 0)
1280 {
1281 builder.Append("\r\n ");
1282 }
1283
1284 builder.Append(line, index, take);
1285 index += take;
1286 }
1287
1288 return builder.ToString();
1289 }
1290
1315 private static string Html(string? value)
1316 {
1317 return WebUtility.HtmlEncode(value ?? string.Empty);
1318 }
1319
1344 private static string Svg(string? value)
1345 {
1346 return WebUtility.HtmlEncode(value ?? string.Empty);
1347 }
1348
1373 private static string Css(string? value)
1374 {
1375 return (value ?? string.Empty).Replace("'", string.Empty, StringComparison.Ordinal);
1376 }
1377
1402 private static string[] SplitLines(string value)
1403 {
1404 return value
1405 .Replace("\r\n", "\n", StringComparison.Ordinal)
1406 .Replace('\r', '\n')
1407 .Split('\n', StringSplitOptions.None);
1408 }
1409
1434 private static IEnumerable<string> SplitMultiValue(string value)
1435 {
1436 return (value ?? string.Empty)
1437 .Replace("\r\n", "\n", StringComparison.Ordinal)
1438 .Replace('\r', '\n')
1439 .Split(['\n', ';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
1440 .Where(static item => !string.IsNullOrWhiteSpace(item));
1441 }
1442
1507 private static string SvgTextLines(IReadOnlyList<string> lines, double x, double y, double fontSize, double lineHeight, string attributes)
1508 {
1509 var builder = new StringBuilder();
1510 builder.Append($"""<text x="{x:0.##}" y="{y:0.##}" fill="#222a24" font-size="{fontSize:0.##}" {attributes}>""");
1511
1512 for (var i = 0; i < lines.Count; i++)
1513 {
1514 var dy = i == 0 ? "0" : $"{fontSize * lineHeight:0.##}";
1515 builder.Append($"""<tspan x="{x:0.##}" dy="{dy}">{Svg(lines[i])}</tspan>""");
1516 }
1517
1518 builder.Append("</text>");
1519 return builder.ToString();
1520 }
1521
1578 private static string EmbedSvg(string svg, double x, double y, double width, double height)
1579 {
1580 try
1581 {
1582 var element = XElement.Parse(svg);
1583 element.SetAttributeValue("x", x.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture));
1584 element.SetAttributeValue("y", y.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture));
1585 element.SetAttributeValue("width", width.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture));
1586 element.SetAttributeValue("height", height.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture));
1587 element.SetAttributeValue("preserveAspectRatio", "xMidYMid meet");
1588 element.SetAttributeValue("class", null);
1589 return element.ToString(SaveOptions.DisableFormatting);
1590 }
1591 catch
1592 {
1593 var dataUrl = $"data:image/svg+xml;base64,{Convert.ToBase64String(Encoding.UTF8.GetBytes(svg))}";
1594 return $"""<image href="{dataUrl}" x="{x:0.##}" y="{y:0.##}" width="{width:0.##}" height="{height:0.##}" preserveAspectRatio="xMidYMid meet"/>""";
1595 }
1596 }
1597
1622 private static string EscapeVCard(string value)
1623 {
1624 return value
1625 .Replace("\\", "\\\\", StringComparison.Ordinal)
1626 .Replace(";", "\\;", StringComparison.Ordinal)
1627 .Replace(",", "\\,", StringComparison.Ordinal)
1628 .Replace("\r\n", "\\n", StringComparison.Ordinal)
1629 .Replace("\n", "\\n", StringComparison.Ordinal);
1630 }
1631}
record CardProfile(string Brand, string Tagline, string BackBrand, string BackTagline, string Category, string Name, string Role, string Email, string Phone, string Address, string Website, string LandingSlug, string AccentColor, string ShortBio, string LandingDescription, string VCardNote, string InternalMemo, string? LogoImageDataUrl, bool RemoveLogoBackground, double CardWidthMm, double CardHeightMm, string FrontFontFamily, string BackFontFamily, double FrontBrandFontSize, double FrontTaglineFontSize, double FrontCategoryFontSize, double FrontEmailFontSize, double BackBrandFontSize, double BackTaglineFontSize, double BackNameFontSize, double BackRoleFontSize, string LandingTheme, bool IncludePhoneInVCard, bool IncludeAddressInVCard, string? ImportedFrontImageDataUrl, string? ImportedBackImageDataUrl)