Geen omschrijving

CVProfileDocumentView.swift 60KB

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