ios-pdf-export
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePlatform Notes
平台说明
- Optional helper plugins may help in some environments, but they must not be treated as required for this skill.
- 可选的辅助插件可能在部分环境中提供帮助,但不得将其视为使用本技能的必需项。
iOS PDF Export (Native UIGraphicsPDFRenderer)
iOS PDF导出(原生UIGraphicsPDFRenderer)
<!-- dual-compat-start -->
<!-- dual-compat-start -->
Use When
适用场景
- Native iOS PDF export system using UIGraphicsPDFRenderer (zero dependencies). Reusable drawing-based generator with branded letterheads, data tables, summary cards, and share via UIActivityViewController. Use when adding PDF export to any iOS app...
- The task needs reusable judgment, domain constraints, or a proven workflow rather than ad hoc advice.
- 基于UIGraphicsPDFRenderer的原生iOS PDF导出系统(零依赖)。可复用的基于绘图的生成器,支持品牌信头、数据表、摘要卡片,并可通过UIActivityViewController进行分享。适用于为任意iOS应用添加PDF导出功能的场景……
- 任务需要可复用的判断逻辑、领域约束或成熟的工作流,而非临时建议。
Do Not Use When
不适用场景
- The task is unrelated to or would be better handled by a more specific companion skill.
ios-pdf-export - The request only needs a trivial answer and none of this skill's constraints or references materially help.
- 任务与无关,或更适合由更具体的配套技能处理。
ios-pdf-export - 请求仅需简单答案,本技能的约束条件或参考内容无法提供实质性帮助。
Required Inputs
必需输入
- Gather relevant project context, constraints, and the concrete problem to solve.
- Confirm the desired deliverable: design, code, review, migration plan, audit, or documentation.
- 收集相关项目背景、约束条件以及需要解决的具体问题。
- 确认期望交付物:设计方案、代码、评审意见、迁移计划、审计报告或文档。
Workflow
工作流
- Read this first, then load only the referenced deep-dive files that are necessary for the task.
SKILL.md - Apply the ordered guidance, checklists, and decision rules in this skill instead of cherry-picking isolated snippets.
- Produce the deliverable with assumptions, risks, and follow-up work made explicit when they matter.
- 首先阅读本,然后仅加载完成任务所需的相关深度文档。
SKILL.md - 应用本技能中的有序指导、检查清单和决策规则,而非随意挑选孤立片段。
- 交付成果时,若相关需明确说明假设条件、风险以及后续工作。
Quality Standards
质量标准
- Keep outputs execution-oriented, concise, and aligned with the repository's baseline engineering standards.
- Preserve compatibility with existing project conventions unless the skill explicitly requires a stronger standard.
- Prefer deterministic, reviewable steps over vague advice or tool-specific magic.
- 输出内容需以执行为导向,简洁明了,并与仓库的基线工程标准保持一致。
- 除非技能明确要求更高标准,否则需保持与现有项目约定的兼容性。
- 优先采用可确定、可评审的步骤,而非模糊建议或工具特定的“魔法操作”。
Anti-Patterns
反模式
- Treating examples as copy-paste truth without checking fit, constraints, or failure modes.
- Loading every reference file by default instead of using progressive disclosure.
- 将示例视为可直接复制粘贴的标准答案,而不检查是否适配、约束条件或失败模式。
- 默认加载所有参考文档,而非逐步按需披露。
Outputs
输出成果
- A concrete result that fits the task: implementation guidance, review findings, architecture decisions, templates, or generated artifacts.
- Clear assumptions, tradeoffs, or unresolved gaps when the task cannot be completed from available context alone.
- References used, companion skills, or follow-up actions when they materially improve execution.
- 符合任务要求的具体结果:实现指导、评审发现、架构决策、模板或生成的工件。
- 当无法仅通过现有上下文完成任务时,需明确说明假设条件、权衡方案或未解决的空白。
- 若能实质性提升执行效果,需列出使用的参考资料、配套技能或后续行动。
Evidence Produced
生成的证据
| Category | Artifact | Format | Example |
|---|---|---|---|
| Correctness | PDF export test plan | Markdown doc covering page layout, paginated content, fonts, and image rendering | |
| 类别 | 工件 | 格式 | 示例 |
|---|---|---|---|
| 正确性 | PDF导出测试计划 | 涵盖页面布局、分页内容、字体和图像渲染的Markdown文档 | |
References
参考资料
- Use the links and companion skills already referenced in this file when deeper context is needed.
Generate professional branded PDF documents from any iOS screen using the built-in API. Zero external dependencies — pure Core Graphics drawing. Supports A4 portrait/landscape, multi-page pagination, letterheads, tables, summary cards, info sections, status badges, and sharing.
UIGraphicsPDFRenderer- 当需要更深入的上下文时,使用本文件中已引用的链接和配套技能。
使用内置的 API从任意iOS屏幕生成专业品牌PDF文档。零外部依赖——纯Core Graphics绘图。支持A4纵向/横向、多页分页、信头、表格、摘要卡片、信息区域、状态徽章以及分享功能。
UIGraphicsPDFRendererOverview
概述
Library choice: Native (0 KB added to bundle). Alternatives like TPPDF (third-party dependency), PDFKit (viewer-only, not for generation), and libHaru (C library, complex bridging) were rejected.
UIGraphicsPDFRendererArchitecture: A core class provides reusable drawing primitives. Per-module exporters compose these primitives for each screen. handles temporary file storage and sharing via .
PDFGeneratorPDFShareHelperUIActivityViewControllerCore/PDF/
PDFGenerator.swift — Drawing primitives (letterhead, tables, cards, footer)
PDFShareHelper.swift — Save to temp + share via UIActivityViewController
Core/UI/Components/
PDFExportButton.swift — Reusable toolbar button (icon + "PDF" label)
Per-module exporters (one struct per feature):
SalesReportPDFExporter.swift, InventoryPDFExporter.swift, NetworkPDFExporter.swift库选择: 原生(包体积增加0 KB)。已弃用替代方案如TPPDF(第三方依赖)、PDFKit(仅用于查看,不支持生成)和libHaru(C库,桥接复杂)。
UIGraphicsPDFRenderer架构: 核心类提供可复用的绘图原语。每个模块的导出器将这些原语组合用于对应屏幕。处理临时文件存储并通过实现分享。
PDFGeneratorPDFShareHelperUIActivityViewControllerCore/PDF/
PDFGenerator.swift — 绘图原语(信头、表格、卡片、页脚)
PDFShareHelper.swift — 保存到临时目录 + 通过UIActivityViewController分享
Core/UI/Components/
PDFExportButton.swift — 可复用工具栏按钮(图标 + "PDF"标签)
各模块导出器(每个功能对应一个结构体):
SalesReportPDFExporter.swift, InventoryPDFExporter.swift, NetworkPDFExporter.swiftDependencies
依赖项
None. Uses only Apple SDK classes: , , , , , , .
UIGraphicsPDFRendererCGContextNSAttributedStringUIFontUIColorUIActivityViewControllerFileManager无依赖。 仅使用Apple SDK类:、、、、、、。
UIGraphicsPDFRendererCGContextNSAttributedStringUIFontUIColorUIActivityViewControllerFileManagerStep 1: PDFGenerator — Constants & Types
步骤1:PDFGenerator — 常量与类型
swift
final class PDFGenerator {
static let a4Width: CGFloat = 595.0 // A4 in points (72 dpi)
static let a4Height: CGFloat = 842.0
static let a4LandWidth: CGFloat = 842.0
static let a4LandHeight: CGFloat = 595.0
static let margin: CGFloat = 40.0
// Brand colours (customise per project)
static let brandRed = UIColor(red: 198/255, green: 40/255, blue: 40/255, alpha: 1)
static let headerBG = UIColor(red: 176/255, green: 228/255, blue: 252/255, alpha: 1)
static let altRow = UIColor(red: 248/255, green: 249/255, blue: 250/255, alpha: 1)
static let summaryBG = UIColor(red: 240/255, green: 244/255, blue: 248/255, alpha: 1)
static let accentBlue = UIColor(red: 32/255, green: 107/255, blue: 196/255, alpha: 1)
static let textBlack = UIColor(red: 33/255, green: 37/255, blue: 41/255, alpha: 1)
static let textGray = UIColor(red: 108/255, green: 117/255, blue: 125/255, alpha: 1)
struct FranchiseInfo {
let name: String; let address: String?; let phone: String?
let email: String?; let taxId: String?; let currency: String
}
struct TableColumn {
let header: String; let widthWeight: CGFloat; let alignment: NSTextAlignment
init(header: String, widthWeight: CGFloat, alignment: NSTextAlignment = .left) {
self.header = header; self.widthWeight = widthWeight; self.alignment = alignment
}
}
let pageWidth: CGFloat; let pageHeight: CGFloat; let landscape: Bool
var pageBounds: CGRect { CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight) }
var contentWidth: CGFloat { pageWidth - (Self.margin * 2) }
init(landscape: Bool = false) {
self.landscape = landscape
self.pageWidth = landscape ? Self.a4LandWidth : Self.a4Width
self.pageHeight = landscape ? Self.a4LandHeight : Self.a4Height
}
func needsNewPage(y: CGFloat) -> Bool { y > pageHeight - 50 - Self.margin }
func beginNewPage(context: UIGraphicsPDFRendererContext) -> CGFloat {
context.beginPage(); return Self.margin + 10
}
}swift
final class PDFGenerator {
static let a4Width: CGFloat = 595.0 // A4纸张的点数(72 dpi)
static let a4Height: CGFloat = 842.0
static let a4LandWidth: CGFloat = 842.0
static let a4LandHeight: CGFloat = 595.0
static let margin: CGFloat = 40.0
// 品牌颜色(可根据项目自定义)
static let brandRed = UIColor(red: 198/255, green: 40/255, blue: 40/255, alpha: 1)
static let headerBG = UIColor(red: 176/255, green: 228/255, blue: 252/255, alpha: 1)
static let altRow = UIColor(red: 248/255, green: 249/255, blue: 250/255, alpha: 1)
static let summaryBG = UIColor(red: 240/255, green: 244/255, blue: 248/255, alpha: 1)
static let accentBlue = UIColor(red: 32/255, green: 107/255, blue: 196/255, alpha: 1)
static let textBlack = UIColor(red: 33/255, green: 37/255, blue: 41/255, alpha: 1)
static let textGray = UIColor(red: 108/255, green: 117/255, blue: 125/255, alpha: 1)
struct FranchiseInfo {
let name: String; let address: String?; let phone: String?
let email: String?; let taxId: String?; let currency: String
}
struct TableColumn {
let header: String; let widthWeight: CGFloat; let alignment: NSTextAlignment
init(header: String, widthWeight: CGFloat, alignment: NSTextAlignment = .left) {
self.header = header; self.widthWeight = widthWeight; self.alignment = alignment
}
}
let pageWidth: CGFloat; let pageHeight: CGFloat; let landscape: Bool
var pageBounds: CGRect { CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight) }
var contentWidth: CGFloat { pageWidth - (Self.margin * 2) }
init(landscape: Bool = false) {
self.landscape = landscape
self.pageWidth = landscape ? Self.a4LandWidth : Self.a4Width
self.pageHeight = landscape ? Self.a4LandHeight : Self.a4Height
}
func needsNewPage(y: CGFloat) -> Bool { y > pageHeight - 50 - Self.margin }
func beginNewPage(context: UIGraphicsPDFRendererContext) -> CGFloat {
context.beginPage(); return Self.margin + 10
}
}Step 2: Drawing Functions
步骤2:绘图函数
Each function draws at the given Y position and returns the new Y. All use for text and / for shapes.
NSAttributedString.draw(in:)UIBezierPathUIRectFillswift
extension PDFGenerator {
// ── Letterhead: logo (50x50 centred) + name (14pt bold red) + address/phone/email (9pt gray) + divider
func drawLetterhead(y: CGFloat, logo: UIImage?, info: FranchiseInfo) -> CGFloat
// ── Report Title: centred title (14pt bold uppercase) + optional subtitle (10pt gray)
func drawReportTitle(y: CGFloat, title: String, subtitle: String? = nil) -> CGFloat
// ── Summary Cards: row of KPI boxes — label (9pt gray) + value (13pt bold accent blue)
func drawSummaryCards(y: CGFloat, items: [(label: String, value: String)]) -> CGFloat
// ── Info Section: key-value pairs for detail screens (label: value format)
func drawInfoSection(y: CGFloat, title: String?, items: [(label: String, value: String)]) -> CGFloat
// ── Status Badge: centred coloured rounded rect with white text
func drawStatusBadge(y: CGFloat, status: String, bgColor: UIColor) -> CGFloat
// ── Chart Image: scaled bitmap centred on page, max 250pt height
func drawChartImage(y: CGFloat, image: UIImage) -> CGFloat
// ── Footer: "Generated by X on DATE" (left) + "Page N of M" (right) at bottom
func drawFooter(pageNumber: Int, totalPages: Int, generatedBy: String)
// ── Divider: thin gray horizontal line
func drawDivider(y: CGFloat) -> CGFloat
// ── Data Table (see full implementation below)
func drawTable(y: CGFloat, context: UIGraphicsPDFRendererContext,
columns: [TableColumn], rows: [[String]],
totalsRow: [String]?, pageNumber: inout Int,
footerUser: String) -> CGFloat
}每个函数在指定Y位置进行绘制,并返回新的Y坐标。所有文本使用绘制,形状使用/绘制。
NSAttributedString.draw(in:)UIBezierPathUIRectFillswift
extension PDFGenerator {
// ── 信头:居中Logo(50x50)+ 名称(14pt粗体红色)+ 地址/电话/邮箱(9pt灰色)+ 分隔线
func drawLetterhead(y: CGFloat, logo: UIImage?, info: FranchiseInfo) -> CGFloat
// ── 报告标题:居中标题(14pt粗体大写)+ 可选副标题(10pt灰色)
func drawReportTitle(y: CGFloat, title: String, subtitle: String? = nil) -> CGFloat
// ── 摘要卡片:KPI框行 — 标签(9pt灰色)+ 值(13pt粗体强调蓝色)
func drawSummaryCards(y: CGFloat, items: [(label: String, value: String)]) -> CGFloat
// ── 信息区域:详情页的键值对(标签:值格式)
func drawInfoSection(y: CGFloat, title: String?, items: [(label: String, value: String)]) -> CGFloat
// ── 状态徽章:居中彩色圆角矩形,带白色文本
func drawStatusBadge(y: CGFloat, status: String, bgColor: UIColor) -> CGFloat
// ── 图表图像:页面居中缩放位图,最大高度250pt
func drawChartImage(y: CGFloat, image: UIImage) -> CGFloat
// ── 页脚:底部左侧显示"Generated by X on DATE" + 右侧显示"Page N of M"
func drawFooter(pageNumber: Int, totalPages: Int, generatedBy: String)
// ── 分隔线:细灰色水平线
func drawDivider(y: CGFloat) -> CGFloat
// ── 数据表(完整实现见下文)
func drawTable(y: CGFloat, context: UIGraphicsPDFRendererContext,
columns: [TableColumn], rows: [[String]],
totalsRow: [String]?, pageNumber: inout Int,
footerUser: String) -> CGFloat
}Table Implementation (Key Details)
表格实现(关键细节)
The table is the most complex component — weight-based columns, alternating rows, multi-line cells, auto page breaks with header redraw.
swift
func drawTable(y: CGFloat, context: UIGraphicsPDFRendererContext,
columns: [TableColumn], rows: [[String]],
totalsRow: [String]? = nil, pageNumber: inout Int,
footerUser: String) -> CGFloat {
let totalWeight = columns.reduce(0) { $0 + $1.widthWeight }
let rowHeight: CGFloat = 18; let cellPadding: CGFloat = 4
var currentY = y
// Calculate column positions from weights
var colPositions: [CGFloat] = []; var colWidths: [CGFloat] = []
var xPos = Self.margin
for col in columns {
colPositions.append(xPos)
let w = (col.widthWeight / totalWeight) * contentWidth
colWidths.append(w); xPos += w
}
// Header row (light blue bg, bold white text 8pt)
Self.headerBG.setFill()
UIRectFill(CGRect(x: Self.margin, y: currentY, width: contentWidth, height: rowHeight))
let headerAttrs: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: UIColor.white
]
for (i, col) in columns.enumerated() {
let rect = CGRect(x: colPositions[i] + cellPadding, y: currentY + 3,
width: colWidths[i] - cellPadding * 2, height: rowHeight - 6)
NSAttributedString(string: col.header, attributes:
attributesWithAlignment(headerAttrs, alignment: col.alignment)).draw(in: rect)
}
currentY += rowHeight
// Data rows
for (rowIdx, row) in rows.enumerated() {
let hasMultiLine = row.contains { $0.contains("\n") }
let thisRowHeight = hasMultiLine ? rowHeight + 10 : rowHeight
// Page break: draw footer, start new page, redraw header
if needsNewPage(y: currentY + thisRowHeight) {
drawFooter(pageNumber: pageNumber, totalPages: -1, generatedBy: footerUser)
pageNumber += 1; currentY = beginNewPage(context: context)
// Redraw header on new page (same code as above)
Self.headerBG.setFill()
UIRectFill(CGRect(x: Self.margin, y: currentY, width: contentWidth, height: rowHeight))
for (i, col) in columns.enumerated() {
let rect = CGRect(x: colPositions[i] + cellPadding, y: currentY + 3,
width: colWidths[i] - cellPadding * 2, height: rowHeight - 6)
NSAttributedString(string: col.header, attributes:
attributesWithAlignment(headerAttrs, alignment: col.alignment)).draw(in: rect)
}
currentY += rowHeight
}
if rowIdx % 2 == 1 { Self.altRow.setFill()
UIRectFill(CGRect(x: Self.margin, y: currentY, width: contentWidth, height: thisRowHeight)) }
// Draw cell values (multi-line: first line 8pt, second line 7pt gray)
for (colIdx, value) in row.enumerated() {
guard colIdx < columns.count else { continue }
let cellX = colPositions[colIdx] + cellPadding
let cellW = colWidths[colIdx] - cellPadding * 2
if value.contains("\n") {
let lines = value.split(separator: "\n", maxSplits: 1).map(String.init)
NSAttributedString(string: lines[0], attributes: bodyAttrs(columns[colIdx].alignment))
.draw(in: CGRect(x: cellX, y: currentY + 3, width: cellW, height: 12))
if lines.count > 1 {
NSAttributedString(string: lines[1], attributes: subAttrs(columns[colIdx].alignment))
.draw(in: CGRect(x: cellX, y: currentY + 14, width: cellW, height: 10))
}
} else {
NSAttributedString(string: value, attributes: bodyAttrs(columns[colIdx].alignment))
.draw(in: CGRect(x: cellX, y: currentY + 3, width: cellW, height: 12))
}
}
currentY += thisRowHeight
}
// Totals row (same bg as header, bold white)
if let totals = totalsRow { /* same pattern as header row with bold text */ }
return currentY + 8
}
// Helper: returns attrs dict with given alignment + .byTruncatingTail
private func attributesWithAlignment(_ attrs: [NSAttributedString.Key: Any],
alignment: NSTextAlignment) -> [NSAttributedString.Key: Any]表格是最复杂的组件——基于权重的列、交替行、多行单元格、自动分页并重新绘制表头。
swift
func drawTable(y: CGFloat, context: UIGraphicsPDFRendererContext,
columns: [TableColumn], rows: [[String]],
totalsRow: [String]? = nil, pageNumber: inout Int,
footerUser: String) -> CGFloat {
let totalWeight = columns.reduce(0) { $0 + $1.widthWeight }
let rowHeight: CGFloat = 18; let cellPadding: CGFloat = 4
var currentY = y
// 根据权重计算列位置
var colPositions: [CGFloat] = []; var colWidths: [CGFloat] = []
var xPos = Self.margin
for col in columns {
colPositions.append(xPos)
let w = (col.widthWeight / totalWeight) * contentWidth
colWidths.append(w); xPos += w
}
// 表头行(浅蓝色背景,8pt粗体白色文本)
Self.headerBG.setFill()
UIRectFill(CGRect(x: Self.margin, y: currentY, width: contentWidth, height: rowHeight))
let headerAttrs: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: UIColor.white
]
for (i, col) in columns.enumerated() {
let rect = CGRect(x: colPositions[i] + cellPadding, y: currentY + 3,
width: colWidths[i] - cellPadding * 2, height: rowHeight - 6)
NSAttributedString(string: col.header, attributes:
attributesWithAlignment(headerAttrs, alignment: col.alignment)).draw(in: rect)
}
currentY += rowHeight
// 数据行
for (rowIdx, row) in rows.enumerated() {
let hasMultiLine = row.contains { $0.contains("\n") }
let thisRowHeight = hasMultiLine ? rowHeight + 10 : rowHeight
// 分页:绘制页脚,开始新页面,重新绘制表头
if needsNewPage(y: currentY + thisRowHeight) {
drawFooter(pageNumber: pageNumber, totalPages: -1, generatedBy: footerUser)
pageNumber += 1; currentY = beginNewPage(context: context)
// 在新页面重新绘制表头(代码与上方相同)
Self.headerBG.setFill()
UIRectFill(CGRect(x: Self.margin, y: currentY, width: contentWidth, height: rowHeight))
for (i, col) in columns.enumerated() {
let rect = CGRect(x: colPositions[i] + cellPadding, y: currentY + 3,
width: colWidths[i] - cellPadding * 2, height: rowHeight - 6)
NSAttributedString(string: col.header, attributes:
attributesWithAlignment(headerAttrs, alignment: col.alignment)).draw(in: rect)
}
currentY += rowHeight
}
if rowIdx % 2 == 1 { Self.altRow.setFill()
UIRectFill(CGRect(x: Self.margin, y: currentY, width: contentWidth, height: thisRowHeight)) }
// 绘制单元格值(多行:第一行8pt,第二行7pt灰色)
for (colIdx, value) in row.enumerated() {
guard colIdx < columns.count else { continue }
let cellX = colPositions[colIdx] + cellPadding
let cellW = colWidths[colIdx] - cellPadding * 2
if value.contains("\n") {
let lines = value.split(separator: "\n", maxSplits: 1).map(String.init)
NSAttributedString(string: lines[0], attributes: bodyAttrs(columns[colIdx].alignment))
.draw(in: CGRect(x: cellX, y: currentY + 3, width: cellW, height: 12))
if lines.count > 1 {
NSAttributedString(string: lines[1], attributes: subAttrs(columns[colIdx].alignment))
.draw(in: CGRect(x: cellX, y: currentY + 14, width: cellW, height: 10))
}
} else {
NSAttributedString(string: value, attributes: bodyAttrs(columns[colIdx].alignment))
.draw(in: CGRect(x: cellX, y: currentY + 3, width: cellW, height: 12))
}
}
currentY += thisRowHeight
}
// 总计行(与表头相同背景,粗体白色文本)
if let totals = totalsRow { /* 与表头行相同模式,使用粗体文本 */ }
return currentY + 8
}
// 辅助函数:返回包含指定对齐方式 + .byTruncatingTail的属性字典
private func attributesWithAlignment(_ attrs: [NSAttributedString.Key: Any],
alignment: NSTextAlignment) -> [NSAttributedString.Key: Any]Step 3: PDF Share Helper
步骤3:PDF分享助手
swift
struct PDFShareHelper {
static func share(data: Data, filename: String,
from viewController: UIViewController, sourceView: UIView? = nil) {
let sanitised = filename.replacingOccurrences(
of: "[^a-zA-Z0-9._-]", with: "_", options: .regularExpression)
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("\(sanitised).pdf")
try? data.write(to: tempURL)
let activityVC = UIActivityViewController(activityItems: [tempURL],
applicationActivities: nil)
// iPad REQUIRES popover — crashes without this
if let popover = activityVC.popoverPresentationController {
popover.sourceView = sourceView ?? viewController.view
popover.sourceRect = sourceView?.bounds
?? CGRect(x: viewController.view.bounds.midX,
y: viewController.view.bounds.midY, width: 0, height: 0)
popover.permittedArrowDirections = [.up, .down]
}
viewController.present(activityVC, animated: true)
}
}swift
struct PDFShareHelper {
static func share(data: Data, filename: String,
from viewController: UIViewController, sourceView: UIView? = nil) {
let sanitised = filename.replacingOccurrences(
of: "[^a-zA-Z0-9._-]", with: "_", options: .regularExpression)
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("\(sanitised).pdf")
try? data.write(to: tempURL)
let activityVC = UIActivityViewController(activityItems: [tempURL],
applicationActivities: nil)
// iPad 必须配置popover — 否则会崩溃
if let popover = activityVC.popoverPresentationController {
popover.sourceView = sourceView ?? viewController.view
popover.sourceRect = sourceView?.bounds
?? CGRect(x: viewController.view.bounds.midX,
y: viewController.view.bounds.midY, width: 0, height: 0)
popover.permittedArrowDirections = [.up, .down]
}
viewController.present(activityVC, animated: true)
}
}Step 4: Per-Module Exporters
步骤4:各模块导出器
Each exporter is a with functions. Pattern:
structstaticswift
struct SalesReportPDFExporter {
static func exportTopSellers(
report: TopSellersReport, franchiseInfo: PDFGenerator.FranchiseInfo,
logo: UIImage?, currency: String, startDate: String, endDate: String,
generatedBy: String, from viewController: UIViewController
) {
let pdf = PDFGenerator(landscape: true)
let renderer = UIGraphicsPDFRenderer(bounds: pdf.pageBounds)
let data = renderer.pdfData { context in
context.beginPage()
var y = PDFGenerator.margin; var pageNum = 1
y = pdf.drawLetterhead(y: y, logo: logo, info: franchiseInfo)
y = pdf.drawReportTitle(y: y, title: "TOP SELLERS REPORT",
subtitle: "Period: \(startDate) to \(endDate)")
y = pdf.drawSummaryCards(y: y, items: [
("Sellers", "\(report.summary.totalDistributors)"),
("Invoices", "\(report.summary.totalInvoices)"),
("Revenue", "\(currency) \(report.summary.totalAmount)"),
])
let columns: [PDFGenerator.TableColumn] = [
.init(header: "#", widthWeight: 0.3, alignment: .center),
.init(header: "Name", widthWeight: 2.0),
.init(header: "Invoices", widthWeight: 0.6, alignment: .right),
.init(header: "Amount", widthWeight: 1.0, alignment: .right)
]
let rows = report.rows.enumerated().map { idx, r in
["\(idx + 1)", r.fullName ?? "-", "\(r.totalInvoices)", "\(currency) \(r.totalAmount)"]
}
y = pdf.drawTable(y: y, context: context, columns: columns, rows: rows,
totalsRow: nil, pageNumber: &pageNum, footerUser: generatedBy)
pdf.drawFooter(pageNumber: pageNum, totalPages: pageNum, generatedBy: generatedBy)
}
PDFShareHelper.share(data: data, filename: "top_sellers_\(startDate)_\(endDate)",
from: viewController)
}
}每个导出器是一个包含函数的。模式如下:
staticstructswift
struct SalesReportPDFExporter {
static func exportTopSellers(
report: TopSellersReport, franchiseInfo: PDFGenerator.FranchiseInfo,
logo: UIImage?, currency: String, startDate: String, endDate: String,
generatedBy: String, from viewController: UIViewController
) {
let pdf = PDFGenerator(landscape: true)
let renderer = UIGraphicsPDFRenderer(bounds: pdf.pageBounds)
let data = renderer.pdfData { context in
context.beginPage()
var y = PDFGenerator.margin; var pageNum = 1
y = pdf.drawLetterhead(y: y, logo: logo, info: franchiseInfo)
y = pdf.drawReportTitle(y: y, title: "TOP SELLERS REPORT",
subtitle: "Period: \(startDate) to \(endDate)")
y = pdf.drawSummaryCards(y: y, items: [
("Sellers", "\(report.summary.totalDistributors)"),
("Invoices", "\(report.summary.totalInvoices)"),
("Revenue", "\(currency) \(report.summary.totalAmount)"),
])
let columns: [PDFGenerator.TableColumn] = [
.init(header: "#", widthWeight: 0.3, alignment: .center),
.init(header: "Name", widthWeight: 2.0),
.init(header: "Invoices", widthWeight: 0.6, alignment: .right),
.init(header: "Amount", widthWeight: 1.0, alignment: .right)
]
let rows = report.rows.enumerated().map { idx, r in
["\(idx + 1)", r.fullName ?? "-", "\(r.totalInvoices)", "\(currency) \(r.totalAmount)"]
}
y = pdf.drawTable(y: y, context: context, columns: columns, rows: rows,
totalsRow: nil, pageNumber: &pageNum, footerUser: generatedBy)
pdf.drawFooter(pageNumber: pageNum, totalPages: pageNum, generatedBy: generatedBy)
}
PDFShareHelper.share(data: data, filename: "top_sellers_\(startDate)_\(endDate)",
from: viewController)
}
}Step 5: PDFExportButton + SwiftUI Integration
步骤5:PDFExportButton + SwiftUI集成
swift
// Reusable toolbar button
struct PDFExportButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 4) {
Image(systemName: "doc.richtext").font(.system(size: 14))
Text("PDF").font(.caption).fontWeight(.medium)
}
}
}
}
// Screen integration — get topmost VC for share sheet presentation
struct TopSellersReportView: View {
@StateObject private var viewModel = TopSellersViewModel()
var body: some View {
List { /* report content */ }
.navigationTitle("Top Sellers")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if viewModel.report != nil { PDFExportButton { exportPDF() } }
}
}
}
private func exportPDF() {
guard let report = viewModel.report,
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = scene.windows.first?.rootViewController else { return }
var topVC = rootVC
while let presented = topVC.presentedViewController { topVC = presented }
SalesReportPDFExporter.exportTopSellers(
report: report, franchiseInfo: viewModel.franchiseInfo,
logo: UIImage(named: "company_logo"), currency: viewModel.currency,
startDate: viewModel.startDate, endDate: viewModel.endDate,
generatedBy: viewModel.username, from: topVC)
}
}swift
// 可复用工具栏按钮
struct PDFExportButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 4) {
Image(systemName: "doc.richtext").font(.system(size: 14))
Text("PDF").font(.caption).fontWeight(.medium)
}
}
}
}
// 屏幕集成 — 获取顶层VC用于分享弹窗展示
struct TopSellersReportView: View {
@StateObject private var viewModel = TopSellersViewModel()
var body: some View {
List { /* 报告内容 */ }
.navigationTitle("Top Sellers")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if viewModel.report != nil { PDFExportButton { exportPDF() } }
}
}
}
private func exportPDF() {
guard let report = viewModel.report,
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = scene.windows.first?.rootViewController else { return }
var topVC = rootVC
while let presented = topVC.presentedViewController { topVC = presented }
SalesReportPDFExporter.exportTopSellers(
report: report, franchiseInfo: viewModel.franchiseInfo,
logo: UIImage(named: "company_logo"), currency: viewModel.currency,
startDate: viewModel.startDate, endDate: viewModel.endDate,
generatedBy: viewModel.username, from: topVC)
}
}Step 6: Localised Strings
步骤6:本地化字符串
// Localizable.strings (translate all)
"pdf_export" = "Export PDF";
"pdf_generating" = "Generating PDF…";
"pdf_export_success" = "PDF exported successfully";
"pdf_export_error" = "Failed to export PDF";
"pdf_share" = "Share PDF";
"pdf_generated_by" = "Generated by %@";
"pdf_generated_on" = "Generated on %@";
"pdf_page_of" = "Page %d of %d";
"pdf_report_period" = "Period: %@ to %@";
"pdf_report_summary" = "Report Summary";
"pdf_invoice_title" = "INVOICE";
"pdf_invoice_bill_to" = "Bill To";
"pdf_thank_you" = "Thank you for your business!";// Localizable.strings(需翻译所有内容)
"pdf_export" = "导出PDF";
"pdf_generating" = "正在生成PDF…";
"pdf_export_success" = "PDF导出成功";
"pdf_export_error" = "PDF导出失败";
"pdf_share" = "分享PDF";
"pdf_generated_by" = "由%@生成";
"pdf_generated_on" = "生成于%@";
"pdf_page_of" = "第%d页,共%d页";
"pdf_report_period" = "周期:%@至%@";
"pdf_report_summary" = "报告摘要";
"pdf_invoice_title" = "发票";
"pdf_invoice_bill_to" = "收款方";
"pdf_thank_you" = "感谢您的业务支持!";PDF Design Specification
PDF设计规范
Letterhead
信头
[Logo 50x50]
FRANCHISE NAME ← 14pt bold red, uppercase
123 Main Street, City ← 9pt gray
Tel: +1 234 567 · info@co.com ← 9pt gray
───────────────────────────────── ← divider [Logo 50x50]
FRANCHISE NAME ← 14pt粗体红色,大写
123 Main Street, City ← 9pt灰色
Tel: +1 234 567 · info@co.com ← 9pt灰色
───────────────────────────────── ← 分隔线Summary Cards
摘要卡片
┌──────────┬──────────┬──────────┬──────────┐
│ Label │ Label │ Label │ Label │ ← 9pt gray
│ Value │ Value │ Value │ Value │ ← 13pt bold blue
└──────────┴──────────┴──────────┴──────────┘┌──────────┬──────────┬──────────┬──────────┐
│ Label │ Label │ Label │ Label │ ← 9pt灰色
│ Value │ Value │ Value │ Value │ ← 13pt粗体蓝色
└──────────┴──────────┴──────────┴──────────┘Data Table
数据表
┌────┬──────────────┬────────┬──────────┐
│ # │ Product │ Qty │ Amount │ ← Light blue bg, bold white
├────┼──────────────┼────────┼──────────┤
│ 1 │ Widget A │ 120 │ USD 500 │ ← White
│ 2 │ Widget B │ 80 │ USD 320 │ ← Gray stripe
├────┼──────────────┼────────┼──────────┤
│ │ TOTALS │ 200 │ USD 820 │ ← Light blue bg
└────┴──────────────┴────────┴──────────┘┌────┬──────────────┬────────┬──────────┐
│ # │ Product │ Qty │ Amount │ ← 浅蓝色背景,粗体白色文本
├────┼──────────────┼────────┼──────────┤
│ 1 │ Widget A │ 120 │ USD 500 │ ← 白色背景
│ 2 │ Widget B │ 80 │ USD 320 │ ← 灰色条纹背景
├────┼──────────────┼────────┼──────────┤
│ │ TOTALS │ 200 │ USD 820 │ ← 浅蓝色背景
└────┴──────────────┴────────┴──────────┘Footer
页脚
Generated by admin on 17 February 2026, 2:30 PM Page 1 of 3Generated by admin on 17 February 2026, 2:30 PM Page 1 of 3Portrait vs Landscape Decision
纵向与横向决策
| Content Type | Orientation | Reason |
|---|---|---|
| 4 columns or fewer | Portrait | Fits comfortably |
| 5+ wide columns | Landscape | Needs horizontal space |
| Invoice / detail view | Portrait | Standard document format |
| Lists with many columns | Landscape | Table readability |
| 内容类型 | 方向 | 原因 |
|---|---|---|
| 4列及以下 | 纵向 | 布局舒适 |
| 5列及以上 | 横向 | 需要更多水平空间 |
| 发票/详情页 | 纵向 | 标准文档格式 |
| 多列列表 | 横向 | 表格可读性更高 |
Patterns & Anti-Patterns
模式与反模式
DO
建议
- Use with
structfunctions for exporters (stateless, no DI needed)static - Pass for share sheet presentation
UIViewController - Use for column alignment (left, center, right)
NSTextAlignment - Use weight-based column sizing (proportional, adapts to page width)
- Truncate text with line break mode
.byTruncatingTail - Support multi-line cells via delimiter
\n - Always configure for iPad compatibility
popoverPresentationController - Use (auto-cleaned, no permissions needed)
FileManager.default.temporaryDirectory - Sanitise filenames (replace special chars with )
_ - Use (modern, block-based, handles context lifecycle)
UIGraphicsPDFRenderer
- 为导出器使用包含函数的
static(无状态,无需依赖注入)struct - 传递用于分享弹窗展示
UIViewController - 使用进行列对齐(左对齐、居中、右对齐)
NSTextAlignment - 使用基于权重的列尺寸(比例适配,可适应页面宽度)
- 使用换行模式截断文本
.byTruncatingTail - 通过分隔符支持多行单元格
\n - 始终为iPad配置以确保兼容性
popoverPresentationController - 使用(自动清理,无需权限)
FileManager.default.temporaryDirectory - 清理文件名(将特殊字符替换为)
_ - 使用(现代、基于块、处理上下文生命周期)
UIGraphicsPDFRenderer
DON'T
不建议
- Don't use third-party PDF libraries (TPPDF, libHaru — unnecessary dependency)
- Don't hardcode text — use for user-facing strings
NSLocalizedString - Don't skip the letterhead — branding matters for exported documents
- Don't forget the footer with page numbers and "generated by" attribution
- Don't screenshot SwiftUI views for PDF — draw everything with Core Graphics
- Don't forget iPad popover configuration — crashes without it on iPad
- Don't store PDFs permanently — use temporary directory, let OS manage cleanup
- Don't use deprecated — use
UIGraphicsBeginPDFContextUIGraphicsPDFRenderer
- 不要使用第三方PDF库(TPPDF、libHaru — 不必要的依赖)
- 不要硬编码文本 — 对用户可见的字符串使用
NSLocalizedString - 不要省略信头 — 品牌标识对导出文档很重要
- 不要忘记包含页码和"生成者"信息的页脚
- 不要为PDF截图SwiftUI视图 — 使用Core Graphics绘制所有内容
- 不要忘记iPad的popover配置 — 否则在iPad上会崩溃
- 不要永久存储PDF — 使用临时目录,让系统管理清理
- 不要使用已弃用的— 使用
UIGraphicsBeginPDFContextUIGraphicsPDFRenderer
Integration with Other Skills
与其他技能的集成
ios-pdf-export
├── android-pdf-export (platform counterpart, same visual output)
├── dual-auth-rbac (franchise info for letterheads)
├── report-print-pdf (shared report layout concepts)
└── webapp-gui-design (design patterns for report screens)ios-pdf-export
├── android-pdf-export (平台对应技能,视觉输出一致)
├── dual-auth-rbac (信头所需的加盟商信息)
├── report-print-pdf (共享报告布局概念)
└── webapp-gui-design (报告屏幕的设计模式)Checklist
检查清单
- Create with drawing primitives (letterhead, table, cards, footer)
PDFGenerator - Create (save to temp + share via UIActivityViewController)
PDFShareHelper - Create SwiftUI component for toolbar
PDFExportButton - Ensure API returns franchise contact info (address, phone, email, tax_id)
- Store franchise info in user session/manager for letterhead access
- Create per-module exporter structs with one static function per screen
- Add PDF button to each screen's toolbar
- Configure for iPad share sheet
popoverPresentationController - Add localised strings (translate to all supported languages)
- Test: export → share sheet opens → PDF renders correctly in viewer
- Test on iPad: share sheet presents as popover without crash
- 创建包含绘图原语(信头、表格、卡片、页脚)的
PDFGenerator - 创建(保存到临时目录 + 通过UIActivityViewController分享)
PDFShareHelper - 创建用于工具栏的PDFExportButton SwiftUI组件
- 确保API返回加盟商联系信息(地址、电话、邮箱、税号)
- 将加盟商信息存储在用户会话/管理器中,以便信头访问
- 为每个屏幕创建包含一个静态函数的各模块导出器结构体
- 在每个屏幕的工具栏中添加PDF按钮
- 为iPad分享弹窗配置
popoverPresentationController - 添加本地化字符串(翻译为所有支持的语言)
- 测试:导出 → 分享弹窗打开 → PDF在查看器中正确渲染
- 在iPad上测试:分享弹窗以popover形式展示且无崩溃