No Description

CVProfileDocumentView.swift 28KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  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. private let profile: SavedProfile
  175. private let template: CVTemplate
  176. private let style: DocumentStyle
  177. init(profile: SavedProfile, template: CVTemplate) {
  178. self.profile = profile
  179. self.template = template
  180. self.style = DocumentStyle.make(for: template)
  181. super.init(frame: .zero)
  182. translatesAutoresizingMaskIntoConstraints = false
  183. wantsLayer = true
  184. layer?.backgroundColor = NSColor.clear.cgColor
  185. userInterfaceLayoutDirection = .leftToRight
  186. let card = NSView()
  187. card.translatesAutoresizingMaskIntoConstraints = false
  188. card.wantsLayer = true
  189. card.layer?.backgroundColor = style.cardBackground.cgColor
  190. card.layer?.cornerRadius = template.family == .executive ? 6 : 10
  191. card.layer?.borderWidth = 1
  192. card.layer?.borderColor = style.rule.cgColor
  193. card.layer?.masksToBounds = true
  194. let root = buildRoot()
  195. root.translatesAutoresizingMaskIntoConstraints = false
  196. card.addSubview(root)
  197. addSubview(card)
  198. NSLayoutConstraint.activate([
  199. card.leadingAnchor.constraint(equalTo: leadingAnchor),
  200. card.trailingAnchor.constraint(equalTo: trailingAnchor),
  201. card.topAnchor.constraint(equalTo: topAnchor),
  202. card.bottomAnchor.constraint(equalTo: bottomAnchor),
  203. card.widthAnchor.constraint(equalToConstant: 640),
  204. root.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 36),
  205. root.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -36),
  206. root.topAnchor.constraint(equalTo: card.topAnchor, constant: 32),
  207. root.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -36)
  208. ])
  209. }
  210. @available(*, unavailable)
  211. required init?(coder: NSCoder) {
  212. fatalError("init(coder:) has not been implemented")
  213. }
  214. // MARK: - Composition
  215. private func buildRoot() -> NSView {
  216. switch template.layout {
  217. case .singleColumn:
  218. return singleColumnLayout()
  219. case .twoColumn(let side, let tinted):
  220. return twoColumnLayout(sidebar: side, tinted: tinted)
  221. }
  222. }
  223. private func singleColumnLayout() -> NSView {
  224. let v = NSStackView()
  225. v.orientation = .vertical
  226. v.alignment = .leading
  227. v.spacing = style.columnVerticalSpacing + 3
  228. v.addArrangedSubview(headerBlock())
  229. v.addArrangedSubview(hairline())
  230. v.addArrangedSubview(bodyColumn(compact: false))
  231. return v
  232. }
  233. private func twoColumnLayout(sidebar: CVTemplate.SidebarSide, tinted: Bool) -> NSView {
  234. let v = NSStackView()
  235. v.orientation = .vertical
  236. v.alignment = .leading
  237. v.spacing = style.columnVerticalSpacing + 2
  238. v.addArrangedSubview(headerBlock())
  239. v.addArrangedSubview(hairline())
  240. let row = NSStackView()
  241. row.orientation = .horizontal
  242. row.alignment = .top
  243. row.spacing = template.family == .minimal ? 18 : 22
  244. let sidebarCol = sidebarColumn(tinted: tinted)
  245. let mainCol = bodyColumn(compact: true)
  246. if sidebar == .leading {
  247. row.addArrangedSubview(sidebarCol)
  248. row.addArrangedSubview(mainCol)
  249. } else {
  250. row.addArrangedSubview(mainCol)
  251. row.addArrangedSubview(sidebarCol)
  252. }
  253. sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: template.family == .executive ? 0.34 : 0.32).isActive = true
  254. v.addArrangedSubview(row)
  255. return v
  256. }
  257. private func headerBlock() -> NSView {
  258. let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
  259. let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
  260. let contactParts = [profile.personal.email, profile.personal.phone, profile.personal.address].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  261. let contactText = contactParts.isEmpty ? "Add contact details in your profile" : contactParts.joined(separator: " · ")
  262. let roleColor = style.roleUsesThemeColor ? template.themeColor : style.muted
  263. let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2)
  264. let role = label(roleText, font: style.roleFont, color: roleColor, maxLines: 2)
  265. let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.92), maxLines: 3)
  266. let textCol = NSStackView(views: [name, role, contact])
  267. textCol.orientation = .vertical
  268. textCol.spacing = template.family == .professional ? 3 : 4
  269. textCol.alignment = .leading
  270. switch template.headline {
  271. case .centered:
  272. textCol.alignment = .centerX
  273. name.alignment = .center
  274. role.alignment = .center
  275. contact.alignment = .center
  276. let accent = headlineAccent()
  277. let stack = NSStackView(views: [textCol, accent])
  278. stack.orientation = .vertical
  279. stack.spacing = 8
  280. stack.alignment = .centerX
  281. return stack
  282. case .avatarStacked:
  283. textCol.alignment = .centerX
  284. name.alignment = .center
  285. role.alignment = .center
  286. contact.alignment = .center
  287. let accent = headlineAccent()
  288. let avatar = initialsBadge(for: nameText)
  289. let stack = NSStackView(views: [avatar, textCol, accent])
  290. stack.orientation = .vertical
  291. stack.spacing = 8
  292. stack.alignment = .centerX
  293. return stack
  294. case .leftAligned, .leftWithInitials:
  295. let row = NSStackView()
  296. row.orientation = .horizontal
  297. row.spacing = 14
  298. row.alignment = .centerY
  299. row.addArrangedSubview(textCol)
  300. if template.headline == .leftWithInitials {
  301. row.addArrangedSubview(NSView())
  302. row.addArrangedSubview(initialsBadge(for: nameText))
  303. }
  304. let col = NSStackView(views: [row, headlineAccent()])
  305. col.orientation = .vertical
  306. col.spacing = 8
  307. col.alignment = .leading
  308. return col
  309. }
  310. }
  311. private func initialsBadge(for fullName: String) -> NSView {
  312. let initials = Self.initials(from: fullName)
  313. let t = NSTextField(labelWithString: initials)
  314. t.font = .systemFont(ofSize: 13, weight: .bold)
  315. t.textColor = template.themeColor
  316. t.alignment = .center
  317. t.translatesAutoresizingMaskIntoConstraints = false
  318. let wrap = NSView()
  319. wrap.translatesAutoresizingMaskIntoConstraints = false
  320. wrap.wantsLayer = true
  321. wrap.layer?.cornerRadius = 22
  322. wrap.layer?.borderWidth = 1.5
  323. wrap.layer?.borderColor = template.themeColor.withAlphaComponent(0.35).cgColor
  324. wrap.addSubview(t)
  325. NSLayoutConstraint.activate([
  326. wrap.widthAnchor.constraint(equalToConstant: 44),
  327. wrap.heightAnchor.constraint(equalToConstant: 44),
  328. t.centerXAnchor.constraint(equalTo: wrap.centerXAnchor),
  329. t.centerYAnchor.constraint(equalTo: wrap.centerYAnchor)
  330. ])
  331. return wrap
  332. }
  333. private static func initials(from fullName: String) -> String {
  334. let parts = fullName.split(separator: " ").filter { !$0.isEmpty }
  335. if parts.count >= 2 {
  336. let a = parts[0].prefix(1)
  337. let b = parts[1].prefix(1)
  338. return "\(a)\(b)".uppercased()
  339. }
  340. if let first = parts.first { return String(first.prefix(2)).uppercased() }
  341. return "CV"
  342. }
  343. private func headlineAccent() -> NSView {
  344. let bar = NSView()
  345. bar.translatesAutoresizingMaskIntoConstraints = false
  346. bar.wantsLayer = true
  347. switch template.accent {
  348. case .none:
  349. bar.heightAnchor.constraint(equalToConstant: 1).isActive = true
  350. return bar
  351. case .redUnderline:
  352. bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
  353. bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
  354. bar.widthAnchor.constraint(equalToConstant: template.family == .minimal ? 140 : 168).isActive = true
  355. return bar
  356. case .redBar:
  357. bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
  358. bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
  359. bar.widthAnchor.constraint(equalToConstant: 56).isActive = true
  360. return bar
  361. case .blueBar:
  362. bar.layer?.backgroundColor = template.themeColor.cgColor
  363. bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
  364. bar.widthAnchor.constraint(equalToConstant: template.family == .executive ? 100 : 120).isActive = true
  365. return bar
  366. }
  367. }
  368. private func hairline() -> NSView {
  369. let v = NSView()
  370. v.translatesAutoresizingMaskIntoConstraints = false
  371. v.wantsLayer = true
  372. v.layer?.backgroundColor = style.rule.cgColor
  373. let h: CGFloat = template.family == .executive ? 1.5 : 1
  374. v.heightAnchor.constraint(equalToConstant: h).isActive = true
  375. return v
  376. }
  377. private func sidebarColumn(tinted: Bool) -> NSView {
  378. let box = NSStackView()
  379. box.orientation = .vertical
  380. box.spacing = 12
  381. box.alignment = .leading
  382. if tinted {
  383. box.wantsLayer = true
  384. box.layer?.backgroundColor = template.themeColor.withAlphaComponent(template.family == .creative ? 0.12 : 0.08).cgColor
  385. box.layer?.cornerRadius = 8
  386. }
  387. box.edgeInsets = NSEdgeInsets(top: tinted ? 14 : 0, left: tinted ? 14 : 0, bottom: tinted ? 14 : 0, right: tinted ? 14 : 0)
  388. box.addArrangedSubview(sectionHeading("Contact"))
  389. for line in contactLines() {
  390. box.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
  391. }
  392. if let skillsBlock = ancillaryBlock(title: "Languages & more", body: combinedAncillaryText()) {
  393. box.addArrangedSubview(skillsBlock)
  394. }
  395. return box
  396. }
  397. private func bodyColumn(compact: Bool) -> NSView {
  398. let v = NSStackView()
  399. v.orientation = .vertical
  400. v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
  401. v.alignment = .leading
  402. if let summary = nonEmpty(profile.careerSummary) {
  403. v.addArrangedSubview(sectionHeading("Summary"))
  404. v.addArrangedSubview(paragraph(summary, compact: compact))
  405. }
  406. let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
  407. if !jobs.isEmpty {
  408. v.addArrangedSubview(sectionHeading("Experience"))
  409. for job in jobs {
  410. v.addArrangedSubview(experienceBlock(job: job, compact: compact))
  411. }
  412. }
  413. let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
  414. if !schools.isEmpty {
  415. v.addArrangedSubview(sectionHeading("Education"))
  416. for edu in schools {
  417. v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
  418. }
  419. }
  420. if let cert = nonEmpty(profile.certificates) {
  421. v.addArrangedSubview(sectionHeading("Certificates"))
  422. v.addArrangedSubview(paragraph(cert, compact: compact))
  423. }
  424. if let interests = nonEmpty(profile.interests) {
  425. v.addArrangedSubview(sectionHeading("Interests"))
  426. v.addArrangedSubview(paragraph(interests, compact: compact))
  427. }
  428. if let ref = nonEmpty(profile.referral) {
  429. v.addArrangedSubview(sectionHeading("Referrals"))
  430. v.addArrangedSubview(paragraph(ref, compact: compact))
  431. }
  432. return v
  433. }
  434. private func ancillaryBlock(title: String, body: String?) -> NSStackView? {
  435. guard let body, !body.isEmpty else { return nil }
  436. let s = NSStackView()
  437. s.orientation = .vertical
  438. s.spacing = 6
  439. s.alignment = .leading
  440. s.addArrangedSubview(sectionHeading(title))
  441. s.addArrangedSubview(paragraph(body, compact: true))
  442. return s
  443. }
  444. private func contactLines() -> [String] {
  445. var lines: [String] = []
  446. let p = profile.personal
  447. if !p.email.isEmpty { lines.append(p.email) }
  448. if !p.phone.isEmpty { lines.append(p.phone) }
  449. if !p.address.isEmpty { lines.append(p.address) }
  450. return lines.isEmpty ? ["—"] : lines
  451. }
  452. private func combinedAncillaryText() -> String? {
  453. let chunks = [profile.languages, profile.interests].map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
  454. return chunks.isEmpty ? nil : chunks.joined(separator: "\n\n")
  455. }
  456. private func experienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
  457. let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
  458. let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
  459. let v = NSStackView()
  460. v.orientation = .vertical
  461. v.spacing = template.family == .professional ? 4 : 6
  462. v.alignment = .leading
  463. if !titleLine.isEmpty {
  464. v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
  465. }
  466. if !meta.isEmpty {
  467. v.addArrangedSubview(label(meta, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
  468. }
  469. for bullet in Self.bulletChunks(from: job.description) {
  470. v.addArrangedSubview(bulletRow(bullet, compact: compact))
  471. }
  472. return v
  473. }
  474. private func educationBlock(edu: EducationPayload, compact: Bool) -> NSView {
  475. let v = NSStackView()
  476. v.orientation = .vertical
  477. v.spacing = 4
  478. v.alignment = .leading
  479. let head = [edu.institution, edu.degree].filter { !$0.isEmpty }.joined(separator: " — ")
  480. if !head.isEmpty {
  481. v.addArrangedSubview(label(head, font: style.eduTitleFont, color: style.ink, maxLines: 0))
  482. }
  483. if !edu.year.isEmpty {
  484. v.addArrangedSubview(label(edu.year, font: style.eduMetaFont, color: style.muted, maxLines: 0))
  485. }
  486. return v
  487. }
  488. private func bulletRow(_ text: String, compact: Bool) -> NSView {
  489. let marker: String = template.family == .minimal ? "·" : "•"
  490. let dot = NSTextField(labelWithString: marker)
  491. dot.font = style.bulletMarkerFont
  492. dot.textColor = style.bulletMarkerColor
  493. dot.translatesAutoresizingMaskIntoConstraints = false
  494. let bodyFont = compact ? style.bodyCompactFont : style.bulletBodyFont
  495. let body = label(text, font: bodyFont, color: style.ink, maxLines: 0)
  496. let row = NSStackView(views: [dot, body])
  497. row.orientation = .horizontal
  498. row.spacing = template.family == .creative ? 10 : 8
  499. row.alignment = .top
  500. dot.setContentHuggingPriority(.required, for: .horizontal)
  501. return row
  502. }
  503. private func paragraph(_ text: String, compact: Bool) -> NSTextField {
  504. let font = compact ? style.bodyCompactFont : style.bodyFont
  505. return label(text, font: font, color: style.ink, maxLines: 0)
  506. }
  507. private func sectionHeading(_ raw: String) -> NSTextField {
  508. let upper = raw.uppercased()
  509. let s: String
  510. switch template.sectionLabelStyle {
  511. case .uppercase: s = upper
  512. case .slashed: s = "// \(upper)"
  513. case .bracketed: s = "[ \(upper) ]"
  514. }
  515. let t = NSTextField(labelWithString: s)
  516. t.font = style.sectionFont
  517. t.textColor = style.sectionInk
  518. t.alignment = .left
  519. return t
  520. }
  521. private func label(_ string: String, font: NSFont, color: NSColor, maxLines: Int) -> NSTextField {
  522. let isWrapping = maxLines == 0
  523. let t: NSTextField
  524. if isWrapping {
  525. t = NSTextField(wrappingLabelWithString: string)
  526. t.maximumNumberOfLines = 0
  527. } else {
  528. t = NSTextField(labelWithString: string)
  529. t.maximumNumberOfLines = maxLines
  530. }
  531. t.font = font
  532. t.textColor = color
  533. t.alignment = .left
  534. return t
  535. }
  536. private func displayable(_ value: String, placeholder: String) -> String {
  537. let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
  538. return t.isEmpty ? placeholder : t
  539. }
  540. private func nonEmpty(_ value: String) -> String? {
  541. let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
  542. return t.isEmpty ? nil : t
  543. }
  544. private static func bulletChunks(from text: String) -> [String] {
  545. let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
  546. if trimmed.isEmpty { return [] }
  547. let byNewline = trimmed.components(separatedBy: .newlines)
  548. .map { $0.trimmingCharacters(in: .whitespaces) }
  549. .filter { !$0.isEmpty }
  550. if byNewline.count > 1 { return byNewline }
  551. let byBullet = trimmed.split(separator: "•")
  552. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  553. .filter { !$0.isEmpty }
  554. if byBullet.count > 1 { return byBullet.map { String($0) } }
  555. return [trimmed]
  556. }
  557. }
  558. // MARK: - Payload helpers
  559. private extension WorkExperiencePayload {
  560. var isEffectivelyEmpty: Bool {
  561. [jobTitle, company, duration, description].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  562. }
  563. }
  564. private extension EducationPayload {
  565. var isEffectivelyEmpty: Bool {
  566. [degree, institution, year].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  567. }
  568. }