Без опису

CVProfileDocumentView.swift 73KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646
  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. let colors = CVResumeAppearance.colors()
  39. let sectionInk = CVResumeAppearance.sectionHeadingColor(for: template)
  40. let cardBG = CVResumeAppearance.paperBackground(
  41. variant: template.galleryLayoutVariant,
  42. base: colors.cardBackground
  43. )
  44. switch template.family {
  45. case .minimal:
  46. return DocumentStyle(
  47. nameFont: .systemFont(ofSize: 20, weight: .regular),
  48. roleFont: .systemFont(ofSize: 13.5, weight: .regular),
  49. contactFont: .systemFont(ofSize: 11.5, weight: .regular),
  50. sectionFont: .systemFont(ofSize: 10.5, weight: .semibold),
  51. bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
  52. bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
  53. expTitleFont: .systemFont(ofSize: 13, weight: .medium),
  54. expMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
  55. eduTitleFont: .systemFont(ofSize: 13, weight: .medium),
  56. eduMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
  57. bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
  58. bulletMarkerFont: .systemFont(ofSize: 11, weight: .light),
  59. bulletMarkerColor: theme.withAlphaComponent(0.55),
  60. ink: colors.ink,
  61. muted: colors.muted,
  62. rule: colors.rule,
  63. cardBackground: cardBG,
  64. columnVerticalSpacing: 15,
  65. bodyBlockSpacing: 15,
  66. roleUsesThemeColor: false,
  67. sectionInk: sectionInk
  68. )
  69. case .professional:
  70. return DocumentStyle(
  71. nameFont: .systemFont(ofSize: 21, weight: .semibold),
  72. roleFont: .systemFont(ofSize: 13.5, weight: .medium),
  73. contactFont: .systemFont(ofSize: 11.5, weight: .regular),
  74. sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
  75. bodyFont: .systemFont(ofSize: 12, weight: .regular),
  76. bodyCompactFont: .systemFont(ofSize: 11.5, weight: .regular),
  77. expTitleFont: .systemFont(ofSize: 13.5, weight: .semibold),
  78. expMetaFont: .systemFont(ofSize: 11.5, weight: .semibold),
  79. eduTitleFont: .systemFont(ofSize: 13, weight: .semibold),
  80. eduMetaFont: .systemFont(ofSize: 11.5, weight: .medium),
  81. bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
  82. bulletMarkerFont: .systemFont(ofSize: 12, weight: .bold),
  83. bulletMarkerColor: theme,
  84. ink: colors.ink,
  85. muted: colors.muted,
  86. rule: colors.rule,
  87. cardBackground: cardBG,
  88. columnVerticalSpacing: 13,
  89. bodyBlockSpacing: 13,
  90. roleUsesThemeColor: false,
  91. sectionInk: sectionInk
  92. )
  93. case .modern:
  94. return DocumentStyle(
  95. nameFont: .systemFont(ofSize: 22, weight: .bold),
  96. roleFont: .systemFont(ofSize: 14, weight: .semibold),
  97. contactFont: .systemFont(ofSize: 12, weight: .regular),
  98. sectionFont: .systemFont(ofSize: 11, weight: .heavy),
  99. bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
  100. bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
  101. expTitleFont: .systemFont(ofSize: 14, weight: .bold),
  102. expMetaFont: .systemFont(ofSize: 12, weight: .medium),
  103. eduTitleFont: .systemFont(ofSize: 13.5, weight: .bold),
  104. eduMetaFont: .systemFont(ofSize: 12, weight: .regular),
  105. bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
  106. bulletMarkerFont: .systemFont(ofSize: 13, weight: .bold),
  107. bulletMarkerColor: theme,
  108. ink: colors.ink,
  109. muted: colors.muted,
  110. rule: colors.rule,
  111. cardBackground: cardBG,
  112. columnVerticalSpacing: 17,
  113. bodyBlockSpacing: 16,
  114. roleUsesThemeColor: true,
  115. sectionInk: sectionInk
  116. )
  117. case .executive:
  118. let serifName = NSFont(name: "Georgia-Bold", size: 23) ?? .systemFont(ofSize: 23, weight: .semibold)
  119. let serifRole = NSFont(name: "Georgia", size: 14) ?? .systemFont(ofSize: 14, weight: .regular)
  120. let serifBody = NSFont(name: "Georgia", size: 12.5) ?? .systemFont(ofSize: 12.5, weight: .regular)
  121. let serifCompact = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12, weight: .regular)
  122. let georgia12 = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12)
  123. let georgia115 = NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5)
  124. let expMeta = NSFont(name: "Georgia-Italic", size: 12)
  125. ?? NSFontManager.shared.convert(georgia12, toHaveTrait: .italicFontMask)
  126. let eduMeta = NSFont(name: "Georgia-Italic", size: 11.5)
  127. ?? NSFontManager.shared.convert(georgia115, toHaveTrait: .italicFontMask)
  128. let execCard = NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1)
  129. return DocumentStyle(
  130. nameFont: serifName,
  131. roleFont: serifRole,
  132. contactFont: NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5),
  133. sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
  134. bodyFont: serifBody,
  135. bodyCompactFont: serifCompact,
  136. expTitleFont: NSFont(name: "Georgia-Bold", size: 14) ?? .systemFont(ofSize: 14, weight: .semibold),
  137. expMetaFont: expMeta,
  138. eduTitleFont: NSFont(name: "Georgia-Bold", size: 13.5) ?? .systemFont(ofSize: 13.5, weight: .semibold),
  139. eduMetaFont: eduMeta,
  140. bulletBodyFont: serifCompact,
  141. bulletMarkerFont: .systemFont(ofSize: 11, weight: .bold),
  142. bulletMarkerColor: colors.ink.withAlphaComponent(0.75),
  143. ink: colors.ink,
  144. muted: colors.muted,
  145. rule: colors.rule,
  146. cardBackground: execCard,
  147. columnVerticalSpacing: 18,
  148. bodyBlockSpacing: 17,
  149. roleUsesThemeColor: false,
  150. sectionInk: sectionInk
  151. )
  152. case .creative:
  153. return DocumentStyle(
  154. nameFont: .systemFont(ofSize: 23, weight: .heavy),
  155. roleFont: .systemFont(ofSize: 14, weight: .semibold),
  156. contactFont: .systemFont(ofSize: 11.5, weight: .medium),
  157. sectionFont: .systemFont(ofSize: 11.5, weight: .heavy),
  158. bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
  159. bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
  160. expTitleFont: .systemFont(ofSize: 14, weight: .heavy),
  161. expMetaFont: .systemFont(ofSize: 12, weight: .semibold),
  162. eduTitleFont: .systemFont(ofSize: 13.5, weight: .heavy),
  163. eduMetaFont: .systemFont(ofSize: 12, weight: .medium),
  164. bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
  165. bulletMarkerFont: .systemFont(ofSize: 13, weight: .heavy),
  166. bulletMarkerColor: CVResumeAppearance.accentColor(for: template),
  167. ink: colors.ink,
  168. muted: colors.muted,
  169. rule: theme.withAlphaComponent(0.22),
  170. cardBackground: cardBG,
  171. columnVerticalSpacing: 18,
  172. bodyBlockSpacing: 17,
  173. roleUsesThemeColor: true,
  174. sectionInk: sectionInk
  175. )
  176. }
  177. }
  178. }
  179. /// Full-width résumé layout that injects `SavedProfile` into the visual language of `CVTemplate`.
  180. final class CVProfileDocumentView: NSView {
  181. /// Card width used in the CV preview; also the horizontal fitting size for this view.
  182. /// Without this, a parent `NSStackView` that only pins `width ≤ …` can size the document from
  183. /// wrapping `NSTextField` intrinsic widths (~0) and the whole page collapses to a thin strip.
  184. private static let cardWidth: CGFloat = 640
  185. private let profile: SavedProfile
  186. private let template: CVTemplate
  187. private var style: DocumentStyle
  188. /// Matches `CVTemplatePreviewView` so the same template id + layout recipe renders the same silhouette as the gallery card.
  189. private let variant: Int
  190. private weak var cardView: NSView?
  191. init(profile: SavedProfile, template: CVTemplate) {
  192. self.profile = profile
  193. self.template = template
  194. self.style = DocumentStyle.make(for: template)
  195. self.variant = template.galleryLayoutVariant
  196. super.init(frame: .zero)
  197. translatesAutoresizingMaskIntoConstraints = false
  198. wantsLayer = true
  199. layer?.backgroundColor = NSColor.clear.cgColor
  200. userInterfaceLayoutDirection = .leftToRight
  201. setContentHuggingPriority(.defaultLow, for: .horizontal)
  202. installCardContent()
  203. }
  204. private func installCardContent() {
  205. subviews.forEach { $0.removeFromSuperview() }
  206. let card = NSView()
  207. card.translatesAutoresizingMaskIntoConstraints = false
  208. card.wantsLayer = true
  209. card.layer?.backgroundColor = style.cardBackground.cgColor
  210. card.layer?.cornerRadius = template.family == .executive ? 6 : 10
  211. card.layer?.borderWidth = 1
  212. card.layer?.borderColor = style.rule.cgColor
  213. card.layer?.masksToBounds = true
  214. cardView = card
  215. let root = buildRoot()
  216. root.translatesAutoresizingMaskIntoConstraints = false
  217. enforceLeftToRightLayout(on: root)
  218. card.addSubview(root)
  219. addSubview(card)
  220. NSLayoutConstraint.activate([
  221. card.leadingAnchor.constraint(equalTo: leadingAnchor),
  222. card.trailingAnchor.constraint(equalTo: trailingAnchor),
  223. card.topAnchor.constraint(equalTo: topAnchor),
  224. card.bottomAnchor.constraint(equalTo: bottomAnchor),
  225. card.widthAnchor.constraint(equalToConstant: Self.cardWidth),
  226. root.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 36),
  227. root.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -36),
  228. root.topAnchor.constraint(equalTo: card.topAnchor, constant: 32),
  229. root.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -36)
  230. ])
  231. }
  232. @available(*, unavailable)
  233. required init?(coder: NSCoder) {
  234. fatalError("init(coder:) has not been implemented")
  235. }
  236. override var intrinsicContentSize: NSSize {
  237. NSSize(width: Self.cardWidth, height: NSView.noIntrinsicMetric)
  238. }
  239. override func layout() {
  240. super.layout()
  241. // Wrapping `NSTextField`s default to a very small intrinsic width until
  242. // `preferredMaxLayoutWidth` tracks the column width — stacks then collapse and text reflows like a narrow strip.
  243. updateWrappingTextPreferredWidths()
  244. }
  245. /// Any wrapping body (`maximumNumberOfLines == 0`) needs a concrete wrap width inside stack-driven layout.
  246. private func updateWrappingTextPreferredWidths() {
  247. for field in Self.collectWrappingTextFields(in: self) {
  248. guard let parent = field.superview, parent.bounds.width > 2 else { continue }
  249. let w = parent.bounds.width
  250. if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
  251. field.preferredMaxLayoutWidth = w
  252. }
  253. }
  254. }
  255. private static func collectWrappingTextFields(in root: NSView) -> [NSTextField] {
  256. var out: [NSTextField] = []
  257. func visit(_ v: NSView) {
  258. if let tf = v as? NSTextField, tf.maximumNumberOfLines == 0 {
  259. out.append(tf)
  260. }
  261. for c in v.subviews { visit(c) }
  262. }
  263. visit(root)
  264. return out
  265. }
  266. // MARK: - Composition
  267. private func buildRoot() -> NSView {
  268. switch template.family {
  269. case .modern:
  270. return buildModernFamilyDocument()
  271. case .creative:
  272. return buildCreativeFamilyDocument()
  273. case .executive:
  274. return buildExecutiveDocument()
  275. case .minimal:
  276. return buildMinimalDocument()
  277. case .professional:
  278. return buildProfessionalDocument()
  279. }
  280. }
  281. // MARK: - Modern (gallery uses three distinct silhouettes from `variant`)
  282. private func buildModernFamilyDocument() -> NSView {
  283. switch variant % 3 {
  284. case 0: return modernClassicBandDocument()
  285. case 1: return modernRailDocument()
  286. default: return modernSplitHeaderDocument()
  287. }
  288. }
  289. private func modernClassicBandDocument() -> NSView {
  290. let theme = template.themeColor
  291. let white = NSColor.white
  292. let nameText = displayable(profile.personal.fullName, placeholder: L("Your name"))
  293. let roleText = displayable(profile.personal.jobTitle, placeholder: L("Professional headline"))
  294. let header = NSView()
  295. header.translatesAutoresizingMaskIntoConstraints = false
  296. header.wantsLayer = true
  297. header.layer?.backgroundColor = theme.cgColor
  298. header.layer?.cornerRadius = variant % 2 == 0 ? 8 : 6
  299. let name = label(nameText, font: .systemFont(ofSize: 22, weight: .bold), color: white, maxLines: 2)
  300. let role = label(roleText, font: .systemFont(ofSize: 14, weight: .medium), color: white.withAlphaComponent(0.92), maxLines: 2)
  301. let textCol = NSStackView(views: [name, role])
  302. textCol.orientation = .vertical
  303. textCol.spacing = 4
  304. textCol.alignment = .leading
  305. textCol.translatesAutoresizingMaskIntoConstraints = false
  306. let iconRow = NSStackView()
  307. iconRow.orientation = .horizontal
  308. iconRow.spacing = 10
  309. iconRow.translatesAutoresizingMaskIntoConstraints = false
  310. for sym in ["mappin.and.ellipse", "phone.fill", "envelope.fill"] {
  311. guard let img = NSImage(systemSymbolName: sym, accessibilityDescription: nil) else { continue }
  312. let iv = NSImageView(image: img)
  313. iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  314. iv.contentTintColor = white.withAlphaComponent(0.88)
  315. iconRow.addArrangedSubview(iv)
  316. }
  317. let topRow = NSStackView()
  318. topRow.orientation = .horizontal
  319. topRow.spacing = 14
  320. topRow.alignment = .centerY
  321. topRow.translatesAutoresizingMaskIntoConstraints = false
  322. topRow.addArrangedSubview(textCol)
  323. topRow.addArrangedSubview(NSView())
  324. topRow.addArrangedSubview(iconRow)
  325. header.addSubview(topRow)
  326. NSLayoutConstraint.activate([
  327. topRow.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 18),
  328. topRow.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -18),
  329. topRow.topAnchor.constraint(equalTo: header.topAnchor, constant: 14),
  330. topRow.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -14)
  331. ])
  332. let body: NSView
  333. switch template.layout {
  334. case .singleColumn:
  335. body = modernMainContentColumn(compact: false, includeSummaryInMain: true)
  336. case .twoColumn(let side, let tinted):
  337. let main = modernMainContentColumn(compact: true, includeSummaryInMain: false)
  338. let sideCol = modernAboutHighlightsSidebar(tinted: tinted)
  339. let row = NSStackView()
  340. row.orientation = .horizontal
  341. row.spacing = 20
  342. row.alignment = .top
  343. row.translatesAutoresizingMaskIntoConstraints = false
  344. let mult: CGFloat = (variant % 4 == 2) ? 0.36 : 0.32
  345. if side == .leading {
  346. row.addArrangedSubview(sideCol)
  347. row.addArrangedSubview(main)
  348. sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
  349. } else {
  350. row.addArrangedSubview(main)
  351. row.addArrangedSubview(sideCol)
  352. sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
  353. }
  354. body = row
  355. }
  356. let wrap = NSStackView(views: [header, body])
  357. wrap.orientation = .vertical
  358. wrap.spacing = 18
  359. wrap.alignment = .leading
  360. return wrap
  361. }
  362. private func modernRailDocument() -> NSView {
  363. let theme = template.themeColor
  364. let rail = NSView()
  365. rail.translatesAutoresizingMaskIntoConstraints = false
  366. rail.wantsLayer = true
  367. rail.layer?.backgroundColor = theme.cgColor
  368. rail.layer?.cornerRadius = 2
  369. rail.widthAnchor.constraint(equalToConstant: 3 + CGFloat(variant % 2)).isActive = true
  370. let inner = NSStackView()
  371. inner.orientation = .vertical
  372. inner.spacing = 10
  373. inner.alignment = .leading
  374. inner.translatesAutoresizingMaskIntoConstraints = false
  375. let nameText = displayable(profile.personal.fullName, placeholder: L("Your name"))
  376. let roleText = displayable(profile.personal.jobTitle, placeholder: L("Professional headline"))
  377. let contactParts = [profile.personal.email, profile.personal.phone].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  378. let contactLine = contactParts.isEmpty ? L("Add contact in your profile") : contactParts.joined(separator: " · ")
  379. inner.addArrangedSubview(label(nameText, font: .systemFont(ofSize: 21, weight: .bold), color: style.ink, maxLines: 2))
  380. inner.addArrangedSubview(label(roleText, font: .systemFont(ofSize: 14, weight: .semibold), color: theme, maxLines: 2))
  381. inner.addArrangedSubview(label(contactLine, font: style.contactFont, color: style.muted, maxLines: 2))
  382. inner.addArrangedSubview(hairline())
  383. inner.addArrangedSubview(skillTagRow(theme: theme, maxTags: 5))
  384. inner.addArrangedSubview(modernPrimaryBody(theme: theme))
  385. let row = NSStackView(views: [rail, inner])
  386. row.orientation = .horizontal
  387. row.spacing = 14
  388. row.alignment = .top
  389. return row
  390. }
  391. private func modernSplitHeaderDocument() -> NSView {
  392. let theme = template.themeColor
  393. let nameText = displayable(profile.personal.fullName, placeholder: L("Your name"))
  394. let roleText = displayable(profile.personal.jobTitle, placeholder: L("Professional headline"))
  395. let loc = profile.personal.address.trimmingCharacters(in: .whitespacesAndNewlines)
  396. let left = NSStackView()
  397. left.orientation = .vertical
  398. left.spacing = 5
  399. left.alignment = .leading
  400. left.addArrangedSubview(label(nameText, font: .systemFont(ofSize: 21, weight: .bold), color: style.ink, maxLines: 2))
  401. left.addArrangedSubview(label(roleText, font: .systemFont(ofSize: 13.5, weight: .medium), color: style.muted, maxLines: 2))
  402. if !loc.isEmpty {
  403. left.addArrangedSubview(label(loc, font: style.contactFont, color: style.muted.withAlphaComponent(0.88), maxLines: 2))
  404. }
  405. let right = NSStackView()
  406. right.orientation = .vertical
  407. right.spacing = 8
  408. right.alignment = .leading
  409. right.wantsLayer = true
  410. right.layer?.backgroundColor = theme.cgColor
  411. right.layer?.cornerRadius = 8
  412. right.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
  413. let onW = NSColor.white
  414. if !profile.personal.email.isEmpty {
  415. right.addArrangedSubview(label(profile.personal.email, font: .systemFont(ofSize: 12, weight: .medium), color: onW.withAlphaComponent(0.95), maxLines: 2))
  416. }
  417. if !profile.personal.phone.isEmpty {
  418. right.addArrangedSubview(label(profile.personal.phone, font: .systemFont(ofSize: 12, weight: .medium), color: onW.withAlphaComponent(0.92), maxLines: 1))
  419. }
  420. if !loc.isEmpty {
  421. right.addArrangedSubview(label(loc, font: .systemFont(ofSize: 11.5, weight: .regular), color: onW.withAlphaComponent(0.8), maxLines: 2))
  422. }
  423. let top = NSStackView(views: [left, right])
  424. top.orientation = .horizontal
  425. top.spacing = 16
  426. top.alignment = .top
  427. left.widthAnchor.constraint(equalTo: top.widthAnchor, multiplier: 0.54).isActive = true
  428. let col = NSStackView(views: [top, hairline(), modernPrimaryBody(theme: theme)])
  429. col.orientation = .vertical
  430. col.spacing = 16
  431. col.alignment = .leading
  432. return col
  433. }
  434. private func modernPrimaryBody(theme: NSColor) -> NSView {
  435. switch template.layout {
  436. case .singleColumn:
  437. return modernMainContentColumn(compact: false, includeSummaryInMain: true)
  438. case .twoColumn(let side, let tinted):
  439. let main = modernMainContentColumn(compact: true, includeSummaryInMain: false)
  440. let sideCol = modernAboutHighlightsSidebar(tinted: tinted)
  441. let row = NSStackView()
  442. row.orientation = .horizontal
  443. row.spacing = 20
  444. row.alignment = .top
  445. let mult: CGFloat = (variant % 4 == 2) ? 0.36 : 0.32
  446. if side == .leading {
  447. row.addArrangedSubview(sideCol)
  448. row.addArrangedSubview(main)
  449. sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
  450. } else {
  451. row.addArrangedSubview(main)
  452. row.addArrangedSubview(sideCol)
  453. sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
  454. }
  455. return row
  456. }
  457. }
  458. private func modernMainContentColumn(compact: Bool, includeSummaryInMain: Bool) -> NSView {
  459. let theme = template.themeColor
  460. let v = NSStackView()
  461. v.orientation = .vertical
  462. v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
  463. v.alignment = .leading
  464. if includeSummaryInMain, let summary = nonEmpty(profile.careerSummary) {
  465. v.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: L("About"), theme: theme))
  466. v.addArrangedSubview(paragraph(summary, compact: compact))
  467. }
  468. let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
  469. if !jobs.isEmpty {
  470. v.addArrangedSubview(modernSectionRow(symbol: "briefcase.fill", title: L("Experience"), theme: theme))
  471. for (index, job) in jobs.enumerated() {
  472. v.addArrangedSubview(experienceBlock(job: job, compact: compact))
  473. if index == 0 {
  474. v.addArrangedSubview(skillTagRow(theme: theme, maxTags: 5))
  475. }
  476. }
  477. }
  478. let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
  479. if !schools.isEmpty {
  480. v.addArrangedSubview(modernSectionRow(symbol: "graduationcap.fill", title: L("Education"), theme: theme))
  481. for edu in schools {
  482. v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
  483. }
  484. }
  485. appendCertificatesInterestsReferrals(to: v, compact: compact)
  486. return v
  487. }
  488. private func modernAboutHighlightsSidebar(tinted: Bool) -> NSView {
  489. let theme = template.themeColor
  490. let box = NSStackView()
  491. box.orientation = .vertical
  492. box.spacing = 12
  493. box.alignment = .leading
  494. if tinted {
  495. box.wantsLayer = true
  496. box.layer?.backgroundColor = theme.withAlphaComponent(0.1).cgColor
  497. box.layer?.cornerRadius = 8
  498. box.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
  499. }
  500. if let summary = nonEmpty(profile.careerSummary) {
  501. box.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: L("About"), theme: theme))
  502. box.addArrangedSubview(paragraph(summary, compact: true))
  503. }
  504. if let hi = highlightsBodyText() {
  505. box.addArrangedSubview(modernSectionRow(symbol: "star.fill", title: L("Highlights"), theme: theme))
  506. box.addArrangedSubview(paragraph(hi, compact: true))
  507. }
  508. if box.arrangedSubviews.isEmpty {
  509. box.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: L("About"), theme: theme))
  510. box.addArrangedSubview(paragraph(L("Add a career summary or interests in your profile to populate this column."), compact: true))
  511. }
  512. return box
  513. }
  514. private func modernSectionRow(symbol: String, title: String, theme: NSColor) -> NSView {
  515. guard let img = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) else {
  516. return sectionHeading(title)
  517. }
  518. let iv = NSImageView(image: img)
  519. iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  520. iv.contentTintColor = theme
  521. let t = label(title.uppercased(), font: style.sectionFont, color: style.ink, maxLines: 1)
  522. let r = NSStackView(views: [iv, t])
  523. r.orientation = .horizontal
  524. r.spacing = 8
  525. r.alignment = .centerY
  526. return r
  527. }
  528. private func skillTagRow(theme: NSColor, maxTags: Int) -> NSView {
  529. let tags = skillTokensFromProfile(max: maxTags)
  530. guard !tags.isEmpty else { return NSView() }
  531. let row = NSStackView()
  532. row.orientation = .horizontal
  533. row.spacing = 8
  534. row.alignment = .centerY
  535. for s in tags {
  536. let tag = NSView()
  537. tag.wantsLayer = true
  538. tag.layer?.backgroundColor = theme.withAlphaComponent(0.14).cgColor
  539. tag.layer?.cornerRadius = 6
  540. tag.translatesAutoresizingMaskIntoConstraints = false
  541. let lab = label(s, font: .systemFont(ofSize: 11, weight: .semibold), color: theme.blended(withFraction: 0.35, of: style.ink) ?? style.ink, maxLines: 1)
  542. lab.alignment = .center
  543. lab.translatesAutoresizingMaskIntoConstraints = false
  544. tag.addSubview(lab)
  545. NSLayoutConstraint.activate([
  546. lab.leadingAnchor.constraint(equalTo: tag.leadingAnchor, constant: 10),
  547. lab.trailingAnchor.constraint(equalTo: tag.trailingAnchor, constant: -10),
  548. lab.topAnchor.constraint(equalTo: tag.topAnchor, constant: 5),
  549. lab.bottomAnchor.constraint(equalTo: tag.bottomAnchor, constant: -5)
  550. ])
  551. row.addArrangedSubview(tag)
  552. }
  553. return row
  554. }
  555. // MARK: - Creative (dark sidebar in gallery — match filled page)
  556. private func buildCreativeFamilyDocument() -> NSView {
  557. switch template.layout {
  558. case .singleColumn:
  559. return creativeSingleColumnDocument()
  560. case .twoColumn(let side, _):
  561. return creativeTwoColumnDocument(sidebar: side)
  562. }
  563. }
  564. private func creativeDeepBackground() -> NSColor {
  565. let theme = template.themeColor
  566. let navy = NSColor(srgbRed: 0.08, green: 0.1, blue: 0.18, alpha: 1)
  567. let plum = NSColor(srgbRed: 0.14, green: 0.07, blue: 0.24, alpha: 1)
  568. switch variant % 4 {
  569. case 0: return theme.blended(withFraction: 0.52, of: navy) ?? theme
  570. case 1: return theme.blended(withFraction: 0.7, of: NSColor.black) ?? theme
  571. case 2: return style.ink.blended(withFraction: 0.38, of: theme) ?? theme
  572. default: return theme.blended(withFraction: 0.4, of: plum) ?? theme
  573. }
  574. }
  575. private func creativeSingleColumnDocument() -> NSView {
  576. let theme = template.themeColor
  577. let nameText = displayable(profile.personal.fullName, placeholder: L("Your name"))
  578. let roleText = displayable(profile.personal.jobTitle, placeholder: L("Professional headline"))
  579. let banner = NSView()
  580. banner.translatesAutoresizingMaskIntoConstraints = false
  581. banner.wantsLayer = true
  582. banner.layer?.backgroundColor = theme.cgColor
  583. banner.layer?.cornerRadius = variant % 4 == 1 ? 8 : 6
  584. let inner = label(" \(nameText) · \(roleText) ", font: .systemFont(ofSize: 14, weight: .bold), color: .white, maxLines: 2)
  585. inner.translatesAutoresizingMaskIntoConstraints = false
  586. banner.addSubview(inner)
  587. NSLayoutConstraint.activate([
  588. inner.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 14),
  589. inner.trailingAnchor.constraint(lessThanOrEqualTo: banner.trailingAnchor, constant: -14),
  590. inner.topAnchor.constraint(equalTo: banner.topAnchor, constant: 12),
  591. inner.bottomAnchor.constraint(equalTo: banner.bottomAnchor, constant: -12)
  592. ])
  593. let main = creativeMainStack(theme: theme)
  594. let col = NSStackView(views: [banner, main])
  595. col.orientation = .vertical
  596. col.spacing = 16
  597. col.alignment = .leading
  598. return col
  599. }
  600. private func creativeTwoColumnDocument(sidebar: CVTemplate.SidebarSide) -> NSView {
  601. let theme = template.themeColor
  602. let deep = creativeDeepBackground()
  603. let onSidebar = NSColor.white.withAlphaComponent(0.95)
  604. let skillPrefix = (variant % 3 == 0) ? "• " : "▸ "
  605. let sidebarStack = NSStackView()
  606. sidebarStack.orientation = .vertical
  607. sidebarStack.spacing = 12
  608. sidebarStack.alignment = .leading
  609. sidebarStack.wantsLayer = true
  610. sidebarStack.layer?.backgroundColor = deep.cgColor
  611. sidebarStack.layer?.cornerRadius = variant % 2 == 0 ? 10 : 8
  612. sidebarStack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
  613. let nm = displayable(profile.personal.fullName, placeholder: L("Your name"))
  614. let role = displayable(profile.personal.jobTitle, placeholder: L("Professional headline"))
  615. sidebarStack.addArrangedSubview(label(nm, font: .systemFont(ofSize: 18, weight: .bold), color: onSidebar, maxLines: 2))
  616. sidebarStack.addArrangedSubview(label(role, font: .systemFont(ofSize: 13, weight: .medium), color: onSidebar.withAlphaComponent(0.85), maxLines: 2))
  617. if !profile.personal.email.isEmpty {
  618. sidebarStack.addArrangedSubview(label(profile.personal.email, font: .systemFont(ofSize: 11.5), color: onSidebar.withAlphaComponent(0.82), maxLines: 2))
  619. }
  620. if !profile.personal.phone.isEmpty {
  621. sidebarStack.addArrangedSubview(label(profile.personal.phone, font: .systemFont(ofSize: 11.5), color: onSidebar.withAlphaComponent(0.82), maxLines: 1))
  622. }
  623. sidebarStack.addArrangedSubview(creativeSidebarHeading(L("STRENGTHS"), onSidebar: onSidebar, accent: theme))
  624. for token in skillTokensFromProfile(max: 8) {
  625. sidebarStack.addArrangedSubview(label("\(skillPrefix)\(token)", font: .systemFont(ofSize: 12, weight: .semibold), color: onSidebar.withAlphaComponent(0.92), maxLines: 2))
  626. }
  627. let main = creativeMainStack(theme: theme)
  628. let row = NSStackView()
  629. row.orientation = .horizontal
  630. row.spacing = 18
  631. row.alignment = .top
  632. let sidebarMult = 0.32 + CGFloat(variant % 3) * 0.02
  633. if sidebar == .leading {
  634. row.addArrangedSubview(sidebarStack)
  635. row.addArrangedSubview(main)
  636. sidebarStack.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
  637. } else {
  638. row.addArrangedSubview(main)
  639. row.addArrangedSubview(sidebarStack)
  640. sidebarStack.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
  641. }
  642. return row
  643. }
  644. private func creativeSidebarHeading(_ raw: String, onSidebar: NSColor, accent: NSColor) -> NSView {
  645. let t = label(raw, font: .systemFont(ofSize: 10.5, weight: .heavy), color: onSidebar, maxLines: 1)
  646. let bar = NSView()
  647. bar.translatesAutoresizingMaskIntoConstraints = false
  648. bar.wantsLayer = true
  649. bar.layer?.backgroundColor = accent.cgColor
  650. bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
  651. let c = NSStackView(views: [t, bar])
  652. c.orientation = .vertical
  653. c.spacing = 4
  654. c.alignment = .leading
  655. bar.leadingAnchor.constraint(equalTo: t.leadingAnchor).isActive = true
  656. bar.widthAnchor.constraint(equalToConstant: 72).isActive = true
  657. return c
  658. }
  659. private func creativeMainHeader(theme: NSColor) -> NSView {
  660. let v = NSView()
  661. v.translatesAutoresizingMaskIntoConstraints = false
  662. let stripe = NSView()
  663. stripe.translatesAutoresizingMaskIntoConstraints = false
  664. stripe.wantsLayer = true
  665. stripe.layer?.backgroundColor = theme.cgColor
  666. v.addSubview(stripe)
  667. let row = NSStackView()
  668. row.orientation = .horizontal
  669. row.spacing = 8
  670. row.translatesAutoresizingMaskIntoConstraints = false
  671. let lab = label(L("PORTFOLIO SNAPSHOT"), font: .systemFont(ofSize: 12, weight: .heavy), color: style.ink, maxLines: 1)
  672. row.addArrangedSubview(stripe)
  673. row.addArrangedSubview(lab)
  674. v.addSubview(row)
  675. NSLayoutConstraint.activate([
  676. stripe.widthAnchor.constraint(equalToConstant: 4),
  677. stripe.heightAnchor.constraint(equalToConstant: 18),
  678. row.leadingAnchor.constraint(equalTo: v.leadingAnchor),
  679. row.topAnchor.constraint(equalTo: v.topAnchor),
  680. row.bottomAnchor.constraint(equalTo: v.bottomAnchor)
  681. ])
  682. return v
  683. }
  684. private func creativeMainStack(theme: NSColor) -> NSView {
  685. let stack = NSStackView()
  686. stack.orientation = .vertical
  687. stack.spacing = style.bodyBlockSpacing
  688. stack.alignment = .leading
  689. stack.addArrangedSubview(creativeMainHeader(theme: theme))
  690. if let summary = nonEmpty(profile.careerSummary) {
  691. stack.addArrangedSubview(sectionHeading(L("Profile")))
  692. stack.addArrangedSubview(paragraph(summary, compact: false))
  693. }
  694. let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
  695. if !jobs.isEmpty {
  696. stack.addArrangedSubview(sectionHeading(L("Impact")))
  697. for job in jobs {
  698. let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
  699. if !titleLine.isEmpty {
  700. stack.addArrangedSubview(label(titleLine, font: .systemFont(ofSize: 13.5, weight: .heavy), color: style.ink, maxLines: 0))
  701. }
  702. for bullet in Self.bulletChunks(from: job.description) {
  703. let mark = (variant % 2 == 0) ? "— " : "▸ "
  704. stack.addArrangedSubview(label("\(mark)\(bullet)", font: style.bodyFont, color: style.muted, maxLines: 0))
  705. }
  706. }
  707. }
  708. let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
  709. if !schools.isEmpty {
  710. stack.addArrangedSubview(sectionHeading(L("Education")))
  711. for edu in schools {
  712. stack.addArrangedSubview(educationBlock(edu: edu, compact: false))
  713. }
  714. }
  715. appendCertificatesInterestsReferrals(to: stack, compact: false)
  716. return stack
  717. }
  718. // MARK: - Executive (matches gallery serif layout)
  719. private func buildExecutiveDocument() -> NSView {
  720. let centeredHead = (variant % 2) == 0
  721. let nameText = displayable(profile.personal.fullName, placeholder: L("Your name"))
  722. let roleText = displayable(profile.personal.jobTitle, placeholder: L("Professional headline"))
  723. let contactParts = [profile.personal.email, profile.personal.phone, profile.personal.address].filter {
  724. !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
  725. }
  726. let contactText = contactParts.isEmpty ? L("Add contact details in your profile") : contactParts.joined(separator: " · ")
  727. let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2)
  728. let role = label(roleText, font: style.roleFont, color: style.muted, maxLines: 2)
  729. let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.9), maxLines: 3)
  730. if centeredHead {
  731. name.alignment = .center
  732. role.alignment = .center
  733. contact.alignment = .center
  734. }
  735. let rule = executiveHeaderRule(wide: variant % 3 == 0)
  736. let head = NSStackView(views: [name, role, contact, rule])
  737. head.orientation = .vertical
  738. head.spacing = 8
  739. head.alignment = centeredHead ? .centerX : .leading
  740. let body: NSView
  741. switch template.layout {
  742. case .singleColumn:
  743. body = executiveMainColumn(compact: false, tightLeading: variant % 5 == 2)
  744. case .twoColumn(let side, let tinted):
  745. let main = executiveMainColumn(compact: true, tightLeading: variant % 5 == 2)
  746. let sideCol = executiveSidebarColumn(tinted: tinted, showMetrics: variant % 4 == 1)
  747. let row = NSStackView()
  748. row.orientation = .horizontal
  749. row.spacing = 20
  750. row.alignment = .top
  751. if side == .leading {
  752. row.addArrangedSubview(sideCol)
  753. row.addArrangedSubview(main)
  754. } else {
  755. row.addArrangedSubview(main)
  756. row.addArrangedSubview(sideCol)
  757. }
  758. let mult: CGFloat = (variant % 5 == 3) ? 0.38 : 0.33
  759. sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
  760. body = row
  761. }
  762. let wrap = NSStackView(views: [head, body])
  763. wrap.orientation = .vertical
  764. wrap.spacing = style.columnVerticalSpacing
  765. wrap.alignment = .leading
  766. return wrap
  767. }
  768. private func executiveHeaderRule(wide: Bool) -> NSView {
  769. let theme = template.themeColor
  770. let v = NSView()
  771. v.translatesAutoresizingMaskIntoConstraints = false
  772. v.wantsLayer = true
  773. v.layer?.backgroundColor = theme.withAlphaComponent(0.45).cgColor
  774. v.heightAnchor.constraint(equalToConstant: wide ? 2 : 1.5).isActive = true
  775. v.widthAnchor.constraint(equalToConstant: wide ? 160 : 110).isActive = true
  776. return v
  777. }
  778. private func executiveMainColumn(compact: Bool, tightLeading: Bool) -> NSView {
  779. let stack = NSStackView()
  780. stack.orientation = .vertical
  781. stack.spacing = (compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2) - (tightLeading ? 2 : 0)
  782. stack.alignment = .leading
  783. let summaryTitle = (variant % 6 == 3) ? L("Summary") : L("Professional Summary")
  784. if let summary = nonEmpty(profile.careerSummary) {
  785. stack.addArrangedSubview(sectionHeading(summaryTitle))
  786. stack.addArrangedSubview(paragraph(summary, compact: compact))
  787. }
  788. let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
  789. if !jobs.isEmpty {
  790. stack.addArrangedSubview(sectionHeading(L("Selected Experience")))
  791. for job in jobs {
  792. stack.addArrangedSubview(executiveExperienceBlock(job: job, compact: compact))
  793. }
  794. }
  795. let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
  796. if !schools.isEmpty {
  797. stack.addArrangedSubview(sectionHeading(L("Education")))
  798. for edu in schools {
  799. stack.addArrangedSubview(educationBlock(edu: edu, compact: compact))
  800. }
  801. }
  802. appendCertificatesInterestsReferrals(to: stack, compact: compact)
  803. return stack
  804. }
  805. private func executiveExperienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
  806. let v = NSStackView()
  807. v.orientation = .vertical
  808. v.spacing = 4
  809. v.alignment = .leading
  810. let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: ", ")
  811. if !titleLine.isEmpty {
  812. v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
  813. }
  814. let duration = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
  815. if !duration.isEmpty {
  816. v.addArrangedSubview(label(duration, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
  817. }
  818. for bullet in Self.bulletChunks(from: job.description) {
  819. v.addArrangedSubview(label(bullet, font: style.bodyFont, color: style.muted, maxLines: 0))
  820. }
  821. return v
  822. }
  823. private func executiveSidebarColumn(tinted: Bool, showMetrics: Bool) -> NSView {
  824. let stack = NSStackView()
  825. stack.orientation = .vertical
  826. stack.spacing = 10
  827. stack.alignment = .leading
  828. if tinted {
  829. stack.wantsLayer = true
  830. let fill = (variant % 3 == 0)
  831. ? CVResumeAppearance.colors().sidebarTint
  832. : template.themeColor.withAlphaComponent(0.07)
  833. stack.layer?.backgroundColor = fill.cgColor
  834. stack.layer?.cornerRadius = variant % 4 == 2 ? 8 : 6
  835. stack.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
  836. }
  837. let skills = skillTokensFromProfile(max: 12)
  838. if !skills.isEmpty {
  839. stack.addArrangedSubview(sectionHeading(L("Core Competencies")))
  840. for s in skills {
  841. stack.addArrangedSubview(label("· \(s)", font: style.bodyFont, color: style.ink, maxLines: 0))
  842. }
  843. }
  844. if let cert = nonEmpty(profile.certificates) {
  845. stack.addArrangedSubview(sectionHeading(L("Tools")))
  846. stack.addArrangedSubview(paragraph(cert, compact: true))
  847. }
  848. if showMetrics, let hi = highlightsBodyText() {
  849. stack.addArrangedSubview(sectionHeading(L("Impact")))
  850. stack.addArrangedSubview(paragraph(hi, compact: true))
  851. }
  852. if stack.arrangedSubviews.isEmpty {
  853. stack.addArrangedSubview(sectionHeading(L("Contact")))
  854. for line in contactLines() {
  855. stack.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
  856. }
  857. }
  858. return stack
  859. }
  860. // MARK: - Minimal (matches gallery light typography)
  861. private func buildMinimalDocument() -> NSView {
  862. let nameText = displayable(profile.personal.fullName, placeholder: L("Your name"))
  863. let roleText = displayable(profile.personal.jobTitle, placeholder: L("Professional headline"))
  864. let contactParts = [profile.personal.email, profile.personal.phone].filter {
  865. !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
  866. }
  867. let contactText = contactParts.isEmpty ? L("Add contact in your profile") : contactParts.joined(separator: " ")
  868. let nameWeight: NSFont.Weight = (variant % 3 == 0) ? .ultraLight : .light
  869. let nameSize: CGFloat = template.headline == .centered ? 22 + CGFloat(variant % 2) : 20 + CGFloat(variant % 3)
  870. let name = label(nameText, font: .systemFont(ofSize: nameSize, weight: nameWeight), color: style.ink, maxLines: 2)
  871. let role = label(roleText.uppercased(), font: .systemFont(ofSize: 12.5, weight: .medium), color: style.muted, maxLines: 2)
  872. let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.75), maxLines: 2)
  873. let head = NSStackView()
  874. head.orientation = .vertical
  875. head.spacing = template.headline == .avatarStacked ? 10 : 6 + CGFloat(variant % 4)
  876. head.alignment = template.headline == .centered ? .centerX : .leading
  877. if template.headline == .avatarStacked {
  878. head.addArrangedSubview(initialsBadge(for: nameText))
  879. }
  880. if template.headline == .centered {
  881. name.alignment = .center
  882. role.alignment = .center
  883. contact.alignment = .center
  884. }
  885. head.addArrangedSubview(name)
  886. head.addArrangedSubview(role)
  887. head.addArrangedSubview(contact)
  888. head.addArrangedSubview(hairline())
  889. if variant % 5 == 1 {
  890. head.addArrangedSubview(hairline())
  891. }
  892. let swapEdu = (variant % 4) == 2
  893. let body: NSView
  894. switch template.layout {
  895. case .singleColumn:
  896. body = minimalMainColumn(spacing: style.bodyBlockSpacing, educationBeforeExperience: swapEdu)
  897. case .twoColumn(let side, _):
  898. let main = minimalMainColumn(spacing: style.bodyBlockSpacing - 2, educationBeforeExperience: swapEdu)
  899. let aside = minimalSkillsAside(numbered: variant % 3 == 1)
  900. let row = NSStackView()
  901. row.orientation = .horizontal
  902. row.spacing = 18 + CGFloat(variant % 3)
  903. row.alignment = .top
  904. if side == .leading {
  905. row.addArrangedSubview(aside)
  906. row.addArrangedSubview(main)
  907. } else {
  908. row.addArrangedSubview(main)
  909. row.addArrangedSubview(aside)
  910. }
  911. let mult: CGFloat = (variant % 5 == 0) ? 0.34 : 0.30
  912. aside.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
  913. body = row
  914. }
  915. let wrap = NSStackView(views: [head, body])
  916. wrap.orientation = .vertical
  917. wrap.spacing = style.columnVerticalSpacing
  918. wrap.alignment = .leading
  919. return wrap
  920. }
  921. private func minimalMainColumn(spacing: CGFloat, educationBeforeExperience: Bool) -> NSView {
  922. let stack = NSStackView()
  923. stack.orientation = .vertical
  924. stack.spacing = spacing
  925. stack.alignment = .leading
  926. let appendSummary: () -> Void = { [self] in
  927. if let summary = nonEmpty(profile.careerSummary) {
  928. stack.addArrangedSubview(sectionHeading(L("Profile")))
  929. stack.addArrangedSubview(paragraph(summary, compact: false))
  930. }
  931. }
  932. let appendExperience: () -> Void = { [self] in
  933. let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
  934. if !jobs.isEmpty {
  935. stack.addArrangedSubview(sectionHeading(L("Experience")))
  936. for job in jobs {
  937. stack.addArrangedSubview(experienceBlock(job: job, compact: false))
  938. }
  939. }
  940. }
  941. let appendEducation: () -> Void = { [self] in
  942. let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
  943. if !schools.isEmpty {
  944. stack.addArrangedSubview(sectionHeading(L("Education")))
  945. for edu in schools {
  946. stack.addArrangedSubview(educationBlock(edu: edu, compact: false))
  947. }
  948. }
  949. }
  950. if educationBeforeExperience {
  951. appendEducation()
  952. appendSummary()
  953. appendExperience()
  954. } else {
  955. appendSummary()
  956. appendExperience()
  957. appendEducation()
  958. }
  959. appendCertificatesInterestsReferrals(to: stack, compact: false)
  960. return stack
  961. }
  962. private func minimalSkillsAside(numbered: Bool) -> NSView {
  963. let stack = NSStackView()
  964. stack.orientation = .vertical
  965. stack.spacing = 8
  966. stack.alignment = .leading
  967. let skills = skillTokensFromProfile(max: 12)
  968. if skills.isEmpty {
  969. stack.addArrangedSubview(sectionHeading(L("Contact")))
  970. for line in contactLines() {
  971. stack.addArrangedSubview(label(line, font: style.contactFont, color: style.muted, maxLines: 0))
  972. }
  973. return stack
  974. }
  975. stack.addArrangedSubview(sectionHeading(L("Skills")))
  976. for (i, s) in skills.enumerated() {
  977. let prefix = numbered ? "\(i + 1). " : "· "
  978. stack.addArrangedSubview(label("\(prefix)\(s)", font: style.bodyCompactFont, color: style.muted, maxLines: 0))
  979. }
  980. return stack
  981. }
  982. // MARK: - Professional
  983. private func buildProfessionalDocument() -> NSView {
  984. switch template.layout {
  985. case .singleColumn:
  986. return singleColumnLayout()
  987. case .twoColumn(let side, let tinted):
  988. return twoColumnLayout(sidebar: side, tinted: tinted)
  989. }
  990. }
  991. private func singleColumnLayout() -> NSView {
  992. let v = NSStackView()
  993. v.orientation = .vertical
  994. v.alignment = .leading
  995. v.spacing = style.columnVerticalSpacing + 3
  996. v.addArrangedSubview(headerBlock())
  997. if template.family == .professional && (variant % 6) == 4 {
  998. v.addArrangedSubview(professionalInlineSkillsRow())
  999. }
  1000. v.addArrangedSubview(hairline())
  1001. let body = bodyColumn(compact: false, experienceFirst: professionalExperienceFirst)
  1002. v.addArrangedSubview(usesProfessionalSingleColumnRail ? bodyWithLeadingAccentRail(body) : body)
  1003. return v
  1004. }
  1005. private var professionalExperienceFirst: Bool {
  1006. template.family == .professional && (variant % 3) == 1
  1007. }
  1008. private func professionalInlineSkillsRow() -> NSView {
  1009. let tokens = skillTokensFromProfile(max: 6)
  1010. guard !tokens.isEmpty else { return NSView() }
  1011. let joined = tokens.joined(separator: " · ")
  1012. return label(joined, font: .systemFont(ofSize: 11.5, weight: .medium), color: template.themeColor, maxLines: 0)
  1013. }
  1014. /// Matches the CV Maker thumbnail: professional ATS single-column layouts use a full-height theme rail.
  1015. private var usesProfessionalSingleColumnRail: Bool {
  1016. if case .singleColumn = template.layout, template.family == .professional { return true }
  1017. return false
  1018. }
  1019. private func bodyWithLeadingAccentRail(_ content: NSView) -> NSView {
  1020. let wrap = NSView()
  1021. wrap.translatesAutoresizingMaskIntoConstraints = false
  1022. let rail = NSView()
  1023. rail.translatesAutoresizingMaskIntoConstraints = false
  1024. rail.wantsLayer = true
  1025. rail.layer?.backgroundColor = template.themeColor.cgColor
  1026. rail.layer?.cornerRadius = 1
  1027. content.translatesAutoresizingMaskIntoConstraints = false
  1028. wrap.addSubview(rail)
  1029. wrap.addSubview(content)
  1030. NSLayoutConstraint.activate([
  1031. rail.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
  1032. rail.topAnchor.constraint(equalTo: content.topAnchor),
  1033. rail.bottomAnchor.constraint(equalTo: content.bottomAnchor),
  1034. rail.widthAnchor.constraint(equalToConstant: 3),
  1035. content.leadingAnchor.constraint(equalTo: rail.trailingAnchor, constant: 12),
  1036. content.trailingAnchor.constraint(equalTo: wrap.trailingAnchor),
  1037. content.topAnchor.constraint(equalTo: wrap.topAnchor),
  1038. content.bottomAnchor.constraint(equalTo: wrap.bottomAnchor)
  1039. ])
  1040. return wrap
  1041. }
  1042. private func twoColumnLayout(sidebar: CVTemplate.SidebarSide, tinted: Bool) -> NSView {
  1043. let v = NSStackView()
  1044. v.orientation = .vertical
  1045. v.alignment = .leading
  1046. v.spacing = style.columnVerticalSpacing + 2
  1047. v.addArrangedSubview(headerBlock())
  1048. v.addArrangedSubview(hairline())
  1049. let row = NSStackView()
  1050. row.orientation = .horizontal
  1051. row.alignment = .top
  1052. row.spacing = 22
  1053. let sidebarCol = sidebarColumn(tinted: tinted)
  1054. let mainCol = bodyColumn(compact: true, experienceFirst: professionalExperienceFirst)
  1055. if sidebar == .leading {
  1056. row.addArrangedSubview(sidebarCol)
  1057. row.addArrangedSubview(mainCol)
  1058. } else {
  1059. row.addArrangedSubview(mainCol)
  1060. row.addArrangedSubview(sidebarCol)
  1061. }
  1062. let sidebarMult: CGFloat = (variant % 5 == 2) ? 0.38 : 0.32
  1063. sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
  1064. v.addArrangedSubview(row)
  1065. return v
  1066. }
  1067. private func headerBlock() -> NSView {
  1068. let nameText = displayable(profile.personal.fullName, placeholder: L("Your name"))
  1069. let roleText = displayable(profile.personal.jobTitle, placeholder: L("Professional headline"))
  1070. let contactParts = [profile.personal.email, profile.personal.phone, profile.personal.address].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  1071. let contactText = contactParts.isEmpty ? L("Add contact details in your profile") : contactParts.joined(separator: " · ")
  1072. let roleColor = style.roleUsesThemeColor ? template.themeColor : style.muted
  1073. let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2)
  1074. let role = label(roleText, font: style.roleFont, color: roleColor, maxLines: 2)
  1075. let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.92), maxLines: 3)
  1076. let textCol = NSStackView(views: [name, role, contact])
  1077. textCol.orientation = .vertical
  1078. textCol.spacing = template.family == .professional ? 3 : 4
  1079. textCol.alignment = .leading
  1080. switch template.headline {
  1081. case .centered:
  1082. textCol.alignment = .centerX
  1083. name.alignment = .center
  1084. role.alignment = .center
  1085. contact.alignment = .center
  1086. let accent = headlineAccent()
  1087. let stack = NSStackView(views: [textCol, accent])
  1088. stack.orientation = .vertical
  1089. stack.spacing = 8
  1090. stack.alignment = .centerX
  1091. return stack
  1092. case .avatarStacked:
  1093. textCol.alignment = .centerX
  1094. name.alignment = .center
  1095. role.alignment = .center
  1096. contact.alignment = .center
  1097. let accent = headlineAccent()
  1098. let avatar = initialsBadge(for: nameText)
  1099. let stack = NSStackView(views: [avatar, textCol, accent])
  1100. stack.orientation = .vertical
  1101. stack.spacing = 8
  1102. stack.alignment = .centerX
  1103. return stack
  1104. case .leftAligned, .leftWithInitials:
  1105. let row = NSStackView()
  1106. row.orientation = .horizontal
  1107. row.spacing = 14
  1108. row.alignment = .centerY
  1109. row.addArrangedSubview(textCol)
  1110. if template.headline == .leftWithInitials {
  1111. row.addArrangedSubview(NSView())
  1112. row.addArrangedSubview(initialsBadge(for: nameText))
  1113. }
  1114. let col = NSStackView(views: [row, headlineAccent()])
  1115. col.orientation = .vertical
  1116. col.spacing = 8
  1117. col.alignment = .leading
  1118. return col
  1119. }
  1120. }
  1121. private func initialsBadge(for fullName: String) -> NSView {
  1122. let initials = Self.initials(from: fullName)
  1123. let t = NSTextField(labelWithString: initials)
  1124. t.font = .systemFont(ofSize: 13, weight: .bold)
  1125. t.textColor = template.themeColor
  1126. t.alignment = .center
  1127. t.translatesAutoresizingMaskIntoConstraints = false
  1128. let wrap = NSView()
  1129. wrap.translatesAutoresizingMaskIntoConstraints = false
  1130. wrap.wantsLayer = true
  1131. wrap.layer?.cornerRadius = 22
  1132. wrap.layer?.borderWidth = 1.5
  1133. wrap.layer?.borderColor = template.themeColor.withAlphaComponent(0.35).cgColor
  1134. wrap.addSubview(t)
  1135. NSLayoutConstraint.activate([
  1136. wrap.widthAnchor.constraint(equalToConstant: 44),
  1137. wrap.heightAnchor.constraint(equalToConstant: 44),
  1138. t.centerXAnchor.constraint(equalTo: wrap.centerXAnchor),
  1139. t.centerYAnchor.constraint(equalTo: wrap.centerYAnchor)
  1140. ])
  1141. return wrap
  1142. }
  1143. private static func initials(from fullName: String) -> String {
  1144. let parts = fullName.split(separator: " ").filter { !$0.isEmpty }
  1145. if parts.count >= 2 {
  1146. let a = parts[0].prefix(1)
  1147. let b = parts[1].prefix(1)
  1148. return "\(a)\(b)".uppercased()
  1149. }
  1150. if let first = parts.first { return String(first.prefix(2)).uppercased() }
  1151. return L("CV")
  1152. }
  1153. private func headlineAccent() -> NSView {
  1154. let bar = NSView()
  1155. bar.translatesAutoresizingMaskIntoConstraints = false
  1156. bar.wantsLayer = true
  1157. switch template.accent {
  1158. case .none:
  1159. bar.heightAnchor.constraint(equalToConstant: 1).isActive = true
  1160. return bar
  1161. case .redUnderline:
  1162. bar.layer?.backgroundColor = CVResumeAppearance.accentColor(for: template).cgColor
  1163. bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
  1164. bar.widthAnchor.constraint(equalToConstant: template.family == .minimal ? 140 : 168).isActive = true
  1165. return bar
  1166. case .redBar:
  1167. bar.layer?.backgroundColor = CVResumeAppearance.accentColor(for: template).cgColor
  1168. bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
  1169. bar.widthAnchor.constraint(equalToConstant: 56).isActive = true
  1170. return bar
  1171. case .blueBar:
  1172. bar.layer?.backgroundColor = template.themeColor.cgColor
  1173. if template.headline == .centered {
  1174. bar.heightAnchor.constraint(equalToConstant: 2.5).isActive = true
  1175. bar.widthAnchor.constraint(equalToConstant: 148).isActive = true
  1176. } else {
  1177. bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
  1178. bar.widthAnchor.constraint(equalToConstant: template.family == .executive ? 100 : 120).isActive = true
  1179. }
  1180. return bar
  1181. }
  1182. }
  1183. private func hairline() -> NSView {
  1184. let v = NSView()
  1185. v.translatesAutoresizingMaskIntoConstraints = false
  1186. v.wantsLayer = true
  1187. v.layer?.backgroundColor = style.rule.cgColor
  1188. let h: CGFloat = template.family == .executive ? 1.5 : 1
  1189. v.heightAnchor.constraint(equalToConstant: h).isActive = true
  1190. return v
  1191. }
  1192. private func sidebarColumn(tinted: Bool) -> NSView {
  1193. let box = NSStackView()
  1194. box.orientation = .vertical
  1195. box.spacing = 12
  1196. box.alignment = .leading
  1197. if tinted {
  1198. box.wantsLayer = true
  1199. let tint = (variant % 4 == 1)
  1200. ? template.themeColor.withAlphaComponent(0.08)
  1201. : CVResumeAppearance.colors().sidebarTint
  1202. box.layer?.backgroundColor = tint.cgColor
  1203. box.layer?.cornerRadius = variant % 3 == 0 ? 8 : 6
  1204. }
  1205. box.edgeInsets = NSEdgeInsets(top: tinted ? 14 : 0, left: tinted ? 14 : 0, bottom: tinted ? 14 : 0, right: tinted ? 14 : 0)
  1206. box.addArrangedSubview(sectionHeading(L("Contact")))
  1207. for line in contactLines() {
  1208. box.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
  1209. }
  1210. let skills = skillTokensFromProfile(max: 8)
  1211. if !skills.isEmpty {
  1212. box.addArrangedSubview(sectionHeading(L("Skills")))
  1213. if variant % 5 == 2 {
  1214. box.addArrangedSubview(skillTagRow(theme: template.themeColor, maxTags: 5))
  1215. } else {
  1216. for token in skills {
  1217. box.addArrangedSubview(label("· \(token)", font: style.contactFont, color: style.ink, maxLines: 0))
  1218. }
  1219. }
  1220. }
  1221. if variant % 7 == 3, let tools = nonEmpty(profile.certificates) {
  1222. box.addArrangedSubview(sectionHeading(L("Tools")))
  1223. box.addArrangedSubview(paragraph(tools, compact: true))
  1224. } else if let ancillary = combinedAncillaryText(), !ancillary.isEmpty {
  1225. box.addArrangedSubview(sectionHeading(L("Languages & more")))
  1226. box.addArrangedSubview(paragraph(ancillary, compact: true))
  1227. }
  1228. return box
  1229. }
  1230. private func bodyColumn(compact: Bool, experienceFirst: Bool = false) -> NSView {
  1231. let v = NSStackView()
  1232. v.orientation = .vertical
  1233. v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
  1234. v.alignment = .leading
  1235. let summaryTitle = sectionHeading(summarySectionTitle)
  1236. let summaryBody: NSView? = nonEmpty(profile.careerSummary).map { paragraph($0, compact: compact) }
  1237. let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
  1238. let experienceHeading = sectionHeading(L("Experience"))
  1239. var experienceBlocks: [NSView] = []
  1240. for job in jobs {
  1241. experienceBlocks.append(experienceBlock(job: job, compact: compact))
  1242. }
  1243. let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
  1244. var educationBlocks: [NSView] = []
  1245. for edu in schools {
  1246. educationBlocks.append(educationBlock(edu: edu, compact: compact))
  1247. }
  1248. let appendSummary: () -> Void = { [self] in
  1249. if let body = summaryBody {
  1250. v.addArrangedSubview(summaryTitle)
  1251. v.addArrangedSubview(body)
  1252. }
  1253. }
  1254. let appendExperience: () -> Void = { [self] in
  1255. if !jobs.isEmpty {
  1256. v.addArrangedSubview(experienceHeading)
  1257. experienceBlocks.forEach { v.addArrangedSubview($0) }
  1258. }
  1259. }
  1260. let appendEducation: () -> Void = { [self] in
  1261. if !schools.isEmpty {
  1262. v.addArrangedSubview(sectionHeading(L("Education")))
  1263. educationBlocks.forEach { v.addArrangedSubview($0) }
  1264. }
  1265. }
  1266. if experienceFirst {
  1267. appendExperience()
  1268. appendSummary()
  1269. appendEducation()
  1270. } else {
  1271. appendSummary()
  1272. appendExperience()
  1273. appendEducation()
  1274. }
  1275. appendCertificatesInterestsReferrals(to: v, compact: compact)
  1276. return v
  1277. }
  1278. private func appendCertificatesInterestsReferrals(to v: NSStackView, compact: Bool) {
  1279. if let cert = nonEmpty(profile.certificates) {
  1280. v.addArrangedSubview(sectionHeading(L("Certificates")))
  1281. v.addArrangedSubview(paragraph(cert, compact: compact))
  1282. }
  1283. if let interests = nonEmpty(profile.interests) {
  1284. v.addArrangedSubview(sectionHeading(L("Interests")))
  1285. v.addArrangedSubview(paragraph(interests, compact: compact))
  1286. }
  1287. if let ref = nonEmpty(profile.referral) {
  1288. v.addArrangedSubview(sectionHeading(L("Referrals")))
  1289. v.addArrangedSubview(paragraph(ref, compact: compact))
  1290. }
  1291. }
  1292. private func skillTokensFromProfile(max: Int) -> [String] {
  1293. let raw = profile.languages.trimmingCharacters(in: .whitespacesAndNewlines)
  1294. if raw.isEmpty { return [] }
  1295. let parts = raw.split(whereSeparator: { $0 == "," || $0 == "·" || $0 == "|" || $0 == ";" })
  1296. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  1297. .filter { !$0.isEmpty }
  1298. if parts.count > 1 { return Array(parts.prefix(max)) }
  1299. return raw.split(separator: " ").map(String.init).filter { $0.count > 1 }.prefix(max).map { String($0) }
  1300. }
  1301. private func highlightsBodyText() -> String? {
  1302. if let t = nonEmpty(profile.interests) { return t }
  1303. if let r = nonEmpty(profile.referral) { return r }
  1304. let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
  1305. if let first = jobs.first {
  1306. let bullets = Self.bulletChunks(from: first.description)
  1307. if let b = bullets.first { return b }
  1308. }
  1309. return nil
  1310. }
  1311. private func ancillaryBlock(title: String, body: String?) -> NSStackView? {
  1312. guard let body, !body.isEmpty else { return nil }
  1313. let s = NSStackView()
  1314. s.orientation = .vertical
  1315. s.spacing = 6
  1316. s.alignment = .leading
  1317. s.addArrangedSubview(sectionHeading(title))
  1318. s.addArrangedSubview(paragraph(body, compact: true))
  1319. return s
  1320. }
  1321. private func contactLines() -> [String] {
  1322. var lines: [String] = []
  1323. let p = profile.personal
  1324. if !p.email.isEmpty { lines.append(p.email) }
  1325. if !p.phone.isEmpty { lines.append(p.phone) }
  1326. if !p.address.isEmpty { lines.append(p.address) }
  1327. return lines.isEmpty ? ["—"] : lines
  1328. }
  1329. private func combinedAncillaryText() -> String? {
  1330. let chunks = [profile.languages, profile.interests].map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
  1331. return chunks.isEmpty ? nil : chunks.joined(separator: "\n\n")
  1332. }
  1333. private func experienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
  1334. let v = NSStackView()
  1335. v.orientation = .vertical
  1336. v.spacing = template.family == .professional ? 4 : 6
  1337. v.alignment = .leading
  1338. if template.family == .professional {
  1339. let title = job.jobTitle.trimmingCharacters(in: .whitespacesAndNewlines)
  1340. let company = job.company.trimmingCharacters(in: .whitespacesAndNewlines)
  1341. let duration = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
  1342. if !title.isEmpty {
  1343. v.addArrangedSubview(label(title, font: style.expTitleFont, color: style.ink, maxLines: 0))
  1344. }
  1345. let metaParts = [company, duration].filter { !$0.isEmpty }
  1346. if !metaParts.isEmpty {
  1347. let metaJoined = metaParts.joined(separator: " · ")
  1348. v.addArrangedSubview(label(metaJoined, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
  1349. } else if title.isEmpty {
  1350. let fallback = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
  1351. if !fallback.isEmpty {
  1352. v.addArrangedSubview(label(fallback, font: style.expTitleFont, color: style.ink, maxLines: 0))
  1353. }
  1354. }
  1355. } else {
  1356. let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
  1357. let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
  1358. if !titleLine.isEmpty {
  1359. v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
  1360. }
  1361. if !meta.isEmpty {
  1362. v.addArrangedSubview(label(meta, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
  1363. }
  1364. }
  1365. for bullet in Self.bulletChunks(from: job.description) {
  1366. v.addArrangedSubview(bulletRow(bullet, compact: compact))
  1367. }
  1368. return v
  1369. }
  1370. private func educationBlock(edu: EducationPayload, compact: Bool) -> NSView {
  1371. let v = NSStackView()
  1372. v.orientation = .vertical
  1373. v.spacing = 4
  1374. v.alignment = .leading
  1375. let institution = edu.institution.trimmingCharacters(in: .whitespacesAndNewlines)
  1376. let degree = edu.degree.trimmingCharacters(in: .whitespacesAndNewlines)
  1377. let year = edu.year.trimmingCharacters(in: .whitespacesAndNewlines)
  1378. if template.family == .professional {
  1379. if !institution.isEmpty {
  1380. v.addArrangedSubview(label(institution, font: style.eduTitleFont, color: style.ink, maxLines: 0))
  1381. }
  1382. let subParts = [degree, year].filter { !$0.isEmpty }
  1383. if !subParts.isEmpty {
  1384. let sub = subParts.joined(separator: " · ")
  1385. v.addArrangedSubview(label(sub, font: style.eduMetaFont, color: style.muted, maxLines: 0))
  1386. }
  1387. } else {
  1388. let head = [edu.institution, edu.degree].filter { !$0.isEmpty }.joined(separator: " — ")
  1389. if !head.isEmpty {
  1390. v.addArrangedSubview(label(head, font: style.eduTitleFont, color: style.ink, maxLines: 0))
  1391. }
  1392. if !edu.year.isEmpty {
  1393. v.addArrangedSubview(label(edu.year, font: style.eduMetaFont, color: style.muted, maxLines: 0))
  1394. }
  1395. }
  1396. return v
  1397. }
  1398. private func bulletRow(_ text: String, compact: Bool) -> NSView {
  1399. let marker: String = template.family == .minimal ? "·" : "•"
  1400. let dot = NSTextField(labelWithString: marker)
  1401. dot.font = style.bulletMarkerFont
  1402. dot.textColor = style.bulletMarkerColor
  1403. dot.translatesAutoresizingMaskIntoConstraints = false
  1404. let bodyFont = compact ? style.bodyCompactFont : style.bulletBodyFont
  1405. let body = label(text, font: bodyFont, color: style.ink, maxLines: 0)
  1406. let row = NSStackView(views: [dot, body])
  1407. row.orientation = .horizontal
  1408. row.spacing = template.family == .creative ? 10 : 8
  1409. row.alignment = .top
  1410. dot.setContentHuggingPriority(.required, for: .horizontal)
  1411. return row
  1412. }
  1413. private func paragraph(_ text: String, compact: Bool) -> NSTextField {
  1414. let font = compact ? style.bodyCompactFont : style.bodyFont
  1415. return label(text, font: font, color: style.ink, maxLines: 0)
  1416. }
  1417. /// Gallery + ATS “Clear Path” style use “Profile”; other families keep the neutral résumé label.
  1418. private var summarySectionTitle: String {
  1419. template.family == .professional ? L("Profile") : L("Summary")
  1420. }
  1421. private func sectionHeading(_ raw: String) -> NSTextField {
  1422. let upper = raw.uppercased()
  1423. let s: String
  1424. switch template.sectionLabelStyle {
  1425. case .uppercase: s = upper
  1426. case .slashed: s = "// \(upper)"
  1427. case .bracketed: s = "[ \(upper) ]"
  1428. }
  1429. return label(s, font: style.sectionFont, color: style.sectionInk, maxLines: 1)
  1430. }
  1431. /// Résumé templates are designed LTR (columns, rails, sidebars). Keep layout LTR even when UI copy is Arabic.
  1432. private func enforceLeftToRightLayout(on view: NSView) {
  1433. view.userInterfaceLayoutDirection = .leftToRight
  1434. for child in view.subviews {
  1435. enforceLeftToRightLayout(on: child)
  1436. }
  1437. }
  1438. private func label(_ string: String, font: NSFont, color: NSColor, maxLines: Int) -> NSTextField {
  1439. let isWrapping = maxLines == 0
  1440. let t: NSTextField
  1441. if isWrapping {
  1442. t = NSTextField(wrappingLabelWithString: string)
  1443. t.maximumNumberOfLines = 0
  1444. } else {
  1445. t = NSTextField(labelWithString: string)
  1446. t.maximumNumberOfLines = maxLines
  1447. }
  1448. t.translatesAutoresizingMaskIntoConstraints = false
  1449. t.font = font
  1450. t.textColor = color
  1451. t.alignment = .left
  1452. applyLeftToRightTextLayout(to: t)
  1453. return t
  1454. }
  1455. /// Mixed Arabic/English body copy still measures and wraps in column width when base direction is LTR.
  1456. private func applyLeftToRightTextLayout(to field: NSTextField) {
  1457. let paragraph = NSMutableParagraphStyle()
  1458. paragraph.alignment = field.alignment
  1459. paragraph.baseWritingDirection = .leftToRight
  1460. paragraph.lineBreakMode = field.maximumNumberOfLines == 0 ? .byWordWrapping : .byTruncatingTail
  1461. field.attributedStringValue = NSAttributedString(string: field.stringValue, attributes: [
  1462. .font: field.font ?? .systemFont(ofSize: 12),
  1463. .foregroundColor: field.textColor ?? .labelColor,
  1464. .paragraphStyle: paragraph
  1465. ])
  1466. }
  1467. private func displayable(_ value: String, placeholder: String) -> String {
  1468. let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
  1469. return t.isEmpty ? placeholder : t
  1470. }
  1471. private func nonEmpty(_ value: String) -> String? {
  1472. let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
  1473. return t.isEmpty ? nil : t
  1474. }
  1475. private static func bulletChunks(from text: String) -> [String] {
  1476. let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
  1477. if trimmed.isEmpty { return [] }
  1478. let byNewline = trimmed.components(separatedBy: .newlines)
  1479. .map { $0.trimmingCharacters(in: .whitespaces) }
  1480. .filter { !$0.isEmpty }
  1481. if byNewline.count > 1 { return byNewline }
  1482. let byBullet = trimmed.split(separator: "•")
  1483. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  1484. .filter { !$0.isEmpty }
  1485. if byBullet.count > 1 { return byBullet.map { String($0) } }
  1486. return [trimmed]
  1487. }
  1488. }
  1489. // MARK: - Payload helpers
  1490. private extension WorkExperiencePayload {
  1491. var isEffectivelyEmpty: Bool {
  1492. [jobTitle, company, duration, description].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  1493. }
  1494. }
  1495. private extension EducationPayload {
  1496. var isEffectivelyEmpty: Bool {
  1497. [degree, institution, year].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  1498. }
  1499. }