#!/usr/bin/env python3 """Render a Bitwarden JSON export as a dense PDF. Every leaf value in the export is printed: well-known fields first in a readable order, then every remaining value alphabetically. The PDF starts with a summary and an index that carries page numbers. """ from __future__ import annotations import argparse import datetime as dt import html import json import re import sys from collections import Counter from pathlib import Path TYPE_NAMES = {1: "login", 2: "secure note", 3: "card", 4: "identity", 5: "ssh key"} # Preferred display order; matched by exact path, prefix or list index. PRIORITY = [ "type", "name", "folderId", "organizationId", "collectionIds", "favorite", "reprompt", "login.username", "login.password", "login.totp", "login.passwordRevisionDate", "login.uris", "login.fido2Credentials", "card.cardholderName", "card.brand", "card.number", "card.expMonth", "card.expYear", "card.code", "identity.title", "identity.firstName", "identity.middleName", "identity.lastName", "identity.address1", "identity.address2", "identity.address3", "identity.city", "identity.state", "identity.postalCode", "identity.country", "identity.company", "identity.email", "identity.phone", "identity.ssn", "identity.username", "identity.passportNumber", "identity.licenseNumber", "secureNote", "sshKey.privateKey", "sshKey.publicKey", "sshKey.fingerprint", "sshKey.keyFingerprint", "fields", "notes", "passwordHistory", "attachments", "id", "creationDate", "revisionDate", "deletedDate", ] SECRET_RE = re.compile( r"(?:^|\.)(password|totp|code|ssn|pin|cvv|privateKey|passportNumber|licenseNumber|number)$" ) class Empty: """Marks an empty dict/list so it is visible in the output.""" def __init__(self, text: str) -> None: self.text = text def esc(value: object) -> str: return html.escape(str(value), quote=True) def flatten(obj, prefix: str = "", out=None): """Flatten nested JSON into (dotted.path[index], leaf) pairs.""" if out is None: out = [] if isinstance(obj, dict): if not obj: out.append((prefix, Empty("{}"))) return out for key, value in obj.items(): flatten(value, f"{prefix}.{key}" if prefix else str(key), out) elif isinstance(obj, list): if not obj: out.append((prefix, Empty("[]"))) return out for index, value in enumerate(obj): flatten(value, f"{prefix}[{index}]", out) else: out.append((prefix, obj)) return out def natural_key(path: str): parts = re.split(r"(\d+)", path) return tuple((1, int(p)) if p.isdigit() else (0, p) for p in parts if p) def rank(path: str): priority = len(PRIORITY) for index, prefix in enumerate(PRIORITY): if path == prefix or path.startswith(prefix + ".") or path.startswith(prefix + "["): priority = index break return (priority, natural_key(path)) def fmt_value(value): """Return (kind, text) so CSS can style empties, numbers and text.""" if isinstance(value, Empty): return ("empty", value.text) if isinstance(value, bool): return ("code", "true" if value else "false") if value is None: return ("empty", "null") if isinstance(value, (int, float)): return ("code", repr(value) if isinstance(value, float) else str(value)) text = str(value) if text == "": return ("empty", '""') return ("text", text) def is_secret(path: str) -> bool: return bool(SECRET_RE.search(re.sub(r"\[\d+\]", "", path))) def mask_text(text: str) -> str: return f"{'*' * 10} ({len(text)} chars)" def type_num(item) -> int | None: value = item.get("type") return value if isinstance(value, int) and not isinstance(value, bool) else None def type_label(item) -> str: value = type_num(item) if value is None: return "unknown" return TYPE_NAMES.get(value, f"type {value}") def badge_class(item) -> str: value = type_num(item) return f"t-{value}" if value in TYPE_NAMES else "t-other" def folder_paths(folders) -> dict: by_id = { f["id"]: f for f in folders if isinstance(f, dict) and isinstance(f.get("id"), str) } paths = {} for fid, folder in by_id.items(): parts = [folder.get("name") or fid] parent = folder.get("parentId") seen = {fid} while isinstance(parent, str) and parent in by_id and parent not in seen: seen.add(parent) parts.append(by_id[parent].get("name") or parent) parent = by_id[parent].get("parentId") paths[fid] = " / ".join(reversed(parts)) return paths def sort_items(items, mode: str, folders: dict): def folder_of(item): fid = item.get("folderId") return folders.get(fid, str(fid)) if fid else "" def name_of(item): return str(item.get("name") or "").casefold() if mode == "as-is": return list(items) if mode == "name": key = lambda it: (name_of(it), folder_of(it)) elif mode == "type": key = lambda it: (f"{type_num(it) or 99:03d}", folder_of(it), name_of(it)) else: # folder key = lambda it: (folder_of(it), name_of(it)) return sorted(items, key=key) CSS = """ @page { size: A4; margin: 9mm 8mm 11mm 8mm; @bottom-left { content: string(title) " - " string(source); font-family: "DejaVu Sans Mono", monospace; font-size: 5.8pt; color: #8a8a8a; } @bottom-right { content: counter(page) " / " counter(pages); font-family: "DejaVu Sans Mono", monospace; font-size: 6pt; color: #8a8a8a; } } html { font-family: "DejaVu Sans", sans-serif; font-size: 7.1pt; color: #111; } body { margin: 0; } a { color: inherit; text-decoration: none; } h1 { string-set: title content(); font-size: 15pt; margin: 0 0 0.8mm; letter-spacing: -0.2pt; } h2 { font-size: 8.4pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #555; margin: 4.5mm 0 1.2mm; border-bottom: 0.6pt solid #333; padding-bottom: 0.5mm; } .sub { color: #666; font-size: 6.6pt; margin-bottom: 1mm; } .sub .src { string-set: source content(); font-family: "DejaVu Sans Mono", monospace; } table { width: 100%; border-collapse: collapse; table-layout: fixed; } .mono, th, td.k { font-family: "DejaVu Sans Mono", monospace; } table.stats th, table.stats td { text-align: left; padding: 0.35mm 1.5mm 0.35mm 0; vertical-align: top; } table.stats th { width: 32%; color: #666; font-weight: 400; font-size: 6.4pt; } table.stats td { overflow-wrap: anywhere; } table.split { width: 100%; } table.split td.col { vertical-align: top; padding: 0; width: 50%; } table.split td.col + td.col { padding-left: 5mm; } table.grid th { text-align: left; font-size: 6.1pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.3pt; color: #666; border-bottom: 0.5pt solid #999; padding: 0.5mm 1mm; } table.grid td { font-family: "DejaVu Sans Mono", monospace; font-size: 6.5pt; padding: 0.35mm 1mm; border-bottom: 0.3pt dotted #dcdcdc; overflow-wrap: anywhere; vertical-align: top; } table.grid tbody tr:nth-child(even) { background: #f6f6f6; } td.r, th.r { text-align: right; } .dot { display: inline-block; width: 1.4mm; height: 1.4mm; border-radius: 0.7mm; margin-right: 1mm; background: #777; } .t-1 { background: #1f6feb; } .t-2 { background: #6e7781; } .t-3 { background: #8250df; } .t-4 { background: #b35c00; } .t-5 { background: #1a7f37; } .t-other { background: #555; } .item { margin: 0 0 2.4mm; } .item h3 { font-size: 7.8pt; margin: 0 0 0.6mm; padding: 0.5mm 0 0.5mm; border-bottom: 0.5pt solid #888; break-after: avoid; } .item h3 .idx { font-family: "DejaVu Sans Mono", monospace; color: #999; margin-right: 1.4mm; } .badge { font-family: "DejaVu Sans Mono", monospace; font-size: 5.8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.2pt; color: #fff; padding: 0.25mm 1mm; border-radius: 0.6mm; margin-right: 1.2mm; } .item h3 .iname { font-weight: 700; } .item h3 .meta { color: #666; font-size: 6.4pt; font-weight: 400; margin-left: 1.6mm; } table.kv th { width: 24%; text-align: left; font-family: "DejaVu Sans Mono", monospace; font-size: 6.1pt; font-weight: 400; color: #7a7a7a; padding: 0.4mm 1.6mm 0.4mm 0; vertical-align: top; overflow-wrap: anywhere; } table.kv td { font-family: "DejaVu Sans Mono", monospace; font-size: 6.9pt; padding: 0.4mm 0; vertical-align: top; white-space: pre-wrap; overflow-wrap: anywhere; border-bottom: 0.25pt dotted #e4e4e4; } table.kv tr { break-inside: avoid; } td.empty { color: #a0a0a0; } td.code { color: #0a5c2b; } td.mask { color: #8a5a00; } .summary-note { color: #666; font-size: 6.3pt; margin-top: 1mm; } """ PAPER_OVERRIDE = {"a4": "A4", "letter": "letter"} def build_html(*, data, items, source_name, title, mask, sort_mode, paper): folders = [f for f in (data.get("folders") or []) if isinstance(f, dict)] fpaths = folder_paths(folders) collections = [c for c in (data.get("collections") or []) if isinstance(c, dict)] coll_names = { c.get("id"): (c.get("name") or c.get("id")) for c in collections if c.get("id") } items = sort_items(items, sort_mode, fpaths) leaves = sum(len(flatten(item)) for item in items) type_counts = Counter(type_label(item) for item in items) notes_count = sum(1 for item in items if item.get("notes")) field_count = sum( len(item.get("fields") or []) for item in items if isinstance(item.get("fields"), list) ) attach_count = sum( len(item.get("attachments") or []) for item in items if isinstance(item.get("attachments"), list) ) history_count = sum( len(item.get("passwordHistory") or []) for item in items if isinstance(item.get("passwordHistory"), list) ) folder_counts = Counter( fpaths.get(item.get("folderId"), str(item.get("folderId"))) if item.get("folderId") else "(no folder)" for item in items ) def date_range(key): values = sorted( str(item[key]) for item in items if isinstance(item.get(key), str) and item[key] ) return f"{values[0]} .. {values[-1]}" if values else "" def row(label, value, klass=""): cls = f' class="{klass}"' if klass else "" return f"{esc(label)}{esc(value)}\n" meta_rows = [ row("source", source_name), row("generated", dt.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M %Z")), row("items", len(items)), row("folders", len(folders)), ] if collections: meta_rows.append(row("collections", len(collections))) meta_rows.extend( [ row("values printed", leaves), row("items with notes", notes_count), row("custom fields", field_count), row("attachments", attach_count), row("password history", history_count), row("created range", date_range("creationDate")), row("revised range", date_range("revisionDate")), ] ) for key, value in data.items(): if key in {"items", "folders", "collections"}: continue kind, text = fmt_value(value) meta_rows.append(row(f"export.{key}", text, "empty" if kind == "empty" else "")) type_rows = "".join( f'{esc(label)}' f'{count}\n' for label, count in sorted(type_counts.items(), key=lambda kv: (-kv[1], kv[0])) ) folder_rows = "".join( f"{esc(name)}{count}\n" for name, count in sorted(folder_counts.items(), key=lambda kv: kv[0].casefold()) ) collection_rows = "" if collections: collection_rows = "".join( f"{esc(c.get('name') or c.get('id') or '')}" f"{esc(c.get('organizationId') or '')}" f"{esc(c.get('externalId') or '')}" f"{esc(c.get('readOnly'))}\n" for c in collections ) index_rows = [] for index, item in enumerate(items, 1): fid = item.get("folderId") folder = fpaths.get(fid, str(fid)) if fid else "" login = item.get("login") if isinstance(item.get("login"), dict) else {} username = login.get("username") or "" index_rows.append( f'{index}' f'{esc(type_label(item))}' f'{esc(item.get("name") or "(unnamed)")}' f"{esc(folder)}{esc(username)}" f'\n' ) item_html = [] for index, item in enumerate(items, 1): header_rows = [] fid = item.get("folderId") folder = fpaths.get(fid, str(fid)) if fid else "" if fid: header_rows.append(row("folder", folder)) cids = item.get("collectionIds") if isinstance(cids, list) and cids: header_rows.append( row("collections", ", ".join(str(coll_names.get(cid, cid)) for cid in cids)) ) for path, value in sorted(flatten(item), key=lambda kv: rank(kv[0])): kind, text = fmt_value(value) if mask and kind == "text" and is_secret(path): kind, text = "mask", mask_text(text) header_rows.append(row(path, text, kind)) meta_bits = [] if folder: meta_bits.append(folder) if isinstance(cids, list) and cids: meta_bits.append(", ".join(str(coll_names.get(cid, cid)) for cid in cids)) meta = ( f'{esc(" ยท ".join(meta_bits))}' if meta_bits else "" ) item_html.append( f'
\n' f'

{index}' f'{esc(type_label(item))}' f'{esc(item.get("name") or "(unnamed)")}{meta}

\n' f'\n{"".join(header_rows)}
\n' f"
\n" ) paper_css = PAPER_OVERRIDE.get(paper, "A4") return f""" {esc(title)}

{esc(title)}

{esc(source_name)} · {len(items)} items · {leaves} values · generated {dt.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M %Z")}

Summary

{"".join(meta_rows)}
{type_rows}
typeitems
{folder_rows}
folderitems
{f'

Collections

{collection_rows}
nameorganizationIdexternalIdreadOnly
' if collections else ''}

Index

{"".join(index_rows)}
#typenamefolderusernamepage

Items

{"".join(item_html)} """ def load_export(path: Path): with path.open("r", encoding="utf-8") as handle: data = json.load(handle) if not isinstance(data, dict): raise SystemExit("error: export root must be a JSON object") if data.get("encrypted") is True: raise SystemExit( "error: this is an encrypted export and cannot be read.\n" " Re-export from Bitwarden with 'Export as .json' and no file password." ) items = data.get("items") or [] if not isinstance(items, list): raise SystemExit("error: 'items' is not a list; is this a Bitwarden export?") items = [item for item in items if isinstance(item, dict)] return data, items def main(argv=None) -> int: parser = argparse.ArgumentParser( description="Render a Bitwarden JSON export as a dense PDF." ) parser.add_argument("input", type=Path, help="Bitwarden .json export") parser.add_argument("-o", "--output", type=Path, help="output PDF (default: input.pdf)") parser.add_argument("--title", help="document title") parser.add_argument( "--sort", choices=["folder", "name", "type", "as-is"], default="folder", help="item order (default: folder)", ) parser.add_argument( "--mask-secrets", action="store_true", help="mask passwords, TOTP, card numbers/codes, SSN and private keys", ) parser.add_argument("--paper", choices=["a4", "letter"], default="a4") parser.add_argument("--html-out", type=Path, help="also write the HTML source") args = parser.parse_args(argv) if not args.input.is_file(): raise SystemExit(f"error: no such file: {args.input}") data, items = load_export(args.input) title = args.title or "Bitwarden export" document = build_html( data=data, items=items, source_name=args.input.name, title=title, mask=args.mask_secrets, sort_mode=args.sort, paper=args.paper, ) if args.html_out: args.html_out.parent.mkdir(parents=True, exist_ok=True) args.html_out.write_text(document, encoding="utf-8") output = args.output or args.input.with_suffix(".pdf") output.parent.mkdir(parents=True, exist_ok=True) from weasyprint import HTML HTML(string=document, base_url=str(args.input.parent)).write_pdf(str(output)) print(f"wrote {output} ({len(items)} items)") return 0 if __name__ == "__main__": sys.exit(main())