commit 41385b35e6b733a115ada0a9a2f0d0f232ccc49d Author: Juergen Holly Date: Fri Nov 13 14:09:10 2026 +0100 Initial commit: Bitwarden export to PDF renderer Adds the render.py WeasyPrint renderer, the pod-based bitwarden-print.sh wrapper, a Containerfile, a sample export (JSON + PDF), README and LICENSE. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf4f8a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +__pycache__/ +*.pyc +.venv/ +build/ +dist/ diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..dcc6023 --- /dev/null +++ b/Containerfile @@ -0,0 +1,28 @@ +FROM docker.io/python:3.12-slim-bookworm + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +# Runtime libraries required by WeasyPrint's Pango/HarfBuzz/Cairo stack. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + libharfbuzz0b \ + libfontconfig1 \ + libcairo2 \ + libgdk-pixbuf-2.0-0 \ + libffi8 \ + libjpeg62-turbo \ + libopenjp2-7 \ + shared-mime-info \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install "weasyprint==62.3" "pydyf==0.10.0" + +WORKDIR /app +COPY render.py /app/render.py + +ENTRYPOINT ["python3", "/app/render.py"] +CMD ["--help"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..efe9321 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 juergen + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7828358 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# bitwarden-print + +Render a Bitwarden `.json` export as a dense PDF: a summary, a page-numbered +index, and one compact block per vault item that prints **every** value in the +export — known fields first (username, password, TOTP, card, identity, SSH key, +custom fields, notes, history, attachments), then everything else +alphabetically, including unknown/extra keys and empty objects/lists. + +Everything runs in a throwaway Podman pod on this machine; the vault never +leaves it. + +## Requirements + +- Podman (tested with 6.x) +- On macOS: `podman machine start` (the default machine is used) + +## Quick start + +```sh +./bitwarden-print.sh ~/Downloads/bitwarden_export_20261113.json +# -> ~/Downloads/bitwarden_export_20261113.pdf +``` + +Custom output and options: + +```sh +./bitwarden-print.sh vault.json -o ~/Desktop/vault.pdf \ + --paper letter --sort name --title "Vault 2026-11-13" +``` + +Mask secrets instead of printing them: + +```sh +./bitwarden-print.sh vault.json --mask-secrets +``` + +Options: + +| Option | Meaning | +| --- | --- | +| `-o, --output FILE` | output PDF (default: next to the export, same basename) | +| `--sort folder\|name\|type\|as-is` | item order (default: `folder`, then name) | +| `--title TEXT` | document title (also the page footer) | +| `--paper a4\|letter` | page size (default: `a4`) | +| `--mask-secrets` | mask `login.password`, `login.totp`, `card.number`, `card.code`, `identity.ssn`, `sshKey.privateKey`, …; values keep a length hint | + +## What the PDF contains + +1. **Header** — source file, item count, value count, timestamp. +2. **Summary** — export metadata, item/type/folder counts, custom field, + attachment and password-history totals, created/revised date ranges, plus a + collections table if the export has one. +3. **Index** — one line per item (type, name, folder, username) with a real + page number, clickable in the PDF. +4. **Items** — for each item a heading (number, type badge, name, folder) and a + two-column table of `json.path` → value. Values wrap, newlines in notes are + preserved, and empty values (`null`, `""`, `{}`, `[]`) are shown dimmed so + nothing silently disappears. + +Items are not truncated: an export with many entries produces many pages, but +each line is only a few millimetres tall. + +## How it runs + +``` +./bitwarden-print.sh + -> podman build -t localhost/bitwarden-print:latest . # Python + WeasyPrint image + -> podman pod create --name bitwarden-print-pod + -> podman create --pod bitwarden-print-pod ... bitwarden-print-run + -> podman cp export.json bitwarden-print-run:/export.json # no bind mounts + -> podman start -a bitwarden-print-run # render.py -> /output.pdf + -> podman cp bitwarden-print-run:/output.pdf + -> pod, container and temp file are removed +``` + +The input is copied into the container instead of bind-mounted, so arbitrary +host paths work (including `/tmp`, which the Podman machine does not share) and +no SELinux relabelling is required. + +Manual equivalent: + +```sh +podman build -t localhost/bitwarden-print:latest . +podman pod create --name bitwarden-print-pod +podman create --pod bitwarden-print-pod --name bw localhost/bitwarden-print:latest \ + /export.json -o /output.pdf +podman cp vault.json bw:/export.json +podman start -a bw +podman cp bw:/output.pdf ./vault.pdf +podman rm -f bw && podman pod rm -f bitwarden-print-pod +``` + +## Files + +- `bitwarden-print.sh` — CLI wrapper that builds the image and drives the pod +- `Containerfile` — `python:3.12-slim-bookworm` + WeasyPrint (Pango/Cairo) + DejaVu fonts +- `render.py` — JSON → HTML → PDF, no data leaves the container +- `examples/sample-export.json` — synthetic vault used for testing (all values fake) + +Run the renderer directly on a host that has WeasyPrint installed: + +```sh +pip install weasyprint==62.3 pydyf==0.10.0 +./render.py examples/sample-export.json -o /tmp/sample.pdf --html-out /tmp/sample.html +``` + +## Security notes + +- The generated PDF contains your vault in **plaintext** unless you pass + `--mask-secrets`. Treat the PDF like the export itself: keep it local, and + delete it when you are done. +- `--mask-secrets` only masks well-known secret paths. Secrets stored inside + custom fields (`fields[i].value`), notes or file names are printed as-is. +- Encrypted exports (`"encrypted": true`) are rejected with a hint: re-export + from Bitwarden as `.json` without a file password first. +- The container is removed after each run, but the image + (`localhost/bitwarden-print:latest`) stays cached for the next run. diff --git a/bitwarden-print.sh b/bitwarden-print.sh new file mode 100755 index 0000000..fd2867a --- /dev/null +++ b/bitwarden-print.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Render a Bitwarden JSON export to PDF inside a dedicated Podman pod. +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +IMAGE=${IMAGE:-localhost/bitwarden-print:latest} +POD=${POD:-bitwarden-print-pod} +CONTAINER=${CONTAINER:-bitwarden-print-run} + +usage() { + cat <<'EOF' +Usage: ./bitwarden-print.sh EXPORT.json [-o OUT.pdf] [options] + +Creates a PDF with every value from a Bitwarden JSON export, densely typeset. + +Options: + -o, --output FILE output PDF (default: next to the export, same basename) + --sort MODE folder (default), name, type or as-is + --title TEXT document title + --paper SIZE a4 (default) or letter + --mask-secrets mask passwords, TOTP, card numbers/codes, SSN, keys + -h, --help show this help + +Environment: + IMAGE, POD, CONTAINER override image/pod/container names +EOF +} + +INPUT="" +OUTPUT="" +render_args=() + +while [ $# -gt 0 ]; do + case "$1" in + -o|--output) + [ $# -ge 2 ] || { echo "error: $1 needs a value" >&2; exit 2; } + OUTPUT=$2; shift 2 ;; + --sort) + [ $# -ge 2 ] || { echo "error: $1 needs a value" >&2; exit 2; } + render_args+=(--sort "$2"); shift 2 ;; + --paper) + [ $# -ge 2 ] || { echo "error: $1 needs a value" >&2; exit 2; } + render_args+=(--paper "$2"); shift 2 ;; + --title) + [ $# -ge 2 ] || { echo "error: $1 needs a value" >&2; exit 2; } + render_args+=(--title "$2"); shift 2 ;; + --mask-secrets) + render_args+=(--mask-secrets); shift ;; + -h|--help) + usage; exit 0 ;; + -*) + echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;; + *) + [ -z "$INPUT" ] || { echo "error: only one export file can be given" >&2; exit 2; } + INPUT=$1; shift ;; + esac +done + +[ -n "$INPUT" ] || { usage >&2; exit 2; } +[ -f "$INPUT" ] || { echo "error: no such file: $INPUT" >&2; exit 1; } + +in_dir=$(cd -- "$(dirname -- "$INPUT")" && pwd) +in_name=$(basename -- "$INPUT") + +if [ -z "$OUTPUT" ]; then + OUTPUT="$in_dir/${in_name%.json}.pdf" +fi +out_dir=$(dirname -- "$OUTPUT") +mkdir -p -- "$out_dir" +out_dir=$(cd -- "$out_dir" && pwd) +out_name=$(basename -- "$OUTPUT") + +cleanup() { + podman rm -f "$CONTAINER" >/dev/null 2>&1 || true + podman pod exists "$POD" >/dev/null 2>&1 && podman pod rm -f "$POD" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> building image $IMAGE" +podman build -q -t "$IMAGE" "$SCRIPT_DIR" + +echo "==> creating pod $POD" +podman pod rm -f "$POD" >/dev/null 2>&1 || true +podman pod create --name "$POD" >/dev/null + +# Copy the export in instead of bind-mounting it: works for any host path, +# including paths the Podman machine does not share (e.g. /tmp) and hosts +# where SELinux relabelling is not allowed. +echo "==> rendering $in_name" +podman create --pod "$POD" --name "$CONTAINER" \ + "$IMAGE" "/export.json" -o "/output.pdf" \ + ${render_args[@]+"${render_args[@]}"} >/dev/null +podman cp "$INPUT" "$CONTAINER:/export.json" + +status=0 +podman start -a "$CONTAINER" || status=$? +if [ "$status" -ne 0 ]; then + echo "error: rendering failed (exit $status)" >&2 + exit "$status" +fi + +tmp_pdf=$(mktemp "${TMPDIR:-/tmp}/bitwarden-print.XXXXXX.pdf") +if ! podman cp "$CONTAINER:/output.pdf" "$tmp_pdf"; then + echo "error: container produced no PDF at /output.pdf" >&2 + exit 1 +fi +mv -f "$tmp_pdf" "$out_dir/$out_name" + +echo "==> PDF: $out_dir/$out_name" diff --git a/examples/sample-export.json b/examples/sample-export.json new file mode 100644 index 0000000..aa039e3 --- /dev/null +++ b/examples/sample-export.json @@ -0,0 +1,173 @@ +{ + "encrypted": false, + "folders": [ + { "id": "f1b2c3d4-0000-4000-8000-000000000001", "name": "Work" }, + { "id": "f1b2c3d4-0000-4000-8000-000000000002", "name": "Personal" }, + { "id": "f1b2c3d4-0000-4000-8000-000000000003", "name": "Infrastructure" } + ], + "items": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "organizationId": null, + "folderId": "f1b2c3d4-0000-4000-8000-000000000001", + "type": 1, + "name": "GitHub", + "notes": "Recovery codes are in the safe.\n2FA enforced on the org.", + "favorite": true, + "reprompt": 1, + "login": { + "uris": [ + { "match": null, "uri": "https://github.com/login" }, + { "match": 0, "uri": "https://gist.github.com" } + ], + "username": "jdoe@example.com", + "password": "correct-horse-battery-staple", + "totp": "otpauth://totp/GitHub:jdoe?secret=JBSWY3DPEHPK3PXP", + "passwordRevisionDate": "2024-03-11T09:12:44.000Z" + }, + "collectionIds": null, + "creationDate": "2019-05-02T14:22:10.000Z", + "revisionDate": "2024-03-11T09:12:44.000Z", + "deletedDate": null, + "fields": [ + { "name": "recovery email", "value": "recovery@example.com", "type": 0, "linkedId": null }, + { "name": "PIN", "value": "4711", "type": 1, "linkedId": null } + ], + "passwordHistory": [ + { "lastUsedDate": "2023-11-01T08:00:00.000Z", "password": "old-secret-1" }, + { "lastUsedDate": "2022-06-15T08:00:00.000Z", "password": "old-secret-0" } + ], + "attachments": [ + { + "id": "att-0001", + "fileName": "recovery-codes.txt", + "size": "482", + "sizeName": "482 bytes", + "url": "https://vault.example/attachments/att-0001" + } + ] + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "organizationId": "org-1", + "folderId": "f1b2c3d4-0000-4000-8000-000000000001", + "type": 1, + "name": "Internal wiki", + "notes": null, + "favorite": false, + "reprompt": 0, + "login": { + "uris": [{ "match": 0, "uri": "https://wiki.internal.example" }], + "username": "svc-wiki", + "password": "S3cr3t!wiki#2024", + "totp": null + }, + "collectionIds": ["col-1"], + "creationDate": "2021-01-07T10:00:00.000Z", + "revisionDate": "2024-09-30T16:45:00.000Z", + "fields": null, + "passwordHistory": null, + "attachments": null + }, + { + "id": "33333333-3333-4333-8333-333333333333", + "organizationId": null, + "folderId": "f1b2c3d4-0000-4000-8000-000000000002", + "type": 3, + "name": "Visa Business", + "notes": "Expires 2027, travel card.", + "favorite": false, + "reprompt": 0, + "card": { + "cardholderName": "Jane Doe", + "brand": "Visa", + "number": "4111111111111111", + "expMonth": "9", + "expYear": "2027", + "code": "123" + }, + "creationDate": "2020-02-02T12:00:00.000Z", + "revisionDate": "2023-04-18T07:30:00.000Z", + "fields": [] + }, + { + "id": "44444444-4444-4444-8444-444444444444", + "organizationId": null, + "folderId": "f1b2c3d4-0000-4000-8000-000000000002", + "type": 4, + "name": "Passport", + "notes": null, + "favorite": false, + "reprompt": 0, + "identity": { + "title": "Dr.", + "firstName": "Jane", + "middleName": "Q.", + "lastName": "Doe", + "address1": "1 Example Street", + "address2": "Apt 4", + "address3": "", + "city": "Springfield", + "state": "IL", + "postalCode": "62704", + "country": "United States", + "company": "Example GmbH", + "email": "jane@example.com", + "phone": "+1 555 0100", + "ssn": "000-00-0000", + "username": "jdoe", + "passportNumber": "X1234567", + "licenseNumber": "D-998877" + }, + "creationDate": "2018-08-08T08:08:08.000Z", + "revisionDate": "2022-12-24T18:00:00.000Z" + }, + { + "id": "55555555-5555-4555-8555-555555555555", + "organizationId": null, + "folderId": "f1b2c3d4-0000-4000-8000-000000000003", + "type": 2, + "name": "Router recovery phrase", + "notes": "word word word word word word word word", + "favorite": false, + "reprompt": 0, + "secureNote": { "type": 0 }, + "creationDate": "2023-03-03T03:03:03.000Z", + "revisionDate": "2023-03-03T03:03:03.000Z", + "fields": [ + { "name": "admin password", "value": "admin-router-pass", "type": 1, "linkedId": null } + ] + }, + { + "id": "66666666-6666-4666-8666-666666666666", + "organizationId": null, + "folderId": "f1b2c3d4-0000-4000-8000-000000000003", + "type": 5, + "name": "deploy@build-host", + "notes": "Key used by CI.", + "favorite": false, + "reprompt": 0, + "sshKey": { + "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----", + "publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample deploy@build-host", + "fingerprint": "SHA256:abcdefghijklmnopqrstuvwxyz0123456789", + "keyFingerprint": "SHA256:abcdefghijklmnopqrstuvwxyz0123456789" + }, + "creationDate": "2024-01-15T09:00:00.000Z", + "revisionDate": "2024-01-15T09:00:00.000Z", + "fields": null, + "attachments": null, + "unknownTopLevelKey": "kept because every value must be printed", + "extraObject": { "nested": { "flag": true, "count": 3 }, "emptyList": [], "emptyObject": {} } + } + ], + "collections": [ + { + "id": "col-1", + "organizationId": "org-1", + "name": "Shared infra", + "externalId": null, + "readOnly": false + } + ] +} diff --git a/examples/sample-export.pdf b/examples/sample-export.pdf new file mode 100644 index 0000000..3930e90 Binary files /dev/null and b/examples/sample-export.pdf differ diff --git a/render.py b/render.py new file mode 100755 index 0000000..e550b29 --- /dev/null +++ b/render.py @@ -0,0 +1,590 @@ +#!/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())