ios-pdf-export

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Platform 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
    ios-pdf-export
    or would be better handled by a more specific companion skill.
  • 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
    SKILL.md
    first, then load only the referenced deep-dive files that are necessary for the task.
  • 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

生成的证据

CategoryArtifactFormatExample
CorrectnessPDF export test planMarkdown doc covering page layout, paginated content, fonts, and image rendering
docs/ios/pdf-export-tests.md
类别工件格式示例
正确性PDF导出测试计划涵盖页面布局、分页内容、字体和图像渲染的Markdown文档
docs/ios/pdf-export-tests.md

References

参考资料

  • Use the links and companion skills already referenced in this file when deeper context is needed.
<!-- dual-compat-end -->
Generate professional branded PDF documents from any iOS screen using the built-in
UIGraphicsPDFRenderer
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.
  • 当需要更深入的上下文时,使用本文件中已引用的链接和配套技能。
<!-- dual-compat-end -->
使用内置的
UIGraphicsPDFRenderer
API从任意iOS屏幕生成专业品牌PDF文档。零外部依赖——纯Core Graphics绘图。支持A4纵向/横向、多页分页、信头、表格、摘要卡片、信息区域、状态徽章以及分享功能。

Overview

概述

Library choice: Native
UIGraphicsPDFRenderer
(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.
Architecture: A core
PDFGenerator
class provides reusable drawing primitives. Per-module exporters compose these primitives for each screen.
PDFShareHelper
handles temporary file storage and sharing via
UIActivityViewController
.
Core/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
库选择: 原生
UIGraphicsPDFRenderer
(包体积增加0 KB)。已弃用替代方案如TPPDF(第三方依赖)、PDFKit(仅用于查看,不支持生成)和libHaru(C库,桥接复杂)。
架构: 核心
PDFGenerator
类提供可复用的绘图原语。每个模块的导出器将这些原语组合用于对应屏幕。
PDFShareHelper
处理临时文件存储并通过
UIActivityViewController
实现分享。
Core/PDF/
  PDFGenerator.swift           — 绘图原语(信头、表格、卡片、页脚)
  PDFShareHelper.swift         — 保存到临时目录 + 通过UIActivityViewController分享
Core/UI/Components/
  PDFExportButton.swift        — 可复用工具栏按钮(图标 + "PDF"标签)
各模块导出器(每个功能对应一个结构体):
  SalesReportPDFExporter.swift, InventoryPDFExporter.swift, NetworkPDFExporter.swift

Dependencies

依赖项

None. Uses only Apple SDK classes:
UIGraphicsPDFRenderer
,
CGContext
,
NSAttributedString
,
UIFont
,
UIColor
,
UIActivityViewController
,
FileManager
.
无依赖。 仅使用Apple SDK类:
UIGraphicsPDFRenderer
CGContext
NSAttributedString
UIFont
UIColor
UIActivityViewController
FileManager

Step 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
NSAttributedString.draw(in:)
for text and
UIBezierPath
/
UIRectFill
for shapes.
swift
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:)
绘制,形状使用
UIBezierPath
/
UIRectFill
绘制。
swift
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
struct
with
static
functions. Pattern:
swift
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)
    }
}
每个导出器是一个包含
static
函数的
struct
。模式如下:
swift
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 3
Generated by admin on 17 February 2026, 2:30 PM     Page 1 of 3

Portrait vs Landscape Decision

纵向与横向决策

Content TypeOrientationReason
4 columns or fewerPortraitFits comfortably
5+ wide columnsLandscapeNeeds horizontal space
Invoice / detail viewPortraitStandard document format
Lists with many columnsLandscapeTable readability
内容类型方向原因
4列及以下纵向布局舒适
5列及以上横向需要更多水平空间
发票/详情页纵向标准文档格式
多列列表横向表格可读性更高

Patterns & Anti-Patterns

模式与反模式

DO

建议

  • Use
    struct
    with
    static
    functions for exporters (stateless, no DI needed)
  • Pass
    UIViewController
    for share sheet presentation
  • Use
    NSTextAlignment
    for column alignment (left, center, right)
  • Use weight-based column sizing (proportional, adapts to page width)
  • Truncate text with
    .byTruncatingTail
    line break mode
  • Support multi-line cells via
    \n
    delimiter
  • Always configure
    popoverPresentationController
    for iPad compatibility
  • Use
    FileManager.default.temporaryDirectory
    (auto-cleaned, no permissions needed)
  • Sanitise filenames (replace special chars with
    _
    )
  • Use
    UIGraphicsPDFRenderer
    (modern, block-based, handles context lifecycle)
  • 为导出器使用包含
    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
    NSLocalizedString
    for user-facing strings
  • 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
    UIGraphicsBeginPDFContext
    — use
    UIGraphicsPDFRenderer
  • 不要使用第三方PDF库(TPPDF、libHaru — 不必要的依赖)
  • 不要硬编码文本 — 对用户可见的字符串使用
    NSLocalizedString
  • 不要省略信头 — 品牌标识对导出文档很重要
  • 不要忘记包含页码和"生成者"信息的页脚
  • 不要为PDF截图SwiftUI视图 — 使用Core Graphics绘制所有内容
  • 不要忘记iPad的popover配置 — 否则在iPad上会崩溃
  • 不要永久存储PDF — 使用临时目录,让系统管理清理
  • 不要使用已弃用的
    UIGraphicsBeginPDFContext
    — 使用
    UIGraphicsPDFRenderer

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
    PDFGenerator
    with drawing primitives (letterhead, table, cards, footer)
  • Create
    PDFShareHelper
    (save to temp + share via UIActivityViewController)
  • Create
    PDFExportButton
    SwiftUI component for toolbar
  • 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
    popoverPresentationController
    for iPad share sheet
  • 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
  • 创建
    PDFShareHelper
    (保存到临时目录 + 通过UIActivityViewController分享)
  • 创建用于工具栏的PDFExportButton SwiftUI组件
  • 确保API返回加盟商联系信息(地址、电话、邮箱、税号)
  • 将加盟商信息存储在用户会话/管理器中,以便信头访问
  • 为每个屏幕创建包含一个静态函数的各模块导出器结构体
  • 在每个屏幕的工具栏中添加PDF按钮
  • 为iPad分享弹窗配置
    popoverPresentationController
  • 添加本地化字符串(翻译为所有支持的语言)
  • 测试:导出 → 分享弹窗打开 → PDF在查看器中正确渲染
  • 在iPad上测试:分享弹窗以popover形式展示且无崩溃