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"