16public sealed class LandingPageExporter
58 public string BuildCardSideSvg(
CardProfile profile,
string qrSvg,
bool front)
60 if (front && !
string.IsNullOrWhiteSpace(profile.ImportedFrontImageDataUrl))
62 return BuildImportedImageCardSvg(profile, profile.ImportedFrontImageDataUrl);
65 if (!front && !
string.IsNullOrWhiteSpace(profile.ImportedBackImageDataUrl))
67 return BuildImportedImageCardSvg(profile, profile.ImportedBackImageDataUrl);
70 return front ? BuildFrontCardSvg(profile, qrSvg) : BuildBackCardSvg(profile);
113 public string BuildCardSideHtml(
CardProfile profile,
string qrSvg,
bool front)
116 ? BuildFrontCard(profile, qrSvg)
117 : BuildBackCard(profile);
118 var title = front ?
"Business Card Front" :
"Business Card Back";
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();
171 public string BuildHtml(
CardProfile profile,
string qrPayload)
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\" />";
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))
239 html.AppendLine($
" <a class=\"cta secondary\" href=\"tel:{Html(profile.Phone)}\">전화하기</a>");
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();
300 private static string BuildFrontCard(
CardProfile profile,
string qrSvg)
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>
310 <div class="qr-stack
">
311 <div class="qr-box
">{qrSvg}</div>
341 private static string BuildBackCard(
CardProfile profile)
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\" />";
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>
384 private static string BuildCardCss(
CardProfile profile)
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; }}");
436 var name = profile.Name.Trim();
438 var lines =
new List<string>
442 "PRODID:-//CodeMaru//Codemaru//KO",
443 $
"N;CHARSET=UTF-8:{EscapeVCard(name)};;;;",
444 $
"FN;CHARSET=UTF-8:{EscapeVCard(name)}"
447 AddUtf8Value(lines,
"ORG", profile.BackBrand);
448 AddUtf8Value(lines,
"TITLE", profile.Role);
449 AddPlainValues(lines,
"EMAIL;TYPE=INTERNET", profile.Email);
451 if (profile.IncludePhoneInVCard)
453 AddPlainValues(lines,
"TEL;TYPE=CELL,VOICE", profile.Phone);
456 if (profile.IncludeAddressInVCard && !
string.IsNullOrWhiteSpace(profile.Address))
458 lines.Add($
"ADR;TYPE=WORK;CHARSET=UTF-8:;;{EscapeVCard(profile.Address)};;;;");
461 AddPlainValue(lines,
"URL", GetVCardContactUrl(profile));
462 AddUtf8Value(lines,
"NOTE", profile.VCardNote);
463 AddPhoto(lines, profile.LogoImageDataUrl);
464 lines.Add(
"END:VCARD");
466 return string.Join(
"\r\n", lines.Select(FoldVCardLineByUtf8Bytes)) +
"\r\n";
493 public string BuildWindowsVCard(
CardProfile profile)
495 var name = profile.Name.Trim();
497 var lines =
new List<string>
501 $
"N;CHARSET=ks_c_5601-1987;ENCODING=QUOTED-PRINTABLE:{ToKoreanQuotedPrintable(name)};;;;",
502 $
"FN;CHARSET=ks_c_5601-1987;ENCODING=QUOTED-PRINTABLE:{ToKoreanQuotedPrintable(name)}"
505 AddKoreanQuotedPrintableValue(lines,
"ORG", profile.BackBrand);
506 AddKoreanQuotedPrintableValue(lines,
"TITLE", profile.Role);
507 AddPlainValues(lines,
"EMAIL;INTERNET", profile.Email);
509 if (profile.IncludePhoneInVCard)
511 AddPlainValues(lines,
"TEL;CELL;VOICE", profile.Phone);
514 if (profile.IncludeAddressInVCard && !
string.IsNullOrWhiteSpace(profile.Address))
516 lines.Add($
"ADR;WORK;CHARSET=ks_c_5601-1987;ENCODING=QUOTED-PRINTABLE:;;{ToKoreanQuotedPrintable(profile.Address)};;;;");
519 AddPlainValue(lines,
"URL;WORK", GetVCardContactUrl(profile));
520 AddKoreanQuotedPrintableValue(lines,
"NOTE", profile.VCardNote);
521 lines.Add(
"END:VCARD");
523 return string.Join(
"\r\n", lines.Select(FoldQuotedPrintableVCardLine)) +
"\r\n";
558 private static string BuildFrontCardSvg(
CardProfile profile,
string qrSvg)
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;
568 var maxRadiusByHeight = (height / 2) - 18;
569 var qrCircleRadius = Math.Min(maxRadiusByHeight, 112);
570 var qrCircleCx = width - qrCircleRadius - 24;
571 var qrCircleCy = height / 2;
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);
579 var categoryLines = SplitLines(profile.Category);
580 var categoryText = SvgTextLines(categoryLines, 64, 305, profile.FrontCategoryFontSize * fontScale, 1.25,
"font-weight=\"700\"");
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>
590 <text x=
"64" y=
"{height - 70:0.##}" fill=
"#4d574f" font-size=
"{profile.FrontEmailFontSize * fontScale:0.##}">{Svg(profile.Email)}</text>
592 <circle cx=
"{qrCircleCx:0.##}" cy=
"{qrCircleCy:0.##}" r=
"{qrCircleRadius:0.##}" fill=
"#ffffff" stroke=
"#202520" stroke-width=
"2"/>
630 private static string BuildImportedImageCardSvg(CardProfile profile, string imageDataUrl)
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;
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"/>
668 private static string BuildBackCardSvg(CardProfile profile)
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"/>
""";
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"/>
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>
717 private static (string Background, string Panel, string Text, string Muted) GetThemePalette(string theme)
721 "dark
" => ("#101412
", "#171d1a
", "#f4f7f2
", "#aeb9ae
"),
722 "blue
" => ("#eef5fb
", "#ffffff
", "#122436
", "#5a6c7d
"),
723 "green
" => ("#eef5ee
", "#ffffff
", "#18261c
", "#607064
"),
724 _ => ("#f7f8f5
", "#ffffff
", "#172119
", "#5d685f
")
752 private static string DisplayValue(string value)
754 return string.IsNullOrWhiteSpace(value) ? "-
" : value;
789 private static void AddUtf8Value(ICollection<string> lines, string key, string value)
791 if (string.IsNullOrWhiteSpace(value))
796 lines.Add($"{key};CHARSET=UTF-8:{EscapeVCard(value)}
");
831 private static void AddPlainValue(ICollection<string> lines, string key, string value)
833 if (string.IsNullOrWhiteSpace(value))
838 lines.Add($"{key}:{EscapeVCard(value)}
");
873 private static void AddPlainValues(ICollection<string> lines, string key, string value)
875 foreach (var item in SplitMultiValue(value))
877 AddPlainValue(lines, key, item);
913 private static void AddKoreanQuotedPrintableValue(ICollection<string> lines, string key, string value)
915 if (string.IsNullOrWhiteSpace(value))
920 lines.Add($"{key};CHARSET=ks_c_5601-1987;ENCODING=QUOTED-PRINTABLE:{ToKoreanQuotedPrintable(value)}
");
947 private static string GetVCardContactUrl(CardProfile profile)
949 return string.IsNullOrWhiteSpace(profile.Website)
951 : profile.Website.Trim().TrimEnd('/');
978 private static string ToKoreanQuotedPrintable(string value)
980 return ToQuotedPrintable(value, GetKoreanEncoding());
999 private static Encoding GetKoreanEncoding()
1001 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
1002 return Encoding.GetEncoding(949);
1037 private static string ToQuotedPrintable(string value, Encoding encoding)
1039 if (string.IsNullOrEmpty(value))
1041 return string.Empty;
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);
1048 foreach (var b in bytes)
1052 builder.Append("=0A
");
1054 else if ((b >= 33 && b <= 60) || (b >= 62 && b <= 126))
1056 builder.Append((char)b);
1060 builder.Append('=').Append(b.ToString("X2
", System.Globalization.CultureInfo.InvariantCulture));
1064 return builder.ToString();
1091 private static void AddPhoto(ICollection<string> lines, string? dataUrl)
1093 if (string.IsNullOrWhiteSpace(dataUrl) || !TryReadDataUrl(dataUrl, out var contentType, out var base64))
1098 var type = contentType.Contains("jpeg
", StringComparison.OrdinalIgnoreCase) ||
1099 contentType.Contains("jpg
", StringComparison.OrdinalIgnoreCase)
1103 // base64 데이터에 공백/줄바꿈이 섞여 있으면 Windows People이 디코딩 중 크래시 발생.
1104 // 안전을 위해 모든 whitespace를 제거하고 단일 라인으로 출력한다.
1105 var cleanBase64 = new string(base64.Where(static c => !char.IsWhiteSpace(c)).ToArray());
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}
");
1156 private static bool TryReadDataUrl(string dataUrl, out string contentType, out string base64)
1158 contentType = string.Empty;
1159 base64 = string.Empty;
1161 const string marker = ";base64,
";
1162 var markerIndex = dataUrl.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
1163 if (!dataUrl.StartsWith("data:
", StringComparison.OrdinalIgnoreCase) || markerIndex < 0)
1168 contentType = dataUrl[5..markerIndex];
1169 base64 = dataUrl[(markerIndex + marker.Length)..];
1170 return base64.Length > 0;
1197 private static string FoldVCardLineByUtf8Bytes(string line)
1199 const int maxBytes = 75;
1200 var bytes = Encoding.UTF8.GetBytes(line);
1201 if (bytes.Length <= maxBytes)
1206 var builder = new StringBuilder(line.Length + 16);
1207 var currentBytes = 0;
1209 foreach (var rune in line.EnumerateRunes())
1211 var runeText = rune.ToString();
1212 var runeBytes = Encoding.UTF8.GetByteCount(runeText);
1213 if (currentBytes > 0 && currentBytes + runeBytes > maxBytes)
1215 builder.Append("\r\n
");
1219 builder.Append(runeText);
1220 currentBytes += runeBytes;
1223 return builder.ToString();
1250 private static string FoldQuotedPrintableVCardLine(string line)
1252 const int maxLength = 73;
1253 if (line.Length <= maxLength)
1258 var builder = new StringBuilder(line.Length + 32);
1261 while (index < line.Length)
1263 var take = Math.Min(maxLength, line.Length - index);
1264 while (take > 1 && line[index + take - 1] == '=')
1269 if (take > 2 && line[index + take - 2] == '=')
1276 take = Math.Min(maxLength, line.Length - index);
1281 builder.Append("\r\n
");
1284 builder.Append(line, index, take);
1288 return builder.ToString();
1315 private static string Html(string? value)
1317 return WebUtility.HtmlEncode(value ?? string.Empty);
1344 private static string Svg(string? value)
1346 return WebUtility.HtmlEncode(value ?? string.Empty);
1373 private static string Css(string? value)
1375 return (value ?? string.Empty).Replace("
'", string.Empty, StringComparison.Ordinal);
1402 private static string[] SplitLines(string value)
1405 .Replace("\r\n", "\n", StringComparison.Ordinal)
1406 .Replace('\r
', '\n
')
1407 .Split('\n
', StringSplitOptions.None);
1434 private static IEnumerable<string> SplitMultiValue(string value)
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));
1507 private static string SvgTextLines(IReadOnlyList<string> lines, double x, double y, double fontSize, double lineHeight, string attributes)
1509 var builder = new StringBuilder();
1510 builder.Append($"""<text x="{x:0.##}" y="{y:0.##}" fill="#222a24" font-size="{fontSize:0.##}" {attributes}>""");
1512 for (var i = 0; i < lines.Count; i++)
1514 var dy = i == 0 ? "0" : $"{fontSize * lineHeight:0.##}";
1515 builder.Append($"""<tspan x="{x:0.##}" dy="{dy}">{Svg(lines[i])}</tspan>""");
1518 builder.Append("</text>");
1519 return builder.ToString();
1578 private static string EmbedSvg(string svg, double x, double y, double width, double height)
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);
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"/>""";
1622 private static string EscapeVCard(string 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);
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)