Skip to content

Commit 4664ed1

Browse files
committed
feat: cli & export to pdf
1 parent 44054f4 commit 4664ed1

File tree

5 files changed

+213
-1
lines changed

5 files changed

+213
-1
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ __pycache__
1010
node_modules/
1111

1212
boredcharts/static/plotlyjs.min.js
13+
*.pdf

bored-charts/boredcharts/cli.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import argparse
2+
import asyncio
3+
from pathlib import Path
4+
5+
from boredcharts.pdf import UrlToPdfFile, print_to_pdf_manual
6+
7+
8+
def init() -> None:
9+
"""create a new project scaffolding"""
10+
raise NotImplementedError
11+
12+
13+
def list_() -> None:
14+
"""list available reports"""
15+
raise NotImplementedError
16+
17+
18+
def export(report: str, *, writer: UrlToPdfFile = print_to_pdf_manual) -> None:
19+
"""write to pdf
20+
21+
TODO:
22+
- [x] write to pdf
23+
- [ ] spin up server
24+
- [ ] provide list of reports
25+
"""
26+
url = f"http://localhost:4000/{report}" # TODO: spin up server & replace base url
27+
file = Path(report).absolute().with_suffix(".pdf")
28+
asyncio.run(writer(url, file))
29+
print(f"Exported {report} to {file}")
30+
31+
32+
def dev() -> None:
33+
"""run uvicorn with reload"""
34+
raise NotImplementedError
35+
36+
37+
def serve() -> None:
38+
"""run uvicorn without reload"""
39+
raise NotImplementedError
40+
41+
42+
def main() -> None:
43+
parser = argparse.ArgumentParser(description="boredcharts CLI")
44+
subparsers = parser.add_subparsers(dest="command")
45+
subparsers.required = True
46+
47+
parser_init = subparsers.add_parser("init", help="Create a new project scaffolding")
48+
parser_init.set_defaults(func=init)
49+
50+
parser_init = subparsers.add_parser("list", help="List available reports")
51+
parser_init.set_defaults(func=list_)
52+
53+
parser_export = subparsers.add_parser("export", help="Write to PDF")
54+
parser_export.add_argument("report", type=str, help="The report to export")
55+
parser_export.set_defaults(func=export)
56+
57+
parser_dev = subparsers.add_parser("dev", help="Run uvicorn with reload")
58+
parser_dev.set_defaults(func=dev)
59+
60+
parser_serve = subparsers.add_parser("serve", help="Run uvicorn without reload")
61+
parser_serve.set_defaults(func=serve)
62+
63+
args = parser.parse_args()
64+
65+
func_args = {k: v for k, v in vars(args).items() if k != "func" and k != "command"}
66+
args.func(**func_args)

bored-charts/boredcharts/pdf.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import asyncio
2+
import json
3+
import subprocess
4+
import tempfile
5+
from pathlib import Path
6+
from typing import Protocol
7+
8+
from playwright.async_api import async_playwright
9+
10+
11+
class UrlToPdfFile(Protocol):
12+
async def __call__(self, url: str, file: Path) -> None: ...
13+
14+
15+
async def print_to_pdf_manual(url: str, file: Path) -> None:
16+
"""this one seems to work the best"""
17+
async with async_playwright() as p:
18+
args = [
19+
"--headless=new",
20+
"--virtual-time-budget=10000", # seems to wait for ajax too?
21+
"--run-all-compositor-stages-before-draw", # also recommended, dunno
22+
"--no-pdf-header-footer",
23+
f"--print-to-pdf={file.as_posix()}",
24+
url,
25+
]
26+
process = await asyncio.create_subprocess_exec(
27+
p.chromium.executable_path,
28+
*args,
29+
stdout=subprocess.PIPE,
30+
stderr=subprocess.PIPE,
31+
)
32+
_, stderr = await process.communicate()
33+
34+
if process.returncode != 0:
35+
raise ChildProcessError(f"Could not export to pdf {stderr.decode()}")
36+
37+
38+
async def print_to_pdf_pw_adv(url: str, file: Path) -> None:
39+
# in headless mode this doesn't seem to actually download the pdf
40+
prefs = {
41+
"printing": {
42+
"print_preview_sticky_settings": {
43+
"appState": json.dumps(
44+
{
45+
"version": 2,
46+
"recentDestinations": [
47+
{"id": "Save as PDF", "origin": "local", "account": ""}
48+
],
49+
"selectedDestinationId": "Save as PDF",
50+
"isHeaderFooterEnabled": False,
51+
}
52+
)
53+
}
54+
},
55+
}
56+
with tempfile.TemporaryDirectory() as pref_dir:
57+
pref_file = Path(pref_dir) / "Default" / "Preferences"
58+
pref_file.parent.mkdir(parents=True, exist_ok=True)
59+
with pref_file.open("w") as f:
60+
json.dump(prefs, f)
61+
62+
async with async_playwright() as p:
63+
context = await p.chromium.launch_persistent_context(
64+
user_data_dir=pref_file.parent.parent,
65+
ignore_default_args=["--headless"],
66+
args=[
67+
"--headless=new",
68+
"--kiosk-printing",
69+
],
70+
)
71+
page = await context.new_page()
72+
73+
await page.goto(url, wait_until="networkidle")
74+
await page.evaluate("window.print();")
75+
await context.close()
76+
77+
78+
async def print_to_pdf_pw_basic(url: str, file: Path) -> None:
79+
# playwright's built-in pdf export results in text that can't be selected well
80+
async with async_playwright() as p:
81+
browser = await p.chromium.launch()
82+
context = await browser.new_context()
83+
page = await context.new_page()
84+
85+
await page.goto(url, wait_until="networkidle")
86+
await page.pdf(
87+
path=file,
88+
format="A4",
89+
margin={"top": "1cm", "right": "1cm", "bottom": "1cm", "left": "1cm"},
90+
)
91+
await context.close()
92+
await browser.close()

bored-charts/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies = [
1212
"plotly>=5.23.0",
1313
"altair>=5.4.0",
1414
"seaborn>=0.13.2",
15+
"playwright>=1.46.0",
1516
]
1617
readme = "README.md"
1718
license = "MIT"
@@ -47,6 +48,9 @@ classifiers = [
4748
Repository = "https://github.com/oliverlambson/bored-charts.git"
4849
Issues = "https://github.com/oliverlambson/bored-charts/issues"
4950

51+
[project.scripts]
52+
boredcharts = "boredcharts.cli:main"
53+
5054
[build-system]
5155
requires = ["hatchling"]
5256
build-backend = "hatchling.build"

uv.lock

Lines changed: 50 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)