From 41385b35e6b733a115ada0a9a2f0d0f232ccc49d Mon Sep 17 00:00:00 2001 From: Juergen Holly Date: Fri, 13 Nov 2026 14:09:10 +0100 Subject: [PATCH] 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. --- .gitignore | 6 + Containerfile | 28 ++ LICENSE | 18 ++ README.md | 117 +++++++ bitwarden-print.sh | 109 +++++++ examples/sample-export.json | 173 +++++++++++ examples/sample-export.pdf | Bin 0 -> 32933 bytes render.py | 590 ++++++++++++++++++++++++++++++++++++ 8 files changed, 1041 insertions(+) create mode 100644 .gitignore create mode 100644 Containerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bitwarden-print.sh create mode 100644 examples/sample-export.json create mode 100644 examples/sample-export.pdf create mode 100755 render.py 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 0000000000000000000000000000000000000000..3930e9093d180c855b0e78154025befda84b5242 GIT binary patch literal 32933 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 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())