| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652 |
- //
- // CVProfileDocumentView.swift
- // App for Indeed
- //
- // Renders saved profile data in a layout that follows the selected CV template’s
- // family (professional / modern / minimal / executive / creative), headline,
- // accent, section labels, and column structure.
- //
- import Cocoa
- /// Typography and chrome derived from `CVTemplate.family` so the filled résumé
- /// visibly matches the gallery card the user picked—not a single generic layout.
- private struct DocumentStyle {
- let nameFont: NSFont
- let roleFont: NSFont
- let contactFont: NSFont
- let sectionFont: NSFont
- let bodyFont: NSFont
- let bodyCompactFont: NSFont
- let expTitleFont: NSFont
- let expMetaFont: NSFont
- let eduTitleFont: NSFont
- let eduMetaFont: NSFont
- let bulletBodyFont: NSFont
- let bulletMarkerFont: NSFont
- let bulletMarkerColor: NSColor
- let ink: NSColor
- let muted: NSColor
- let rule: NSColor
- let cardBackground: NSColor
- let columnVerticalSpacing: CGFloat
- let bodyBlockSpacing: CGFloat
- /// When true, the headline job title uses the template theme color.
- let roleUsesThemeColor: Bool
- /// Section heading text color (often theme; executive stays conservative).
- let sectionInk: NSColor
- static func make(for template: CVTemplate) -> DocumentStyle {
- let theme = template.themeColor
- let colors = CVResumeAppearance.colors()
- let sectionInk = CVResumeAppearance.sectionHeadingColor(for: template)
- let cardBG = CVResumeAppearance.paperBackground(
- variant: template.galleryLayoutVariant,
- base: colors.cardBackground
- )
- switch template.family {
- case .minimal:
- return DocumentStyle(
- nameFont: .systemFont(ofSize: 20, weight: .regular),
- roleFont: .systemFont(ofSize: 13.5, weight: .regular),
- contactFont: .systemFont(ofSize: 11.5, weight: .regular),
- sectionFont: .systemFont(ofSize: 10.5, weight: .semibold),
- bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
- bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
- expTitleFont: .systemFont(ofSize: 13, weight: .medium),
- expMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
- eduTitleFont: .systemFont(ofSize: 13, weight: .medium),
- eduMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
- bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
- bulletMarkerFont: .systemFont(ofSize: 11, weight: .light),
- bulletMarkerColor: theme.withAlphaComponent(0.55),
- ink: colors.ink,
- muted: colors.muted,
- rule: colors.rule,
- cardBackground: cardBG,
- columnVerticalSpacing: 15,
- bodyBlockSpacing: 15,
- roleUsesThemeColor: false,
- sectionInk: sectionInk
- )
- case .professional:
- return DocumentStyle(
- nameFont: .systemFont(ofSize: 21, weight: .semibold),
- roleFont: .systemFont(ofSize: 13.5, weight: .medium),
- contactFont: .systemFont(ofSize: 11.5, weight: .regular),
- sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
- bodyFont: .systemFont(ofSize: 12, weight: .regular),
- bodyCompactFont: .systemFont(ofSize: 11.5, weight: .regular),
- expTitleFont: .systemFont(ofSize: 13.5, weight: .semibold),
- expMetaFont: .systemFont(ofSize: 11.5, weight: .semibold),
- eduTitleFont: .systemFont(ofSize: 13, weight: .semibold),
- eduMetaFont: .systemFont(ofSize: 11.5, weight: .medium),
- bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
- bulletMarkerFont: .systemFont(ofSize: 12, weight: .bold),
- bulletMarkerColor: theme,
- ink: colors.ink,
- muted: colors.muted,
- rule: colors.rule,
- cardBackground: cardBG,
- columnVerticalSpacing: 13,
- bodyBlockSpacing: 13,
- roleUsesThemeColor: false,
- sectionInk: sectionInk
- )
- case .modern:
- return DocumentStyle(
- nameFont: .systemFont(ofSize: 22, weight: .bold),
- roleFont: .systemFont(ofSize: 14, weight: .semibold),
- contactFont: .systemFont(ofSize: 12, weight: .regular),
- sectionFont: .systemFont(ofSize: 11, weight: .heavy),
- bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
- bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
- expTitleFont: .systemFont(ofSize: 14, weight: .bold),
- expMetaFont: .systemFont(ofSize: 12, weight: .medium),
- eduTitleFont: .systemFont(ofSize: 13.5, weight: .bold),
- eduMetaFont: .systemFont(ofSize: 12, weight: .regular),
- bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
- bulletMarkerFont: .systemFont(ofSize: 13, weight: .bold),
- bulletMarkerColor: theme,
- ink: colors.ink,
- muted: colors.muted,
- rule: colors.rule,
- cardBackground: cardBG,
- columnVerticalSpacing: 17,
- bodyBlockSpacing: 16,
- roleUsesThemeColor: true,
- sectionInk: sectionInk
- )
- case .executive:
- let serifName = NSFont(name: "Georgia-Bold", size: 23) ?? .systemFont(ofSize: 23, weight: .semibold)
- let serifRole = NSFont(name: "Georgia", size: 14) ?? .systemFont(ofSize: 14, weight: .regular)
- let serifBody = NSFont(name: "Georgia", size: 12.5) ?? .systemFont(ofSize: 12.5, weight: .regular)
- let serifCompact = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12, weight: .regular)
- let georgia12 = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12)
- let georgia115 = NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5)
- let expMeta = NSFont(name: "Georgia-Italic", size: 12)
- ?? NSFontManager.shared.convert(georgia12, toHaveTrait: .italicFontMask)
- let eduMeta = NSFont(name: "Georgia-Italic", size: 11.5)
- ?? NSFontManager.shared.convert(georgia115, toHaveTrait: .italicFontMask)
- let execCard = CVResumeAppearance.isDark
- ? cardBG
- : NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1)
- return DocumentStyle(
- nameFont: serifName,
- roleFont: serifRole,
- contactFont: NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5),
- sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
- bodyFont: serifBody,
- bodyCompactFont: serifCompact,
- expTitleFont: NSFont(name: "Georgia-Bold", size: 14) ?? .systemFont(ofSize: 14, weight: .semibold),
- expMetaFont: expMeta,
- eduTitleFont: NSFont(name: "Georgia-Bold", size: 13.5) ?? .systemFont(ofSize: 13.5, weight: .semibold),
- eduMetaFont: eduMeta,
- bulletBodyFont: serifCompact,
- bulletMarkerFont: .systemFont(ofSize: 11, weight: .bold),
- bulletMarkerColor: colors.ink.withAlphaComponent(0.75),
- ink: colors.ink,
- muted: colors.muted,
- rule: colors.rule,
- cardBackground: execCard,
- columnVerticalSpacing: 18,
- bodyBlockSpacing: 17,
- roleUsesThemeColor: false,
- sectionInk: sectionInk
- )
- case .creative:
- return DocumentStyle(
- nameFont: .systemFont(ofSize: 23, weight: .heavy),
- roleFont: .systemFont(ofSize: 14, weight: .semibold),
- contactFont: .systemFont(ofSize: 11.5, weight: .medium),
- sectionFont: .systemFont(ofSize: 11.5, weight: .heavy),
- bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
- bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
- expTitleFont: .systemFont(ofSize: 14, weight: .heavy),
- expMetaFont: .systemFont(ofSize: 12, weight: .semibold),
- eduTitleFont: .systemFont(ofSize: 13.5, weight: .heavy),
- eduMetaFont: .systemFont(ofSize: 12, weight: .medium),
- bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
- bulletMarkerFont: .systemFont(ofSize: 13, weight: .heavy),
- bulletMarkerColor: CVResumeAppearance.accentColor(for: template),
- ink: colors.ink,
- muted: colors.muted,
- rule: theme.withAlphaComponent(0.22),
- cardBackground: cardBG,
- columnVerticalSpacing: 18,
- bodyBlockSpacing: 17,
- roleUsesThemeColor: true,
- sectionInk: sectionInk
- )
- }
- }
- }
- /// Full-width résumé layout that injects `SavedProfile` into the visual language of `CVTemplate`.
- final class CVProfileDocumentView: NSView {
- /// Card width used in the CV preview; also the horizontal fitting size for this view.
- /// Without this, a parent `NSStackView` that only pins `width ≤ …` can size the document from
- /// wrapping `NSTextField` intrinsic widths (~0) and the whole page collapses to a thin strip.
- private static let cardWidth: CGFloat = 640
- private let profile: SavedProfile
- private let template: CVTemplate
- private var style: DocumentStyle
- /// Matches `CVTemplatePreviewView` so the same template id + layout recipe renders the same silhouette as the gallery card.
- private let variant: Int
- private var appearanceObserver: NSObjectProtocol?
- private weak var cardView: NSView?
- init(profile: SavedProfile, template: CVTemplate) {
- self.profile = profile
- self.template = template
- self.style = DocumentStyle.make(for: template)
- self.variant = template.galleryLayoutVariant
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.backgroundColor = NSColor.clear.cgColor
- userInterfaceLayoutDirection = .leftToRight
- setContentHuggingPriority(.defaultLow, for: .horizontal)
- installCardContent()
- appearanceObserver = NotificationCenter.default.addObserver(
- forName: AppAppearanceManager.didChangeNotification,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- self?.refreshForAppearanceChange()
- }
- }
- deinit {
- if let appearanceObserver {
- NotificationCenter.default.removeObserver(appearanceObserver)
- }
- }
- override func viewDidChangeEffectiveAppearance() {
- super.viewDidChangeEffectiveAppearance()
- refreshForAppearanceChange()
- }
- private func refreshForAppearanceChange() {
- style = DocumentStyle.make(for: template)
- installCardContent()
- }
- private func installCardContent() {
- subviews.forEach { $0.removeFromSuperview() }
- let card = NSView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.backgroundColor = style.cardBackground.cgColor
- card.layer?.cornerRadius = template.family == .executive ? 6 : 10
- card.layer?.borderWidth = 1
- card.layer?.borderColor = style.rule.cgColor
- card.layer?.masksToBounds = true
- cardView = card
- let root = buildRoot()
- root.translatesAutoresizingMaskIntoConstraints = false
- card.addSubview(root)
- addSubview(card)
- NSLayoutConstraint.activate([
- card.leadingAnchor.constraint(equalTo: leadingAnchor),
- card.trailingAnchor.constraint(equalTo: trailingAnchor),
- card.topAnchor.constraint(equalTo: topAnchor),
- card.bottomAnchor.constraint(equalTo: bottomAnchor),
- card.widthAnchor.constraint(equalToConstant: Self.cardWidth),
- root.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 36),
- root.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -36),
- root.topAnchor.constraint(equalTo: card.topAnchor, constant: 32),
- root.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -36)
- ])
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- override var intrinsicContentSize: NSSize {
- NSSize(width: Self.cardWidth, height: NSView.noIntrinsicMetric)
- }
- override func layout() {
- super.layout()
- // Wrapping `NSTextField`s default to a very small intrinsic width until
- // `preferredMaxLayoutWidth` tracks the column width — stacks then collapse and text reflows like a narrow strip.
- updateWrappingTextPreferredWidths()
- }
- /// Any wrapping body (`maximumNumberOfLines == 0`) needs a concrete wrap width inside stack-driven layout.
- private func updateWrappingTextPreferredWidths() {
- for field in Self.collectWrappingTextFields(in: self) {
- guard let parent = field.superview, parent.bounds.width > 2 else { continue }
- let w = parent.bounds.width
- if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
- field.preferredMaxLayoutWidth = w
- }
- }
- }
- private static func collectWrappingTextFields(in root: NSView) -> [NSTextField] {
- var out: [NSTextField] = []
- func visit(_ v: NSView) {
- if let tf = v as? NSTextField, tf.maximumNumberOfLines == 0 {
- out.append(tf)
- }
- for c in v.subviews { visit(c) }
- }
- visit(root)
- return out
- }
- // MARK: - Composition
- private func buildRoot() -> NSView {
- switch template.family {
- case .modern:
- return buildModernFamilyDocument()
- case .creative:
- return buildCreativeFamilyDocument()
- case .executive:
- return buildExecutiveDocument()
- case .minimal:
- return buildMinimalDocument()
- case .professional:
- return buildProfessionalDocument()
- }
- }
- // MARK: - Modern (gallery uses three distinct silhouettes from `variant`)
- private func buildModernFamilyDocument() -> NSView {
- switch variant % 3 {
- case 0: return modernClassicBandDocument()
- case 1: return modernRailDocument()
- default: return modernSplitHeaderDocument()
- }
- }
- private func modernClassicBandDocument() -> NSView {
- let theme = template.themeColor
- let white = NSColor.white
- let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
- let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
- let header = NSView()
- header.translatesAutoresizingMaskIntoConstraints = false
- header.wantsLayer = true
- header.layer?.backgroundColor = theme.cgColor
- header.layer?.cornerRadius = variant % 2 == 0 ? 8 : 6
- let name = label(nameText, font: .systemFont(ofSize: 22, weight: .bold), color: white, maxLines: 2)
- let role = label(roleText, font: .systemFont(ofSize: 14, weight: .medium), color: white.withAlphaComponent(0.92), maxLines: 2)
- let textCol = NSStackView(views: [name, role])
- textCol.orientation = .vertical
- textCol.spacing = 4
- textCol.alignment = .leading
- textCol.translatesAutoresizingMaskIntoConstraints = false
- let iconRow = NSStackView()
- iconRow.orientation = .horizontal
- iconRow.spacing = 10
- iconRow.translatesAutoresizingMaskIntoConstraints = false
- for sym in ["mappin.and.ellipse", "phone.fill", "envelope.fill"] {
- guard let img = NSImage(systemSymbolName: sym, accessibilityDescription: nil) else { continue }
- let iv = NSImageView(image: img)
- iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
- iv.contentTintColor = white.withAlphaComponent(0.88)
- iconRow.addArrangedSubview(iv)
- }
- let topRow = NSStackView()
- topRow.orientation = .horizontal
- topRow.spacing = 14
- topRow.alignment = .centerY
- topRow.translatesAutoresizingMaskIntoConstraints = false
- topRow.addArrangedSubview(textCol)
- topRow.addArrangedSubview(NSView())
- topRow.addArrangedSubview(iconRow)
- header.addSubview(topRow)
- NSLayoutConstraint.activate([
- topRow.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 18),
- topRow.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -18),
- topRow.topAnchor.constraint(equalTo: header.topAnchor, constant: 14),
- topRow.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -14)
- ])
- let body: NSView
- switch template.layout {
- case .singleColumn:
- body = modernMainContentColumn(compact: false, includeSummaryInMain: true)
- case .twoColumn(let side, let tinted):
- let main = modernMainContentColumn(compact: true, includeSummaryInMain: false)
- let sideCol = modernAboutHighlightsSidebar(tinted: tinted)
- let row = NSStackView()
- row.orientation = .horizontal
- row.spacing = 20
- row.alignment = .top
- row.translatesAutoresizingMaskIntoConstraints = false
- let mult: CGFloat = (variant % 4 == 2) ? 0.36 : 0.32
- if side == .leading {
- row.addArrangedSubview(sideCol)
- row.addArrangedSubview(main)
- sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
- } else {
- row.addArrangedSubview(main)
- row.addArrangedSubview(sideCol)
- sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
- }
- body = row
- }
- let wrap = NSStackView(views: [header, body])
- wrap.orientation = .vertical
- wrap.spacing = 18
- wrap.alignment = .leading
- return wrap
- }
- private func modernRailDocument() -> NSView {
- let theme = template.themeColor
- let rail = NSView()
- rail.translatesAutoresizingMaskIntoConstraints = false
- rail.wantsLayer = true
- rail.layer?.backgroundColor = theme.cgColor
- rail.layer?.cornerRadius = 2
- rail.widthAnchor.constraint(equalToConstant: 3 + CGFloat(variant % 2)).isActive = true
- let inner = NSStackView()
- inner.orientation = .vertical
- inner.spacing = 10
- inner.alignment = .leading
- inner.translatesAutoresizingMaskIntoConstraints = false
- let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
- let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
- let contactParts = [profile.personal.email, profile.personal.phone].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
- let contactLine = contactParts.isEmpty ? "Add contact in your profile" : contactParts.joined(separator: " · ")
- inner.addArrangedSubview(label(nameText, font: .systemFont(ofSize: 21, weight: .bold), color: style.ink, maxLines: 2))
- inner.addArrangedSubview(label(roleText, font: .systemFont(ofSize: 14, weight: .semibold), color: theme, maxLines: 2))
- inner.addArrangedSubview(label(contactLine, font: style.contactFont, color: style.muted, maxLines: 2))
- inner.addArrangedSubview(hairline())
- inner.addArrangedSubview(skillTagRow(theme: theme, maxTags: 5))
- inner.addArrangedSubview(modernPrimaryBody(theme: theme))
- let row = NSStackView(views: [rail, inner])
- row.orientation = .horizontal
- row.spacing = 14
- row.alignment = .top
- return row
- }
- private func modernSplitHeaderDocument() -> NSView {
- let theme = template.themeColor
- let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
- let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
- let loc = profile.personal.address.trimmingCharacters(in: .whitespacesAndNewlines)
- let left = NSStackView()
- left.orientation = .vertical
- left.spacing = 5
- left.alignment = .leading
- left.addArrangedSubview(label(nameText, font: .systemFont(ofSize: 21, weight: .bold), color: style.ink, maxLines: 2))
- left.addArrangedSubview(label(roleText, font: .systemFont(ofSize: 13.5, weight: .medium), color: style.muted, maxLines: 2))
- if !loc.isEmpty {
- left.addArrangedSubview(label(loc, font: style.contactFont, color: style.muted.withAlphaComponent(0.88), maxLines: 2))
- }
- let right = NSStackView()
- right.orientation = .vertical
- right.spacing = 8
- right.alignment = .leading
- right.wantsLayer = true
- right.layer?.backgroundColor = theme.cgColor
- right.layer?.cornerRadius = 8
- right.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
- let onW = NSColor.white
- if !profile.personal.email.isEmpty {
- right.addArrangedSubview(label(profile.personal.email, font: .systemFont(ofSize: 12, weight: .medium), color: onW.withAlphaComponent(0.95), maxLines: 2))
- }
- if !profile.personal.phone.isEmpty {
- right.addArrangedSubview(label(profile.personal.phone, font: .systemFont(ofSize: 12, weight: .medium), color: onW.withAlphaComponent(0.92), maxLines: 1))
- }
- if !loc.isEmpty {
- right.addArrangedSubview(label(loc, font: .systemFont(ofSize: 11.5, weight: .regular), color: onW.withAlphaComponent(0.8), maxLines: 2))
- }
- let top = NSStackView(views: [left, right])
- top.orientation = .horizontal
- top.spacing = 16
- top.alignment = .top
- left.widthAnchor.constraint(equalTo: top.widthAnchor, multiplier: 0.54).isActive = true
- let col = NSStackView(views: [top, hairline(), modernPrimaryBody(theme: theme)])
- col.orientation = .vertical
- col.spacing = 16
- col.alignment = .leading
- return col
- }
- private func modernPrimaryBody(theme: NSColor) -> NSView {
- switch template.layout {
- case .singleColumn:
- return modernMainContentColumn(compact: false, includeSummaryInMain: true)
- case .twoColumn(let side, let tinted):
- let main = modernMainContentColumn(compact: true, includeSummaryInMain: false)
- let sideCol = modernAboutHighlightsSidebar(tinted: tinted)
- let row = NSStackView()
- row.orientation = .horizontal
- row.spacing = 20
- row.alignment = .top
- let mult: CGFloat = (variant % 4 == 2) ? 0.36 : 0.32
- if side == .leading {
- row.addArrangedSubview(sideCol)
- row.addArrangedSubview(main)
- sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
- } else {
- row.addArrangedSubview(main)
- row.addArrangedSubview(sideCol)
- sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
- }
- return row
- }
- }
- private func modernMainContentColumn(compact: Bool, includeSummaryInMain: Bool) -> NSView {
- let theme = template.themeColor
- let v = NSStackView()
- v.orientation = .vertical
- v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
- v.alignment = .leading
- if includeSummaryInMain, let summary = nonEmpty(profile.careerSummary) {
- v.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: "About", theme: theme))
- v.addArrangedSubview(paragraph(summary, compact: compact))
- }
- let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
- if !jobs.isEmpty {
- v.addArrangedSubview(modernSectionRow(symbol: "briefcase.fill", title: "Experience", theme: theme))
- for (index, job) in jobs.enumerated() {
- v.addArrangedSubview(experienceBlock(job: job, compact: compact))
- if index == 0 {
- v.addArrangedSubview(skillTagRow(theme: theme, maxTags: 5))
- }
- }
- }
- let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
- if !schools.isEmpty {
- v.addArrangedSubview(modernSectionRow(symbol: "graduationcap.fill", title: "Education", theme: theme))
- for edu in schools {
- v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
- }
- }
- appendCertificatesInterestsReferrals(to: v, compact: compact)
- return v
- }
- private func modernAboutHighlightsSidebar(tinted: Bool) -> NSView {
- let theme = template.themeColor
- let box = NSStackView()
- box.orientation = .vertical
- box.spacing = 12
- box.alignment = .leading
- if tinted {
- box.wantsLayer = true
- box.layer?.backgroundColor = theme.withAlphaComponent(0.1).cgColor
- box.layer?.cornerRadius = 8
- box.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
- }
- if let summary = nonEmpty(profile.careerSummary) {
- box.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: "About", theme: theme))
- box.addArrangedSubview(paragraph(summary, compact: true))
- }
- if let hi = highlightsBodyText() {
- box.addArrangedSubview(modernSectionRow(symbol: "star.fill", title: "Highlights", theme: theme))
- box.addArrangedSubview(paragraph(hi, compact: true))
- }
- if box.arrangedSubviews.isEmpty {
- box.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: "About", theme: theme))
- box.addArrangedSubview(paragraph("Add a career summary or interests in your profile to populate this column.", compact: true))
- }
- return box
- }
- private func modernSectionRow(symbol: String, title: String, theme: NSColor) -> NSView {
- guard let img = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) else {
- return sectionHeading(title)
- }
- let iv = NSImageView(image: img)
- iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
- iv.contentTintColor = theme
- let t = label(title.uppercased(), font: style.sectionFont, color: style.ink, maxLines: 1)
- let r = NSStackView(views: [iv, t])
- r.orientation = .horizontal
- r.spacing = 8
- r.alignment = .centerY
- return r
- }
- private func skillTagRow(theme: NSColor, maxTags: Int) -> NSView {
- let tags = skillTokensFromProfile(max: maxTags)
- guard !tags.isEmpty else { return NSView() }
- let row = NSStackView()
- row.orientation = .horizontal
- row.spacing = 8
- row.alignment = .centerY
- for s in tags {
- let tag = NSView()
- tag.wantsLayer = true
- tag.layer?.backgroundColor = theme.withAlphaComponent(0.14).cgColor
- tag.layer?.cornerRadius = 6
- tag.translatesAutoresizingMaskIntoConstraints = false
- let lab = label(s, font: .systemFont(ofSize: 11, weight: .semibold), color: theme.blended(withFraction: 0.35, of: style.ink) ?? style.ink, maxLines: 1)
- lab.alignment = .center
- lab.translatesAutoresizingMaskIntoConstraints = false
- tag.addSubview(lab)
- NSLayoutConstraint.activate([
- lab.leadingAnchor.constraint(equalTo: tag.leadingAnchor, constant: 10),
- lab.trailingAnchor.constraint(equalTo: tag.trailingAnchor, constant: -10),
- lab.topAnchor.constraint(equalTo: tag.topAnchor, constant: 5),
- lab.bottomAnchor.constraint(equalTo: tag.bottomAnchor, constant: -5)
- ])
- row.addArrangedSubview(tag)
- }
- return row
- }
- // MARK: - Creative (dark sidebar in gallery — match filled page)
- private func buildCreativeFamilyDocument() -> NSView {
- switch template.layout {
- case .singleColumn:
- return creativeSingleColumnDocument()
- case .twoColumn(let side, _):
- return creativeTwoColumnDocument(sidebar: side)
- }
- }
- private func creativeDeepBackground() -> NSColor {
- let theme = template.themeColor
- let navy = NSColor(srgbRed: 0.08, green: 0.1, blue: 0.18, alpha: 1)
- let plum = NSColor(srgbRed: 0.14, green: 0.07, blue: 0.24, alpha: 1)
- switch variant % 4 {
- case 0: return theme.blended(withFraction: 0.52, of: navy) ?? theme
- case 1: return theme.blended(withFraction: 0.7, of: NSColor.black) ?? theme
- case 2: return style.ink.blended(withFraction: 0.38, of: theme) ?? theme
- default: return theme.blended(withFraction: 0.4, of: plum) ?? theme
- }
- }
- private func creativeSingleColumnDocument() -> NSView {
- let theme = template.themeColor
- let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
- let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
- let banner = NSView()
- banner.translatesAutoresizingMaskIntoConstraints = false
- banner.wantsLayer = true
- banner.layer?.backgroundColor = theme.cgColor
- banner.layer?.cornerRadius = variant % 4 == 1 ? 8 : 6
- let inner = label(" \(nameText) · \(roleText) ", font: .systemFont(ofSize: 14, weight: .bold), color: .white, maxLines: 2)
- inner.translatesAutoresizingMaskIntoConstraints = false
- banner.addSubview(inner)
- NSLayoutConstraint.activate([
- inner.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 14),
- inner.trailingAnchor.constraint(lessThanOrEqualTo: banner.trailingAnchor, constant: -14),
- inner.topAnchor.constraint(equalTo: banner.topAnchor, constant: 12),
- inner.bottomAnchor.constraint(equalTo: banner.bottomAnchor, constant: -12)
- ])
- let main = creativeMainStack(theme: theme)
- let col = NSStackView(views: [banner, main])
- col.orientation = .vertical
- col.spacing = 16
- col.alignment = .leading
- return col
- }
- private func creativeTwoColumnDocument(sidebar: CVTemplate.SidebarSide) -> NSView {
- let theme = template.themeColor
- let deep = creativeDeepBackground()
- let onSidebar = NSColor.white.withAlphaComponent(0.95)
- let skillPrefix = (variant % 3 == 0) ? "• " : "▸ "
- let sidebarStack = NSStackView()
- sidebarStack.orientation = .vertical
- sidebarStack.spacing = 12
- sidebarStack.alignment = .leading
- sidebarStack.wantsLayer = true
- sidebarStack.layer?.backgroundColor = deep.cgColor
- sidebarStack.layer?.cornerRadius = variant % 2 == 0 ? 10 : 8
- sidebarStack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
- let nm = displayable(profile.personal.fullName, placeholder: "Your name")
- let role = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
- sidebarStack.addArrangedSubview(label(nm, font: .systemFont(ofSize: 18, weight: .bold), color: onSidebar, maxLines: 2))
- sidebarStack.addArrangedSubview(label(role, font: .systemFont(ofSize: 13, weight: .medium), color: onSidebar.withAlphaComponent(0.85), maxLines: 2))
- if !profile.personal.email.isEmpty {
- sidebarStack.addArrangedSubview(label(profile.personal.email, font: .systemFont(ofSize: 11.5), color: onSidebar.withAlphaComponent(0.82), maxLines: 2))
- }
- if !profile.personal.phone.isEmpty {
- sidebarStack.addArrangedSubview(label(profile.personal.phone, font: .systemFont(ofSize: 11.5), color: onSidebar.withAlphaComponent(0.82), maxLines: 1))
- }
- sidebarStack.addArrangedSubview(creativeSidebarHeading("STRENGTHS", onSidebar: onSidebar, accent: theme))
- for token in skillTokensFromProfile(max: 8) {
- sidebarStack.addArrangedSubview(label("\(skillPrefix)\(token)", font: .systemFont(ofSize: 12, weight: .semibold), color: onSidebar.withAlphaComponent(0.92), maxLines: 2))
- }
- let main = creativeMainStack(theme: theme)
- let row = NSStackView()
- row.orientation = .horizontal
- row.spacing = 18
- row.alignment = .top
- let sidebarMult = 0.32 + CGFloat(variant % 3) * 0.02
- if sidebar == .leading {
- row.addArrangedSubview(sidebarStack)
- row.addArrangedSubview(main)
- sidebarStack.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
- } else {
- row.addArrangedSubview(main)
- row.addArrangedSubview(sidebarStack)
- sidebarStack.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
- }
- return row
- }
- private func creativeSidebarHeading(_ raw: String, onSidebar: NSColor, accent: NSColor) -> NSView {
- let t = label(raw, font: .systemFont(ofSize: 10.5, weight: .heavy), color: onSidebar, maxLines: 1)
- let bar = NSView()
- bar.translatesAutoresizingMaskIntoConstraints = false
- bar.wantsLayer = true
- bar.layer?.backgroundColor = accent.cgColor
- bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
- let c = NSStackView(views: [t, bar])
- c.orientation = .vertical
- c.spacing = 4
- c.alignment = .leading
- bar.leadingAnchor.constraint(equalTo: t.leadingAnchor).isActive = true
- bar.widthAnchor.constraint(equalToConstant: 72).isActive = true
- return c
- }
- private func creativeMainHeader(theme: NSColor) -> NSView {
- let v = NSView()
- v.translatesAutoresizingMaskIntoConstraints = false
- let stripe = NSView()
- stripe.translatesAutoresizingMaskIntoConstraints = false
- stripe.wantsLayer = true
- stripe.layer?.backgroundColor = theme.cgColor
- v.addSubview(stripe)
- let row = NSStackView()
- row.orientation = .horizontal
- row.spacing = 8
- row.translatesAutoresizingMaskIntoConstraints = false
- let lab = label("PORTFOLIO SNAPSHOT", font: .systemFont(ofSize: 12, weight: .heavy), color: style.ink, maxLines: 1)
- row.addArrangedSubview(stripe)
- row.addArrangedSubview(lab)
- v.addSubview(row)
- NSLayoutConstraint.activate([
- stripe.widthAnchor.constraint(equalToConstant: 4),
- stripe.heightAnchor.constraint(equalToConstant: 18),
- row.leadingAnchor.constraint(equalTo: v.leadingAnchor),
- row.topAnchor.constraint(equalTo: v.topAnchor),
- row.bottomAnchor.constraint(equalTo: v.bottomAnchor)
- ])
- return v
- }
- private func creativeMainStack(theme: NSColor) -> NSView {
- let stack = NSStackView()
- stack.orientation = .vertical
- stack.spacing = style.bodyBlockSpacing
- stack.alignment = .leading
- stack.addArrangedSubview(creativeMainHeader(theme: theme))
- if let summary = nonEmpty(profile.careerSummary) {
- stack.addArrangedSubview(sectionHeading("Profile"))
- stack.addArrangedSubview(paragraph(summary, compact: false))
- }
- let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
- if !jobs.isEmpty {
- stack.addArrangedSubview(sectionHeading("Impact"))
- for job in jobs {
- let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
- if !titleLine.isEmpty {
- stack.addArrangedSubview(label(titleLine, font: .systemFont(ofSize: 13.5, weight: .heavy), color: style.ink, maxLines: 0))
- }
- for bullet in Self.bulletChunks(from: job.description) {
- let mark = (variant % 2 == 0) ? "— " : "▸ "
- stack.addArrangedSubview(label("\(mark)\(bullet)", font: style.bodyFont, color: style.muted, maxLines: 0))
- }
- }
- }
- let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
- if !schools.isEmpty {
- stack.addArrangedSubview(sectionHeading("Education"))
- for edu in schools {
- stack.addArrangedSubview(educationBlock(edu: edu, compact: false))
- }
- }
- appendCertificatesInterestsReferrals(to: stack, compact: false)
- return stack
- }
- // MARK: - Executive (matches gallery serif layout)
- private func buildExecutiveDocument() -> NSView {
- let centeredHead = (variant % 2) == 0
- let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
- let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
- let contactParts = [profile.personal.email, profile.personal.phone, profile.personal.address].filter {
- !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- }
- let contactText = contactParts.isEmpty ? "Add contact details in your profile" : contactParts.joined(separator: " · ")
- let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2)
- let role = label(roleText, font: style.roleFont, color: style.muted, maxLines: 2)
- let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.9), maxLines: 3)
- if centeredHead {
- name.alignment = .center
- role.alignment = .center
- contact.alignment = .center
- }
- let rule = executiveHeaderRule(wide: variant % 3 == 0)
- let head = NSStackView(views: [name, role, contact, rule])
- head.orientation = .vertical
- head.spacing = 8
- head.alignment = centeredHead ? .centerX : .leading
- let body: NSView
- switch template.layout {
- case .singleColumn:
- body = executiveMainColumn(compact: false, tightLeading: variant % 5 == 2)
- case .twoColumn(let side, let tinted):
- let main = executiveMainColumn(compact: true, tightLeading: variant % 5 == 2)
- let sideCol = executiveSidebarColumn(tinted: tinted, showMetrics: variant % 4 == 1)
- let row = NSStackView()
- row.orientation = .horizontal
- row.spacing = 20
- row.alignment = .top
- if side == .leading {
- row.addArrangedSubview(sideCol)
- row.addArrangedSubview(main)
- } else {
- row.addArrangedSubview(main)
- row.addArrangedSubview(sideCol)
- }
- let mult: CGFloat = (variant % 5 == 3) ? 0.38 : 0.33
- sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
- body = row
- }
- let wrap = NSStackView(views: [head, body])
- wrap.orientation = .vertical
- wrap.spacing = style.columnVerticalSpacing
- wrap.alignment = .leading
- return wrap
- }
- private func executiveHeaderRule(wide: Bool) -> NSView {
- let theme = template.themeColor
- let v = NSView()
- v.translatesAutoresizingMaskIntoConstraints = false
- v.wantsLayer = true
- v.layer?.backgroundColor = theme.withAlphaComponent(0.45).cgColor
- v.heightAnchor.constraint(equalToConstant: wide ? 2 : 1.5).isActive = true
- v.widthAnchor.constraint(equalToConstant: wide ? 160 : 110).isActive = true
- return v
- }
- private func executiveMainColumn(compact: Bool, tightLeading: Bool) -> NSView {
- let stack = NSStackView()
- stack.orientation = .vertical
- stack.spacing = (compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2) - (tightLeading ? 2 : 0)
- stack.alignment = .leading
- let summaryTitle = (variant % 6 == 3) ? "Summary" : "Professional Summary"
- if let summary = nonEmpty(profile.careerSummary) {
- stack.addArrangedSubview(sectionHeading(summaryTitle))
- stack.addArrangedSubview(paragraph(summary, compact: compact))
- }
- let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
- if !jobs.isEmpty {
- stack.addArrangedSubview(sectionHeading("Selected Experience"))
- for job in jobs {
- stack.addArrangedSubview(executiveExperienceBlock(job: job, compact: compact))
- }
- }
- let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
- if !schools.isEmpty {
- stack.addArrangedSubview(sectionHeading("Education"))
- for edu in schools {
- stack.addArrangedSubview(educationBlock(edu: edu, compact: compact))
- }
- }
- appendCertificatesInterestsReferrals(to: stack, compact: compact)
- return stack
- }
- private func executiveExperienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
- let v = NSStackView()
- v.orientation = .vertical
- v.spacing = 4
- v.alignment = .leading
- let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: ", ")
- if !titleLine.isEmpty {
- v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
- }
- let duration = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
- if !duration.isEmpty {
- v.addArrangedSubview(label(duration, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
- }
- for bullet in Self.bulletChunks(from: job.description) {
- v.addArrangedSubview(label(bullet, font: style.bodyFont, color: style.muted, maxLines: 0))
- }
- return v
- }
- private func executiveSidebarColumn(tinted: Bool, showMetrics: Bool) -> NSView {
- let stack = NSStackView()
- stack.orientation = .vertical
- stack.spacing = 10
- stack.alignment = .leading
- if tinted {
- stack.wantsLayer = true
- let fill = (variant % 3 == 0)
- ? CVResumeAppearance.colors().sidebarTint
- : template.themeColor.withAlphaComponent(0.07)
- stack.layer?.backgroundColor = fill.cgColor
- stack.layer?.cornerRadius = variant % 4 == 2 ? 8 : 6
- stack.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
- }
- let skills = skillTokensFromProfile(max: 12)
- if !skills.isEmpty {
- stack.addArrangedSubview(sectionHeading("Core Competencies"))
- for s in skills {
- stack.addArrangedSubview(label("· \(s)", font: style.bodyFont, color: style.ink, maxLines: 0))
- }
- }
- if let cert = nonEmpty(profile.certificates) {
- stack.addArrangedSubview(sectionHeading("Tools"))
- stack.addArrangedSubview(paragraph(cert, compact: true))
- }
- if showMetrics, let hi = highlightsBodyText() {
- stack.addArrangedSubview(sectionHeading("Impact"))
- stack.addArrangedSubview(paragraph(hi, compact: true))
- }
- if stack.arrangedSubviews.isEmpty {
- stack.addArrangedSubview(sectionHeading("Contact"))
- for line in contactLines() {
- stack.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
- }
- }
- return stack
- }
- // MARK: - Minimal (matches gallery light typography)
- private func buildMinimalDocument() -> NSView {
- let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
- let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
- let contactParts = [profile.personal.email, profile.personal.phone].filter {
- !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- }
- let contactText = contactParts.isEmpty ? "Add contact in your profile" : contactParts.joined(separator: " ")
- let nameWeight: NSFont.Weight = (variant % 3 == 0) ? .ultraLight : .light
- let nameSize: CGFloat = template.headline == .centered ? 22 + CGFloat(variant % 2) : 20 + CGFloat(variant % 3)
- let name = label(nameText, font: .systemFont(ofSize: nameSize, weight: nameWeight), color: style.ink, maxLines: 2)
- let role = label(roleText.uppercased(), font: .systemFont(ofSize: 12.5, weight: .medium), color: style.muted, maxLines: 2)
- let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.75), maxLines: 2)
- let head = NSStackView()
- head.orientation = .vertical
- head.spacing = template.headline == .avatarStacked ? 10 : 6 + CGFloat(variant % 4)
- head.alignment = template.headline == .centered ? .centerX : .leading
- if template.headline == .avatarStacked {
- head.addArrangedSubview(initialsBadge(for: nameText))
- }
- if template.headline == .centered {
- name.alignment = .center
- role.alignment = .center
- contact.alignment = .center
- }
- head.addArrangedSubview(name)
- head.addArrangedSubview(role)
- head.addArrangedSubview(contact)
- head.addArrangedSubview(hairline())
- if variant % 5 == 1 {
- head.addArrangedSubview(hairline())
- }
- let swapEdu = (variant % 4) == 2
- let body: NSView
- switch template.layout {
- case .singleColumn:
- body = minimalMainColumn(spacing: style.bodyBlockSpacing, educationBeforeExperience: swapEdu)
- case .twoColumn(let side, _):
- let main = minimalMainColumn(spacing: style.bodyBlockSpacing - 2, educationBeforeExperience: swapEdu)
- let aside = minimalSkillsAside(numbered: variant % 3 == 1)
- let row = NSStackView()
- row.orientation = .horizontal
- row.spacing = 18 + CGFloat(variant % 3)
- row.alignment = .top
- if side == .leading {
- row.addArrangedSubview(aside)
- row.addArrangedSubview(main)
- } else {
- row.addArrangedSubview(main)
- row.addArrangedSubview(aside)
- }
- let mult: CGFloat = (variant % 5 == 0) ? 0.34 : 0.30
- aside.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
- body = row
- }
- let wrap = NSStackView(views: [head, body])
- wrap.orientation = .vertical
- wrap.spacing = style.columnVerticalSpacing
- wrap.alignment = .leading
- return wrap
- }
- private func minimalMainColumn(spacing: CGFloat, educationBeforeExperience: Bool) -> NSView {
- let stack = NSStackView()
- stack.orientation = .vertical
- stack.spacing = spacing
- stack.alignment = .leading
- let appendSummary: () -> Void = { [self] in
- if let summary = nonEmpty(profile.careerSummary) {
- stack.addArrangedSubview(sectionHeading("Profile"))
- stack.addArrangedSubview(paragraph(summary, compact: false))
- }
- }
- let appendExperience: () -> Void = { [self] in
- let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
- if !jobs.isEmpty {
- stack.addArrangedSubview(sectionHeading("Experience"))
- for job in jobs {
- stack.addArrangedSubview(experienceBlock(job: job, compact: false))
- }
- }
- }
- let appendEducation: () -> Void = { [self] in
- let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
- if !schools.isEmpty {
- stack.addArrangedSubview(sectionHeading("Education"))
- for edu in schools {
- stack.addArrangedSubview(educationBlock(edu: edu, compact: false))
- }
- }
- }
- if educationBeforeExperience {
- appendEducation()
- appendSummary()
- appendExperience()
- } else {
- appendSummary()
- appendExperience()
- appendEducation()
- }
- appendCertificatesInterestsReferrals(to: stack, compact: false)
- return stack
- }
- private func minimalSkillsAside(numbered: Bool) -> NSView {
- let stack = NSStackView()
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .leading
- let skills = skillTokensFromProfile(max: 12)
- if skills.isEmpty {
- stack.addArrangedSubview(sectionHeading("Contact"))
- for line in contactLines() {
- stack.addArrangedSubview(label(line, font: style.contactFont, color: style.muted, maxLines: 0))
- }
- return stack
- }
- stack.addArrangedSubview(sectionHeading("Skills"))
- for (i, s) in skills.enumerated() {
- let prefix = numbered ? "\(i + 1). " : "· "
- stack.addArrangedSubview(label("\(prefix)\(s)", font: style.bodyCompactFont, color: style.muted, maxLines: 0))
- }
- return stack
- }
- // MARK: - Professional
- private func buildProfessionalDocument() -> NSView {
- switch template.layout {
- case .singleColumn:
- return singleColumnLayout()
- case .twoColumn(let side, let tinted):
- return twoColumnLayout(sidebar: side, tinted: tinted)
- }
- }
- private func singleColumnLayout() -> NSView {
- let v = NSStackView()
- v.orientation = .vertical
- v.alignment = .leading
- v.spacing = style.columnVerticalSpacing + 3
- v.addArrangedSubview(headerBlock())
- if template.family == .professional && (variant % 6) == 4 {
- v.addArrangedSubview(professionalInlineSkillsRow())
- }
- v.addArrangedSubview(hairline())
- let body = bodyColumn(compact: false, experienceFirst: professionalExperienceFirst)
- v.addArrangedSubview(usesProfessionalSingleColumnRail ? bodyWithLeadingAccentRail(body) : body)
- return v
- }
- private var professionalExperienceFirst: Bool {
- template.family == .professional && (variant % 3) == 1
- }
- private func professionalInlineSkillsRow() -> NSView {
- let tokens = skillTokensFromProfile(max: 6)
- guard !tokens.isEmpty else { return NSView() }
- let joined = tokens.joined(separator: " · ")
- return label(joined, font: .systemFont(ofSize: 11.5, weight: .medium), color: template.themeColor, maxLines: 0)
- }
- /// Matches the CV Maker thumbnail: professional ATS single-column layouts use a full-height theme rail.
- private var usesProfessionalSingleColumnRail: Bool {
- if case .singleColumn = template.layout, template.family == .professional { return true }
- return false
- }
- private func bodyWithLeadingAccentRail(_ content: NSView) -> NSView {
- let wrap = NSView()
- wrap.translatesAutoresizingMaskIntoConstraints = false
- let rail = NSView()
- rail.translatesAutoresizingMaskIntoConstraints = false
- rail.wantsLayer = true
- rail.layer?.backgroundColor = template.themeColor.cgColor
- rail.layer?.cornerRadius = 1
- content.translatesAutoresizingMaskIntoConstraints = false
- wrap.addSubview(rail)
- wrap.addSubview(content)
- NSLayoutConstraint.activate([
- rail.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
- rail.topAnchor.constraint(equalTo: content.topAnchor),
- rail.bottomAnchor.constraint(equalTo: content.bottomAnchor),
- rail.widthAnchor.constraint(equalToConstant: 3),
- content.leadingAnchor.constraint(equalTo: rail.trailingAnchor, constant: 12),
- content.trailingAnchor.constraint(equalTo: wrap.trailingAnchor),
- content.topAnchor.constraint(equalTo: wrap.topAnchor),
- content.bottomAnchor.constraint(equalTo: wrap.bottomAnchor)
- ])
- return wrap
- }
- private func twoColumnLayout(sidebar: CVTemplate.SidebarSide, tinted: Bool) -> NSView {
- let v = NSStackView()
- v.orientation = .vertical
- v.alignment = .leading
- v.spacing = style.columnVerticalSpacing + 2
- v.addArrangedSubview(headerBlock())
- v.addArrangedSubview(hairline())
- let row = NSStackView()
- row.orientation = .horizontal
- row.alignment = .top
- row.spacing = 22
- let sidebarCol = sidebarColumn(tinted: tinted)
- let mainCol = bodyColumn(compact: true, experienceFirst: professionalExperienceFirst)
- if sidebar == .leading {
- row.addArrangedSubview(sidebarCol)
- row.addArrangedSubview(mainCol)
- } else {
- row.addArrangedSubview(mainCol)
- row.addArrangedSubview(sidebarCol)
- }
- let sidebarMult: CGFloat = (variant % 5 == 2) ? 0.38 : 0.32
- sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
- v.addArrangedSubview(row)
- return v
- }
- private func headerBlock() -> NSView {
- let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
- let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
- let contactParts = [profile.personal.email, profile.personal.phone, profile.personal.address].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
- let contactText = contactParts.isEmpty ? "Add contact details in your profile" : contactParts.joined(separator: " · ")
- let roleColor = style.roleUsesThemeColor ? template.themeColor : style.muted
- let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2)
- let role = label(roleText, font: style.roleFont, color: roleColor, maxLines: 2)
- let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.92), maxLines: 3)
- let textCol = NSStackView(views: [name, role, contact])
- textCol.orientation = .vertical
- textCol.spacing = template.family == .professional ? 3 : 4
- textCol.alignment = .leading
- switch template.headline {
- case .centered:
- textCol.alignment = .centerX
- name.alignment = .center
- role.alignment = .center
- contact.alignment = .center
- let accent = headlineAccent()
- let stack = NSStackView(views: [textCol, accent])
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .centerX
- return stack
- case .avatarStacked:
- textCol.alignment = .centerX
- name.alignment = .center
- role.alignment = .center
- contact.alignment = .center
- let accent = headlineAccent()
- let avatar = initialsBadge(for: nameText)
- let stack = NSStackView(views: [avatar, textCol, accent])
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .centerX
- return stack
- case .leftAligned, .leftWithInitials:
- let row = NSStackView()
- row.orientation = .horizontal
- row.spacing = 14
- row.alignment = .centerY
- row.addArrangedSubview(textCol)
- if template.headline == .leftWithInitials {
- row.addArrangedSubview(NSView())
- row.addArrangedSubview(initialsBadge(for: nameText))
- }
- let col = NSStackView(views: [row, headlineAccent()])
- col.orientation = .vertical
- col.spacing = 8
- col.alignment = .leading
- return col
- }
- }
- private func initialsBadge(for fullName: String) -> NSView {
- let initials = Self.initials(from: fullName)
- let t = NSTextField(labelWithString: initials)
- t.font = .systemFont(ofSize: 13, weight: .bold)
- t.textColor = template.themeColor
- t.alignment = .center
- t.translatesAutoresizingMaskIntoConstraints = false
- let wrap = NSView()
- wrap.translatesAutoresizingMaskIntoConstraints = false
- wrap.wantsLayer = true
- wrap.layer?.cornerRadius = 22
- wrap.layer?.borderWidth = 1.5
- wrap.layer?.borderColor = template.themeColor.withAlphaComponent(0.35).cgColor
- wrap.addSubview(t)
- NSLayoutConstraint.activate([
- wrap.widthAnchor.constraint(equalToConstant: 44),
- wrap.heightAnchor.constraint(equalToConstant: 44),
- t.centerXAnchor.constraint(equalTo: wrap.centerXAnchor),
- t.centerYAnchor.constraint(equalTo: wrap.centerYAnchor)
- ])
- return wrap
- }
- private static func initials(from fullName: String) -> String {
- let parts = fullName.split(separator: " ").filter { !$0.isEmpty }
- if parts.count >= 2 {
- let a = parts[0].prefix(1)
- let b = parts[1].prefix(1)
- return "\(a)\(b)".uppercased()
- }
- if let first = parts.first { return String(first.prefix(2)).uppercased() }
- return "CV"
- }
- private func headlineAccent() -> NSView {
- let bar = NSView()
- bar.translatesAutoresizingMaskIntoConstraints = false
- bar.wantsLayer = true
- switch template.accent {
- case .none:
- bar.heightAnchor.constraint(equalToConstant: 1).isActive = true
- return bar
- case .redUnderline:
- bar.layer?.backgroundColor = CVResumeAppearance.accentColor(for: template).cgColor
- bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
- bar.widthAnchor.constraint(equalToConstant: template.family == .minimal ? 140 : 168).isActive = true
- return bar
- case .redBar:
- bar.layer?.backgroundColor = CVResumeAppearance.accentColor(for: template).cgColor
- bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
- bar.widthAnchor.constraint(equalToConstant: 56).isActive = true
- return bar
- case .blueBar:
- bar.layer?.backgroundColor = template.themeColor.cgColor
- if template.headline == .centered {
- bar.heightAnchor.constraint(equalToConstant: 2.5).isActive = true
- bar.widthAnchor.constraint(equalToConstant: 148).isActive = true
- } else {
- bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
- bar.widthAnchor.constraint(equalToConstant: template.family == .executive ? 100 : 120).isActive = true
- }
- return bar
- }
- }
- private func hairline() -> NSView {
- let v = NSView()
- v.translatesAutoresizingMaskIntoConstraints = false
- v.wantsLayer = true
- v.layer?.backgroundColor = style.rule.cgColor
- let h: CGFloat = template.family == .executive ? 1.5 : 1
- v.heightAnchor.constraint(equalToConstant: h).isActive = true
- return v
- }
- private func sidebarColumn(tinted: Bool) -> NSView {
- let box = NSStackView()
- box.orientation = .vertical
- box.spacing = 12
- box.alignment = .leading
- if tinted {
- box.wantsLayer = true
- let tint = (variant % 4 == 1)
- ? template.themeColor.withAlphaComponent(0.08)
- : CVResumeAppearance.colors().sidebarTint
- box.layer?.backgroundColor = tint.cgColor
- box.layer?.cornerRadius = variant % 3 == 0 ? 8 : 6
- }
- box.edgeInsets = NSEdgeInsets(top: tinted ? 14 : 0, left: tinted ? 14 : 0, bottom: tinted ? 14 : 0, right: tinted ? 14 : 0)
- box.addArrangedSubview(sectionHeading("Contact"))
- for line in contactLines() {
- box.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
- }
- let skills = skillTokensFromProfile(max: 8)
- if !skills.isEmpty {
- box.addArrangedSubview(sectionHeading("Skills"))
- if variant % 5 == 2 {
- box.addArrangedSubview(skillTagRow(theme: template.themeColor, maxTags: 5))
- } else {
- for token in skills {
- box.addArrangedSubview(label("· \(token)", font: style.contactFont, color: style.ink, maxLines: 0))
- }
- }
- }
- if variant % 7 == 3, let tools = nonEmpty(profile.certificates) {
- box.addArrangedSubview(sectionHeading("Tools"))
- box.addArrangedSubview(paragraph(tools, compact: true))
- } else if let ancillary = combinedAncillaryText(), !ancillary.isEmpty {
- box.addArrangedSubview(sectionHeading("Languages & more"))
- box.addArrangedSubview(paragraph(ancillary, compact: true))
- }
- return box
- }
- private func bodyColumn(compact: Bool, experienceFirst: Bool = false) -> NSView {
- let v = NSStackView()
- v.orientation = .vertical
- v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
- v.alignment = .leading
- let summaryTitle = sectionHeading(summarySectionTitle)
- let summaryBody: NSView? = nonEmpty(profile.careerSummary).map { paragraph($0, compact: compact) }
- let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
- let experienceHeading = sectionHeading("Experience")
- var experienceBlocks: [NSView] = []
- for job in jobs {
- experienceBlocks.append(experienceBlock(job: job, compact: compact))
- }
- let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
- var educationBlocks: [NSView] = []
- for edu in schools {
- educationBlocks.append(educationBlock(edu: edu, compact: compact))
- }
- let appendSummary: () -> Void = { [self] in
- if let body = summaryBody {
- v.addArrangedSubview(summaryTitle)
- v.addArrangedSubview(body)
- }
- }
- let appendExperience: () -> Void = { [self] in
- if !jobs.isEmpty {
- v.addArrangedSubview(experienceHeading)
- experienceBlocks.forEach { v.addArrangedSubview($0) }
- }
- }
- let appendEducation: () -> Void = { [self] in
- if !schools.isEmpty {
- v.addArrangedSubview(sectionHeading("Education"))
- educationBlocks.forEach { v.addArrangedSubview($0) }
- }
- }
- if experienceFirst {
- appendExperience()
- appendSummary()
- appendEducation()
- } else {
- appendSummary()
- appendExperience()
- appendEducation()
- }
- appendCertificatesInterestsReferrals(to: v, compact: compact)
- return v
- }
- private func appendCertificatesInterestsReferrals(to v: NSStackView, compact: Bool) {
- if let cert = nonEmpty(profile.certificates) {
- v.addArrangedSubview(sectionHeading("Certificates"))
- v.addArrangedSubview(paragraph(cert, compact: compact))
- }
- if let interests = nonEmpty(profile.interests) {
- v.addArrangedSubview(sectionHeading("Interests"))
- v.addArrangedSubview(paragraph(interests, compact: compact))
- }
- if let ref = nonEmpty(profile.referral) {
- v.addArrangedSubview(sectionHeading("Referrals"))
- v.addArrangedSubview(paragraph(ref, compact: compact))
- }
- }
- private func skillTokensFromProfile(max: Int) -> [String] {
- let raw = profile.languages.trimmingCharacters(in: .whitespacesAndNewlines)
- if raw.isEmpty { return [] }
- let parts = raw.split(whereSeparator: { $0 == "," || $0 == "·" || $0 == "|" || $0 == ";" })
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- if parts.count > 1 { return Array(parts.prefix(max)) }
- return raw.split(separator: " ").map(String.init).filter { $0.count > 1 }.prefix(max).map { String($0) }
- }
- private func highlightsBodyText() -> String? {
- if let t = nonEmpty(profile.interests) { return t }
- if let r = nonEmpty(profile.referral) { return r }
- let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
- if let first = jobs.first {
- let bullets = Self.bulletChunks(from: first.description)
- if let b = bullets.first { return b }
- }
- return nil
- }
- private func ancillaryBlock(title: String, body: String?) -> NSStackView? {
- guard let body, !body.isEmpty else { return nil }
- let s = NSStackView()
- s.orientation = .vertical
- s.spacing = 6
- s.alignment = .leading
- s.addArrangedSubview(sectionHeading(title))
- s.addArrangedSubview(paragraph(body, compact: true))
- return s
- }
- private func contactLines() -> [String] {
- var lines: [String] = []
- let p = profile.personal
- if !p.email.isEmpty { lines.append(p.email) }
- if !p.phone.isEmpty { lines.append(p.phone) }
- if !p.address.isEmpty { lines.append(p.address) }
- return lines.isEmpty ? ["—"] : lines
- }
- private func combinedAncillaryText() -> String? {
- let chunks = [profile.languages, profile.interests].map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
- return chunks.isEmpty ? nil : chunks.joined(separator: "\n\n")
- }
- private func experienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
- let v = NSStackView()
- v.orientation = .vertical
- v.spacing = template.family == .professional ? 4 : 6
- v.alignment = .leading
- if template.family == .professional {
- let title = job.jobTitle.trimmingCharacters(in: .whitespacesAndNewlines)
- let company = job.company.trimmingCharacters(in: .whitespacesAndNewlines)
- let duration = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
- if !title.isEmpty {
- v.addArrangedSubview(label(title, font: style.expTitleFont, color: style.ink, maxLines: 0))
- }
- let metaParts = [company, duration].filter { !$0.isEmpty }
- if !metaParts.isEmpty {
- let metaJoined = metaParts.joined(separator: " · ")
- v.addArrangedSubview(label(metaJoined, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
- } else if title.isEmpty {
- let fallback = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
- if !fallback.isEmpty {
- v.addArrangedSubview(label(fallback, font: style.expTitleFont, color: style.ink, maxLines: 0))
- }
- }
- } else {
- let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
- let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
- if !titleLine.isEmpty {
- v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
- }
- if !meta.isEmpty {
- v.addArrangedSubview(label(meta, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
- }
- }
- for bullet in Self.bulletChunks(from: job.description) {
- v.addArrangedSubview(bulletRow(bullet, compact: compact))
- }
- return v
- }
- private func educationBlock(edu: EducationPayload, compact: Bool) -> NSView {
- let v = NSStackView()
- v.orientation = .vertical
- v.spacing = 4
- v.alignment = .leading
- let institution = edu.institution.trimmingCharacters(in: .whitespacesAndNewlines)
- let degree = edu.degree.trimmingCharacters(in: .whitespacesAndNewlines)
- let year = edu.year.trimmingCharacters(in: .whitespacesAndNewlines)
- if template.family == .professional {
- if !institution.isEmpty {
- v.addArrangedSubview(label(institution, font: style.eduTitleFont, color: style.ink, maxLines: 0))
- }
- let subParts = [degree, year].filter { !$0.isEmpty }
- if !subParts.isEmpty {
- let sub = subParts.joined(separator: " · ")
- v.addArrangedSubview(label(sub, font: style.eduMetaFont, color: style.muted, maxLines: 0))
- }
- } else {
- let head = [edu.institution, edu.degree].filter { !$0.isEmpty }.joined(separator: " — ")
- if !head.isEmpty {
- v.addArrangedSubview(label(head, font: style.eduTitleFont, color: style.ink, maxLines: 0))
- }
- if !edu.year.isEmpty {
- v.addArrangedSubview(label(edu.year, font: style.eduMetaFont, color: style.muted, maxLines: 0))
- }
- }
- return v
- }
- private func bulletRow(_ text: String, compact: Bool) -> NSView {
- let marker: String = template.family == .minimal ? "·" : "•"
- let dot = NSTextField(labelWithString: marker)
- dot.font = style.bulletMarkerFont
- dot.textColor = style.bulletMarkerColor
- dot.translatesAutoresizingMaskIntoConstraints = false
- let bodyFont = compact ? style.bodyCompactFont : style.bulletBodyFont
- let body = label(text, font: bodyFont, color: style.ink, maxLines: 0)
- let row = NSStackView(views: [dot, body])
- row.orientation = .horizontal
- row.spacing = template.family == .creative ? 10 : 8
- row.alignment = .top
- dot.setContentHuggingPriority(.required, for: .horizontal)
- return row
- }
- private func paragraph(_ text: String, compact: Bool) -> NSTextField {
- let font = compact ? style.bodyCompactFont : style.bodyFont
- return label(text, font: font, color: style.ink, maxLines: 0)
- }
- /// Gallery + ATS “Clear Path” style use “Profile”; other families keep the neutral résumé label.
- private var summarySectionTitle: String {
- template.family == .professional ? "Profile" : "Summary"
- }
- private func sectionHeading(_ raw: String) -> NSTextField {
- let upper = raw.uppercased()
- let s: String
- switch template.sectionLabelStyle {
- case .uppercase: s = upper
- case .slashed: s = "// \(upper)"
- case .bracketed: s = "[ \(upper) ]"
- }
- let t = NSTextField(labelWithString: s)
- t.font = style.sectionFont
- t.textColor = style.sectionInk
- t.alignment = .left
- return t
- }
- private func label(_ string: String, font: NSFont, color: NSColor, maxLines: Int) -> NSTextField {
- let isWrapping = maxLines == 0
- let t: NSTextField
- if isWrapping {
- t = NSTextField(wrappingLabelWithString: string)
- t.maximumNumberOfLines = 0
- } else {
- t = NSTextField(labelWithString: string)
- t.maximumNumberOfLines = maxLines
- }
- t.font = font
- t.textColor = color
- t.alignment = .left
- return t
- }
- private func displayable(_ value: String, placeholder: String) -> String {
- let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
- return t.isEmpty ? placeholder : t
- }
- private func nonEmpty(_ value: String) -> String? {
- let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
- return t.isEmpty ? nil : t
- }
- private static func bulletChunks(from text: String) -> [String] {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty { return [] }
- let byNewline = trimmed.components(separatedBy: .newlines)
- .map { $0.trimmingCharacters(in: .whitespaces) }
- .filter { !$0.isEmpty }
- if byNewline.count > 1 { return byNewline }
- let byBullet = trimmed.split(separator: "•")
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- if byBullet.count > 1 { return byBullet.map { String($0) } }
- return [trimmed]
- }
- }
- // MARK: - Payload helpers
- private extension WorkExperiencePayload {
- var isEffectivelyEmpty: Bool {
- [jobTitle, company, duration, description].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
- }
- }
- private extension EducationPayload {
- var isEffectivelyEmpty: Bool {
- [degree, institution, year].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
- }
- }
|