Skip to content

Commit

Permalink
feat: cli & export to pdf
Browse files Browse the repository at this point in the history
  • Loading branch information
oliverlambson committed Sep 1, 2024
1 parent 44054f4 commit 4664ed1
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 1 deletion.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ __pycache__
node_modules/

boredcharts/static/plotlyjs.min.js
*.pdf
66 changes: 66 additions & 0 deletions bored-charts/boredcharts/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import argparse
import asyncio
from pathlib import Path

from boredcharts.pdf import UrlToPdfFile, print_to_pdf_manual


def init() -> None:
"""create a new project scaffolding"""
raise NotImplementedError


def list_() -> None:
"""list available reports"""
raise NotImplementedError


def export(report: str, *, writer: UrlToPdfFile = print_to_pdf_manual) -> None:
"""write to pdf
TODO:
- [x] write to pdf
- [ ] spin up server
- [ ] provide list of reports
"""
url = f"http://localhost:4000/{report}" # TODO: spin up server & replace base url
file = Path(report).absolute().with_suffix(".pdf")
asyncio.run(writer(url, file))
print(f"Exported {report} to {file}")


def dev() -> None:
"""run uvicorn with reload"""
raise NotImplementedError


def serve() -> None:
"""run uvicorn without reload"""
raise NotImplementedError


def main() -> None:
parser = argparse.ArgumentParser(description="boredcharts CLI")
subparsers = parser.add_subparsers(dest="command")
subparsers.required = True

parser_init = subparsers.add_parser("init", help="Create a new project scaffolding")
parser_init.set_defaults(func=init)

parser_init = subparsers.add_parser("list", help="List available reports")
parser_init.set_defaults(func=list_)

parser_export = subparsers.add_parser("export", help="Write to PDF")
parser_export.add_argument("report", type=str, help="The report to export")
parser_export.set_defaults(func=export)

parser_dev = subparsers.add_parser("dev", help="Run uvicorn with reload")
parser_dev.set_defaults(func=dev)

parser_serve = subparsers.add_parser("serve", help="Run uvicorn without reload")
parser_serve.set_defaults(func=serve)

args = parser.parse_args()

func_args = {k: v for k, v in vars(args).items() if k != "func" and k != "command"}
args.func(**func_args)
92 changes: 92 additions & 0 deletions bored-charts/boredcharts/pdf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import asyncio
import json
import subprocess
import tempfile
from pathlib import Path
from typing import Protocol

from playwright.async_api import async_playwright


class UrlToPdfFile(Protocol):
async def __call__(self, url: str, file: Path) -> None: ...


async def print_to_pdf_manual(url: str, file: Path) -> None:
"""this one seems to work the best"""
async with async_playwright() as p:
args = [
"--headless=new",
"--virtual-time-budget=10000", # seems to wait for ajax too?
"--run-all-compositor-stages-before-draw", # also recommended, dunno
"--no-pdf-header-footer",
f"--print-to-pdf={file.as_posix()}",
url,
]
process = await asyncio.create_subprocess_exec(
p.chromium.executable_path,
*args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
_, stderr = await process.communicate()

if process.returncode != 0:
raise ChildProcessError(f"Could not export to pdf {stderr.decode()}")


async def print_to_pdf_pw_adv(url: str, file: Path) -> None:
# in headless mode this doesn't seem to actually download the pdf
prefs = {
"printing": {
"print_preview_sticky_settings": {
"appState": json.dumps(
{
"version": 2,
"recentDestinations": [
{"id": "Save as PDF", "origin": "local", "account": ""}
],
"selectedDestinationId": "Save as PDF",
"isHeaderFooterEnabled": False,
}
)
}
},
}
with tempfile.TemporaryDirectory() as pref_dir:
pref_file = Path(pref_dir) / "Default" / "Preferences"
pref_file.parent.mkdir(parents=True, exist_ok=True)
with pref_file.open("w") as f:
json.dump(prefs, f)

async with async_playwright() as p:
context = await p.chromium.launch_persistent_context(
user_data_dir=pref_file.parent.parent,
ignore_default_args=["--headless"],
args=[
"--headless=new",
"--kiosk-printing",
],
)
page = await context.new_page()

await page.goto(url, wait_until="networkidle")
await page.evaluate("window.print();")
await context.close()


async def print_to_pdf_pw_basic(url: str, file: Path) -> None:
# playwright's built-in pdf export results in text that can't be selected well
async with async_playwright() as p:
browser = await p.chromium.launch()
context = await browser.new_context()
page = await context.new_page()

await page.goto(url, wait_until="networkidle")
await page.pdf(
path=file,
format="A4",
margin={"top": "1cm", "right": "1cm", "bottom": "1cm", "left": "1cm"},
)
await context.close()
await browser.close()
4 changes: 4 additions & 0 deletions bored-charts/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"plotly>=5.23.0",
"altair>=5.4.0",
"seaborn>=0.13.2",
"playwright>=1.46.0",
]
readme = "README.md"
license = "MIT"
Expand Down Expand Up @@ -47,6 +48,9 @@ classifiers = [
Repository = "https://github.com/oliverlambson/bored-charts.git"
Issues = "https://github.com/oliverlambson/bored-charts/issues"

[project.scripts]
boredcharts = "boredcharts.cli:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Expand Down
51 changes: 50 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 4664ed1

Please sign in to comment.