暂无描述

CVProfileDocumentView.swift 59KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304
  1. //
  2. // CVProfileDocumentView.swift
  3. // App for Indeed
  4. //
  5. // Renders saved profile data in a layout that follows the selected CV template’s
  6. // family (professional / modern / minimal / executive / creative), headline,
  7. // accent, section labels, and column structure.
  8. //
  9. import Cocoa
  10. /// Typography and chrome derived from `CVTemplate.family` so the filled résumé
  11. /// visibly matches the gallery card the user picked—not a single generic layout.
  12. private struct DocumentStyle {
  13. let nameFont: NSFont
  14. let roleFont: NSFont
  15. let contactFont: NSFont
  16. let sectionFont: NSFont
  17. let bodyFont: NSFont
  18. let bodyCompactFont: NSFont
  19. let expTitleFont: NSFont
  20. let expMetaFont: NSFont
  21. let eduTitleFont: NSFont
  22. let eduMetaFont: NSFont
  23. let bulletBodyFont: NSFont
  24. let bulletMarkerFont: NSFont
  25. let bulletMarkerColor: NSColor
  26. let ink: NSColor
  27. let muted: NSColor
  28. let rule: NSColor
  29. let cardBackground: NSColor
  30. let columnVerticalSpacing: CGFloat
  31. let bodyBlockSpacing: CGFloat
  32. /// When true, the headline job title uses the template theme color.
  33. let roleUsesThemeColor: Bool
  34. /// Section heading text color (often theme; executive stays conservative).
  35. let sectionInk: NSColor
  36. static func make(for template: CVTemplate) -> DocumentStyle {
  37. let theme = template.themeColor
  38. switch template.family {
  39. case .minimal:
  40. return DocumentStyle(
  41. nameFont: .systemFont(ofSize: 20, weight: .regular),
  42. roleFont: .systemFont(ofSize: 13.5, weight: .regular),
  43. contactFont: .systemFont(ofSize: 11.5, weight: .regular),
  44. sectionFont: .systemFont(ofSize: 10.5, weight: .semibold),
  45. bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
  46. bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
  47. expTitleFont: .systemFont(ofSize: 13, weight: .medium),
  48. expMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
  49. eduTitleFont: .systemFont(ofSize: 13, weight: .medium),
  50. eduMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
  51. bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
  52. bulletMarkerFont: .systemFont(ofSize: 11, weight: .light),
  53. bulletMarkerColor: theme.withAlphaComponent(0.55),
  54. ink: NSColor(srgbRed: 42 / 255, green: 48 / 255, blue: 56 / 255, alpha: 1),
  55. muted: NSColor(srgbRed: 110 / 255, green: 118 / 255, blue: 128 / 255, alpha: 1),
  56. rule: NSColor(srgbRed: 228 / 255, green: 230 / 255, blue: 234 / 255, alpha: 1),
  57. cardBackground: NSColor(srgbRed: 0.998, green: 0.998, blue: 0.998, alpha: 1),
  58. columnVerticalSpacing: 15,
  59. bodyBlockSpacing: 15,
  60. roleUsesThemeColor: false,
  61. sectionInk: theme.withAlphaComponent(0.92)
  62. )
  63. case .professional:
  64. return DocumentStyle(
  65. nameFont: .systemFont(ofSize: 21, weight: .semibold),
  66. roleFont: .systemFont(ofSize: 13.5, weight: .medium),
  67. contactFont: .systemFont(ofSize: 11.5, weight: .regular),
  68. sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
  69. bodyFont: .systemFont(ofSize: 12, weight: .regular),
  70. bodyCompactFont: .systemFont(ofSize: 11.5, weight: .regular),
  71. expTitleFont: .systemFont(ofSize: 13.5, weight: .semibold),
  72. expMetaFont: .systemFont(ofSize: 11.5, weight: .semibold),
  73. eduTitleFont: .systemFont(ofSize: 13, weight: .semibold),
  74. eduMetaFont: .systemFont(ofSize: 11.5, weight: .medium),
  75. bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
  76. bulletMarkerFont: .systemFont(ofSize: 12, weight: .bold),
  77. bulletMarkerColor: NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55),
  78. ink: NSColor(srgbRed: 28 / 255, green: 36 / 255, blue: 48 / 255, alpha: 1),
  79. muted: NSColor(srgbRed: 88 / 255, green: 98 / 255, blue: 118 / 255, alpha: 1),
  80. rule: NSColor(srgbRed: 210 / 255, green: 218 / 255, blue: 232 / 255, alpha: 1),
  81. cardBackground: NSColor.white,
  82. columnVerticalSpacing: 13,
  83. bodyBlockSpacing: 13,
  84. roleUsesThemeColor: false,
  85. sectionInk: theme
  86. )
  87. case .modern:
  88. return DocumentStyle(
  89. nameFont: .systemFont(ofSize: 22, weight: .bold),
  90. roleFont: .systemFont(ofSize: 14, weight: .semibold),
  91. contactFont: .systemFont(ofSize: 12, weight: .regular),
  92. sectionFont: .systemFont(ofSize: 11, weight: .heavy),
  93. bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
  94. bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
  95. expTitleFont: .systemFont(ofSize: 14, weight: .bold),
  96. expMetaFont: .systemFont(ofSize: 12, weight: .medium),
  97. eduTitleFont: .systemFont(ofSize: 13.5, weight: .bold),
  98. eduMetaFont: .systemFont(ofSize: 12, weight: .regular),
  99. bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
  100. bulletMarkerFont: .systemFont(ofSize: 13, weight: .bold),
  101. bulletMarkerColor: theme,
  102. ink: NSColor(srgbRed: 24 / 255, green: 34 / 255, blue: 52 / 255, alpha: 1),
  103. muted: NSColor(srgbRed: 96 / 255, green: 110 / 255, blue: 132 / 255, alpha: 1),
  104. rule: NSColor(srgbRed: 200 / 255, green: 214 / 255, blue: 236 / 255, alpha: 1),
  105. cardBackground: NSColor(srgbRed: 0.99, green: 0.995, blue: 1, alpha: 1),
  106. columnVerticalSpacing: 17,
  107. bodyBlockSpacing: 16,
  108. roleUsesThemeColor: true,
  109. sectionInk: theme
  110. )
  111. case .executive:
  112. let serifName = NSFont(name: "Georgia-Bold", size: 23) ?? .systemFont(ofSize: 23, weight: .semibold)
  113. let serifRole = NSFont(name: "Georgia", size: 14) ?? .systemFont(ofSize: 14, weight: .regular)
  114. let serifBody = NSFont(name: "Georgia", size: 12.5) ?? .systemFont(ofSize: 12.5, weight: .regular)
  115. let serifCompact = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12, weight: .regular)
  116. let georgia12 = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12)
  117. let georgia115 = NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5)
  118. let expMeta = NSFont(name: "Georgia-Italic", size: 12)
  119. ?? NSFontManager.shared.convert(georgia12, toHaveTrait: .italicFontMask)
  120. let eduMeta = NSFont(name: "Georgia-Italic", size: 11.5)
  121. ?? NSFontManager.shared.convert(georgia115, toHaveTrait: .italicFontMask)
  122. return DocumentStyle(
  123. nameFont: serifName,
  124. roleFont: serifRole,
  125. contactFont: NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5),
  126. sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
  127. bodyFont: serifBody,
  128. bodyCompactFont: serifCompact,
  129. expTitleFont: NSFont(name: "Georgia-Bold", size: 14) ?? .systemFont(ofSize: 14, weight: .semibold),
  130. expMetaFont: expMeta,
  131. eduTitleFont: NSFont(name: "Georgia-Bold", size: 13.5) ?? .systemFont(ofSize: 13.5, weight: .semibold),
  132. eduMetaFont: eduMeta,
  133. bulletBodyFont: serifCompact,
  134. bulletMarkerFont: .systemFont(ofSize: 11, weight: .bold),
  135. bulletMarkerColor: NSColor(srgbRed: 55 / 255, green: 55 / 255, blue: 62 / 255, alpha: 1),
  136. ink: NSColor(srgbRed: 22 / 255, green: 22 / 255, blue: 28 / 255, alpha: 1),
  137. muted: NSColor(srgbRed: 82 / 255, green: 82 / 255, blue: 90 / 255, alpha: 1),
  138. rule: NSColor(srgbRed: 72 / 255, green: 72 / 255, blue: 78 / 255, alpha: 0.35),
  139. cardBackground: NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1),
  140. columnVerticalSpacing: 18,
  141. bodyBlockSpacing: 17,
  142. roleUsesThemeColor: false,
  143. sectionInk: NSColor(srgbRed: 32 / 255, green: 32 / 255, blue: 38 / 255, alpha: 1)
  144. )
  145. case .creative:
  146. return DocumentStyle(
  147. nameFont: .systemFont(ofSize: 23, weight: .heavy),
  148. roleFont: .systemFont(ofSize: 14, weight: .semibold),
  149. contactFont: .systemFont(ofSize: 11.5, weight: .medium),
  150. sectionFont: .systemFont(ofSize: 11.5, weight: .heavy),
  151. bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
  152. bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
  153. expTitleFont: .systemFont(ofSize: 14, weight: .heavy),
  154. expMetaFont: .systemFont(ofSize: 12, weight: .semibold),
  155. eduTitleFont: .systemFont(ofSize: 13.5, weight: .heavy),
  156. eduMetaFont: .systemFont(ofSize: 12, weight: .medium),
  157. bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
  158. bulletMarkerFont: .systemFont(ofSize: 13, weight: .heavy),
  159. bulletMarkerColor: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1),
  160. ink: NSColor(srgbRed: 32 / 255, green: 26 / 255, blue: 52 / 255, alpha: 1),
  161. muted: NSColor(srgbRed: 108 / 255, green: 96 / 255, blue: 130 / 255, alpha: 1),
  162. rule: theme.withAlphaComponent(0.22),
  163. cardBackground: NSColor(srgbRed: 0.995, green: 0.993, blue: 1, alpha: 1),
  164. columnVerticalSpacing: 18,
  165. bodyBlockSpacing: 17,
  166. roleUsesThemeColor: true,
  167. sectionInk: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1)
  168. )
  169. }
  170. }
  171. }
  172. /// Full-width résumé layout that injects `SavedProfile` into the visual language of `CVTemplate`.
  173. final class CVProfileDocumentView: NSView {
  174. /// Card width used in the CV preview; also the horizontal fitting size for this view.
  175. /// Without this, a parent `NSStackView` that only pins `width ≤ …` can size the document from
  176. /// wrapping `NSTextField` intrinsic widths (~0) and the whole page collapses to a thin strip.
  177. private static let cardWidth: CGFloat = 640
  178. private let profile: SavedProfile
  179. private let template: CVTemplate
  180. private let style: DocumentStyle
  181. /// Matches `CVTemplatePreviewView` so the same template id + layout recipe renders the same silhouette as the gallery card.
  182. private let variant: Int
  183. init(profile: SavedProfile, template: CVTemplate) {
  184. self.profile = profile
  185. self.template = template
  186. self.style = DocumentStyle.make(for: template)
  187. self.variant = template.galleryLayoutVariant
  188. super.init(frame: .zero)
  189. translatesAutoresizingMaskIntoConstraints = false
  190. wantsLayer = true
  191. layer?.backgroundColor = NSColor.clear.cgColor
  192. userInterfaceLayoutDirection = .leftToRight
  193. // Let the preview stack stretch us to the scroll view width; don’t shrink to label intrinsic widths.
  194. setContentHuggingPriority(.defaultLow, for: .horizontal)
  195. let card = NSView()
  196. card.translatesAutoresizingMaskIntoConstraints = false
  197. card.wantsLayer = true
  198. card.layer?.backgroundColor = style.cardBackground.cgColor
  199. card.layer?.cornerRadius = template.family == .executive ? 6 : 10
  200. card.layer?.borderWidth = 1
  201. card.layer?.borderColor = style.rule.cgColor
  202. card.layer?.masksToBounds = true
  203. let root = buildRoot()
  204. root.translatesAutoresizingMaskIntoConstraints = false
  205. card.addSubview(root)
  206. addSubview(card)
  207. NSLayoutConstraint.activate([
  208. card.leadingAnchor.constraint(equalTo: leadingAnchor),
  209. card.trailingAnchor.constraint(equalTo: trailingAnchor),
  210. card.topAnchor.constraint(equalTo: topAnchor),
  211. card.bottomAnchor.constraint(equalTo: bottomAnchor),
  212. card.widthAnchor.constraint(equalToConstant: Self.cardWidth),
  213. root.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 36),
  214. root.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -36),
  215. root.topAnchor.constraint(equalTo: card.topAnchor, constant: 32),
  216. root.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -36)
  217. ])
  218. }
  219. @available(*, unavailable)
  220. required init?(coder: NSCoder) {
  221. fatalError("init(coder:) has not been implemented")
  222. }
  223. override var intrinsicContentSize: NSSize {
  224. NSSize(width: Self.cardWidth, height: NSView.noIntrinsicMetric)
  225. }
  226. override func layout() {
  227. super.layout()
  228. // Wrapping `NSTextField`s default to a very small intrinsic width until
  229. // `preferredMaxLayoutWidth` tracks the column width — stacks then collapse and text reflows like a narrow strip.
  230. updateWrappingTextPreferredWidths()
  231. }
  232. /// Any wrapping body (`maximumNumberOfLines == 0`) needs a concrete wrap width inside stack-driven layout.
  233. private func updateWrappingTextPreferredWidths() {
  234. for field in Self.collectWrappingTextFields(in: self) {
  235. guard let parent = field.superview, parent.bounds.width > 2 else { continue }
  236. let w = parent.bounds.width
  237. if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
  238. field.preferredMaxLayoutWidth = w
  239. }
  240. }
  241. }
  242. private static func collectWrappingTextFields(in root: NSView) -> [NSTextField] {
  243. var out: [NSTextField] = []
  244. func visit(_ v: NSView) {
  245. if let tf = v as? NSTextField, tf.maximumNumberOfLines == 0 {
  246. out.append(tf)
  247. }
  248. for c in v.subviews { visit(c) }
  249. }
  250. visit(root)
  251. return out
  252. }
  253. // MARK: - Composition
  254. private func buildRoot() -> NSView {
  255. switch template.family {
  256. case .modern:
  257. return buildModernFamilyDocument()
  258. case .creative:
  259. return buildCreativeFamilyDocument()
  260. case .professional, .minimal, .executive:
  261. return buildTraditionalFamilyDocument()
  262. }
  263. }
  264. // MARK: - Modern (gallery uses three distinct silhouettes from `variant`)
  265. private func buildModernFamilyDocument() -> NSView {
  266. switch variant % 3 {
  267. case 0: return modernClassicBandDocument()
  268. case 1: return modernRailDocument()
  269. default: return modernSplitHeaderDocument()
  270. }
  271. }
  272. private func modernClassicBandDocument() -> NSView {
  273. let theme = template.themeColor
  274. let white = NSColor.white
  275. let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
  276. let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
  277. let header = NSView()
  278. header.translatesAutoresizingMaskIntoConstraints = false
  279. header.wantsLayer = true
  280. header.layer?.backgroundColor = theme.cgColor
  281. header.layer?.cornerRadius = variant % 2 == 0 ? 8 : 6
  282. let name = label(nameText, font: .systemFont(ofSize: 22, weight: .bold), color: white, maxLines: 2)
  283. let role = label(roleText, font: .systemFont(ofSize: 14, weight: .medium), color: white.withAlphaComponent(0.92), maxLines: 2)
  284. let textCol = NSStackView(views: [name, role])
  285. textCol.orientation = .vertical
  286. textCol.spacing = 4
  287. textCol.alignment = .leading
  288. textCol.translatesAutoresizingMaskIntoConstraints = false
  289. let iconRow = NSStackView()
  290. iconRow.orientation = .horizontal
  291. iconRow.spacing = 10
  292. iconRow.translatesAutoresizingMaskIntoConstraints = false
  293. for sym in ["mappin.and.ellipse", "phone.fill", "envelope.fill"] {
  294. guard let img = NSImage(systemSymbolName: sym, accessibilityDescription: nil) else { continue }
  295. let iv = NSImageView(image: img)
  296. iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  297. iv.contentTintColor = white.withAlphaComponent(0.88)
  298. iconRow.addArrangedSubview(iv)
  299. }
  300. let topRow = NSStackView()
  301. topRow.orientation = .horizontal
  302. topRow.spacing = 14
  303. topRow.alignment = .centerY
  304. topRow.translatesAutoresizingMaskIntoConstraints = false
  305. topRow.addArrangedSubview(textCol)
  306. topRow.addArrangedSubview(NSView())
  307. topRow.addArrangedSubview(iconRow)
  308. header.addSubview(topRow)
  309. NSLayoutConstraint.activate([
  310. topRow.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 18),
  311. topRow.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -18),
  312. topRow.topAnchor.constraint(equalTo: header.topAnchor, constant: 14),
  313. topRow.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -14)
  314. ])
  315. let body: NSView
  316. switch template.layout {
  317. case .singleColumn:
  318. body = modernMainContentColumn(compact: false, includeSummaryInMain: true)
  319. case .twoColumn(let side, let tinted):
  320. let main = modernMainContentColumn(compact: true, includeSummaryInMain: false)
  321. let sideCol = modernAboutHighlightsSidebar(tinted: tinted)
  322. let row = NSStackView()
  323. row.orientation = .horizontal
  324. row.spacing = 20
  325. row.alignment = .top
  326. row.translatesAutoresizingMaskIntoConstraints = false
  327. let mult: CGFloat = (variant % 4 == 2) ? 0.36 : 0.32
  328. if side == .leading {
  329. row.addArrangedSubview(sideCol)
  330. row.addArrangedSubview(main)
  331. sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
  332. } else {
  333. row.addArrangedSubview(main)
  334. row.addArrangedSubview(sideCol)
  335. sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
  336. }
  337. body = row
  338. }
  339. let wrap = NSStackView(views: [header, body])
  340. wrap.orientation = .vertical
  341. wrap.spacing = 18
  342. wrap.alignment = .leading
  343. return wrap
  344. }
  345. private func modernRailDocument() -> NSView {
  346. let theme = template.themeColor
  347. let rail = NSView()
  348. rail.translatesAutoresizingMaskIntoConstraints = false
  349. rail.wantsLayer = true
  350. rail.layer?.backgroundColor = theme.cgColor
  351. rail.layer?.cornerRadius = 2
  352. rail.widthAnchor.constraint(equalToConstant: 3 + CGFloat(variant % 2)).isActive = true
  353. let inner = NSStackView()
  354. inner.orientation = .vertical
  355. inner.spacing = 10
  356. inner.alignment = .leading
  357. inner.translatesAutoresizingMaskIntoConstraints = false
  358. let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
  359. let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
  360. let contactParts = [profile.personal.email, profile.personal.phone].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  361. let contactLine = contactParts.isEmpty ? "Add contact in your profile" : contactParts.joined(separator: " · ")
  362. inner.addArrangedSubview(label(nameText, font: .systemFont(ofSize: 21, weight: .bold), color: style.ink, maxLines: 2))
  363. inner.addArrangedSubview(label(roleText, font: .systemFont(ofSize: 14, weight: .semibold), color: theme, maxLines: 2))
  364. inner.addArrangedSubview(label(contactLine, font: style.contactFont, color: style.muted, maxLines: 2))
  365. inner.addArrangedSubview(hairline())
  366. inner.addArrangedSubview(skillTagRow(theme: theme, maxTags: 5))
  367. inner.addArrangedSubview(modernPrimaryBody(theme: theme))
  368. let row = NSStackView(views: [rail, inner])
  369. row.orientation = .horizontal
  370. row.spacing = 14
  371. row.alignment = .top
  372. return row
  373. }
  374. private func modernSplitHeaderDocument() -> NSView {
  375. let theme = template.themeColor
  376. let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
  377. let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
  378. let loc = profile.personal.address.trimmingCharacters(in: .whitespacesAndNewlines)
  379. let left = NSStackView()
  380. left.orientation = .vertical
  381. left.spacing = 5
  382. left.alignment = .leading
  383. left.addArrangedSubview(label(nameText, font: .systemFont(ofSize: 21, weight: .bold), color: style.ink, maxLines: 2))
  384. left.addArrangedSubview(label(roleText, font: .systemFont(ofSize: 13.5, weight: .medium), color: style.muted, maxLines: 2))
  385. if !loc.isEmpty {
  386. left.addArrangedSubview(label(loc, font: style.contactFont, color: style.muted.withAlphaComponent(0.88), maxLines: 2))
  387. }
  388. let right = NSStackView()
  389. right.orientation = .vertical
  390. right.spacing = 8
  391. right.alignment = .leading
  392. right.wantsLayer = true
  393. right.layer?.backgroundColor = theme.cgColor
  394. right.layer?.cornerRadius = 8
  395. right.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
  396. let onW = NSColor.white
  397. if !profile.personal.email.isEmpty {
  398. right.addArrangedSubview(label(profile.personal.email, font: .systemFont(ofSize: 12, weight: .medium), color: onW.withAlphaComponent(0.95), maxLines: 2))
  399. }
  400. if !profile.personal.phone.isEmpty {
  401. right.addArrangedSubview(label(profile.personal.phone, font: .systemFont(ofSize: 12, weight: .medium), color: onW.withAlphaComponent(0.92), maxLines: 1))
  402. }
  403. if !loc.isEmpty {
  404. right.addArrangedSubview(label(loc, font: .systemFont(ofSize: 11.5, weight: .regular), color: onW.withAlphaComponent(0.8), maxLines: 2))
  405. }
  406. let top = NSStackView(views: [left, right])
  407. top.orientation = .horizontal
  408. top.spacing = 16
  409. top.alignment = .top
  410. left.widthAnchor.constraint(equalTo: top.widthAnchor, multiplier: 0.54).isActive = true
  411. let col = NSStackView(views: [top, hairline(), modernPrimaryBody(theme: theme)])
  412. col.orientation = .vertical
  413. col.spacing = 16
  414. col.alignment = .leading
  415. return col
  416. }
  417. private func modernPrimaryBody(theme: NSColor) -> NSView {
  418. switch template.layout {
  419. case .singleColumn:
  420. return modernMainContentColumn(compact: false, includeSummaryInMain: true)
  421. case .twoColumn(let side, let tinted):
  422. let main = modernMainContentColumn(compact: true, includeSummaryInMain: false)
  423. let sideCol = modernAboutHighlightsSidebar(tinted: tinted)
  424. let row = NSStackView()
  425. row.orientation = .horizontal
  426. row.spacing = 20
  427. row.alignment = .top
  428. let mult: CGFloat = (variant % 4 == 2) ? 0.36 : 0.32
  429. if side == .leading {
  430. row.addArrangedSubview(sideCol)
  431. row.addArrangedSubview(main)
  432. sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
  433. } else {
  434. row.addArrangedSubview(main)
  435. row.addArrangedSubview(sideCol)
  436. sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
  437. }
  438. return row
  439. }
  440. }
  441. private func modernMainContentColumn(compact: Bool, includeSummaryInMain: Bool) -> NSView {
  442. let theme = template.themeColor
  443. let v = NSStackView()
  444. v.orientation = .vertical
  445. v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
  446. v.alignment = .leading
  447. if includeSummaryInMain, let summary = nonEmpty(profile.careerSummary) {
  448. v.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: "About", theme: theme))
  449. v.addArrangedSubview(paragraph(summary, compact: compact))
  450. }
  451. let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
  452. if !jobs.isEmpty {
  453. v.addArrangedSubview(modernSectionRow(symbol: "briefcase.fill", title: "Experience", theme: theme))
  454. for (index, job) in jobs.enumerated() {
  455. v.addArrangedSubview(experienceBlock(job: job, compact: compact))
  456. if index == 0 {
  457. v.addArrangedSubview(skillTagRow(theme: theme, maxTags: 5))
  458. }
  459. }
  460. }
  461. let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
  462. if !schools.isEmpty {
  463. v.addArrangedSubview(modernSectionRow(symbol: "graduationcap.fill", title: "Education", theme: theme))
  464. for edu in schools {
  465. v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
  466. }
  467. }
  468. appendCertificatesInterestsReferrals(to: v, compact: compact)
  469. return v
  470. }
  471. private func modernAboutHighlightsSidebar(tinted: Bool) -> NSView {
  472. let theme = template.themeColor
  473. let box = NSStackView()
  474. box.orientation = .vertical
  475. box.spacing = 12
  476. box.alignment = .leading
  477. if tinted {
  478. box.wantsLayer = true
  479. box.layer?.backgroundColor = theme.withAlphaComponent(0.1).cgColor
  480. box.layer?.cornerRadius = 8
  481. box.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
  482. }
  483. if let summary = nonEmpty(profile.careerSummary) {
  484. box.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: "About", theme: theme))
  485. box.addArrangedSubview(paragraph(summary, compact: true))
  486. }
  487. if let hi = highlightsBodyText() {
  488. box.addArrangedSubview(modernSectionRow(symbol: "star.fill", title: "Highlights", theme: theme))
  489. box.addArrangedSubview(paragraph(hi, compact: true))
  490. }
  491. if box.arrangedSubviews.isEmpty {
  492. box.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: "About", theme: theme))
  493. box.addArrangedSubview(paragraph("Add a career summary or interests in your profile to populate this column.", compact: true))
  494. }
  495. return box
  496. }
  497. private func modernSectionRow(symbol: String, title: String, theme: NSColor) -> NSView {
  498. guard let img = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) else {
  499. return sectionHeading(title)
  500. }
  501. let iv = NSImageView(image: img)
  502. iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  503. iv.contentTintColor = theme
  504. let t = label(title.uppercased(), font: style.sectionFont, color: style.sectionInk, maxLines: 1)
  505. let r = NSStackView(views: [iv, t])
  506. r.orientation = .horizontal
  507. r.spacing = 8
  508. r.alignment = .centerY
  509. return r
  510. }
  511. private func skillTagRow(theme: NSColor, maxTags: Int) -> NSView {
  512. let tags = skillTokensFromProfile(max: maxTags)
  513. guard !tags.isEmpty else { return NSView() }
  514. let row = NSStackView()
  515. row.orientation = .horizontal
  516. row.spacing = 8
  517. row.alignment = .centerY
  518. for s in tags {
  519. let tag = NSView()
  520. tag.wantsLayer = true
  521. tag.layer?.backgroundColor = theme.withAlphaComponent(0.14).cgColor
  522. tag.layer?.cornerRadius = 6
  523. tag.translatesAutoresizingMaskIntoConstraints = false
  524. let lab = label(s, font: .systemFont(ofSize: 11, weight: .semibold), color: theme.blended(withFraction: 0.35, of: style.ink) ?? style.ink, maxLines: 1)
  525. lab.alignment = .center
  526. lab.translatesAutoresizingMaskIntoConstraints = false
  527. tag.addSubview(lab)
  528. NSLayoutConstraint.activate([
  529. lab.leadingAnchor.constraint(equalTo: tag.leadingAnchor, constant: 10),
  530. lab.trailingAnchor.constraint(equalTo: tag.trailingAnchor, constant: -10),
  531. lab.topAnchor.constraint(equalTo: tag.topAnchor, constant: 5),
  532. lab.bottomAnchor.constraint(equalTo: tag.bottomAnchor, constant: -5)
  533. ])
  534. row.addArrangedSubview(tag)
  535. }
  536. return row
  537. }
  538. // MARK: - Creative (dark sidebar in gallery — match filled page)
  539. private func buildCreativeFamilyDocument() -> NSView {
  540. switch template.layout {
  541. case .singleColumn:
  542. return creativeSingleColumnDocument()
  543. case .twoColumn(let side, _):
  544. return creativeTwoColumnDocument(sidebar: side)
  545. }
  546. }
  547. private func creativeDeepBackground() -> NSColor {
  548. let theme = template.themeColor
  549. let navy = NSColor(srgbRed: 0.08, green: 0.1, blue: 0.18, alpha: 1)
  550. let plum = NSColor(srgbRed: 0.14, green: 0.07, blue: 0.24, alpha: 1)
  551. switch variant % 4 {
  552. case 0: return theme.blended(withFraction: 0.52, of: navy) ?? theme
  553. case 1: return theme.blended(withFraction: 0.7, of: NSColor.black) ?? theme
  554. case 2: return style.ink.blended(withFraction: 0.38, of: theme) ?? theme
  555. default: return theme.blended(withFraction: 0.4, of: plum) ?? theme
  556. }
  557. }
  558. private func creativeSingleColumnDocument() -> NSView {
  559. let theme = template.themeColor
  560. let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
  561. let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
  562. let banner = NSView()
  563. banner.translatesAutoresizingMaskIntoConstraints = false
  564. banner.wantsLayer = true
  565. banner.layer?.backgroundColor = theme.cgColor
  566. banner.layer?.cornerRadius = variant % 4 == 1 ? 8 : 6
  567. let inner = label(" \(nameText) · \(roleText) ", font: .systemFont(ofSize: 14, weight: .bold), color: .white, maxLines: 2)
  568. inner.translatesAutoresizingMaskIntoConstraints = false
  569. banner.addSubview(inner)
  570. NSLayoutConstraint.activate([
  571. inner.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 14),
  572. inner.trailingAnchor.constraint(lessThanOrEqualTo: banner.trailingAnchor, constant: -14),
  573. inner.topAnchor.constraint(equalTo: banner.topAnchor, constant: 12),
  574. inner.bottomAnchor.constraint(equalTo: banner.bottomAnchor, constant: -12)
  575. ])
  576. let main = creativeMainStack(theme: theme)
  577. let col = NSStackView(views: [banner, main])
  578. col.orientation = .vertical
  579. col.spacing = 16
  580. col.alignment = .leading
  581. return col
  582. }
  583. private func creativeTwoColumnDocument(sidebar: CVTemplate.SidebarSide) -> NSView {
  584. let theme = template.themeColor
  585. let deep = creativeDeepBackground()
  586. let onSidebar = NSColor.white.withAlphaComponent(0.95)
  587. let skillPrefix = (variant % 3 == 0) ? "• " : "▸ "
  588. let sidebarStack = NSStackView()
  589. sidebarStack.orientation = .vertical
  590. sidebarStack.spacing = 12
  591. sidebarStack.alignment = .leading
  592. sidebarStack.wantsLayer = true
  593. sidebarStack.layer?.backgroundColor = deep.cgColor
  594. sidebarStack.layer?.cornerRadius = variant % 2 == 0 ? 10 : 8
  595. sidebarStack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
  596. let nm = displayable(profile.personal.fullName, placeholder: "Your name")
  597. let role = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
  598. sidebarStack.addArrangedSubview(label(nm, font: .systemFont(ofSize: 18, weight: .bold), color: onSidebar, maxLines: 2))
  599. sidebarStack.addArrangedSubview(label(role, font: .systemFont(ofSize: 13, weight: .medium), color: onSidebar.withAlphaComponent(0.85), maxLines: 2))
  600. if !profile.personal.email.isEmpty {
  601. sidebarStack.addArrangedSubview(label(profile.personal.email, font: .systemFont(ofSize: 11.5), color: onSidebar.withAlphaComponent(0.82), maxLines: 2))
  602. }
  603. if !profile.personal.phone.isEmpty {
  604. sidebarStack.addArrangedSubview(label(profile.personal.phone, font: .systemFont(ofSize: 11.5), color: onSidebar.withAlphaComponent(0.82), maxLines: 1))
  605. }
  606. sidebarStack.addArrangedSubview(creativeSidebarHeading("STRENGTHS", onSidebar: onSidebar, accent: theme))
  607. for token in skillTokensFromProfile(max: 8) {
  608. sidebarStack.addArrangedSubview(label("\(skillPrefix)\(token)", font: .systemFont(ofSize: 12, weight: .semibold), color: onSidebar.withAlphaComponent(0.92), maxLines: 2))
  609. }
  610. let main = creativeMainStack(theme: theme)
  611. let row = NSStackView()
  612. row.orientation = .horizontal
  613. row.spacing = 18
  614. row.alignment = .top
  615. let sidebarMult = 0.32 + CGFloat(variant % 3) * 0.02
  616. if sidebar == .leading {
  617. row.addArrangedSubview(sidebarStack)
  618. row.addArrangedSubview(main)
  619. sidebarStack.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
  620. } else {
  621. row.addArrangedSubview(main)
  622. row.addArrangedSubview(sidebarStack)
  623. sidebarStack.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
  624. }
  625. return row
  626. }
  627. private func creativeSidebarHeading(_ raw: String, onSidebar: NSColor, accent: NSColor) -> NSView {
  628. let t = label(raw, font: .systemFont(ofSize: 10.5, weight: .heavy), color: onSidebar, maxLines: 1)
  629. let bar = NSView()
  630. bar.translatesAutoresizingMaskIntoConstraints = false
  631. bar.wantsLayer = true
  632. bar.layer?.backgroundColor = accent.cgColor
  633. bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
  634. let c = NSStackView(views: [t, bar])
  635. c.orientation = .vertical
  636. c.spacing = 4
  637. c.alignment = .leading
  638. bar.leadingAnchor.constraint(equalTo: t.leadingAnchor).isActive = true
  639. bar.widthAnchor.constraint(equalToConstant: 72).isActive = true
  640. return c
  641. }
  642. private func creativeMainHeader(theme: NSColor) -> NSView {
  643. let v = NSView()
  644. v.translatesAutoresizingMaskIntoConstraints = false
  645. let stripe = NSView()
  646. stripe.translatesAutoresizingMaskIntoConstraints = false
  647. stripe.wantsLayer = true
  648. stripe.layer?.backgroundColor = theme.cgColor
  649. v.addSubview(stripe)
  650. let row = NSStackView()
  651. row.orientation = .horizontal
  652. row.spacing = 8
  653. row.translatesAutoresizingMaskIntoConstraints = false
  654. let lab = label("PORTFOLIO SNAPSHOT", font: .systemFont(ofSize: 12, weight: .heavy), color: style.ink, maxLines: 1)
  655. row.addArrangedSubview(stripe)
  656. row.addArrangedSubview(lab)
  657. v.addSubview(row)
  658. NSLayoutConstraint.activate([
  659. stripe.widthAnchor.constraint(equalToConstant: 4),
  660. stripe.heightAnchor.constraint(equalToConstant: 18),
  661. row.leadingAnchor.constraint(equalTo: v.leadingAnchor),
  662. row.topAnchor.constraint(equalTo: v.topAnchor),
  663. row.bottomAnchor.constraint(equalTo: v.bottomAnchor)
  664. ])
  665. return v
  666. }
  667. private func creativeMainStack(theme: NSColor) -> NSView {
  668. let stack = NSStackView()
  669. stack.orientation = .vertical
  670. stack.spacing = style.bodyBlockSpacing
  671. stack.alignment = .leading
  672. stack.addArrangedSubview(creativeMainHeader(theme: theme))
  673. if let summary = nonEmpty(profile.careerSummary) {
  674. stack.addArrangedSubview(sectionHeading("Profile"))
  675. stack.addArrangedSubview(paragraph(summary, compact: false))
  676. }
  677. let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
  678. if !jobs.isEmpty {
  679. stack.addArrangedSubview(sectionHeading("Impact"))
  680. for job in jobs {
  681. let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
  682. if !titleLine.isEmpty {
  683. stack.addArrangedSubview(label(titleLine, font: .systemFont(ofSize: 13.5, weight: .heavy), color: style.ink, maxLines: 0))
  684. }
  685. for bullet in Self.bulletChunks(from: job.description) {
  686. let mark = (variant % 2 == 0) ? "— " : "▸ "
  687. stack.addArrangedSubview(label("\(mark)\(bullet)", font: style.bodyFont, color: style.muted, maxLines: 0))
  688. }
  689. }
  690. }
  691. let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
  692. if !schools.isEmpty {
  693. stack.addArrangedSubview(sectionHeading("Education"))
  694. for edu in schools {
  695. stack.addArrangedSubview(educationBlock(edu: edu, compact: false))
  696. }
  697. }
  698. appendCertificatesInterestsReferrals(to: stack, compact: false)
  699. return stack
  700. }
  701. // MARK: - Traditional families (professional / minimal / executive)
  702. private func buildTraditionalFamilyDocument() -> NSView {
  703. switch template.layout {
  704. case .singleColumn:
  705. return singleColumnLayout()
  706. case .twoColumn(let side, let tinted):
  707. return twoColumnLayout(sidebar: side, tinted: tinted)
  708. }
  709. }
  710. private func singleColumnLayout() -> NSView {
  711. let v = NSStackView()
  712. v.orientation = .vertical
  713. v.alignment = .leading
  714. v.spacing = style.columnVerticalSpacing + 3
  715. v.addArrangedSubview(headerBlock())
  716. if template.family == .professional && (variant % 6) == 4 {
  717. v.addArrangedSubview(professionalInlineSkillsRow())
  718. }
  719. v.addArrangedSubview(hairline())
  720. let body = bodyColumn(compact: false, experienceFirst: professionalExperienceFirst)
  721. v.addArrangedSubview(usesProfessionalSingleColumnRail ? bodyWithLeadingAccentRail(body) : body)
  722. return v
  723. }
  724. private var professionalExperienceFirst: Bool {
  725. template.family == .professional && (variant % 3) == 1
  726. }
  727. private func professionalInlineSkillsRow() -> NSView {
  728. let tokens = skillTokensFromProfile(max: 6)
  729. guard !tokens.isEmpty else { return NSView() }
  730. let joined = tokens.joined(separator: " · ")
  731. return label(joined, font: .systemFont(ofSize: 11.5, weight: .medium), color: template.themeColor, maxLines: 0)
  732. }
  733. /// Matches the CV Maker thumbnail: professional ATS single-column layouts use a full-height theme rail.
  734. private var usesProfessionalSingleColumnRail: Bool {
  735. if case .singleColumn = template.layout, template.family == .professional { return true }
  736. return false
  737. }
  738. private func bodyWithLeadingAccentRail(_ content: NSView) -> NSView {
  739. let wrap = NSView()
  740. wrap.translatesAutoresizingMaskIntoConstraints = false
  741. let rail = NSView()
  742. rail.translatesAutoresizingMaskIntoConstraints = false
  743. rail.wantsLayer = true
  744. rail.layer?.backgroundColor = template.themeColor.cgColor
  745. rail.layer?.cornerRadius = 1
  746. content.translatesAutoresizingMaskIntoConstraints = false
  747. wrap.addSubview(rail)
  748. wrap.addSubview(content)
  749. NSLayoutConstraint.activate([
  750. rail.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
  751. rail.topAnchor.constraint(equalTo: content.topAnchor),
  752. rail.bottomAnchor.constraint(equalTo: content.bottomAnchor),
  753. rail.widthAnchor.constraint(equalToConstant: 3),
  754. content.leadingAnchor.constraint(equalTo: rail.trailingAnchor, constant: 12),
  755. content.trailingAnchor.constraint(equalTo: wrap.trailingAnchor),
  756. content.topAnchor.constraint(equalTo: wrap.topAnchor),
  757. content.bottomAnchor.constraint(equalTo: wrap.bottomAnchor)
  758. ])
  759. return wrap
  760. }
  761. private func twoColumnLayout(sidebar: CVTemplate.SidebarSide, tinted: Bool) -> NSView {
  762. let v = NSStackView()
  763. v.orientation = .vertical
  764. v.alignment = .leading
  765. v.spacing = style.columnVerticalSpacing + 2
  766. v.addArrangedSubview(headerBlock())
  767. v.addArrangedSubview(hairline())
  768. let row = NSStackView()
  769. row.orientation = .horizontal
  770. row.alignment = .top
  771. row.spacing = template.family == .minimal ? 18 : 22
  772. let sidebarCol = sidebarColumn(tinted: tinted)
  773. let mainCol = bodyColumn(compact: true, experienceFirst: professionalExperienceFirst)
  774. if sidebar == .leading {
  775. row.addArrangedSubview(sidebarCol)
  776. row.addArrangedSubview(mainCol)
  777. } else {
  778. row.addArrangedSubview(mainCol)
  779. row.addArrangedSubview(sidebarCol)
  780. }
  781. let sidebarMult: CGFloat
  782. if template.family == .professional {
  783. sidebarMult = (variant % 5 == 2) ? 0.38 : 0.32
  784. } else {
  785. sidebarMult = template.family == .executive ? 0.34 : 0.32
  786. }
  787. sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
  788. v.addArrangedSubview(row)
  789. return v
  790. }
  791. private func headerBlock() -> NSView {
  792. let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
  793. let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
  794. let contactParts = [profile.personal.email, profile.personal.phone, profile.personal.address].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  795. let contactText = contactParts.isEmpty ? "Add contact details in your profile" : contactParts.joined(separator: " · ")
  796. let roleColor = style.roleUsesThemeColor ? template.themeColor : style.muted
  797. let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2)
  798. let role = label(roleText, font: style.roleFont, color: roleColor, maxLines: 2)
  799. let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.92), maxLines: 3)
  800. let textCol = NSStackView(views: [name, role, contact])
  801. textCol.orientation = .vertical
  802. textCol.spacing = template.family == .professional ? 3 : 4
  803. textCol.alignment = .leading
  804. switch template.headline {
  805. case .centered:
  806. textCol.alignment = .centerX
  807. name.alignment = .center
  808. role.alignment = .center
  809. contact.alignment = .center
  810. let accent = headlineAccent()
  811. let stack = NSStackView(views: [textCol, accent])
  812. stack.orientation = .vertical
  813. stack.spacing = 8
  814. stack.alignment = .centerX
  815. return stack
  816. case .avatarStacked:
  817. textCol.alignment = .centerX
  818. name.alignment = .center
  819. role.alignment = .center
  820. contact.alignment = .center
  821. let accent = headlineAccent()
  822. let avatar = initialsBadge(for: nameText)
  823. let stack = NSStackView(views: [avatar, textCol, accent])
  824. stack.orientation = .vertical
  825. stack.spacing = 8
  826. stack.alignment = .centerX
  827. return stack
  828. case .leftAligned, .leftWithInitials:
  829. let row = NSStackView()
  830. row.orientation = .horizontal
  831. row.spacing = 14
  832. row.alignment = .centerY
  833. row.addArrangedSubview(textCol)
  834. if template.headline == .leftWithInitials {
  835. row.addArrangedSubview(NSView())
  836. row.addArrangedSubview(initialsBadge(for: nameText))
  837. }
  838. let col = NSStackView(views: [row, headlineAccent()])
  839. col.orientation = .vertical
  840. col.spacing = 8
  841. col.alignment = .leading
  842. return col
  843. }
  844. }
  845. private func initialsBadge(for fullName: String) -> NSView {
  846. let initials = Self.initials(from: fullName)
  847. let t = NSTextField(labelWithString: initials)
  848. t.font = .systemFont(ofSize: 13, weight: .bold)
  849. t.textColor = template.themeColor
  850. t.alignment = .center
  851. t.translatesAutoresizingMaskIntoConstraints = false
  852. let wrap = NSView()
  853. wrap.translatesAutoresizingMaskIntoConstraints = false
  854. wrap.wantsLayer = true
  855. wrap.layer?.cornerRadius = 22
  856. wrap.layer?.borderWidth = 1.5
  857. wrap.layer?.borderColor = template.themeColor.withAlphaComponent(0.35).cgColor
  858. wrap.addSubview(t)
  859. NSLayoutConstraint.activate([
  860. wrap.widthAnchor.constraint(equalToConstant: 44),
  861. wrap.heightAnchor.constraint(equalToConstant: 44),
  862. t.centerXAnchor.constraint(equalTo: wrap.centerXAnchor),
  863. t.centerYAnchor.constraint(equalTo: wrap.centerYAnchor)
  864. ])
  865. return wrap
  866. }
  867. private static func initials(from fullName: String) -> String {
  868. let parts = fullName.split(separator: " ").filter { !$0.isEmpty }
  869. if parts.count >= 2 {
  870. let a = parts[0].prefix(1)
  871. let b = parts[1].prefix(1)
  872. return "\(a)\(b)".uppercased()
  873. }
  874. if let first = parts.first { return String(first.prefix(2)).uppercased() }
  875. return "CV"
  876. }
  877. private func headlineAccent() -> NSView {
  878. let bar = NSView()
  879. bar.translatesAutoresizingMaskIntoConstraints = false
  880. bar.wantsLayer = true
  881. switch template.accent {
  882. case .none:
  883. bar.heightAnchor.constraint(equalToConstant: 1).isActive = true
  884. return bar
  885. case .redUnderline:
  886. bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
  887. bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
  888. bar.widthAnchor.constraint(equalToConstant: template.family == .minimal ? 140 : 168).isActive = true
  889. return bar
  890. case .redBar:
  891. bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
  892. bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
  893. bar.widthAnchor.constraint(equalToConstant: 56).isActive = true
  894. return bar
  895. case .blueBar:
  896. bar.layer?.backgroundColor = template.themeColor.cgColor
  897. if template.headline == .centered {
  898. bar.heightAnchor.constraint(equalToConstant: 2.5).isActive = true
  899. bar.widthAnchor.constraint(equalToConstant: 148).isActive = true
  900. } else {
  901. bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
  902. bar.widthAnchor.constraint(equalToConstant: template.family == .executive ? 100 : 120).isActive = true
  903. }
  904. return bar
  905. }
  906. }
  907. private func hairline() -> NSView {
  908. let v = NSView()
  909. v.translatesAutoresizingMaskIntoConstraints = false
  910. v.wantsLayer = true
  911. v.layer?.backgroundColor = style.rule.cgColor
  912. let h: CGFloat = template.family == .executive ? 1.5 : 1
  913. v.heightAnchor.constraint(equalToConstant: h).isActive = true
  914. return v
  915. }
  916. private func sidebarColumn(tinted: Bool) -> NSView {
  917. let box = NSStackView()
  918. box.orientation = .vertical
  919. box.spacing = 12
  920. box.alignment = .leading
  921. if tinted {
  922. box.wantsLayer = true
  923. box.layer?.backgroundColor = template.themeColor.withAlphaComponent(template.family == .creative ? 0.12 : 0.08).cgColor
  924. box.layer?.cornerRadius = 8
  925. }
  926. box.edgeInsets = NSEdgeInsets(top: tinted ? 14 : 0, left: tinted ? 14 : 0, bottom: tinted ? 14 : 0, right: tinted ? 14 : 0)
  927. box.addArrangedSubview(sectionHeading("Contact"))
  928. for line in contactLines() {
  929. box.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
  930. }
  931. if let skillsBlock = ancillaryBlock(title: "Languages & more", body: combinedAncillaryText()) {
  932. box.addArrangedSubview(skillsBlock)
  933. }
  934. return box
  935. }
  936. private func bodyColumn(compact: Bool, experienceFirst: Bool = false) -> NSView {
  937. let v = NSStackView()
  938. v.orientation = .vertical
  939. v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
  940. v.alignment = .leading
  941. let summaryTitle = sectionHeading(summarySectionTitle)
  942. let summaryBody: NSView? = nonEmpty(profile.careerSummary).map { paragraph($0, compact: compact) }
  943. let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
  944. let experienceHeading = sectionHeading("Experience")
  945. var experienceBlocks: [NSView] = []
  946. for job in jobs {
  947. experienceBlocks.append(experienceBlock(job: job, compact: compact))
  948. }
  949. let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
  950. var educationBlocks: [NSView] = []
  951. for edu in schools {
  952. educationBlocks.append(educationBlock(edu: edu, compact: compact))
  953. }
  954. let appendSummary: () -> Void = { [self] in
  955. if let body = summaryBody {
  956. v.addArrangedSubview(summaryTitle)
  957. v.addArrangedSubview(body)
  958. }
  959. }
  960. let appendExperience: () -> Void = { [self] in
  961. if !jobs.isEmpty {
  962. v.addArrangedSubview(experienceHeading)
  963. experienceBlocks.forEach { v.addArrangedSubview($0) }
  964. }
  965. }
  966. let appendEducation: () -> Void = { [self] in
  967. if !schools.isEmpty {
  968. v.addArrangedSubview(sectionHeading("Education"))
  969. educationBlocks.forEach { v.addArrangedSubview($0) }
  970. }
  971. }
  972. if experienceFirst {
  973. appendExperience()
  974. appendSummary()
  975. appendEducation()
  976. } else {
  977. appendSummary()
  978. appendExperience()
  979. appendEducation()
  980. }
  981. appendCertificatesInterestsReferrals(to: v, compact: compact)
  982. return v
  983. }
  984. private func appendCertificatesInterestsReferrals(to v: NSStackView, compact: Bool) {
  985. if let cert = nonEmpty(profile.certificates) {
  986. v.addArrangedSubview(sectionHeading("Certificates"))
  987. v.addArrangedSubview(paragraph(cert, compact: compact))
  988. }
  989. if let interests = nonEmpty(profile.interests) {
  990. v.addArrangedSubview(sectionHeading("Interests"))
  991. v.addArrangedSubview(paragraph(interests, compact: compact))
  992. }
  993. if let ref = nonEmpty(profile.referral) {
  994. v.addArrangedSubview(sectionHeading("Referrals"))
  995. v.addArrangedSubview(paragraph(ref, compact: compact))
  996. }
  997. }
  998. private func skillTokensFromProfile(max: Int) -> [String] {
  999. let raw = profile.languages.trimmingCharacters(in: .whitespacesAndNewlines)
  1000. if raw.isEmpty { return [] }
  1001. let parts = raw.split(whereSeparator: { $0 == "," || $0 == "·" || $0 == "|" || $0 == ";" })
  1002. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  1003. .filter { !$0.isEmpty }
  1004. if parts.count > 1 { return Array(parts.prefix(max)) }
  1005. return raw.split(separator: " ").map(String.init).filter { $0.count > 1 }.prefix(max).map { String($0) }
  1006. }
  1007. private func highlightsBodyText() -> String? {
  1008. if let t = nonEmpty(profile.interests) { return t }
  1009. if let r = nonEmpty(profile.referral) { return r }
  1010. let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
  1011. if let first = jobs.first {
  1012. let bullets = Self.bulletChunks(from: first.description)
  1013. if let b = bullets.first { return b }
  1014. }
  1015. return nil
  1016. }
  1017. private func ancillaryBlock(title: String, body: String?) -> NSStackView? {
  1018. guard let body, !body.isEmpty else { return nil }
  1019. let s = NSStackView()
  1020. s.orientation = .vertical
  1021. s.spacing = 6
  1022. s.alignment = .leading
  1023. s.addArrangedSubview(sectionHeading(title))
  1024. s.addArrangedSubview(paragraph(body, compact: true))
  1025. return s
  1026. }
  1027. private func contactLines() -> [String] {
  1028. var lines: [String] = []
  1029. let p = profile.personal
  1030. if !p.email.isEmpty { lines.append(p.email) }
  1031. if !p.phone.isEmpty { lines.append(p.phone) }
  1032. if !p.address.isEmpty { lines.append(p.address) }
  1033. return lines.isEmpty ? ["—"] : lines
  1034. }
  1035. private func combinedAncillaryText() -> String? {
  1036. let chunks = [profile.languages, profile.interests].map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
  1037. return chunks.isEmpty ? nil : chunks.joined(separator: "\n\n")
  1038. }
  1039. private func experienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
  1040. let v = NSStackView()
  1041. v.orientation = .vertical
  1042. v.spacing = template.family == .professional ? 4 : 6
  1043. v.alignment = .leading
  1044. if template.family == .professional {
  1045. let title = job.jobTitle.trimmingCharacters(in: .whitespacesAndNewlines)
  1046. let company = job.company.trimmingCharacters(in: .whitespacesAndNewlines)
  1047. let duration = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
  1048. if !title.isEmpty {
  1049. v.addArrangedSubview(label(title, font: style.expTitleFont, color: style.ink, maxLines: 0))
  1050. }
  1051. let metaParts = [company, duration].filter { !$0.isEmpty }
  1052. if !metaParts.isEmpty {
  1053. let metaJoined = metaParts.joined(separator: " · ")
  1054. v.addArrangedSubview(label(metaJoined, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
  1055. } else if title.isEmpty {
  1056. let fallback = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
  1057. if !fallback.isEmpty {
  1058. v.addArrangedSubview(label(fallback, font: style.expTitleFont, color: style.ink, maxLines: 0))
  1059. }
  1060. }
  1061. } else {
  1062. let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
  1063. let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
  1064. if !titleLine.isEmpty {
  1065. v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
  1066. }
  1067. if !meta.isEmpty {
  1068. v.addArrangedSubview(label(meta, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
  1069. }
  1070. }
  1071. for bullet in Self.bulletChunks(from: job.description) {
  1072. v.addArrangedSubview(bulletRow(bullet, compact: compact))
  1073. }
  1074. return v
  1075. }
  1076. private func educationBlock(edu: EducationPayload, compact: Bool) -> NSView {
  1077. let v = NSStackView()
  1078. v.orientation = .vertical
  1079. v.spacing = 4
  1080. v.alignment = .leading
  1081. let institution = edu.institution.trimmingCharacters(in: .whitespacesAndNewlines)
  1082. let degree = edu.degree.trimmingCharacters(in: .whitespacesAndNewlines)
  1083. let year = edu.year.trimmingCharacters(in: .whitespacesAndNewlines)
  1084. if template.family == .professional {
  1085. if !institution.isEmpty {
  1086. v.addArrangedSubview(label(institution, font: style.eduTitleFont, color: style.ink, maxLines: 0))
  1087. }
  1088. let subParts = [degree, year].filter { !$0.isEmpty }
  1089. if !subParts.isEmpty {
  1090. let sub = subParts.joined(separator: " · ")
  1091. v.addArrangedSubview(label(sub, font: style.eduMetaFont, color: style.muted, maxLines: 0))
  1092. }
  1093. } else {
  1094. let head = [edu.institution, edu.degree].filter { !$0.isEmpty }.joined(separator: " — ")
  1095. if !head.isEmpty {
  1096. v.addArrangedSubview(label(head, font: style.eduTitleFont, color: style.ink, maxLines: 0))
  1097. }
  1098. if !edu.year.isEmpty {
  1099. v.addArrangedSubview(label(edu.year, font: style.eduMetaFont, color: style.muted, maxLines: 0))
  1100. }
  1101. }
  1102. return v
  1103. }
  1104. private func bulletRow(_ text: String, compact: Bool) -> NSView {
  1105. let marker: String = template.family == .minimal ? "·" : "•"
  1106. let dot = NSTextField(labelWithString: marker)
  1107. dot.font = style.bulletMarkerFont
  1108. dot.textColor = style.bulletMarkerColor
  1109. dot.translatesAutoresizingMaskIntoConstraints = false
  1110. let bodyFont = compact ? style.bodyCompactFont : style.bulletBodyFont
  1111. let body = label(text, font: bodyFont, color: style.ink, maxLines: 0)
  1112. let row = NSStackView(views: [dot, body])
  1113. row.orientation = .horizontal
  1114. row.spacing = template.family == .creative ? 10 : 8
  1115. row.alignment = .top
  1116. dot.setContentHuggingPriority(.required, for: .horizontal)
  1117. return row
  1118. }
  1119. private func paragraph(_ text: String, compact: Bool) -> NSTextField {
  1120. let font = compact ? style.bodyCompactFont : style.bodyFont
  1121. return label(text, font: font, color: style.ink, maxLines: 0)
  1122. }
  1123. /// Gallery + ATS “Clear Path” style use “Profile”; other families keep the neutral résumé label.
  1124. private var summarySectionTitle: String {
  1125. template.family == .professional ? "Profile" : "Summary"
  1126. }
  1127. private func sectionHeading(_ raw: String) -> NSTextField {
  1128. let upper = raw.uppercased()
  1129. let s: String
  1130. switch template.sectionLabelStyle {
  1131. case .uppercase: s = upper
  1132. case .slashed: s = "// \(upper)"
  1133. case .bracketed: s = "[ \(upper) ]"
  1134. }
  1135. let t = NSTextField(labelWithString: s)
  1136. t.font = style.sectionFont
  1137. t.textColor = style.sectionInk
  1138. t.alignment = .left
  1139. return t
  1140. }
  1141. private func label(_ string: String, font: NSFont, color: NSColor, maxLines: Int) -> NSTextField {
  1142. let isWrapping = maxLines == 0
  1143. let t: NSTextField
  1144. if isWrapping {
  1145. t = NSTextField(wrappingLabelWithString: string)
  1146. t.maximumNumberOfLines = 0
  1147. } else {
  1148. t = NSTextField(labelWithString: string)
  1149. t.maximumNumberOfLines = maxLines
  1150. }
  1151. t.font = font
  1152. t.textColor = color
  1153. t.alignment = .left
  1154. return t
  1155. }
  1156. private func displayable(_ value: String, placeholder: String) -> String {
  1157. let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
  1158. return t.isEmpty ? placeholder : t
  1159. }
  1160. private func nonEmpty(_ value: String) -> String? {
  1161. let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
  1162. return t.isEmpty ? nil : t
  1163. }
  1164. private static func bulletChunks(from text: String) -> [String] {
  1165. let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
  1166. if trimmed.isEmpty { return [] }
  1167. let byNewline = trimmed.components(separatedBy: .newlines)
  1168. .map { $0.trimmingCharacters(in: .whitespaces) }
  1169. .filter { !$0.isEmpty }
  1170. if byNewline.count > 1 { return byNewline }
  1171. let byBullet = trimmed.split(separator: "•")
  1172. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  1173. .filter { !$0.isEmpty }
  1174. if byBullet.count > 1 { return byBullet.map { String($0) } }
  1175. return [trimmed]
  1176. }
  1177. }
  1178. // MARK: - Payload helpers
  1179. private extension WorkExperiencePayload {
  1180. var isEffectivelyEmpty: Bool {
  1181. [jobTitle, company, duration, description].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  1182. }
  1183. }
  1184. private extension EducationPayload {
  1185. var isEffectivelyEmpty: Bool {
  1186. [degree, institution, year].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  1187. }
  1188. }