diff --git a/.gitignore b/.gitignore
index 92a2ab1..26f5c6a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
.DS_Store
try*
ref/
+report.pdf
### Python ###
# Byte-compiled / optimized / DLL files
diff --git a/natal/report.css b/natal/report.css
new file mode 100644
index 0000000..d47fb01
--- /dev/null
+++ b/natal/report.css
@@ -0,0 +1,90 @@
+:root {
+ --color-border: white;
+ --color-background-alt: #f5f5f5;
+ --border-width: 0.1rem;
+ --color-text: #595959;
+ --text-size: 0.5rem;
+}
+
+@page {
+ size: A4;
+ margin: 2rem 2rem 1rem 2rem;
+}
+
+.row1,
+.row2,
+.row3 {
+ display: flex;
+}
+
+.row1 {
+ & > .chart {
+ flex: 1 0 auto;
+ /* border: 1px solid red; */
+ text-align: center;
+ padding-bottom: 0.5rem;
+ }
+}
+
+main {
+ font-family: sans-serif, serif, monospace;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.info_col {
+ /* border: 1px solid red; */
+ display: flex;
+ flex-direction: column;
+ & .section {
+ margin-bottom: 1rem;
+ }
+}
+
+table {
+ color: var(--color-text);
+ font-size: var(--text-size);
+ border-collapse: separate;
+ border-spacing: 0;
+ overflow: hidden;
+
+ & tr:nth-child(odd) {
+ background-color: var(--color-background-alt);
+ }
+
+ & td {
+ padding: 0.3rem;
+ border-right: var(--border-width) solid var(--color-border);
+ border-bottom: var(--border-width) solid var(--color-border);
+ text-align: center;
+
+ &:last-child {
+ border-right: none;
+ }
+ }
+
+ & tr:last-child td {
+ border-bottom: none;
+ }
+}
+
+svg {
+ vertical-align: bottom;
+}
+
+.title {
+ font-size: var(--text-size);
+ font-weight: bold;
+ color: var(--color-text);
+ padding: 0.3rem 0;
+}
+
+.section {
+ margin-right: 1rem;
+
+ &:last-child {
+ margin-right: 0;
+ }
+}
diff --git a/natal/report.py b/natal/report.py
new file mode 100644
index 0000000..de8264c
--- /dev/null
+++ b/natal/report.py
@@ -0,0 +1,268 @@
+from io import BytesIO
+from collections import defaultdict
+from natal import Chart, Config, Data, Stats
+from natal.config import Orb
+from natal.const import (
+ ASPECT_MEMBERS,
+ ELEMENT_MEMBERS,
+ EXTRA_MEMBERS,
+ PLANET_MEMBERS,
+ QUALITY_MEMBERS,
+ SIGN_MEMBERS,
+ VERTEX_MEMBERS,
+)
+from natal.stats import StatData, dignity_of
+from pathlib import Path
+from tagit import div, main, style, svg, table, td, tr
+from typing import Iterable
+from weasyprint import HTML
+
+Grid = list[Iterable[str | int]]
+ELEMENTS = [ELEMENT_MEMBERS[i] for i in (0, 2, 3, 1)]
+TEXT_COLOR = "#595959"
+symbol_name_map = {
+ asp.symbol: asp.name
+ for asp in (PLANET_MEMBERS + EXTRA_MEMBERS + VERTEX_MEMBERS + ASPECT_MEMBERS)
+}
+
+
+class Report:
+ def __init__(self, data1: Data, data2: Data | None = None):
+ self.data1 = data1
+ self.data2 = data2
+
+ @property
+ def basic_info(self) -> Grid:
+ time_fmt = "%Y-%m-%d %H:%M"
+ dt1 = self.data1.dt.strftime(time_fmt)
+ output = [["name", "city", "birth"]]
+ output.append([self.data1.name, self.data1.city, dt1])
+ if self.data2:
+ dt2 = self.data2.dt.strftime(time_fmt)
+ output.append([self.data2.name, self.data2.city, dt2])
+ return list(zip(*output))
+
+ @property
+ def element_vs_quality(self) -> Grid:
+ aspectable1 = self.data1.aspectables
+ element_symbols = [svg_of(ele.name) for ele in ELEMENTS]
+ grid = [[""] + element_symbols + ["sum"]]
+ element_count = defaultdict(int)
+ for quality in QUALITY_MEMBERS:
+ row = [svg_of(quality.name)]
+ quality_count = 0
+ for element in ELEMENTS:
+ count = 0
+ symbols = ""
+ for body in aspectable1:
+ if (
+ body.sign.element == element.name
+ and body.sign.quality == quality.name
+ ):
+ symbols += svg_of(body.name)
+ count += 1
+ element_count[element.name] += 1
+ row.append(symbols)
+ quality_count += count
+ row.append(str(quality_count))
+ grid.append(row)
+ grid.append(
+ ["sum"] + list(element_count.values()) + [sum(element_count.values())]
+ )
+ grid.append(
+ [
+ "◐",
+ f"null:{element_count['fire'] + element_count['air']} pos",
+ f"null:{element_count['water'] + element_count['earth']} neg",
+ "",
+ ]
+ )
+ return grid
+
+ @property
+ def quadrants_vs_hemisphere(self) -> Grid:
+ q = self.data1.quadrants
+ first_q = [svg_of(body.name) for body in q[0]]
+ second_q = [svg_of(body.name) for body in q[1]]
+ third_q = [svg_of(body.name) for body in q[2]]
+ forth_q = [svg_of(body.name) for body in q[3]]
+ hemi_symbols = ["←", "→", "↑", "↓"]
+ grid = [[""] + hemi_symbols[:2] + ["sum"]]
+ grid += [["↑"] + [forth_q, third_q] + [len(q[3] + q[2])]]
+ grid += [["↓"] + [first_q, second_q] + [len(q[3] + q[2])]]
+ grid += [
+ ["sum"]
+ + [len(q[3] + q[0]), len(q[1] + q[2])]
+ + [len(q[0] + q[1] + q[2] + q[3])]
+ ]
+ return grid
+
+ @property
+ def signs(self) -> Grid:
+ grid = [["sign", "bodies", "sum"]]
+ for sign in SIGN_MEMBERS:
+ bodies = [
+ svg_of(b.name)
+ for b in self.data1.aspectables
+ if b.sign.name == sign.name
+ ]
+ grid.append([svg_of(sign.name), "".join(bodies), len(bodies) or ""])
+ return grid
+
+ @property
+ def houses(self) -> Grid:
+ grid = [["house", "cusp", "bodies", "sum"]]
+ for hse in self.data1.houses:
+ bodies = [
+ svg_of(b.name)
+ for b in self.data1.aspectables
+ if self.data1.house_of(b) == hse.value
+ ]
+ grid.append(
+ [
+ hse.value,
+ f"{hse.signed_deg:02d}° {svg_of(hse.sign.name)} {hse.minute:02d}'",
+ "".join(bodies),
+ len(bodies) or "",
+ ]
+ )
+ return grid
+
+ @property
+ def celestial_body1(self) -> Grid:
+ return self.celestial_body(self.data1)
+
+ @property
+ def celestial_body2(self) -> Grid:
+ return self.celestial_body(self.data2)
+
+ def celestial_body(self, data: Data) -> Grid:
+ grid = [("body", "sign", "house", "dignity")]
+ for body in data.aspectables:
+ grid.append(
+ (
+ svg_of(body.name),
+ f"{body.signed_deg:02d}° {svg_of(body.sign.name)} {body.minute:02d}'",
+ self.data1.house_of(body),
+ svg_of(dignity_of(body)),
+ )
+ )
+ return grid
+
+ @property
+ def cross_ref(self) -> StatData:
+ stats = Stats(self.data1, self.data2)
+ grid = stats.cross_ref.grid
+ for row in range(len(grid)):
+ for col in range(len(grid[0])):
+ cell = grid[row][col]
+ if name := symbol_name_map.get(cell):
+ grid[row][col] = svg_of(name)
+ return StatData(stats.cross_ref.title, grid)
+
+ @property
+ def full_report(self) -> str:
+ chart = Chart(self.data1, width=400, data2=self.data2)
+ row1 = div(
+ section("Birth Info", self.basic_info)
+ + section("Elements, Modality & Polarity", self.element_vs_quality)
+ + section("Hemisphere & Quadrants", self.quadrants_vs_hemisphere),
+ class_="info_col",
+ ) + div(chart.svg, class_="chart")
+
+ row2 = section(f"{self.data1.name}'s Celestial Bodies", self.celestial_body1)
+
+ if self.data2:
+ row2 += section(
+ f"{self.data2.name}'s Celestial Bodies", self.celestial_body2
+ )
+ row2 += section(report.cross_ref.title, report.cross_ref.grid)
+ row3 = section("Signs", self.signs) + section("Houses", self.houses)
+ css = Path(__file__).parent / "report.css"
+ html = style(css.read_text()) + main(
+ div(row1, class_="row1")
+ + div(row2, class_="row2")
+ + div(row3, class_="row3")
+ )
+ return html
+
+ def create_pdf(self, html: str) -> BytesIO:
+ fp = BytesIO()
+ HTML(string=html).write_pdf(fp)
+ return fp
+
+
+# utils ======================================================================
+
+
+def html_table_of(grid: Grid) -> str:
+ rows = []
+ for row in grid:
+ cells = []
+ for cell in row:
+ if isinstance(cell, str) and cell.startswith("null:"):
+ cells.append(td(cell.split(":")[1], colspan=2))
+ else:
+ cells.append(td(cell))
+ rows.append(tr(cells))
+ return table(rows)
+
+
+def svg_of(name: str, scale: float = 0.5) -> str:
+ if not name:
+ return ""
+ stroke = TEXT_COLOR
+ fill = "none"
+ if name in ["mc", "asc", "dsc", "ic"]:
+ stroke = "none"
+ fill = TEXT_COLOR
+
+ return svg(
+ (Path(__file__).parent / "svg_paths" / f"{name}.svg").read_text(),
+ fill=fill,
+ stroke=stroke,
+ stroke_width=3 * scale,
+ version="1.1",
+ width=f"{20 * scale}px",
+ height=f"{20 * scale}px",
+ transform=f"scale({scale})",
+ xmlns="http://www.w3.org/2000/svg",
+ )
+
+
+def section(title: str, grid: Grid) -> str:
+ return div(
+ div(title, class_="title") + html_table_of(grid),
+ class_="section",
+ )
+
+
+# sample data ================================================================
+
+if __name__ == "__main__":
+ person1 = {
+ "name": "Shing",
+ "city": "Hong Kong",
+ "dt": "1976-04-20 18:58",
+ }
+
+ person2 = {
+ "name": "Belle",
+ "city": "Hong Kong",
+ "dt": "2011-01-23 08:44",
+ }
+
+ orb = Orb(
+ conjunction=2,
+ opposition=2,
+ trine=2,
+ square=2,
+ sextile=1,
+ )
+
+ data1 = Data(**person1, config=Config(theme_type="light", orb=orb))
+ data2 = Data(**person2)
+ report = Report(data1, data2)
+ fp = report.create_pdf(report.full_report)
+ with open("report.pdf", "wb") as f:
+ f.write(fp.getvalue())
diff --git a/natal/svg_paths/detriment.svg b/natal/svg_paths/detriment.svg
new file mode 100644
index 0000000..467c3d8
--- /dev/null
+++ b/natal/svg_paths/detriment.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/natal/svg_paths/domicile.svg b/natal/svg_paths/domicile.svg
new file mode 100644
index 0000000..0e2d2af
--- /dev/null
+++ b/natal/svg_paths/domicile.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/natal/svg_paths/exaltation.svg b/natal/svg_paths/exaltation.svg
new file mode 100644
index 0000000..7889e1b
--- /dev/null
+++ b/natal/svg_paths/exaltation.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/natal/svg_paths/fall.svg b/natal/svg_paths/fall.svg
new file mode 100644
index 0000000..8c24fdf
--- /dev/null
+++ b/natal/svg_paths/fall.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 82b732f..c1e87db 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "natal"
-version = "0.7.3"
+version = "0.7.4"
description = "create Natal Chart with ease"
license = "MIT"
repository = "https://github.com/hoishing/natal"