// // 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 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: NSColor(srgbRed: 42 / 255, green: 48 / 255, blue: 56 / 255, alpha: 1), muted: NSColor(srgbRed: 110 / 255, green: 118 / 255, blue: 128 / 255, alpha: 1), rule: NSColor(srgbRed: 228 / 255, green: 230 / 255, blue: 234 / 255, alpha: 1), cardBackground: NSColor(srgbRed: 0.998, green: 0.998, blue: 0.998, alpha: 1), columnVerticalSpacing: 15, bodyBlockSpacing: 15, roleUsesThemeColor: false, sectionInk: theme.withAlphaComponent(0.92) ) 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: NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55), ink: NSColor(srgbRed: 28 / 255, green: 36 / 255, blue: 48 / 255, alpha: 1), muted: NSColor(srgbRed: 88 / 255, green: 98 / 255, blue: 118 / 255, alpha: 1), rule: NSColor(srgbRed: 210 / 255, green: 218 / 255, blue: 232 / 255, alpha: 1), cardBackground: NSColor.white, columnVerticalSpacing: 13, bodyBlockSpacing: 13, roleUsesThemeColor: false, sectionInk: theme ) 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: NSColor(srgbRed: 24 / 255, green: 34 / 255, blue: 52 / 255, alpha: 1), muted: NSColor(srgbRed: 96 / 255, green: 110 / 255, blue: 132 / 255, alpha: 1), rule: NSColor(srgbRed: 200 / 255, green: 214 / 255, blue: 236 / 255, alpha: 1), cardBackground: NSColor(srgbRed: 0.99, green: 0.995, blue: 1, alpha: 1), columnVerticalSpacing: 17, bodyBlockSpacing: 16, roleUsesThemeColor: true, sectionInk: theme ) 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) 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: NSColor(srgbRed: 55 / 255, green: 55 / 255, blue: 62 / 255, alpha: 1), ink: NSColor(srgbRed: 22 / 255, green: 22 / 255, blue: 28 / 255, alpha: 1), muted: NSColor(srgbRed: 82 / 255, green: 82 / 255, blue: 90 / 255, alpha: 1), rule: NSColor(srgbRed: 72 / 255, green: 72 / 255, blue: 78 / 255, alpha: 0.35), cardBackground: NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1), columnVerticalSpacing: 18, bodyBlockSpacing: 17, roleUsesThemeColor: false, sectionInk: NSColor(srgbRed: 32 / 255, green: 32 / 255, blue: 38 / 255, alpha: 1) ) 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: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1), ink: NSColor(srgbRed: 32 / 255, green: 26 / 255, blue: 52 / 255, alpha: 1), muted: NSColor(srgbRed: 108 / 255, green: 96 / 255, blue: 130 / 255, alpha: 1), rule: theme.withAlphaComponent(0.22), cardBackground: NSColor(srgbRed: 0.995, green: 0.993, blue: 1, alpha: 1), columnVerticalSpacing: 18, bodyBlockSpacing: 17, roleUsesThemeColor: true, sectionInk: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1) ) } } } /// Full-width résumé layout that injects `SavedProfile` into the visual language of `CVTemplate`. final class CVProfileDocumentView: NSView { private let profile: SavedProfile private let template: CVTemplate private let style: DocumentStyle init(profile: SavedProfile, template: CVTemplate) { self.profile = profile self.template = template self.style = DocumentStyle.make(for: template) super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false wantsLayer = true layer?.backgroundColor = NSColor.clear.cgColor userInterfaceLayoutDirection = .leftToRight 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 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: 640), 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") } // MARK: - Composition private func buildRoot() -> 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()) v.addArrangedSubview(hairline()) v.addArrangedSubview(bodyColumn(compact: false)) return v } 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 = template.family == .minimal ? 18 : 22 let sidebarCol = sidebarColumn(tinted: tinted) let mainCol = bodyColumn(compact: true) if sidebar == .leading { row.addArrangedSubview(sidebarCol) row.addArrangedSubview(mainCol) } else { row.addArrangedSubview(mainCol) row.addArrangedSubview(sidebarCol) } sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: template.family == .executive ? 0.34 : 0.32).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 = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).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 = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).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 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 box.layer?.backgroundColor = template.themeColor.withAlphaComponent(template.family == .creative ? 0.12 : 0.08).cgColor box.layer?.cornerRadius = 8 } 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)) } if let skillsBlock = ancillaryBlock(title: "Languages & more", body: combinedAncillaryText()) { box.addArrangedSubview(skillsBlock) } return box } private func bodyColumn(compact: Bool) -> NSView { let v = NSStackView() v.orientation = .vertical v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2 v.alignment = .leading if let summary = nonEmpty(profile.careerSummary) { v.addArrangedSubview(sectionHeading("Summary")) v.addArrangedSubview(paragraph(summary, compact: compact)) } let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty } if !jobs.isEmpty { v.addArrangedSubview(sectionHeading("Experience")) for job in jobs { v.addArrangedSubview(experienceBlock(job: job, compact: compact)) } } let schools = profile.educations.filter { !$0.isEffectivelyEmpty } if !schools.isEmpty { v.addArrangedSubview(sectionHeading("Education")) for edu in schools { v.addArrangedSubview(educationBlock(edu: edu, compact: compact)) } } 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)) } return v } 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 titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ") let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines) let v = NSStackView() v.orientation = .vertical v.spacing = template.family == .professional ? 4 : 6 v.alignment = .leading 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 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) } 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 } } }