#!/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) : "\(piece)" }.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("

\(paragraph.map(renderInlineMarkdown).joined(separator: " "))

") paragraph.removeAll() previousListItem = false } func flushCodeBlock() { guard !codeLines.isEmpty else { return } body.append("
\(htmlEscaped(codeLines.joined(separator: "\n")))
") 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.. 

") body.append("

 

") } body.append("\(renderInlineMarkdown(String(line[textRange])))") if level <= 2 { body.append("

\(String(repeating: "─", count: 85))

") } 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( "

\(marker) \(renderInlineMarkdown(String(line[itemRange])))

" ) previousListItem = true continue } if let match = unorderedListPattern.firstMatch(in: line, range: lineRange), let itemRange = Range(match.range(at: 1), in: line) { flushParagraph() body.append("

\(renderInlineMarkdown(String(line[itemRange])))

") previousListItem = true continue } if previousListItem && rawLine.hasPrefix(" ") { body.append("

\(renderInlineMarkdown(line))

") } else { paragraph.append(line) previousListItem = false } } if inCodeBlock { flushCodeBlock() } flushParagraph() return """ \(body.joined(separator: "\n")) """ } let arguments = CommandLine.arguments guard arguments.count == 3 else { fail("Usage: scripts/ios-app-review-notes-pdf.swift ") } 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)