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 ADDED
@@ -0,0 +1,3 @@
1
+ """Praxigraph: Markdown-driven business documents on your own letterhead."""
2
+
3
+ __version__ = "1.0.0"
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
+ [![CI](https://github.com/Supportlik/Praxigraph/actions/workflows/ci.yml/badge.svg)](https://github.com/Supportlik/Praxigraph/actions/workflows/ci.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/praxigraph.svg)](https://pypi.org/project/praxigraph/)
37
+ [![Python versions](https://img.shields.io/pypi/pyversions/praxigraph.svg)](https://pypi.org/project/praxigraph/)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ praxigraph = praxigraph.cli:main
@@ -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.