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.
This commit is contained in:
juergen committed 2026-11-13 14:09:10 +01:00
commit 41385b35e6
8 files changed
+1041

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
.DS_Store
__pycache__/
*.pyc
.venv/
build/
dist/
+28
View File
@@ -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"]
+18
View File
@@ -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.
+117
View File
@@ -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 <output>
-> 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.
+109
View File
@@ -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"
+173
View File
@@ -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
}
]
}
Binary file not shown.
Executable
+590
View File
@@ -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"<tr><th>{esc(label)}</th><td{cls}>{esc(value)}</td></tr>\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'<tr><td><span class="dot {badge_class({"type": next((n for n, t in TYPE_NAMES.items() if t == label), None)})}"></span>{esc(label)}</td>'
f'<td class="r">{count}</td></tr>\n'
for label, count in sorted(type_counts.items(), key=lambda kv: (-kv[1], kv[0]))
)
folder_rows = "".join(
f"<tr><td>{esc(name)}</td><td class=\"r\">{count}</td></tr>\n"
for name, count in sorted(folder_counts.items(), key=lambda kv: kv[0].casefold())
)
collection_rows = ""
if collections:
collection_rows = "".join(
f"<tr><td>{esc(c.get('name') or c.get('id') or '')}</td>"
f"<td>{esc(c.get('organizationId') or '')}</td>"
f"<td>{esc(c.get('externalId') or '')}</td>"
f"<td>{esc(c.get('readOnly'))}</td></tr>\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'<tr><td class="r">{index}</td>'
f'<td><span class="dot {badge_class(item)}"></span>{esc(type_label(item))}</td>'
f'<td><a href="#item-{index}">{esc(item.get("name") or "(unnamed)")}</a></td>'
f"<td>{esc(folder)}</td><td>{esc(username)}</td>"
f'<td class="r"><a class="pageref" href="#item-{index}"></a></td></tr>\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'<span class="meta">{esc(" · ".join(meta_bits))}</span>' if meta_bits else ""
)
item_html.append(
f'<section class="item">\n'
f'<h3 id="item-{index}"><span class="idx">{index}</span>'
f'<span class="badge {badge_class(item)}">{esc(type_label(item))}</span>'
f'<span class="iname">{esc(item.get("name") or "(unnamed)")}</span>{meta}</h3>\n'
f'<table class="kv">\n{"".join(header_rows)}</table>\n'
f"</section>\n"
)
paper_css = PAPER_OVERRIDE.get(paper, "A4")
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{esc(title)}</title>
<style>{CSS}
@page {{ size: {paper_css}; }}
.pageref::after {{ content: target-counter(attr(href), page); }}
</style>
</head>
<body>
<h1>{esc(title)}</h1>
<p class="sub"><span class="src">{esc(source_name)}</span> &middot; {len(items)} items &middot; {leaves} values &middot; generated {dt.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M %Z")}</p>
<h2>Summary</h2>
<table class="split"><tr>
<td class="col"><table class="stats">{"".join(meta_rows)}</table></td>
<td class="col">
<table class="grid"><thead><tr><th>type</th><th class="r">items</th></tr></thead><tbody>{type_rows}</tbody></table>
<table class="grid" style="margin-top:3mm"><thead><tr><th>folder</th><th class="r">items</th></tr></thead><tbody>{folder_rows}</tbody></table>
</td></tr></table>
{f'<h2>Collections</h2><table class="grid"><thead><tr><th>name</th><th>organizationId</th><th>externalId</th><th>readOnly</th></tr></thead><tbody>{collection_rows}</tbody></table>' if collections else ''}
<h2>Index</h2>
<table class="grid"><thead><tr><th class="r" style="width:6%">#</th><th style="width:14%">type</th><th>name</th><th style="width:20%">folder</th><th style="width:16%">username</th><th class="r" style="width:6%">page</th></tr></thead>
<tbody>{"".join(index_rows)}</tbody></table>
<h2>Items</h2>
{"".join(item_html)}
</body>
</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())