Sin descripción

CVProfileDocumentView.swift 72KB

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