mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-30 19:59:35 +00:00
build(ios): attach app review notes PDF
This commit is contained in:
@@ -68,9 +68,9 @@ Release behavior:
|
||||
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
|
||||
- App Store release also switches the app to `OpenClawPushMode=appStore`, which derives relay transport, official distribution, the canonical production relay, production APNs, production relay profile, `appleStrict` proof, and the App-Attest-capable entitlement file.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots, uploads release notes, and attaches `apps/ios/APP-REVIEW-NOTES.md` as a rendered PDF before archiving and uploading the IPA.
|
||||
- The release archive is validated before upload by inspecting the exported IPA's signed entitlements, embedded App Store profile, and push mode. The upload fails if the IPA is not an App Store production relay build.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build, public metadata, and the App Review PDF attachment, but it does not submit for review or upload the App Store Connect `Notes` field.
|
||||
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- `apps/ios/version.json` is the pinned iOS release version source.
|
||||
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
|
||||
@@ -178,7 +178,7 @@ pnpm ios:release:upload
|
||||
- verifies synced iOS versioning artifacts
|
||||
- resolves the next App Store Connect build number for that short version
|
||||
- generates deterministic App Store screenshots
|
||||
- uploads release notes and screenshots to the editable App Store version
|
||||
- uploads release notes, screenshots, and the App Review PDF attachment to the editable App Store version
|
||||
- generates `apps/ios/build/AppStoreRelease.xcconfig`
|
||||
- archives `OpenClaw`
|
||||
- validates the exported IPA's push mode, signed entitlements, and embedded App Store profile
|
||||
|
||||
@@ -96,7 +96,7 @@ Pinned iOS version `2026.4.10` maps to:
|
||||
- creates or verifies Developer Portal bundle IDs/services through Fastlane `produce`
|
||||
- syncs encrypted App Store signing assets with Fastlane `match`
|
||||
- increments App Store Connect build numbers for the pinned short version
|
||||
- uploads screenshots and release notes before archiving a release build
|
||||
- uploads screenshots, release notes, and the rendered App Review PDF attachment before archiving a release build
|
||||
|
||||
## Release-note resolution order
|
||||
|
||||
@@ -156,4 +156,4 @@ Fastlane and Xcode should consume only the pinned iOS version from `apps/ios/ver
|
||||
|
||||
Changing `package.json.version` alone must not change the iOS app version until a maintainer explicitly runs the pin step.
|
||||
|
||||
App Review submission must remain manual. Automation may create/update the editable App Store version, upload screenshots, upload release notes, and upload builds, but it should not submit a build for review.
|
||||
App Review submission must remain manual. Automation may create/update the editable App Store version, upload screenshots, upload release notes, upload the App Review PDF attachment, and upload builds, but it should not upload the App Store Connect `Notes` field or submit a build for review.
|
||||
|
||||
@@ -47,6 +47,10 @@ PUBLIC_METADATA_FILENAMES = [
|
||||
"subtitle.txt",
|
||||
"support_url.txt"
|
||||
].freeze
|
||||
APP_REVIEW_NOTES_METADATA_FILENAMES = [
|
||||
"notes.txt",
|
||||
"review_notes.txt"
|
||||
].freeze
|
||||
|
||||
def load_env_file(path)
|
||||
return unless File.exist?(path)
|
||||
@@ -732,6 +736,37 @@ def release_notes_metadata_path
|
||||
temp_root
|
||||
end
|
||||
|
||||
def app_review_notes_markdown_path
|
||||
File.join(ios_root, "APP-REVIEW-NOTES.md")
|
||||
end
|
||||
|
||||
def app_review_notes_pdf_path
|
||||
File.join(ios_root, "build", "app-review", "APP-REVIEW-NOTES.pdf")
|
||||
end
|
||||
|
||||
def generate_app_review_notes_pdf!
|
||||
source = app_review_notes_markdown_path
|
||||
UI.user_error!("Missing App Review notes at #{source}.") unless File.exist?(source)
|
||||
|
||||
output = app_review_notes_pdf_path
|
||||
FileUtils.mkdir_p(File.dirname(output))
|
||||
sh(shell_join(["xcrun", "swift", File.join(repo_root, "scripts", "ios-app-review-notes-pdf.swift"), source, output]))
|
||||
output
|
||||
end
|
||||
|
||||
def assert_no_app_review_notes_field_metadata!(metadata_path)
|
||||
notes_dir = File.join(metadata_path, "review_information")
|
||||
APP_REVIEW_NOTES_METADATA_FILENAMES.each do |filename|
|
||||
path = File.join(notes_dir, filename)
|
||||
next unless File.exist?(path)
|
||||
|
||||
UI.user_error!(
|
||||
"Refusing to upload App Review Notes metadata from #{path}. " \
|
||||
"Maintain the App Store Connect Notes field manually so the live setup code is not stored in this repo."
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def public_metadata_path
|
||||
source = File.join(__dir__, "metadata")
|
||||
temp_root = Dir.mktmpdir("openclaw-app-store-metadata")
|
||||
@@ -1014,7 +1049,7 @@ platform :ios do
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
|
||||
desc "Generate screenshots, update App Store version metadata, then upload an App Store build"
|
||||
desc "Generate screenshots, update App Store metadata and review attachment, then upload an App Store build"
|
||||
lane :release_upload do
|
||||
unless ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"
|
||||
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
|
||||
@@ -1044,7 +1079,7 @@ platform :ios do
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
|
||||
desc "Upload App Store metadata (and optionally screenshots)"
|
||||
desc "Upload App Store metadata, App Review PDF attachment, and optionally screenshots"
|
||||
lane :metadata do
|
||||
install_ready_for_review_edit_state_lookup!
|
||||
sync_ios_versioning!
|
||||
@@ -1064,12 +1099,15 @@ platform :ios do
|
||||
validate_required_screenshots!(paths)
|
||||
end
|
||||
|
||||
assert_no_app_review_notes_field_metadata!(File.join(__dir__, "metadata"))
|
||||
metadata_path = public_metadata_path
|
||||
skip_metadata = ENV["DELIVER_METADATA"] != "1"
|
||||
if release_notes_upload_requested? && skip_metadata
|
||||
metadata_path = release_notes_metadata_path
|
||||
skip_metadata = false
|
||||
end
|
||||
assert_no_app_review_notes_field_metadata!(metadata_path) unless skip_metadata
|
||||
app_review_attachment_file = skip_metadata ? nil : generate_app_review_notes_pdf!
|
||||
|
||||
deliver_options = {
|
||||
api_key: api_key,
|
||||
@@ -1083,6 +1121,7 @@ platform :ios do
|
||||
skip_metadata: skip_metadata,
|
||||
skip_binary_upload: true,
|
||||
overwrite_screenshots: screenshot_upload_requested?,
|
||||
app_review_attachment_file: app_review_attachment_file,
|
||||
skip_app_version_update: false,
|
||||
submit_for_review: false,
|
||||
run_precheck_before_submit: false
|
||||
|
||||
@@ -169,5 +169,5 @@ Versioning rules:
|
||||
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
|
||||
- App Store release uses `OpenClawPushMode=appStore`, which derives the canonical production hosted relay, production APNs, production relay profile, and `appleStrict` proof. The release lane rejects custom production relay URL overrides.
|
||||
- The exported IPA is validated before upload by inspecting its push mode, signed entitlements, and embedded App Store profile.
|
||||
- `pnpm ios:release:upload` generates and uploads screenshots and release notes before archiving, then uploads the IPA without submitting it for App Review
|
||||
- `pnpm ios:release:upload` generates and uploads screenshots, release notes, and the App Review PDF attachment before archiving, then uploads the IPA without submitting it for App Review or uploading the App Store Connect `Notes` field
|
||||
- See `apps/ios/VERSIONING.md` for the detailed workflow
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This directory is used by `fastlane deliver` for App Store Connect text metadata.
|
||||
|
||||
## Upload metadata only
|
||||
## Upload public metadata and App Review attachment
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
@@ -10,9 +10,9 @@ APP_STORE_CONNECT_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
|
||||
DELIVER_METADATA=1 fastlane ios metadata
|
||||
```
|
||||
|
||||
## Release notes only
|
||||
## Release notes and App Review attachment
|
||||
|
||||
`pnpm ios:release:upload` uses this mode before archiving so the editable App Store version has current release notes without rewriting all metadata:
|
||||
`pnpm ios:release:upload` uses this mode before archiving so the editable App Store version has current release notes and the App Review PDF attachment without rewriting all metadata:
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
@@ -46,11 +46,12 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`.
|
||||
|
||||
- Locale files live under `metadata/en-US/`.
|
||||
- `release_notes.txt` is generated from `apps/ios/CHANGELOG.md`; after changelog updates, run `pnpm ios:version:sync`.
|
||||
- `apps/ios/APP-REVIEW-NOTES.md` is rendered to `apps/ios/build/app-review/APP-REVIEW-NOTES.pdf` and uploaded as the App Review attachment when metadata is uploaded.
|
||||
- Release notes resolve from `## <pinned iOS version>` first, then fall back to `## Unreleased` while a TestFlight train is still in progress.
|
||||
- When starting a new production release train, pin the iOS version first with `pnpm ios:version:pin -- --from-gateway`.
|
||||
- The release upload flow uploads release notes and screenshots before the IPA, and never submits for App Review.
|
||||
- The release upload flow uploads release notes, screenshots, and the App Review PDF attachment before the IPA, and never submits for App Review.
|
||||
- `privacy_url.txt` is set to `https://openclaw.ai/privacy`.
|
||||
- If app lookup fails in `deliver`, set one of:
|
||||
- `APP_STORE_CONNECT_APP_IDENTIFIER` (bundle ID)
|
||||
- `APP_STORE_CONNECT_APP_ID` (numeric App Store Connect app ID, e.g. from `/apps/<id>/...` URL)
|
||||
- App Review submission is manual. Keep review contact, demo account, and reviewer notes outside this repo and enter them directly in App Store Connect when submitting for review.
|
||||
- App Review submission is manual. Keep review contact, demo account, and the App Store Connect `Notes` field outside this repo and enter them directly in App Store Connect when submitting for review. Do not add `metadata/review_information/notes.txt`; the lane refuses to upload that field.
|
||||
|
||||
@@ -1616,6 +1616,7 @@
|
||||
"gateway:watch:raw": "node scripts/watch-node.mjs gateway --force",
|
||||
"gen:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --write",
|
||||
"ghsa:patch": "node scripts/ghsa-patch.mjs",
|
||||
"ios:app-review-notes:pdf": "xcrun swift scripts/ios-app-review-notes-pdf.swift apps/ios/APP-REVIEW-NOTES.md apps/ios/build/app-review/APP-REVIEW-NOTES.pdf",
|
||||
"ios:build": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
|
||||
"ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'",
|
||||
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
|
||||
|
||||
347
scripts/ios-app-review-notes-pdf.swift
Normal file
347
scripts/ios-app-review-notes-pdf.swift
Normal file
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env swift
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
func fail(_ message: String) -> Never {
|
||||
FileHandle.standardError.write(Data((message + "\n").utf8))
|
||||
exit(1)
|
||||
}
|
||||
|
||||
func htmlEscaped(_ value: String) -> String {
|
||||
var escaped = ""
|
||||
escaped.reserveCapacity(value.count)
|
||||
for character in value {
|
||||
switch character {
|
||||
case "&":
|
||||
escaped += "&"
|
||||
case "<":
|
||||
escaped += "<"
|
||||
case ">":
|
||||
escaped += ">"
|
||||
case "\"":
|
||||
escaped += """
|
||||
default:
|
||||
escaped.append(character)
|
||||
}
|
||||
}
|
||||
return escaped
|
||||
}
|
||||
|
||||
func renderInlineMarkdown(_ value: String) -> String {
|
||||
let escaped = htmlEscaped(value)
|
||||
let pieces = escaped.split(separator: "`", omittingEmptySubsequences: false)
|
||||
guard pieces.count > 1 else {
|
||||
return escaped
|
||||
}
|
||||
|
||||
return pieces.enumerated().map { index, piece in
|
||||
index.isMultiple(of: 2) ? String(piece) : "<code>\(piece)</code>"
|
||||
}.joined()
|
||||
}
|
||||
|
||||
func absoluteFileURL(_ path: String) -> URL {
|
||||
if path.hasPrefix("/") {
|
||||
return URL(fileURLWithPath: path).standardizedFileURL
|
||||
}
|
||||
|
||||
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
.appendingPathComponent(path)
|
||||
.standardizedFileURL
|
||||
}
|
||||
|
||||
func markdownToHTML(_ markdown: String) -> String {
|
||||
var body: [String] = []
|
||||
var paragraph: [String] = []
|
||||
var inCodeBlock = false
|
||||
var codeLines: [String] = []
|
||||
var previousListItem = false
|
||||
|
||||
func flushParagraph() {
|
||||
guard !paragraph.isEmpty else {
|
||||
return
|
||||
}
|
||||
body.append("<p>\(paragraph.map(renderInlineMarkdown).joined(separator: " "))</p>")
|
||||
paragraph.removeAll()
|
||||
previousListItem = false
|
||||
}
|
||||
|
||||
func flushCodeBlock() {
|
||||
guard !codeLines.isEmpty else {
|
||||
return
|
||||
}
|
||||
body.append("<pre><code>\(htmlEscaped(codeLines.joined(separator: "\n")))</code></pre>")
|
||||
codeLines.removeAll()
|
||||
previousListItem = false
|
||||
}
|
||||
|
||||
let headingPattern = try! NSRegularExpression(pattern: "^(#{1,6})\\s+(.+)$")
|
||||
let orderedListPattern = try! NSRegularExpression(pattern: "^\\s*(\\d+)\\.\\s+(.+)$")
|
||||
let unorderedListPattern = try! NSRegularExpression(pattern: "^\\s*-\\s+(.+)$")
|
||||
|
||||
for rawLine in markdown.components(separatedBy: .newlines) {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if line.hasPrefix("```") {
|
||||
if inCodeBlock {
|
||||
flushCodeBlock()
|
||||
inCodeBlock = false
|
||||
} else {
|
||||
flushParagraph()
|
||||
inCodeBlock = true
|
||||
previousListItem = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if inCodeBlock {
|
||||
codeLines.append(rawLine)
|
||||
continue
|
||||
}
|
||||
|
||||
if line.isEmpty {
|
||||
flushParagraph()
|
||||
previousListItem = false
|
||||
continue
|
||||
}
|
||||
|
||||
let lineRange = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||
if let match = headingPattern.firstMatch(in: line, range: lineRange),
|
||||
let levelRange = Range(match.range(at: 1), in: line),
|
||||
let textRange = Range(match.range(at: 2), in: line) {
|
||||
flushParagraph()
|
||||
let level = line[levelRange].count
|
||||
if level == 2 {
|
||||
body.append("<p class=\"section-spacer\"> </p>")
|
||||
body.append("<p class=\"section-spacer\"> </p>")
|
||||
}
|
||||
body.append("<h\(level)>\(renderInlineMarkdown(String(line[textRange])))</h\(level)>")
|
||||
if level <= 2 {
|
||||
body.append("<p class=\"heading-rule\">\(String(repeating: "─", count: 85))</p>")
|
||||
}
|
||||
previousListItem = false
|
||||
continue
|
||||
}
|
||||
|
||||
if let match = orderedListPattern.firstMatch(in: line, range: lineRange),
|
||||
let numberRange = Range(match.range(at: 1), in: line),
|
||||
let itemRange = Range(match.range(at: 2), in: line) {
|
||||
flushParagraph()
|
||||
let marker = htmlEscaped(String(line[numberRange])) + "."
|
||||
body.append(
|
||||
"<p class=\"list-item\"><span class=\"marker\">\(marker)</span> \(renderInlineMarkdown(String(line[itemRange])))</p>"
|
||||
)
|
||||
previousListItem = true
|
||||
continue
|
||||
}
|
||||
|
||||
if let match = unorderedListPattern.firstMatch(in: line, range: lineRange),
|
||||
let itemRange = Range(match.range(at: 1), in: line) {
|
||||
flushParagraph()
|
||||
body.append("<p class=\"list-item\"><span class=\"marker\">•</span> \(renderInlineMarkdown(String(line[itemRange])))</p>")
|
||||
previousListItem = true
|
||||
continue
|
||||
}
|
||||
|
||||
if previousListItem && rawLine.hasPrefix(" ") {
|
||||
body.append("<p class=\"list-continuation\">\(renderInlineMarkdown(line))</p>")
|
||||
} else {
|
||||
paragraph.append(line)
|
||||
previousListItem = false
|
||||
}
|
||||
}
|
||||
|
||||
if inCodeBlock {
|
||||
flushCodeBlock()
|
||||
}
|
||||
flushParagraph()
|
||||
|
||||
return """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.42;
|
||||
}
|
||||
h1 {
|
||||
color: #111111;
|
||||
font-size: 25pt;
|
||||
margin: 0 0 5pt 0;
|
||||
}
|
||||
h2 {
|
||||
color: #111111;
|
||||
font-size: 17pt;
|
||||
margin: 0 0 5pt 0;
|
||||
}
|
||||
.section-spacer {
|
||||
color: #ffffff;
|
||||
font-size: 10pt;
|
||||
margin: 0;
|
||||
}
|
||||
.heading-rule {
|
||||
color: #d8dee4;
|
||||
font-family: Menlo, Monaco, monospace;
|
||||
font-size: 6pt;
|
||||
line-height: 0.55;
|
||||
margin: 0 0 7pt 0;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 9pt 0;
|
||||
}
|
||||
.list-item {
|
||||
margin: 0 0 5pt 0;
|
||||
padding-left: 24pt;
|
||||
text-indent: -24pt;
|
||||
}
|
||||
.marker {
|
||||
display: inline-block;
|
||||
min-width: 20pt;
|
||||
}
|
||||
.list-continuation {
|
||||
margin-left: 24pt;
|
||||
}
|
||||
code {
|
||||
background: #eef2f6;
|
||||
border-radius: 4pt;
|
||||
color: #24292f;
|
||||
font-family: Menlo, Monaco, monospace;
|
||||
font-size: 9.5pt;
|
||||
padding: 1.5pt 3pt;
|
||||
}
|
||||
pre {
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 5pt;
|
||||
color: #24292f;
|
||||
margin: 0 0 10pt 0;
|
||||
padding: 8pt;
|
||||
}
|
||||
pre code {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
\(body.joined(separator: "\n"))
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
|
||||
let arguments = CommandLine.arguments
|
||||
guard arguments.count == 3 else {
|
||||
fail("Usage: scripts/ios-app-review-notes-pdf.swift <APP-REVIEW-NOTES.md> <output.pdf>")
|
||||
}
|
||||
|
||||
let sourceURL = absoluteFileURL(arguments[1])
|
||||
let outputURL = absoluteFileURL(arguments[2])
|
||||
|
||||
guard FileManager.default.fileExists(atPath: sourceURL.path) else {
|
||||
fail("Missing App Review notes Markdown: \(sourceURL.path)")
|
||||
}
|
||||
|
||||
let markdown: String
|
||||
do {
|
||||
markdown = try String(contentsOf: sourceURL, encoding: .utf8)
|
||||
} catch {
|
||||
fail("Failed to read \(sourceURL.path): \(error)")
|
||||
}
|
||||
|
||||
let htmlData = Data(markdownToHTML(markdown).utf8)
|
||||
let attributed: NSAttributedString
|
||||
do {
|
||||
attributed = try NSAttributedString(
|
||||
data: htmlData,
|
||||
options: [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
],
|
||||
documentAttributes: nil
|
||||
)
|
||||
} catch {
|
||||
fail("Failed to render App Review notes Markdown as HTML: \(error)")
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
do {
|
||||
try fileManager.createDirectory(
|
||||
at: outputURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
} catch {
|
||||
fail("Failed to create PDF output directory: \(error)")
|
||||
}
|
||||
|
||||
let pageSize = NSSize(width: 612, height: 792)
|
||||
let margin: CGFloat = 42
|
||||
let textWidth = pageSize.width - (margin * 2)
|
||||
|
||||
let textStorage = NSTextStorage(attributedString: attributed)
|
||||
let layoutManager = NSLayoutManager()
|
||||
layoutManager.usesFontLeading = true
|
||||
textStorage.addLayoutManager(layoutManager)
|
||||
|
||||
let contentSize = NSSize(width: textWidth, height: pageSize.height - (margin * 2))
|
||||
var pageGlyphRanges: [NSRange] = []
|
||||
|
||||
while pageGlyphRanges.last.map({ NSMaxRange($0) < layoutManager.numberOfGlyphs }) ?? true {
|
||||
let textContainer = NSTextContainer(containerSize: contentSize)
|
||||
textContainer.lineFragmentPadding = 0
|
||||
layoutManager.addTextContainer(textContainer)
|
||||
layoutManager.ensureLayout(for: textContainer)
|
||||
|
||||
let glyphRange = layoutManager.glyphRange(for: textContainer)
|
||||
guard glyphRange.length > 0 else {
|
||||
break
|
||||
}
|
||||
|
||||
pageGlyphRanges.append(glyphRange)
|
||||
if pageGlyphRanges.count > 100 {
|
||||
fail("Refusing to render more than 100 App Review notes PDF pages.")
|
||||
}
|
||||
}
|
||||
|
||||
let pdfData = NSMutableData()
|
||||
var mediaBox = CGRect(origin: .zero, size: pageSize)
|
||||
guard let dataConsumer = CGDataConsumer(data: pdfData),
|
||||
let pdfContext = CGContext(consumer: dataConsumer, mediaBox: &mediaBox, nil) else {
|
||||
fail("Failed to create PDF context: \(outputURL.path)")
|
||||
}
|
||||
|
||||
let pageBackground = CGColor(gray: 1, alpha: 1)
|
||||
|
||||
for glyphRange in pageGlyphRanges {
|
||||
pdfContext.beginPDFPage(nil)
|
||||
pdfContext.setFillColor(pageBackground)
|
||||
pdfContext.fill(mediaBox)
|
||||
|
||||
pdfContext.saveGState()
|
||||
pdfContext.translateBy(x: 0, y: pageSize.height)
|
||||
pdfContext.scaleBy(x: 1, y: -1)
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(cgContext: pdfContext, flipped: true)
|
||||
layoutManager.drawBackground(forGlyphRange: glyphRange, at: NSPoint(x: margin, y: margin))
|
||||
layoutManager.drawGlyphs(forGlyphRange: glyphRange, at: NSPoint(x: margin, y: margin))
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
pdfContext.restoreGState()
|
||||
|
||||
pdfContext.endPDFPage()
|
||||
}
|
||||
|
||||
pdfContext.closePDF()
|
||||
|
||||
do {
|
||||
try pdfData.write(to: outputURL, options: .atomic)
|
||||
} catch {
|
||||
fail("Failed to write App Review notes PDF: \(error)")
|
||||
}
|
||||
|
||||
print(outputURL.path)
|
||||
Reference in New Issue
Block a user