praxigraph 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- praxigraph/__init__.py +3 -0
- praxigraph/builder.py +73 -0
- praxigraph/cli.py +60 -0
- praxigraph/config.py +162 -0
- praxigraph/document.py +91 -0
- praxigraph/pdf.py +113 -0
- praxigraph/render.py +191 -0
- praxigraph/themes/letter.css +297 -0
- praxigraph-1.0.0.dist-info/METADATA +182 -0
- praxigraph-1.0.0.dist-info/RECORD +13 -0
- praxigraph-1.0.0.dist-info/WHEEL +4 -0
- praxigraph-1.0.0.dist-info/entry_points.txt +2 -0
- praxigraph-1.0.0.dist-info/licenses/LICENSE +21 -0
praxigraph/__init__.py
ADDED
praxigraph/builder.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Orchestrate the build: load documents, write HTML, render PDFs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import Config, ConfigError
|
|
9
|
+
from .document import Document, load_document
|
|
10
|
+
from .pdf import find_chrome, finalize_pdf, render_pdf
|
|
11
|
+
from .render import render_document
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class BuildResult:
|
|
16
|
+
document: Document
|
|
17
|
+
html_path: Path
|
|
18
|
+
pdf_path: Path | None
|
|
19
|
+
ok: bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_documents(cfg: Config) -> list[Document]:
|
|
23
|
+
documents = [load_document(path) for path in cfg.documents]
|
|
24
|
+
slugs = [doc.slug for doc in documents]
|
|
25
|
+
for slug in slugs:
|
|
26
|
+
if slugs.count(slug) > 1:
|
|
27
|
+
raise ConfigError(f"Duplicate document slug '{slug}' "
|
|
28
|
+
f"(set a distinct 'slug' in the front matter).")
|
|
29
|
+
return documents
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pdf_name(cfg: Config, doc: Document, datestamp: str | None = None) -> str:
|
|
33
|
+
if not cfg.output.date_prefix:
|
|
34
|
+
return f"{doc.slug}.pdf"
|
|
35
|
+
return f"{datestamp or doc.date.isoformat()}_{doc.slug}.pdf"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build(cfg: Config, slugs: list[str] | None = None, html_only: bool = False,
|
|
39
|
+
datestamp: str | None = None) -> list[BuildResult]:
|
|
40
|
+
documents = load_documents(cfg)
|
|
41
|
+
if slugs:
|
|
42
|
+
known = {doc.slug for doc in documents}
|
|
43
|
+
for slug in slugs:
|
|
44
|
+
if slug not in known:
|
|
45
|
+
raise ConfigError(f"Unknown document '{slug}' "
|
|
46
|
+
f"(available: {', '.join(sorted(known))})")
|
|
47
|
+
documents = [doc for doc in documents if doc.slug in slugs]
|
|
48
|
+
if not documents:
|
|
49
|
+
raise ConfigError("No documents to build.")
|
|
50
|
+
|
|
51
|
+
chrome = None if html_only else find_chrome(cfg.chrome)
|
|
52
|
+
cfg.output.html_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
if not html_only:
|
|
54
|
+
cfg.output.pdf_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
results = []
|
|
57
|
+
for doc in documents:
|
|
58
|
+
html_path = cfg.output.html_dir / f"{doc.slug}.html"
|
|
59
|
+
html_path.write_text(render_document(cfg, doc), encoding="utf-8")
|
|
60
|
+
if html_only:
|
|
61
|
+
print(f" HTML {html_path}")
|
|
62
|
+
results.append(BuildResult(doc, html_path, None, True))
|
|
63
|
+
continue
|
|
64
|
+
pdf_path = cfg.output.pdf_dir / pdf_name(cfg, doc, datestamp)
|
|
65
|
+
ok = render_pdf(chrome, html_path, pdf_path)
|
|
66
|
+
if ok:
|
|
67
|
+
finalize_pdf(pdf_path, title=f"{doc.doc_type}: {doc.title}",
|
|
68
|
+
author=cfg.letterhead.name)
|
|
69
|
+
print(f" PDF {pdf_path}")
|
|
70
|
+
else:
|
|
71
|
+
print(f" FAILED {pdf_path}")
|
|
72
|
+
results.append(BuildResult(doc, html_path, pdf_path, ok))
|
|
73
|
+
return results
|
praxigraph/cli.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Command line: `praxigraph build` and `praxigraph validate`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .builder import build, load_documents
|
|
10
|
+
from .config import ConfigError, load_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="praxigraph",
|
|
16
|
+
description="Markdown-driven business documents on your own letterhead "
|
|
17
|
+
"(HTML -> PDF via Chrome).")
|
|
18
|
+
parser.add_argument("--version", action="version",
|
|
19
|
+
version=f"praxigraph {__version__}")
|
|
20
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
|
|
22
|
+
b = sub.add_parser("build", help="generate HTML and PDFs")
|
|
23
|
+
b.add_argument("-c", "--config", default="config.yaml", help="path to the config.yaml")
|
|
24
|
+
b.add_argument("--doc", action="append",
|
|
25
|
+
help="build only this document slug (repeatable)")
|
|
26
|
+
b.add_argument("--html-only", action="store_true",
|
|
27
|
+
help="generate HTML only, no Chrome/PDF")
|
|
28
|
+
b.add_argument("--date", default=None,
|
|
29
|
+
help="override the date prefix of the PDF file names (YYYY-MM-DD)")
|
|
30
|
+
|
|
31
|
+
v = sub.add_parser("validate", help="check config and documents")
|
|
32
|
+
v.add_argument("-c", "--config", default="config.yaml", help="path to the config.yaml")
|
|
33
|
+
return parser
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
37
|
+
args = _parser().parse_args(argv)
|
|
38
|
+
try:
|
|
39
|
+
cfg = load_config(args.config)
|
|
40
|
+
if args.command == "validate":
|
|
41
|
+
documents = load_documents(cfg)
|
|
42
|
+
print(f"OK: configuration and {len(documents)} document(s) are valid.")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
results = build(cfg, slugs=args.doc, html_only=args.html_only,
|
|
46
|
+
datestamp=args.date)
|
|
47
|
+
failed = [r for r in results if not r.ok]
|
|
48
|
+
if failed:
|
|
49
|
+
print(f"Error: {len(failed)} document(s) could not be rendered.",
|
|
50
|
+
file=sys.stderr)
|
|
51
|
+
return 1
|
|
52
|
+
print("done.")
|
|
53
|
+
return 0
|
|
54
|
+
except (ConfigError, RuntimeError) as exc:
|
|
55
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
56
|
+
return 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
sys.exit(main())
|
praxigraph/config.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Load and validate the steering file config.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigError(Exception):
|
|
12
|
+
"""Raised for any invalid or missing configuration value."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
#: German defaults; every label can be overridden under `labels:`.
|
|
16
|
+
DEFAULT_LABELS = {
|
|
17
|
+
"number": "Nr.",
|
|
18
|
+
"date": "Datum",
|
|
19
|
+
"phone": "Tel.",
|
|
20
|
+
"email": "E-Mail",
|
|
21
|
+
"web": "Web",
|
|
22
|
+
"vat_id": "USt-IdNr.",
|
|
23
|
+
"tax_number": "Steuernr.",
|
|
24
|
+
"bank": "Bank",
|
|
25
|
+
"iban": "IBAN",
|
|
26
|
+
"bic": "BIC",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_OPTIONAL_SECTIONS = ("contact", "tax", "bank", "signature")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Letterhead:
|
|
34
|
+
name: str
|
|
35
|
+
street: str
|
|
36
|
+
zip: str
|
|
37
|
+
city: str
|
|
38
|
+
tagline: str | None = None
|
|
39
|
+
country: str | None = None
|
|
40
|
+
logo: Path | None = None
|
|
41
|
+
contact: dict = field(default_factory=dict) # phone, email, website
|
|
42
|
+
tax: dict = field(default_factory=dict) # vat_id, tax_number
|
|
43
|
+
bank: dict = field(default_factory=dict) # name, iban, bic
|
|
44
|
+
signature: dict = field(default_factory=dict) # name, place
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class OutputConfig:
|
|
49
|
+
html_dir: Path
|
|
50
|
+
pdf_dir: Path
|
|
51
|
+
date_prefix: bool = True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Config:
|
|
56
|
+
base: Path
|
|
57
|
+
letterhead: Letterhead
|
|
58
|
+
documents: list[Path]
|
|
59
|
+
theme: str = "letter"
|
|
60
|
+
color: str | None = None
|
|
61
|
+
lang: str = "de"
|
|
62
|
+
date_format: str = "%d.%m.%Y"
|
|
63
|
+
labels: dict = field(default_factory=lambda: dict(DEFAULT_LABELS))
|
|
64
|
+
chrome: str | None = None
|
|
65
|
+
output: OutputConfig | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _require(data: dict, key: str, where: str) -> object:
|
|
69
|
+
if key not in data or data[key] in (None, ""):
|
|
70
|
+
raise ConfigError(f"Missing required field '{key}' in {where}.")
|
|
71
|
+
return data[key]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_letterhead(data: dict, base: Path) -> Letterhead:
|
|
75
|
+
if not isinstance(data, dict):
|
|
76
|
+
raise ConfigError("'letterhead' must be a mapping.")
|
|
77
|
+
head = Letterhead(
|
|
78
|
+
name=str(_require(data, "name", "letterhead")),
|
|
79
|
+
street=str(_require(data, "street", "letterhead")),
|
|
80
|
+
zip=str(_require(data, "zip", "letterhead")),
|
|
81
|
+
city=str(_require(data, "city", "letterhead")),
|
|
82
|
+
tagline=data.get("tagline"),
|
|
83
|
+
country=data.get("country"),
|
|
84
|
+
)
|
|
85
|
+
if data.get("logo"):
|
|
86
|
+
logo = (base / str(data["logo"])).resolve()
|
|
87
|
+
if not logo.exists():
|
|
88
|
+
raise ConfigError(f"Logo file not found: {logo}")
|
|
89
|
+
head.logo = logo
|
|
90
|
+
for section in _OPTIONAL_SECTIONS:
|
|
91
|
+
value = data.get(section) or {}
|
|
92
|
+
if not isinstance(value, dict):
|
|
93
|
+
raise ConfigError(f"'letterhead.{section}' must be a mapping.")
|
|
94
|
+
setattr(head, section, {k: str(v) for k, v in value.items() if v not in (None, "")})
|
|
95
|
+
return head
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_documents(value: object, base: Path) -> list[Path]:
|
|
99
|
+
"""`documents:` is either a directory containing *.md or a list of paths."""
|
|
100
|
+
if value is None:
|
|
101
|
+
value = "documents"
|
|
102
|
+
if isinstance(value, str):
|
|
103
|
+
directory = (base / value).resolve()
|
|
104
|
+
if not directory.is_dir():
|
|
105
|
+
raise ConfigError(f"Documents directory not found: {directory}")
|
|
106
|
+
return sorted(directory.glob("*.md"))
|
|
107
|
+
if isinstance(value, list):
|
|
108
|
+
paths = []
|
|
109
|
+
for item in value:
|
|
110
|
+
path = (base / str(item)).resolve()
|
|
111
|
+
if not path.is_file():
|
|
112
|
+
raise ConfigError(f"Document not found: {path}")
|
|
113
|
+
paths.append(path)
|
|
114
|
+
return paths
|
|
115
|
+
raise ConfigError("'documents' must be a directory name or a list of files.")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_config(path: str | Path) -> Config:
|
|
119
|
+
path = Path(path)
|
|
120
|
+
if not path.is_file():
|
|
121
|
+
raise ConfigError(f"Config file not found: {path}")
|
|
122
|
+
try:
|
|
123
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
124
|
+
except yaml.YAMLError as exc:
|
|
125
|
+
raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc
|
|
126
|
+
if not isinstance(data, dict):
|
|
127
|
+
raise ConfigError(f"{path} must contain a YAML mapping.")
|
|
128
|
+
|
|
129
|
+
base = path.parent.resolve()
|
|
130
|
+
letterhead = _load_letterhead(_require(data, "letterhead", str(path)), base)
|
|
131
|
+
|
|
132
|
+
labels = dict(DEFAULT_LABELS)
|
|
133
|
+
overrides = data.get("labels") or {}
|
|
134
|
+
if not isinstance(overrides, dict):
|
|
135
|
+
raise ConfigError("'labels' must be a mapping.")
|
|
136
|
+
for key, value in overrides.items():
|
|
137
|
+
if key not in DEFAULT_LABELS:
|
|
138
|
+
raise ConfigError(f"Unknown label '{key}' "
|
|
139
|
+
f"(known: {', '.join(sorted(DEFAULT_LABELS))})")
|
|
140
|
+
labels[key] = str(value)
|
|
141
|
+
|
|
142
|
+
out = data.get("output") or {}
|
|
143
|
+
if not isinstance(out, dict):
|
|
144
|
+
raise ConfigError("'output' must be a mapping.")
|
|
145
|
+
output = OutputConfig(
|
|
146
|
+
html_dir=(base / str(out.get("html_dir", ".build/html"))).resolve(),
|
|
147
|
+
pdf_dir=(base / str(out.get("pdf_dir", "pdf"))).resolve(),
|
|
148
|
+
date_prefix=bool(out.get("date_prefix", True)),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return Config(
|
|
152
|
+
base=base,
|
|
153
|
+
letterhead=letterhead,
|
|
154
|
+
documents=_resolve_documents(data.get("documents"), base),
|
|
155
|
+
theme=str(data.get("theme", "letter")),
|
|
156
|
+
color=str(data["color"]) if data.get("color") else None,
|
|
157
|
+
lang=str(data.get("lang", "de")),
|
|
158
|
+
date_format=str(data.get("date_format", "%d.%m.%Y")),
|
|
159
|
+
labels=labels,
|
|
160
|
+
chrome=str(data["chrome"]) if data.get("chrome") else None,
|
|
161
|
+
output=output,
|
|
162
|
+
)
|
praxigraph/document.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Load a Markdown document with YAML front matter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from .config import ConfigError
|
|
13
|
+
|
|
14
|
+
_FRONT_MATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Document:
|
|
19
|
+
path: Path
|
|
20
|
+
slug: str
|
|
21
|
+
doc_type: str # e.g. "Protokoll", "Bericht", "Bescheinigung"
|
|
22
|
+
title: str
|
|
23
|
+
date: dt.date
|
|
24
|
+
body_md: str
|
|
25
|
+
number: str | None = None
|
|
26
|
+
recipient: str | None = None
|
|
27
|
+
meta: list[dict] = field(default_factory=list) # extra {label, value} rows
|
|
28
|
+
signature: bool = False
|
|
29
|
+
lang: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_date(value: object, path: Path) -> dt.date:
|
|
33
|
+
"""Front matter dates: a YAML date, or an ISO string YYYY-MM-DD."""
|
|
34
|
+
if isinstance(value, dt.datetime):
|
|
35
|
+
return value.date()
|
|
36
|
+
if isinstance(value, dt.date):
|
|
37
|
+
return value
|
|
38
|
+
if isinstance(value, str):
|
|
39
|
+
try:
|
|
40
|
+
return dt.date.fromisoformat(value.strip())
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
raise ConfigError(f"{path}: 'date' must be a date (YYYY-MM-DD), got {value!r}.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_meta(value: object, path: Path) -> list[dict]:
|
|
47
|
+
if value is None:
|
|
48
|
+
return []
|
|
49
|
+
if not isinstance(value, list):
|
|
50
|
+
raise ConfigError(f"{path}: 'meta' must be a list of {{label, value}} entries.")
|
|
51
|
+
meta = []
|
|
52
|
+
for entry in value:
|
|
53
|
+
if not isinstance(entry, dict) or "label" not in entry or "value" not in entry:
|
|
54
|
+
raise ConfigError(f"{path}: every 'meta' entry needs 'label' and 'value'.")
|
|
55
|
+
meta.append({"label": str(entry["label"]), "value": str(entry["value"])})
|
|
56
|
+
return meta
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_document(path: str | Path) -> Document:
|
|
60
|
+
path = Path(path)
|
|
61
|
+
if not path.is_file():
|
|
62
|
+
raise ConfigError(f"Document not found: {path}")
|
|
63
|
+
text = path.read_text(encoding="utf-8")
|
|
64
|
+
match = _FRONT_MATTER.match(text)
|
|
65
|
+
if not match:
|
|
66
|
+
raise ConfigError(f"{path}: missing YAML front matter (--- block at the top).")
|
|
67
|
+
try:
|
|
68
|
+
front = yaml.safe_load(match.group(1))
|
|
69
|
+
except yaml.YAMLError as exc:
|
|
70
|
+
raise ConfigError(f"{path}: invalid YAML front matter: {exc}") from exc
|
|
71
|
+
if not isinstance(front, dict):
|
|
72
|
+
raise ConfigError(f"{path}: front matter must be a YAML mapping.")
|
|
73
|
+
|
|
74
|
+
for key in ("type", "title", "date"):
|
|
75
|
+
if key not in front or front[key] in (None, ""):
|
|
76
|
+
raise ConfigError(f"{path}: missing required front matter field '{key}'.")
|
|
77
|
+
|
|
78
|
+
recipient = front.get("recipient")
|
|
79
|
+
return Document(
|
|
80
|
+
path=path,
|
|
81
|
+
slug=str(front.get("slug") or path.stem),
|
|
82
|
+
doc_type=str(front["type"]),
|
|
83
|
+
title=str(front["title"]),
|
|
84
|
+
date=_parse_date(front["date"], path),
|
|
85
|
+
body_md=text[match.end():],
|
|
86
|
+
number=str(front["number"]) if front.get("number") not in (None, "") else None,
|
|
87
|
+
recipient=str(recipient).rstrip() if recipient not in (None, "") else None,
|
|
88
|
+
meta=_parse_meta(front.get("meta"), path),
|
|
89
|
+
signature=bool(front.get("signature", False)),
|
|
90
|
+
lang=str(front["lang"]) if front.get("lang") else None,
|
|
91
|
+
)
|
praxigraph/pdf.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""PDF generation via Chrome headless, plus page numbers and metadata (pypdf)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
CHROME_CANDIDATES = [
|
|
11
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
12
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
13
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
14
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
15
|
+
"/usr/bin/google-chrome",
|
|
16
|
+
"/usr/bin/chromium",
|
|
17
|
+
"/usr/bin/chromium-browser",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def find_chrome(configured: str | None = None) -> str:
|
|
22
|
+
"""Find Chrome/Chromium: config value > environment variable > known paths > PATH."""
|
|
23
|
+
candidates = [configured, os.environ.get("PRAXIGRAPH_CHROME"), *CHROME_CANDIDATES]
|
|
24
|
+
for candidate in candidates:
|
|
25
|
+
if candidate and os.path.exists(candidate):
|
|
26
|
+
return candidate
|
|
27
|
+
for name in ("google-chrome", "chromium", "chromium-browser", "chrome"):
|
|
28
|
+
found = shutil.which(name)
|
|
29
|
+
if found:
|
|
30
|
+
return found
|
|
31
|
+
raise RuntimeError(
|
|
32
|
+
"Chrome/Chromium not found. Set the path in the config.yaml under "
|
|
33
|
+
"'chrome:' or via the PRAXIGRAPH_CHROME environment variable.")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_pdf(chrome: str, html_path: Path, pdf_path: Path) -> bool:
|
|
37
|
+
subprocess.run(
|
|
38
|
+
[chrome, "--headless=new", "--disable-gpu", "--no-sandbox",
|
|
39
|
+
"--virtual-time-budget=12000", "--run-all-compositor-stages-before-draw",
|
|
40
|
+
"--no-pdf-header-footer",
|
|
41
|
+
f"--print-to-pdf={pdf_path}", str(html_path)],
|
|
42
|
+
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
43
|
+
return pdf_path.exists() and pdf_path.stat().st_size > 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
#: Helvetica advance widths per 1000 units of em, for the only characters a
|
|
47
|
+
#: page-number stamp can contain. Helvetica is one of the PDF base-14 fonts,
|
|
48
|
+
#: so it needs no embedding and no font file to measure against.
|
|
49
|
+
_STAMP_WIDTHS = {**{str(d): 556 for d in range(10)}, " ": 278, "/": 278}
|
|
50
|
+
|
|
51
|
+
_STAMP_SIZE = 8
|
|
52
|
+
#: Baseline in points from the page bottom: just above the letterhead footer
|
|
53
|
+
#: (which occupies the lowest ~24 mm), right-aligned with the content area.
|
|
54
|
+
_STAMP_BASELINE = 74
|
|
55
|
+
#: Right content edge in points from the right paper edge (15 mm).
|
|
56
|
+
_STAMP_RIGHT_MARGIN = 42.5
|
|
57
|
+
_STAMP_COLOR = "0.5 0.55 0.62"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def finalize_pdf(pdf_path: Path, title: str | None = None,
|
|
61
|
+
author: str | None = None) -> bool:
|
|
62
|
+
"""Post-process a rendered PDF: page numbers and document metadata.
|
|
63
|
+
|
|
64
|
+
Inserts a subtle page number 'i / n' at the bottom right, just above the
|
|
65
|
+
letterhead footer, and sets the PDF title/author metadata. Returns False
|
|
66
|
+
if pypdf is missing, leaving the PDF unchanged.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
import pypdf
|
|
70
|
+
from pypdf.generic import DecodedStreamObject, DictionaryObject, NameObject
|
|
71
|
+
except ImportError:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
writer = pypdf.PdfWriter(clone_from=pypdf.PdfReader(pdf_path))
|
|
75
|
+
total = len(writer.pages)
|
|
76
|
+
helvetica = DictionaryObject({
|
|
77
|
+
NameObject("/Type"): NameObject("/Font"),
|
|
78
|
+
NameObject("/Subtype"): NameObject("/Type1"),
|
|
79
|
+
NameObject("/BaseFont"): NameObject("/Helvetica"),
|
|
80
|
+
})
|
|
81
|
+
for number, page in enumerate(writer.pages, 1):
|
|
82
|
+
text = f"{number} / {total}"
|
|
83
|
+
width = sum(_STAMP_WIDTHS[c] for c in text) / 1000 * _STAMP_SIZE
|
|
84
|
+
box = page.mediabox
|
|
85
|
+
x = float(box.right) - _STAMP_RIGHT_MARGIN - width
|
|
86
|
+
y = float(box.bottom) + _STAMP_BASELINE
|
|
87
|
+
|
|
88
|
+
fonts = page.setdefault(NameObject("/Resources"), DictionaryObject()) \
|
|
89
|
+
.setdefault(NameObject("/Font"), DictionaryObject())
|
|
90
|
+
fonts[NameObject("/PraxiHelv")] = helvetica
|
|
91
|
+
|
|
92
|
+
contents = page.get_contents()
|
|
93
|
+
body = contents.get_data() if contents is not None else b""
|
|
94
|
+
stamped = DecodedStreamObject()
|
|
95
|
+
# The page content is wrapped in q/Q before the stamp is appended:
|
|
96
|
+
# Chrome emits a global `cm` transformation with no enclosing q, which
|
|
97
|
+
# would otherwise scale and mirror the stamp along with the page.
|
|
98
|
+
stamped.set_data(
|
|
99
|
+
b"q\n" + body + b"\nQ\n"
|
|
100
|
+
+ f"q BT /PraxiHelv {_STAMP_SIZE} Tf {_STAMP_COLOR} rg "
|
|
101
|
+
f"1 0 0 1 {x:.2f} {y:.2f} Tm ({text}) Tj ET Q\n".encode("ascii"))
|
|
102
|
+
page.replace_contents(stamped)
|
|
103
|
+
page.compress_content_streams()
|
|
104
|
+
|
|
105
|
+
metadata = {"/Creator": "praxigraph"}
|
|
106
|
+
if title:
|
|
107
|
+
metadata["/Title"] = title
|
|
108
|
+
if author:
|
|
109
|
+
metadata["/Author"] = author
|
|
110
|
+
writer.add_metadata(metadata)
|
|
111
|
+
with open(pdf_path, "wb") as fh:
|
|
112
|
+
writer.write(fh)
|
|
113
|
+
return True
|
praxigraph/render.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Assemble the HTML for one document: letterhead + Markdown body."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import html
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import markdown
|
|
11
|
+
|
|
12
|
+
from .config import Config, ConfigError, Letterhead
|
|
13
|
+
from .document import Document
|
|
14
|
+
|
|
15
|
+
THEMES_DIR = Path(__file__).parent / "themes"
|
|
16
|
+
|
|
17
|
+
_MD_EXTENSIONS = ["tables", "fenced_code", "sane_lists"]
|
|
18
|
+
|
|
19
|
+
_IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
20
|
+
".webp": "image/webp"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _esc(value: str) -> str:
|
|
24
|
+
return html.escape(str(value), quote=False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_theme(theme: str, base: Path) -> str:
|
|
28
|
+
"""A bundled theme name, or a path to a custom .css file."""
|
|
29
|
+
bundled = THEMES_DIR / f"{theme}.css"
|
|
30
|
+
if bundled.is_file():
|
|
31
|
+
return bundled.read_text(encoding="utf-8")
|
|
32
|
+
custom = (base / theme).resolve()
|
|
33
|
+
if custom.is_file():
|
|
34
|
+
return custom.read_text(encoding="utf-8")
|
|
35
|
+
names = ", ".join(sorted(p.stem for p in THEMES_DIR.glob("*.css")))
|
|
36
|
+
raise ConfigError(f"Theme '{theme}' not found (bundled: {names}).")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _logo_html(logo: Path) -> str:
|
|
40
|
+
"""SVG is inlined (keeps gradients crisp), raster images become data URIs."""
|
|
41
|
+
suffix = logo.suffix.lower()
|
|
42
|
+
if suffix == ".svg":
|
|
43
|
+
svg = logo.read_text(encoding="utf-8")
|
|
44
|
+
svg = re.sub(r"<\?xml[^>]*\?>", "", svg).strip()
|
|
45
|
+
return f'<div class="logo">{svg}</div>'
|
|
46
|
+
mime = _IMAGE_TYPES.get(suffix)
|
|
47
|
+
if mime is None:
|
|
48
|
+
raise ConfigError(f"Unsupported logo format '{suffix}' "
|
|
49
|
+
f"(supported: .svg, {', '.join(_IMAGE_TYPES)}).")
|
|
50
|
+
data = base64.b64encode(logo.read_bytes()).decode("ascii")
|
|
51
|
+
return f'<div class="logo"><img src="data:{mime};base64,{data}" alt=""></div>'
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _firm_line(head: Letterhead) -> str:
|
|
55
|
+
return _esc(head.name) + (f" – {_esc(head.tagline)}" if head.tagline else "")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _header_html(head: Letterhead) -> str:
|
|
59
|
+
inner = _logo_html(head.logo) if head.logo else \
|
|
60
|
+
f'<div class="logo-fallback">{_firm_line(head)}</div>'
|
|
61
|
+
return f'<header class="letterhead-header">{inner}</header>'
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _footer_section(rows: list[tuple[str | None, str]]) -> str:
|
|
65
|
+
parts = []
|
|
66
|
+
for label, value in rows:
|
|
67
|
+
prefix = f'<span class="footer-label">{_esc(label)}</span> ' if label else ""
|
|
68
|
+
parts.append(f'<div class="footer-row">{prefix}'
|
|
69
|
+
f'<span class="footer-value">{value}</span></div>')
|
|
70
|
+
return f'<div class="footer-section">{"".join(parts)}</div>'
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _footer_html(head: Letterhead, labels: dict) -> str:
|
|
74
|
+
address = [(None, _esc(head.name)
|
|
75
|
+
+ (f"<br>{_esc(head.tagline)}" if head.tagline else "")),
|
|
76
|
+
(None, _esc(head.street)),
|
|
77
|
+
(None, f"{_esc(head.zip)} {_esc(head.city)}")]
|
|
78
|
+
if head.country:
|
|
79
|
+
address.append((None, _esc(head.country)))
|
|
80
|
+
sections = [_footer_section(address)]
|
|
81
|
+
|
|
82
|
+
contact = []
|
|
83
|
+
if head.contact.get("phone"):
|
|
84
|
+
contact.append((labels["phone"], _esc(head.contact["phone"])))
|
|
85
|
+
if head.contact.get("email"):
|
|
86
|
+
email = head.contact["email"]
|
|
87
|
+
contact.append((labels["email"],
|
|
88
|
+
f'<a href="mailto:{html.escape(email)}">{_esc(email)}</a>'))
|
|
89
|
+
if head.contact.get("website"):
|
|
90
|
+
website = head.contact["website"]
|
|
91
|
+
url = website if website.startswith(("http://", "https://")) \
|
|
92
|
+
else f"https://{website}"
|
|
93
|
+
contact.append((labels["web"],
|
|
94
|
+
f'<a href="{html.escape(url)}">{_esc(website)}</a>'))
|
|
95
|
+
if contact:
|
|
96
|
+
sections.append(_footer_section(contact))
|
|
97
|
+
|
|
98
|
+
tax = [(labels[key], _esc(head.tax[key]))
|
|
99
|
+
for key in ("vat_id", "tax_number") if head.tax.get(key)]
|
|
100
|
+
if tax:
|
|
101
|
+
sections.append(_footer_section(tax))
|
|
102
|
+
|
|
103
|
+
bank = [(labels[key], _esc(head.bank[field]))
|
|
104
|
+
for key, field in (("bank", "name"), ("iban", "iban"), ("bic", "bic"))
|
|
105
|
+
if head.bank.get(field)]
|
|
106
|
+
if bank:
|
|
107
|
+
sections.append(_footer_section(bank))
|
|
108
|
+
|
|
109
|
+
return ('<footer class="letterhead-footer"><div class="footer-grid">'
|
|
110
|
+
+ "".join(sections) + "</div></footer>")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _infobox_html(doc: Document, cfg: Config) -> str:
|
|
114
|
+
rows = []
|
|
115
|
+
if doc.number:
|
|
116
|
+
rows.append((cfg.labels["number"], doc.number))
|
|
117
|
+
rows.append((cfg.labels["date"], doc.date.strftime(cfg.date_format)))
|
|
118
|
+
rows.extend((entry["label"], entry["value"]) for entry in doc.meta)
|
|
119
|
+
cells = "".join(
|
|
120
|
+
f'<tr><td class="infobox-label">{_esc(label)}</td>'
|
|
121
|
+
f'<td class="infobox-value">{_esc(value)}</td></tr>'
|
|
122
|
+
for label, value in rows)
|
|
123
|
+
return f'<div class="infobox"><table>{cells}</table></div>'
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _first_page_head(doc: Document, cfg: Config) -> str:
|
|
127
|
+
head = cfg.letterhead
|
|
128
|
+
recipient = ""
|
|
129
|
+
modifier = ""
|
|
130
|
+
if doc.recipient:
|
|
131
|
+
modifier = " with-recipient"
|
|
132
|
+
lines = "<br>".join(_esc(line) for line in doc.recipient.splitlines())
|
|
133
|
+
recipient = (
|
|
134
|
+
f'<div class="recipient-block">'
|
|
135
|
+
f'<div class="addressline">{_firm_line(head)}<br>'
|
|
136
|
+
f'{_esc(head.street)} – {_esc(head.zip)} {_esc(head.city)}</div>'
|
|
137
|
+
f'<div class="recipient">{lines}</div></div>')
|
|
138
|
+
return (f'<div class="first-page-head{modifier}">{recipient}'
|
|
139
|
+
f'{_infobox_html(doc, cfg)}</div>')
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _signature_html(doc: Document, cfg: Config) -> str:
|
|
143
|
+
if not doc.signature:
|
|
144
|
+
return ""
|
|
145
|
+
sig = cfg.letterhead.signature
|
|
146
|
+
name = sig.get("name", cfg.letterhead.name)
|
|
147
|
+
place = sig.get("place", cfg.letterhead.city)
|
|
148
|
+
return (
|
|
149
|
+
'<div class="signature">'
|
|
150
|
+
f'<div class="signature-place-date">{_esc(place)}, '
|
|
151
|
+
f'{doc.date.strftime(cfg.date_format)}</div>'
|
|
152
|
+
'<div class="signature-line"></div>'
|
|
153
|
+
f'<div class="signature-name">{_esc(name)}</div>'
|
|
154
|
+
f'<div class="signature-firm">{_firm_line(cfg.letterhead)}</div>'
|
|
155
|
+
'</div>')
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def render_document(cfg: Config, doc: Document) -> str:
|
|
159
|
+
theme = load_theme(cfg.theme, cfg.base)
|
|
160
|
+
color = (f":root {{ --primary-color: {cfg.color}; }}" if cfg.color else "")
|
|
161
|
+
body_html = markdown.markdown(doc.body_md, extensions=_MD_EXTENSIONS)
|
|
162
|
+
number = f' <span class="doc-number">{_esc(doc.number)}</span>' if doc.number else ""
|
|
163
|
+
return f"""<!DOCTYPE html>
|
|
164
|
+
<html lang="{_esc(doc.lang or cfg.lang)}">
|
|
165
|
+
<head>
|
|
166
|
+
<meta charset="utf-8">
|
|
167
|
+
<title>{_esc(doc.doc_type)}: {_esc(doc.title)}</title>
|
|
168
|
+
<style>{theme}</style>
|
|
169
|
+
<style>{color}</style>
|
|
170
|
+
</head>
|
|
171
|
+
<body>
|
|
172
|
+
{_header_html(cfg.letterhead)}
|
|
173
|
+
{_footer_html(cfg.letterhead, cfg.labels)}
|
|
174
|
+
<table class="page">
|
|
175
|
+
<thead><tr><td><div class="header-space"></div></td></tr></thead>
|
|
176
|
+
<tbody><tr><td class="content">
|
|
177
|
+
{_first_page_head(doc, cfg)}
|
|
178
|
+
<div class="doc-head">
|
|
179
|
+
<div class="doc-type">{_esc(doc.doc_type)}{number}</div>
|
|
180
|
+
<h1 class="doc-title">{_esc(doc.title)}</h1>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="doc-body">
|
|
183
|
+
{body_html}
|
|
184
|
+
</div>
|
|
185
|
+
{_signature_html(doc, cfg)}
|
|
186
|
+
</td></tr></tbody>
|
|
187
|
+
<tfoot><tr><td><div class="footer-space"></div></td></tr></tfoot>
|
|
188
|
+
</table>
|
|
189
|
+
</body>
|
|
190
|
+
</html>
|
|
191
|
+
"""
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/* Theme "letter": A4 business letterhead in the style of a classic black
|
|
2
|
+
* invoice template (Roboto Condensed, black primary color, logo top right,
|
|
3
|
+
* four-column footer with the company data).
|
|
4
|
+
*
|
|
5
|
+
* Chrome headless has no support for CSS margin boxes or `position: running`,
|
|
6
|
+
* so the letterhead uses the two mechanisms Chrome's print engine does honor:
|
|
7
|
+
* `position: fixed` elements repeat on every printed page, and a table's
|
|
8
|
+
* thead/tfoot repeat as well and reserve the vertical space for them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
@import url('https://fonts.googleapis.com/css2?family=Roboto+Condensed:ital,wght@0,100..900;1,100..900&display=swap');
|
|
12
|
+
|
|
13
|
+
:root {
|
|
14
|
+
--primary-color: #000000;
|
|
15
|
+
--muted-color: #666666;
|
|
16
|
+
--rule-color: #000000;
|
|
17
|
+
--page-left: 20mm;
|
|
18
|
+
--page-right: 15mm;
|
|
19
|
+
--header-height: 46mm;
|
|
20
|
+
--footer-height: 30mm;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@page {
|
|
24
|
+
size: A4 portrait;
|
|
25
|
+
margin: 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
* { box-sizing: border-box; }
|
|
29
|
+
|
|
30
|
+
body {
|
|
31
|
+
margin: 0;
|
|
32
|
+
font-family: "Roboto Condensed", "Helvetica Neue", Arial, sans-serif;
|
|
33
|
+
font-size: 9pt;
|
|
34
|
+
line-height: 11.5pt;
|
|
35
|
+
color: #000;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Page skeleton: thead/tfoot spacers reserve the letterhead zones. */
|
|
39
|
+
table.page {
|
|
40
|
+
width: 100%;
|
|
41
|
+
border-collapse: collapse;
|
|
42
|
+
border-spacing: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
table.page > thead > tr > td,
|
|
46
|
+
table.page > tfoot > tr > td {
|
|
47
|
+
padding: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.header-space { height: var(--header-height); }
|
|
51
|
+
.footer-space { height: var(--footer-height); }
|
|
52
|
+
|
|
53
|
+
td.content {
|
|
54
|
+
padding: 0 var(--page-right) 0 var(--page-left);
|
|
55
|
+
vertical-align: top;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Letterhead header: repeated on every page via position: fixed. */
|
|
59
|
+
header.letterhead-header {
|
|
60
|
+
position: fixed;
|
|
61
|
+
top: 0;
|
|
62
|
+
left: var(--page-left);
|
|
63
|
+
right: var(--page-right);
|
|
64
|
+
height: var(--header-height);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
header.letterhead-header .logo,
|
|
68
|
+
header.letterhead-header .logo svg,
|
|
69
|
+
header.letterhead-header .logo img {
|
|
70
|
+
position: absolute;
|
|
71
|
+
top: 9mm;
|
|
72
|
+
right: 0;
|
|
73
|
+
width: 27mm;
|
|
74
|
+
height: 27mm;
|
|
75
|
+
object-fit: contain;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
header.letterhead-header .logo svg,
|
|
79
|
+
header.letterhead-header .logo img {
|
|
80
|
+
top: 0;
|
|
81
|
+
right: 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
header.letterhead-header .logo-fallback {
|
|
85
|
+
position: absolute;
|
|
86
|
+
top: 12mm;
|
|
87
|
+
right: 0;
|
|
88
|
+
font-size: 16pt;
|
|
89
|
+
font-weight: 400;
|
|
90
|
+
color: var(--primary-color);
|
|
91
|
+
text-align: right;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Letterhead footer: four-column company grid, repeated on every page. */
|
|
95
|
+
footer.letterhead-footer {
|
|
96
|
+
position: fixed;
|
|
97
|
+
bottom: 0;
|
|
98
|
+
left: var(--page-left);
|
|
99
|
+
right: var(--page-right);
|
|
100
|
+
height: var(--footer-height);
|
|
101
|
+
font-size: 7pt;
|
|
102
|
+
line-height: 10pt;
|
|
103
|
+
color: var(--primary-color);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
footer.letterhead-footer .footer-grid {
|
|
107
|
+
position: absolute;
|
|
108
|
+
bottom: 8mm;
|
|
109
|
+
left: 0;
|
|
110
|
+
right: 0;
|
|
111
|
+
display: grid;
|
|
112
|
+
grid-template-columns: repeat(4, 1fr);
|
|
113
|
+
column-gap: 2mm;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
footer.letterhead-footer .footer-label {
|
|
117
|
+
display: inline-block;
|
|
118
|
+
margin-right: 1mm;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* E-mail and website are clickable in the PDF, but look like plain text. */
|
|
122
|
+
footer.letterhead-footer a {
|
|
123
|
+
color: inherit;
|
|
124
|
+
text-decoration: none;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* First page: sender line + recipient on the left, info box on the right. */
|
|
128
|
+
.first-page-head {
|
|
129
|
+
display: flex;
|
|
130
|
+
justify-content: space-between;
|
|
131
|
+
gap: 10mm;
|
|
132
|
+
margin-bottom: 10mm;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.first-page-head.with-recipient { min-height: 45mm; }
|
|
136
|
+
|
|
137
|
+
.recipient-block { max-width: 85mm; }
|
|
138
|
+
|
|
139
|
+
.addressline {
|
|
140
|
+
font-size: 7pt;
|
|
141
|
+
line-height: 9pt;
|
|
142
|
+
margin-bottom: 6mm;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.recipient {
|
|
146
|
+
font-size: 9pt;
|
|
147
|
+
line-height: 1.35;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.infobox { width: 65mm; flex: 0 0 65mm; }
|
|
151
|
+
|
|
152
|
+
.infobox table {
|
|
153
|
+
width: 100%;
|
|
154
|
+
border-spacing: 0;
|
|
155
|
+
font-size: 7pt;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.infobox td {
|
|
159
|
+
padding: 0.5mm;
|
|
160
|
+
vertical-align: top;
|
|
161
|
+
word-wrap: anywhere;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.infobox td.infobox-label { padding-left: 0; }
|
|
165
|
+
.infobox td.infobox-value { text-align: right; }
|
|
166
|
+
|
|
167
|
+
/* Document heading */
|
|
168
|
+
.doc-head { margin-bottom: 5mm; }
|
|
169
|
+
|
|
170
|
+
.doc-type {
|
|
171
|
+
font-size: 9pt;
|
|
172
|
+
letter-spacing: 0.15em;
|
|
173
|
+
text-transform: uppercase;
|
|
174
|
+
color: var(--muted-color);
|
|
175
|
+
margin-bottom: 1.5mm;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.doc-type .doc-number { color: var(--muted-color); }
|
|
179
|
+
|
|
180
|
+
h1.doc-title {
|
|
181
|
+
font-size: 14pt;
|
|
182
|
+
line-height: 17pt;
|
|
183
|
+
font-weight: 700;
|
|
184
|
+
color: var(--primary-color);
|
|
185
|
+
margin: 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* Body typography (rendered from Markdown) */
|
|
189
|
+
.doc-body h2 {
|
|
190
|
+
font-size: 11pt;
|
|
191
|
+
line-height: 14pt;
|
|
192
|
+
font-weight: 700;
|
|
193
|
+
margin: 6mm 0 2mm;
|
|
194
|
+
page-break-after: avoid;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.doc-body h3 {
|
|
198
|
+
font-size: 9.5pt;
|
|
199
|
+
font-weight: 700;
|
|
200
|
+
margin: 4mm 0 1.5mm;
|
|
201
|
+
page-break-after: avoid;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.doc-body h4 {
|
|
205
|
+
font-size: 9pt;
|
|
206
|
+
font-weight: 700;
|
|
207
|
+
margin: 3mm 0 1mm;
|
|
208
|
+
page-break-after: avoid;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.doc-body p { margin: 0 0 2.5mm; }
|
|
212
|
+
|
|
213
|
+
.doc-body ul, .doc-body ol {
|
|
214
|
+
margin: 0 0 2.5mm;
|
|
215
|
+
padding-left: 5mm;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.doc-body li { margin-bottom: 1mm; }
|
|
219
|
+
|
|
220
|
+
.doc-body a { color: inherit; }
|
|
221
|
+
|
|
222
|
+
.doc-body hr {
|
|
223
|
+
border: none;
|
|
224
|
+
border-top: 0.5pt solid var(--muted-color);
|
|
225
|
+
margin: 4mm 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.doc-body blockquote {
|
|
229
|
+
margin: 0 0 2.5mm;
|
|
230
|
+
padding: 1mm 0 1mm 3mm;
|
|
231
|
+
border-left: 1.5pt solid var(--rule-color);
|
|
232
|
+
color: #333;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.doc-body code {
|
|
236
|
+
font-family: "SF Mono", "Consolas", "Liberation Mono", monospace;
|
|
237
|
+
font-size: 8pt;
|
|
238
|
+
background: #f2f2f2;
|
|
239
|
+
padding: 0 1mm;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.doc-body pre {
|
|
243
|
+
background: #f2f2f2;
|
|
244
|
+
padding: 2mm;
|
|
245
|
+
margin: 0 0 2.5mm;
|
|
246
|
+
overflow-wrap: anywhere;
|
|
247
|
+
white-space: pre-wrap;
|
|
248
|
+
page-break-inside: avoid;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.doc-body pre code { background: none; padding: 0; }
|
|
252
|
+
|
|
253
|
+
/* Tables in the invoice style: black header row, thin rules between rows. */
|
|
254
|
+
.doc-body table {
|
|
255
|
+
border-collapse: collapse;
|
|
256
|
+
width: 100%;
|
|
257
|
+
margin: 3mm 0 4mm;
|
|
258
|
+
font-variant-numeric: tabular-nums;
|
|
259
|
+
font-feature-settings: "tnum" 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.doc-body table th {
|
|
263
|
+
background: var(--primary-color);
|
|
264
|
+
color: #fff;
|
|
265
|
+
font-weight: 700;
|
|
266
|
+
text-align: left;
|
|
267
|
+
padding: 1.5mm 1mm;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.doc-body table td {
|
|
271
|
+
padding: 1.5mm 1mm;
|
|
272
|
+
vertical-align: top;
|
|
273
|
+
border-bottom: 0.5pt solid #999;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.doc-body table tr { page-break-inside: avoid; }
|
|
277
|
+
|
|
278
|
+
/* Signature block */
|
|
279
|
+
.signature {
|
|
280
|
+
margin-top: 14mm;
|
|
281
|
+
page-break-inside: avoid;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.signature-place-date { margin-bottom: 14mm; }
|
|
285
|
+
|
|
286
|
+
.signature-line {
|
|
287
|
+
width: 60mm;
|
|
288
|
+
border-top: 0.5pt solid #000;
|
|
289
|
+
margin-bottom: 1mm;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.signature-name { font-weight: 700; }
|
|
293
|
+
|
|
294
|
+
.signature-firm {
|
|
295
|
+
font-size: 7pt;
|
|
296
|
+
color: var(--muted-color);
|
|
297
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: praxigraph
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Markdown-driven business document generator on your own letterhead: reports, minutes and certificates as PDF via Chrome headless
|
|
5
|
+
Project-URL: Homepage, https://github.com/Supportlik/Praxigraph
|
|
6
|
+
Project-URL: Repository, https://github.com/Supportlik/Praxigraph
|
|
7
|
+
Project-URL: Issues, https://github.com/Supportlik/Praxigraph/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Supportlik/Praxigraph/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Specification, https://github.com/Supportlik/Praxigraph/blob/main/docs/SPEC.md
|
|
10
|
+
Author-email: Michael Bortlik <michael@bortlik.io>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: generator,letterhead,markdown,minutes,pdf,protocol,report
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Office/Business
|
|
25
|
+
Classifier: Topic :: Printing
|
|
26
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: markdown>=3.5
|
|
29
|
+
Requires-Dist: pypdf>=6.0
|
|
30
|
+
Requires-Dist: pyyaml>=6.0
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Praxigraph
|
|
34
|
+
|
|
35
|
+
[](https://github.com/Supportlik/Praxigraph/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/praxigraph/)
|
|
37
|
+
[](https://pypi.org/project/praxigraph/)
|
|
38
|
+
[](https://github.com/Supportlik/Praxigraph/blob/main/LICENSE)
|
|
39
|
+
|
|
40
|
+
**Praxigraph** (Greek *πρᾶξις* "act, transaction, proceeding" + *γράφειν* "to write": "the one that writes down your proceedings") is a Markdown-driven generator for business documents on your own letterhead: **meeting minutes, status reports, certificates, timesheets** — anything you issue on company paper that is not an invoice. It is the sibling project of [Ergograph](https://github.com/Supportlik/Ergograph), which does the same for CVs and dossiers.
|
|
41
|
+
|
|
42
|
+
The generator contains **no personal data**. Your letterhead (company name, address, contact, tax IDs, bank details, logo) lives in a `config.yaml` outside the repo; each document is one Markdown file with YAML front matter. The code only provides rendering, the theme and the PDF export.
|
|
43
|
+
|
|
44
|
+
## How it works
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
config.yaml + documents/*.md -> HTML (theme "letter") -> PDF (Chrome headless)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
1. `config.yaml` holds the letterhead and steers the build: header logo and the multi-column footer (address, contact, tax IDs, bank) are printed on **every** page.
|
|
51
|
+
2. One Markdown file per document. The front matter carries the document type, title, date, an optional number, recipient and extra info-box rows; the Markdown body becomes the content — headings, lists and tables included.
|
|
52
|
+
3. Chrome (headless) renders the HTML to A4 PDFs, which then get page numbers (`i / n`, bottom right) and title/author metadata stamped in.
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
Requirements: Python ≥ 3.10 and Google Chrome or Chromium. Chrome is only needed
|
|
57
|
+
for the PDF step (`praxigraph build --html-only` works without it) and is not
|
|
58
|
+
installed by pip — Praxigraph looks for an existing installation (see `chrome:` below).
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# as an isolated tool (recommended)
|
|
62
|
+
uv tool install praxigraph
|
|
63
|
+
|
|
64
|
+
# or into the current environment
|
|
65
|
+
pip install praxigraph
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
All three dependencies (PyYAML, Markdown, pypdf) are pure Python.
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
cd examples/minimal/
|
|
74
|
+
praxigraph validate # check config + documents
|
|
75
|
+
praxigraph build # build everything (HTML + PDF)
|
|
76
|
+
praxigraph build --html-only # HTML only, no Chrome
|
|
77
|
+
praxigraph build --doc kickoff-protokoll
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The PDFs end up under `pdf/YYYY-MM-DD_<slug>.pdf` — the date comes from the
|
|
81
|
+
document's front matter, so rebuilding never shuffles your archive
|
|
82
|
+
(disable the prefix with `output.date_prefix: false`).
|
|
83
|
+
|
|
84
|
+
## Example output
|
|
85
|
+
|
|
86
|
+
The rendered example PDFs are committed under
|
|
87
|
+
[`examples/minimal/pdf/`](examples/minimal/pdf/): meeting minutes with a
|
|
88
|
+
recipient address, a status report, and a signed timesheet certificate — all
|
|
89
|
+
for a fictional company.
|
|
90
|
+
|
|
91
|
+
## The steering file `config.yaml`
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
letterhead:
|
|
95
|
+
name: Daniel Falkner # bold first line, also the PDF author
|
|
96
|
+
tagline: IT-Beratung & Systemintegration
|
|
97
|
+
street: Ahornweg 12
|
|
98
|
+
zip: "93049"
|
|
99
|
+
city: Regensburg
|
|
100
|
+
# country: Deutschland # optional, shown in the footer
|
|
101
|
+
logo: assets/logo.svg # optional; SVG is inlined, PNG/JPEG embedded
|
|
102
|
+
contact: # optional; each key is optional too
|
|
103
|
+
phone: +49 941 000000
|
|
104
|
+
email: mail@falkner-it.example
|
|
105
|
+
website: falkner-it.example
|
|
106
|
+
tax: # optional: vat_id, tax_number
|
|
107
|
+
vat_id: DE999999999
|
|
108
|
+
bank: # optional: name, iban, bic
|
|
109
|
+
name: Musterbank
|
|
110
|
+
iban: DE02 1203 0000 0000 2020 51
|
|
111
|
+
bic: BYLADEM1001
|
|
112
|
+
signature: # used by documents with `signature: true`
|
|
113
|
+
name: Daniel Falkner
|
|
114
|
+
place: Regensburg
|
|
115
|
+
|
|
116
|
+
theme: letter # bundled theme, or path to your own .css
|
|
117
|
+
# color: "#1a3c6e" # accent color (title, footer); default black
|
|
118
|
+
# lang: de # HTML language attribute, default de
|
|
119
|
+
# date_format: "%d.%m.%Y"
|
|
120
|
+
|
|
121
|
+
documents: documents # a directory with *.md, or an explicit list of files
|
|
122
|
+
|
|
123
|
+
labels: # optional overrides of the German defaults, e.g. for English paper
|
|
124
|
+
# date: Date
|
|
125
|
+
# vat_id: VAT ID
|
|
126
|
+
|
|
127
|
+
output:
|
|
128
|
+
html_dir: .build/html
|
|
129
|
+
pdf_dir: pdf
|
|
130
|
+
date_prefix: true # date-stamped file names; false = stable names
|
|
131
|
+
|
|
132
|
+
# chrome: /path/to/chrome # optional; otherwise auto-detected
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The letterhead footer renders up to four columns, and empty ones simply
|
|
136
|
+
disappear: address · contact · tax IDs · bank details.
|
|
137
|
+
|
|
138
|
+
## A document
|
|
139
|
+
|
|
140
|
+
````markdown
|
|
141
|
+
---
|
|
142
|
+
type: Protokoll # document type, printed above the title
|
|
143
|
+
title: Kickoff Website-Relaunch
|
|
144
|
+
date: 2026-08-14 # also the file-name prefix
|
|
145
|
+
number: P-2026-001 # optional, shown in the type line and info box
|
|
146
|
+
recipient: | # optional; adds a DIN-letter address block
|
|
147
|
+
Muster GmbH
|
|
148
|
+
Frau Erika Muster
|
|
149
|
+
Musterallee 8
|
|
150
|
+
93047 Regensburg
|
|
151
|
+
meta: # optional extra rows in the info box
|
|
152
|
+
- label: Projekt
|
|
153
|
+
value: Website-Relaunch
|
|
154
|
+
signature: true # optional signature block (place, date, line, name)
|
|
155
|
+
# slug: kickoff # optional, default: file name stem
|
|
156
|
+
# lang: de # optional, overrides config
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Teilnehmer
|
|
160
|
+
|
|
161
|
+
- Erika Muster (Muster GmbH)
|
|
162
|
+
...
|
|
163
|
+
````
|
|
164
|
+
|
|
165
|
+
The body is standard Markdown (Python-Markdown with the `tables`,
|
|
166
|
+
`fenced_code` and `sane_lists` extensions). Tables render in the letterhead
|
|
167
|
+
style: dark header row, thin rules. Front matter values are plain text and are
|
|
168
|
+
HTML-escaped; formatting belongs in the Markdown body.
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
uv run pytest # test suite, no Chrome and no network needed
|
|
174
|
+
cd examples/minimal && uv run praxigraph build
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Design decisions and requirements live in [`docs/SPEC.md`](docs/SPEC.md).
|
|
178
|
+
Version history: [`CHANGELOG.md`](CHANGELOG.md).
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
praxigraph/__init__.py,sha256=qhwn9JVzn7ad4JHpqN4KWdEIEdSZ6MOw7I3cuZ8zyU8,100
|
|
2
|
+
praxigraph/builder.py,sha256=tLpO6NOlN4RSwNYGkgAZLkR0cSF0UONdr1jTLyUZQnM,2624
|
|
3
|
+
praxigraph/cli.py,sha256=LovditRnwDwavnigwysP9yCy5nHE2vcr_zfIxXWuXUI,2207
|
|
4
|
+
praxigraph/config.py,sha256=vHaRxGK4x5dyY6-png-IzKckwttOG5iG9SsQ0F-ouWk,5325
|
|
5
|
+
praxigraph/document.py,sha256=OBtiP7Yn_04Ky3ixBsCNNktvLZOtzBkLhhDxhihRho4,3150
|
|
6
|
+
praxigraph/pdf.py,sha256=StC30V4y-Z30wo5ogGtKFLwlymLCF5XAe94emzTnHZs,4598
|
|
7
|
+
praxigraph/render.py,sha256=MgXk2pRu1BSyFPKIrCb4ynNWJ_ow6yTCe7hmJS5dHrE,6973
|
|
8
|
+
praxigraph/themes/letter.css,sha256=qJIPKb40Civ03Pprz1jGZ4uN3v4-bRX2YvStbxg5KJE,6243
|
|
9
|
+
praxigraph-1.0.0.dist-info/METADATA,sha256=Awk4LiAEgi6KJR9eZJolDgTd-UsYG3ggLppEp6pYSjU,7675
|
|
10
|
+
praxigraph-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
praxigraph-1.0.0.dist-info/entry_points.txt,sha256=HyaHWm17309E2XMwq6pdAGP27LaNyS7osgHa5GlxYlw,51
|
|
12
|
+
praxigraph-1.0.0.dist-info/licenses/LICENSE,sha256=0G76jpWAUx1S_m32OMs7urX5SWzFONxklcvwsQHh_F4,1072
|
|
13
|
+
praxigraph-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Bortlik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|