Skip to content

Instantly share code, notes, and snippets.

@coreequip
Last active March 24, 2026 21:27
Show Gist options
  • Select an option

  • Save coreequip/4b2f0545e2efe6114a29f7415e7533a3 to your computer and use it in GitHub Desktop.

Select an option

Save coreequip/4b2f0545e2efe6114a29f7415e7533a3 to your computer and use it in GitHub Desktop.
pbhtml: macOS CLI for WebArchive/HTML Clipboard Extraction & Repackaging

pbhtml

A lightweight macOS CLI tool for analyzing, extracting, and repackaging clipboard content—specifically designed to handle HTML and Apple's WebArchive format (used by Outlook, Safari, and Mail).

🚀 What it does

pbhtml solves the common problem of editing email signatures or complex web snippets that contain embedded images.

  • Analyze: See exactly what data types (HTML, WebArchive, Plain Text) and sizes are currently in your clipboard.
  • Extract (Paste): Decodes a WebArchive into a clean HTML file and separate asset files (PNG, JPG, etc.). It automatically replaces internal cid: or file:// references with local file paths.
  • Repackage (Copy): Takes your edited HTML and associated assets (e.g., sig-1.png) and bundles them back into a valid binary WebArchive on your clipboard, ready to be pasted back into Outlook or Mail with all images intact.

🛠 Building the tool

The tool is written in Swift and has zero external dependencies. It uses native macOS frameworks (AppKit, Foundation).

Minimum Requirements:

  • macOS 10.15+
  • Swift Compiler (comes with Xcode or Command Line Tools)

Compile command:

swiftc pbhtml.swift -o pbhtml

📖 Usage Examples

1. Analyze the clipboard

Check if your clipboard contains a WebArchive or just plain HTML:

./pbhtml

2. Extract an Outlook Signature

Copy a signature from Outlook or a website, then extract it for editing:

./pbhtml paste my_signature

This creates:

  • my_signature.html (The main HTML)
  • my_signature-1.png, my_signature-2.jpg, etc. (The embedded assets)

3. Repackage and Copy back to Clipboard

After editing my_signature.html, bundle it back with its assets:

./pbhtml copy my_signature

Now you can simply press Cmd+V in Outlook’s signature settings, and your updated signature (including images) will be pasted correctly.

4. Simple Stream Mode

To just output the raw HTML or text from the clipboard to stdout:

./pbhtml paste -- > content.html

💡 Pro Tip: Editing Outlook Signatures

Outlook often adds massive amounts of redundant CSS to signatures. Using pbhtml, you can:

  1. paste the signature to a file.
  2. Manually clean up the HTML/CSS in your favorite editor.
  3. copy it back to the clipboard to "re-inject" the clean version into Outlook.
import Foundation
import AppKit
extension NSPasteboard.PasteboardType {
static let webArchive = NSPasteboard.PasteboardType("com.apple.webarchive")
}
enum PBHTML {
static func main() {
let arguments = CommandLine.arguments
let pasteboard = NSPasteboard.general
do {
if arguments.count == 1 {
analyze(pasteboard)
return
}
let subcommand = arguments[1].lowercased()
// Handle 'analyze' separately as it doesn't strictly need a name
if subcommand == "analyze" {
analyze(pasteboard)
return
}
guard arguments.count >= 3 else {
printUsage()
return
}
let name = arguments[2]
switch subcommand {
case "paste":
try paste(pasteboard, name: name)
case "copy":
try copy(pasteboard, name: name)
default:
print("Unknown subcommand: \(subcommand)")
printUsage()
}
} catch {
printError(error.localizedDescription)
}
}
private static func analyze(_ pasteboard: NSPasteboard) {
guard let types = pasteboard.types, !types.isEmpty else {
print("Clipboard is empty.")
return
}
print("\nClipboard Analysis (\(types.count) types):")
let typeColWidth = 45
let sizeColWidth = 12
let header = "\(pad("DATA TYPE", toWidth: typeColWidth)) | \(pad("SIZE", toWidth: sizeColWidth, alignRight: true)) | METADATA"
let separator = String(repeating: "-", count: typeColWidth) + "-+-" + String(repeating: "-", count: sizeColWidth) + "-+-" + String(repeating: "-", count: 20)
print("\u{001B}[1;34m" + header + "\u{001B}[0m")
print(separator)
for type in types {
let typeName = type.rawValue
let dataLength = pasteboard.data(forType: type)?.count ?? 0
var metadata = ""
if type == .webArchive {
metadata = "Binary Plist (WebArchive)"
} else if type == .html {
metadata = "HTML Content"
} else if let stringValue = pasteboard.string(forType: type) {
metadata = "String (len: \(stringValue.count))"
}
let displayType = typeName.count > typeColWidth ? String(typeName.prefix(typeColWidth - 3)) + "..." : typeName
let row = "\(pad(displayType, toWidth: typeColWidth)) | \(pad(formatBytes(dataLength), toWidth: sizeColWidth, alignRight: true)) | \(metadata)"
if type == .html || type == .webArchive {
print("\u{001B}[1;32m" + row + "\u{001B}[0m")
} else {
print(row)
}
}
print(separator + "\n")
}
private static func paste(_ pasteboard: NSPasteboard, name: String) throws {
if let data = pasteboard.data(forType: .webArchive), name != "--" {
try extractWebArchive(data, baseName: name)
} else {
let content: Data?
if let html = pasteboard.string(forType: .html) {
content = html.data(using: .utf8)
} else if let str = pasteboard.string(forType: .string) {
content = str.data(using: .utf8)
} else {
content = pasteboard.data(forType: .html) ?? pasteboard.data(forType: .string)
}
guard let finalData = content else {
throw createError("No readable content in clipboard.")
}
if name == "--" {
FileHandle.standardOutput.write(finalData)
} else {
let fileURL = URL(fileURLWithPath: name).appendingPathExtension("html")
try finalData.write(to: fileURL)
print("Saved HTML to \(fileURL.lastPathComponent)")
}
}
}
private static func extractWebArchive(_ data: Data, baseName: String) throws {
guard let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any],
let mainResource = plist["WebMainResource"] as? [String: Any],
let mainData = mainResource["WebResourceData"] as? Data,
var htmlString = String(data: mainData, encoding: .utf8) else {
throw createError("Invalid WebArchive data.")
}
let subResources = plist["WebSubresources"] as? [[String: Any]] ?? []
print("Extracting WebArchive '\(baseName)' with \(subResources.count) assets...")
for (index, res) in subResources.enumerated() {
guard let resData = res["WebResourceData"] as? Data,
let originalURL = res["WebResourceURL"] as? String,
let mimeType = res["WebResourceMIMEType"] as? String else { continue }
let ext = extensionFromData(resData) ?? extensionForMimeType(mimeType)
let localName = "\(baseName)-\(index + 1).\(ext)"
let assetURL = URL(fileURLWithPath: localName)
try resData.write(to: assetURL)
print(" -> Asset saved: \(localName) (\(originalURL))")
htmlString = htmlString.replacingOccurrences(of: originalURL, with: localName)
let encodedURL = originalURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? originalURL
if encodedURL != originalURL {
htmlString = htmlString.replacingOccurrences(of: encodedURL, with: localName)
}
}
let htmlURL = URL(fileURLWithPath: baseName).appendingPathExtension("html")
guard let outputData = htmlString.data(using: .utf8) else {
throw createError("Could not encode modified HTML.")
}
try outputData.write(to: htmlURL, options: .atomic)
print("Finished! Main HTML saved as \(htmlURL.lastPathComponent)")
}
private static func copy(_ pasteboard: NSPasteboard, name: String) throws {
if name == "--" {
let inputData = FileHandle.standardInput.readDataToEndOfFile()
guard !inputData.isEmpty else { return }
try setPasteboardContent(pasteboard, htmlData: inputData, webArchiveData: nil)
print("Successfully copied stdin to clipboard as HTML.")
return
}
let baseName = name.replacingOccurrences(of: ".html", with: "", options: .caseInsensitive)
let htmlURL = URL(fileURLWithPath: baseName).appendingPathExtension("html")
let htmlData = try Data(contentsOf: htmlURL)
guard var htmlString = String(data: htmlData, encoding: .utf8) else {
throw createError("Could not decode HTML as UTF-8.")
}
var subResources = [[String: Any]]()
let fm = FileManager.default
let files = try fm.contentsOfDirectory(atPath: ".")
let assetPrefix = "\(baseName)-"
print("Scanning for assets starting with '\(assetPrefix)'...")
for file in files {
if file.hasPrefix(assetPrefix) && file != htmlURL.lastPathComponent {
if htmlString.contains(file) {
let fileData = try Data(contentsOf: URL(fileURLWithPath: file))
let cid = "cid:\(UUID().uuidString)"
let ext = URL(fileURLWithPath: file).pathExtension
let mime = mimeTypeForExtension(ext)
let resource: [String: Any] = [
"WebResourceData": fileData,
"WebResourceMIMEType": mime,
"WebResourceURL": cid
]
subResources.append(resource)
htmlString = htmlString.replacingOccurrences(of: file, with: cid)
print(" <- Packaging asset: \(file) as \(cid)")
}
}
}
guard let finalHtmlData = htmlString.data(using: .utf8) else {
throw createError("Could not encode final HTML.")
}
if subResources.isEmpty {
try setPasteboardContent(pasteboard, htmlData: finalHtmlData, webArchiveData: nil)
print("No assets found. Copied as standard HTML.")
} else {
let mainResource: [String: Any] = [
"WebResourceData": finalHtmlData,
"WebResourceMIMEType": "text/html",
"WebResourceTextEncodingName": "UTF-8",
"WebResourceURL": "about:blank"
]
let webArchive: [String: Any] = [
"WebMainResource": mainResource,
"WebSubresources": subResources
]
let plistData = try PropertyListSerialization.data(fromPropertyList: webArchive, format: .binary, options: 0)
try setPasteboardContent(pasteboard, htmlData: finalHtmlData, webArchiveData: plistData)
print("Finished! Packaged as WebArchive with \(subResources.count) assets and copied to clipboard.")
}
}
private static func setPasteboardContent(_ pasteboard: NSPasteboard, htmlData: Data, webArchiveData: Data?) throws {
pasteboard.clearContents()
var types: [NSPasteboard.PasteboardType] = [.html, .string]
if webArchiveData != nil {
types.insert(.webArchive, at: 0)
}
pasteboard.declareTypes(types, owner: nil)
if let waData = webArchiveData {
pasteboard.setData(waData, forType: .webArchive)
}
pasteboard.setData(htmlData, forType: .html)
if let str = String(data: htmlData, encoding: .utf8) {
pasteboard.setString(str, forType: .string)
} else {
throw createError("Could not set string representation for pasteboard.")
}
}
private static func extensionFromData(_ data: Data) -> String? {
guard data.count >= 12 else { return nil }
let bytes = [UInt8](data.prefix(12))
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "png" }
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "jpg" }
if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "gif" }
if bytes.starts(with: [0x25, 0x50, 0x44, 0x46]) { return "pdf" }
if bytes.starts(with: [0x52, 0x49, 0x46, 0x46]) && bytes.dropFirst(8).starts(with: [0x57, 0x45, 0x42, 0x50]) {
return "webp"
}
return nil
}
private static func extensionForMimeType(_ mime: String) -> String {
let map = ["image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "text/html": "html", "application/pdf": "pdf"]
return map[mime.lowercased()] ?? "bin"
}
private static func mimeTypeForExtension(_ ext: String) -> String {
let map = ["jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp", "pdf": "application/pdf", "html": "text/html", "css": "text/css"]
return map[ext.lowercased()] ?? "application/octet-stream"
}
private static func formatBytes(_ bytes: Int) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useAll]
formatter.countStyle = .file
return formatter.string(fromByteCount: Int64(bytes))
}
private static func pad(_ string: String, toWidth width: Int, alignRight: Bool = false) -> String {
if string.count >= width {
return String(string.prefix(width))
}
let padding = String(repeating: " ", count: width - string.count)
return alignRight ? (padding + string) : (string + padding)
}
private static func createError(_ message: String) -> NSError {
return NSError(domain: "pbhtml", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
}
private static func printError(_ message: String) {
if let data = "Error: \(message)\n".data(using: .utf8) {
FileHandle.standardError.write(data)
}
}
private static func printUsage() {
print("Usage: pbhtml <subcommand> <name>")
print(" subcommand: analyze | paste | copy")
print(" name: filename base OR '--' for stdin/stdout")
}
}
PBHTML.main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment