Convert (flagged) articles in bulletty to PDF
January 9, 2026 ยท View on GitHub
#!/usr/bin/env python3
Convert (flagged) articles in bulletty to PDF
import pypandoc import toml import frontmatter import re
from urllib.parse import urljoin, urlparse from pathlib import Path from optparse import OptionParser from appdirs import AppDirs
def replace_relative_urls(text, base_url): pattern = r'[[^]]]((.?)\s*("(?:.[^"])")?\s)' return re.sub(pattern, lambda match: f'{match.group(0).split("[0][1:]}]({urljoin(base_url, match.group(1))})', text)
def read_md(file_path: Path): frontmattered = False frontmatter = False
text = ""
with open(file_path) as f:
for l in f:
line = l.strip()
if not frontmattered and line == "---":
if frontmatter:
frontmattered = True
frontmatter = not frontmatter
if frontmatter:
line = line.replace(' =', ':')
text += line + '\n'
return text
def postprocess_md(text: str): fm = frontmatter.loads(text)
# better formatting
fm["urlcolor"] = "Maroon"
# link back to original URL
fm["subtitle"] = "[%s](%s)" % (fm["url"], fm["url"])
# fix relative image URLs
base_url = fm["url"]
if base_url.endswith('.html'):
base_url = '/'.join(base_url.split('/')[:-1])
else:
base_url = '/'.join(base_url.split('/')[:-1])
fm.content = replace_relative_urls(fm.content, base_url)
return frontmatter.dumps(fm)
def make_pdf(file_path: Path, out_path: Path, regenerate = True): pdf_path = out_path / Path(file_path.name.replace('.md', '.pdf')) if regenerate or not pdf_path.exists(): text = read_md(file_path) text = postprocess_md(text)
print("Converting %s" % (file_path.name))
try:
pypandoc.convert_text(text, 'pdf', format='md', outputfile=pdf_path, extra_args=["--pdf-engine=xelatex"])
except RuntimeError as e:
print("Error, skipping")
print(e)
def convert_cache(cache_path: Path, out_path: Path, flagged_only = True, regenerate = False): if flagged_only: later = toml.load(bulletty_cache / '.later.toml') for f in later['read_later']: md_file = cache_path / 'categories' / f make_pdf(md_file, out_path, regenerate) else: for md_file in cache_path.rglob('*.md'): make_pdf(md_file, out_path, regenerate)
if name == 'main': parser = OptionParser()
# Adding boolean options
parser.add_option("-f", "--flaggedonly", action="store_true", default=True, help="Only convert read-later flagged articles")
parser.add_option("-r", "--regenerate", action="store_true", default=False, help="Force regeneration articles")
parser.add_option("-o", "--outdir", help="Specify path for PDF output")
parser.add_option("-c", "--cachedir", help="Specify bulletty cache directory (leave empty to detect automatically)")
(options, args) = parser.parse_args()
cache_dir = options.cachedir if options.cachedir else AppDirs("bulletty").user_data_dir
bulletty_cache = Path(cache_dir)
if not options.outdir:
print("Error: Please specify an output directory!\n")
parser.print_help()
exit()
convert_cache(bulletty_cache, options.outdir, options.flaggedonly, options.regenerate)