"""md2star command-line interface.
This module replaces the legacy heredoc'd shell wrappers (``scripts/install.sh``
used to write ``~/.local/bin/md2docx`` and ``~/.local/bin/md2pptx`` directly).
Now there is exactly one source of truth — this file — and four console
entry points (registered in ``pyproject.toml`` ``[project.scripts]``):
* ``md2docx <input.md> [options...]`` → :func:`md2docx_main`
* ``md2pptx <input.md> [options...]`` → :func:`md2pptx_main`
* ``md2pdf <input.md> [options...]`` → :func:`md2pdf_main`
* ``md2star <subcommand> [options...]`` → :func:`main`
The subcommand form (``md2star docx`` / ``md2star pptx`` / ``md2star pdf``)
is the canonical spelling; the ``md2docx`` / ``md2pptx`` / ``md2pdf`` aliases
exist so users do not have to relearn anything.
The CLI is intentionally thin: it parses md2star-specific flags, forwards
everything else verbatim to pandoc, and orchestrates the four-step pipeline:
1. **Preprocess** the input Markdown via :func:`md2star.preprocess_markdown`.
2. **Resolve the reference template**: prefer ``template.{docx,pptx}`` next
to the input file, then the legacy ``.pandoc-reference.{docx,pptx}``,
then the XDG cache, then (the default since v2.5.0) a one-shot fetch
from ``deraison.ai`` cached under XDG, and finally the bundled template.
``--no-remote-templates`` / ``--offline`` skip the fetch.
3. **Invoke pandoc** with the bundled Lua filter, metadata defaults, and
resolved reference doc.
4. **Postprocess** (DOCX only) — re-inject the ``MyTable`` / ``MyTableSmall``
table styles that Pandoc strips when rewriting ``word/styles.xml``.
The PDF format is implemented as a wrapper: it produces the DOCX first
(so all the md2star polish — mermaid, table styles, slide-aware tweaks —
applies) and then asks headless LibreOffice (``soffice --headless
--convert-to pdf``) to render that DOCX to PDF. This guarantees the PDF
visually matches the DOCX you would ship.
Author
------
[Warith HARCHAOUI](https://linkedin.com/in/warith-harchaoui/)
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from importlib import resources
from pathlib import Path
import os_helper as osh
from . import __version__
from .cache import cache_dir, clear_cache
from .errors import (
InvalidInputError,
Md2starError,
MissingDependencyError,
)
from .logging import configure as configure_logging
from .logging import get_logger
from .postprocess import inject_table_styles, strip_table_normal_for_pdf
from .preprocessing import preprocess_markdown
# Module logger — a dotted child of the root "md2star" logger, so it inherits
# the handler + level installed by ``configure_logging`` at CLI startup.
logger = get_logger(__name__)
# Per-format default reference template URL. Kept user-facing — when the URL
# changes you also want to update the README / CHANGELOG / install docs.
_TEMPLATE_URLS: dict[str, str] = {
"docx": "https://deraison.ai/template.docx",
"pptx": "https://deraison.ai/template.pptx",
}
# ─────────────────────────────────────────────────────────────────────
# Data-file resolution (Lua filter, defaults YAMLs, templates, mermaid cfg)
# ─────────────────────────────────────────────────────────────────────
def _data_path(*parts: str) -> Path:
"""Return an absolute filesystem path to a file under ``md2star/data/``.
``importlib.resources.files()`` returns a ``Traversable`` that resolves
to a real path for unzipped wheel installs and for editable installs;
on zipped installs we'd fall back to extracting via ``as_file()``, but
hatchling ships md2star as an unzipped wheel so direct resolution is
fine.
Components are joined one at a time: Python 3.10's
``MultiplexedPath.joinpath`` only accepts a single argument per call,
so the ``joinpath("filters", "md2star.lua")`` form blows up there.
"""
ref = resources.files("md2star.data")
for part in parts:
ref = ref.joinpath(part)
return Path(str(ref))
def _bundled_template(fmt: str) -> Path:
"""Return the absolute path to the bundled ``template.<fmt>`` shipped in the wheel."""
return _data_path(f"template.{fmt}")
# ─────────────────────────────────────────────────────────────────────
# Reference template auto-detection
# ─────────────────────────────────────────────────────────────────────
def _resolve_reference_doc(
input_path: Path,
fmt: str,
*,
allow_remote_templates: bool = True,
offline: bool = False,
) -> Path | None:
"""Resolve the per-project reference template for *input_path*.
Resolution order (highest priority first):
1. ``template.<fmt>`` next to the input file (committable, visible).
The "zero-config per-project branding" path.
2. ``.pandoc-reference.<fmt>`` next to the input file (legacy name;
still honoured with a deprecation notice).
3. ``$XDG_CACHE_HOME/md2star/templates/template.<fmt>`` — silently
reused once cached.
4. **(default)** Download from ``https://deraison.ai/template.<fmt>``
into the XDG cache. Since v2.5.0 this is the *default* branding
whenever no local template (steps 1-3) is found: the deraison.ai
template wins over the bundled fallback. Turn it off with
``--no-remote-templates`` (or the hard ``--offline`` kill-switch),
which drops straight to the bundled default. A failed download
(no network, 404, timeout) also falls back to bundled, so a
conversion never breaks just because deraison.ai is unreachable.
5. Bundled package template (``md2star/data/template.<fmt>``) — the
always-available offline fallback. Conversion always succeeds.
Returns ``None`` only if even the bundled template is missing (an
install bug), in which case the caller lets pandoc fall back to its
own built-in styling.
"""
in_dir = input_path.parent.resolve()
preferred = in_dir / f"template.{fmt}"
if preferred.exists():
return preferred
legacy = in_dir / f".pandoc-reference.{fmt}"
if legacy.exists():
# Still honoured, but nudge the user toward the current name.
logger.warning(
f"md2star: '.pandoc-reference.{fmt}' is deprecated; "
f"rename it to 'template.{fmt}'."
)
return legacy
cached = cache_dir("templates") / f"template.{fmt}"
if cached.exists():
return cached
url = _TEMPLATE_URLS.get(fmt)
if url is not None and allow_remote_templates and not offline:
try:
# os_helper streams the download straight to disk (flat memory,
# progress bar auto-suppressed off-TTY); progress=False keeps the
# CLI quiet since this is a small one-shot template fetch.
osh.download_file(url, str(cached), progress=False)
# Informational: confirm the (default) network fetch happened.
# INFO so it stays visible by default but --quiet can hide it.
logger.info(
f"md2star: cached default template from {url} → {cached}"
)
return cached
except Exception as exc:
# Leave no partial file behind.
cached.unlink(missing_ok=True)
logger.warning(
f"md2star: template download failed ({exc}); "
"falling back to the bundled default."
)
bundled = _bundled_template(fmt)
return bundled if bundled.exists() else None
# ─────────────────────────────────────────────────────────────────────
# Pandoc invocation
# ─────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────
# Localized bibliography heading
# ─────────────────────────────────────────────────────────────────────
# Per-language default heading for the bibliography section md2star
# appends when --bib is passed. The keys match the two-letter language
# prefix used elsewhere in md2star (see md2star/preprocessing/language.py
# and the Lua filter's locale_dicts). When the detected / explicit
# language isn't in this dict, we fall back to English. Translations
# verified against the standard heading used in academic style guides
# for each language.
_BIBLIOGRAPHY_HEADING_BY_LANG: dict[str, str] = {
"en": "Bibliography",
"fr": "Bibliographie",
"es": "Bibliografía",
"de": "Literatur",
"it": "Bibliografia",
"pt": "Bibliografia",
"nl": "Bibliografie",
"ru": "Библиография",
}
def _localized_bibliography_heading(content: str, explicit_lang: str | None) -> str:
"""Return the per-language default heading for the appended bibliography.
Precedence:
1. Explicit ``--lang`` flag (CLI override).
2. ``langdetect`` on the markdown body (best-effort).
3. English fallback ("Bibliography").
The function is intentionally side-effect-free so callers can wrap
it in tests without monkeypatching anything; the language detection
falls back silently when ``langdetect`` is not installed.
"""
lang_prefix = ""
if explicit_lang:
lang_prefix = explicit_lang.split("-")[0].lower()
else:
try:
from .preprocessing.language import get_language_metadata
meta = get_language_metadata(content)
if meta and meta.get("lang"):
lang_prefix = meta["lang"].split("-")[0].lower()
except Exception:
pass
return _BIBLIOGRAPHY_HEADING_BY_LANG.get(lang_prefix, "Bibliography")
def _require_pandoc() -> str:
"""Return the path to ``pandoc``, or exit with a clear message."""
path = shutil.which("pandoc")
if path is None:
raise MissingDependencyError(
"pandoc not found on PATH",
hint=(
"Install it from https://pandoc.org/installing.html "
"(macOS: `brew install pandoc`, Ubuntu: "
"`sudo apt install pandoc`, Windows: "
"`winget install --id JohnMacFarlane.Pandoc`). "
"Then re-run; `md2star doctor` confirms the install."
),
)
return path
def _run_pandoc(
fmt: str,
preprocessed_md: Path,
output: Path,
reference_doc: Path | None,
extra_args: list[str],
) -> int:
"""Spawn pandoc with our Lua filter + metadata, return its exit code."""
# _require_pandoc raises MissingDependencyError (→ exit 127) if absent.
pandoc = _require_pandoc()
# Base invocation: our bundled Lua filter + metadata defaults are always on.
cmd = [
pandoc,
str(preprocessed_md),
"-o", str(output),
"-t", fmt,
"--lua-filter", str(_data_path("filters", "md2star.lua")),
"--metadata-file", str(_data_path("metadata.yaml")),
]
if reference_doc is not None:
# The user can override us via --reference-doc in extra_args; pandoc
# uses the last value, so we always put ours first.
cmd.extend(["--reference-doc", str(reference_doc)])
# Verbatim pandoc pass-through comes last so users can override anything.
cmd.extend(extra_args)
return subprocess.call(cmd)
# ─────────────────────────────────────────────────────────────────────
# Argument parsing
# ─────────────────────────────────────────────────────────────────────
_FORMAT_HELP = {
"docx": "Microsoft Word (.docx)",
"pptx": "Microsoft PowerPoint (.pptx)",
"pdf": "Portable Document Format (.pdf, via headless LibreOffice)",
}
def _make_format_parser(fmt: str) -> argparse.ArgumentParser:
"""Build the per-format parser used by ``md2docx`` / ``md2pptx``."""
parser = argparse.ArgumentParser(
prog=f"md2{fmt}",
description=f"Markdown → {_FORMAT_HELP[fmt]} via md2star + Pandoc.",
epilog=(
"Unknown flags are forwarded to pandoc verbatim. "
"Run `pandoc --help` for the full list."
),
add_help=True,
)
# ── Positional + output ───────────────────────────────────────────
parser.add_argument("input", help="Path to the input .md file.")
parser.add_argument(
"-o", "--output",
help="Output path (default: <input>.<fmt>).",
)
# ── Document metadata forwarded to Pandoc ─────────────────────────
parser.add_argument("--author", help="Document author (Pandoc metadata).")
parser.add_argument(
"--bib",
help="Bibliography file (BibTeX). Enables --citeproc automatically.",
)
parser.add_argument(
"--bibliography-name",
default=None, # resolved below to the language-detected value
help=("Heading inserted before the bibliography. When omitted, "
"md2star uses the localized default for the document's "
"language ('Bibliography' / 'Bibliographie' / 'Bibliografía' "
"/ 'Literatur' / 'Bibliografia' / 'Bibliografie' / "
"'Библиография' / fallback 'Bibliography')."),
)
parser.add_argument("--lang", help="Document language metadata (BCP 47, e.g. en-US).")
parser.add_argument(
"--date",
help=("Override the auto-generated date in the subtitle. The string "
"you pass is used verbatim (e.g. '21 juin 2026', '2026-Q2', "
"'submitted 14 March'). When omitted, md2star formats today's "
"date via the language-aware `date_format` metadata."),
)
# ── Lint toggle (mutually exclusive so --lint/--no-lint can't clash) ─
lint_group = parser.add_mutually_exclusive_group()
lint_group.add_argument(
"--lint", action="store_true",
help=(
"Opt in to the Ollama LLM linter (off by default). Also fills "
"empty image alt text with a vision-model description, using the "
"model chosen by best-engine-ai-helper (override: "
"MD2STAR_ALT_TEXT_MODEL)."
),
)
lint_group.add_argument(
"--no-lint", action="store_true",
help="Explicit no-op (same as the default).",
)
parser.add_argument(
"--skip-phase", action="append", default=[], metavar="NAME",
help=(
"Skip a preprocessing phase. Repeatable. Known phases: "
"lint, remote_images, html_tables, html_images, absolutize, "
"image_assets, language, line_pass, table_resize, "
"table_normalize, image_widths, pptx_isolation."
),
)
parser.add_argument(
"--reference-doc",
help="Override the auto-detected reference template.",
)
# ── Network policy (offline-by-default since v1.2.0) ──────────
parser.add_argument(
"--offline", action="store_true",
help=("Forbid every network-touching phase (overrides "
"--allow-remote-*). Useful for air-gapped runs and to "
"make the refusal explicit in scripts."),
)
parser.add_argument(
"--no-remote-templates", action="store_true",
help=("Opt OUT of the deraison.ai default template. Since "
"v2.5.0 md2star fetches https://deraison.ai/template."
"{docx,pptx} whenever no local template.{docx,pptx} is "
"found, caching it under XDG. Pass this (or --offline) "
"to skip the fetch and use the bundled template."),
)
parser.add_argument(
# Back-compat no-op: remote templates are now the default, so the
# old opt-in flag has nothing left to enable. Accepted silently so
# existing scripts/CI that still pass it don't error out.
"--allow-remote-templates", action="store_true",
help=argparse.SUPPRESS,
)
parser.add_argument(
"--allow-remote-images", action="store_true",
help=("Opt in to downloading ```` images "
"referenced in the markdown. Off by default — the "
"preprocessor leaves remote refs in place and warns "
"once on stderr when any were skipped."),
)
# ── Verbosity (routes md2star's own diagnostics through logging) ──
# Mutually exclusive: --verbose lowers the threshold to DEBUG, --quiet
# raises it to ERROR. The default (neither) is INFO, which preserves the
# pre-logging behaviour where every diagnostic printed unconditionally.
verbosity_group = parser.add_mutually_exclusive_group()
verbosity_group.add_argument(
"-v", "--verbose", action="store_true",
help="Show debug-level diagnostics on stderr.",
)
verbosity_group.add_argument(
"-q", "--quiet", action="store_true",
help="Suppress info + warnings; only errors reach stderr.",
)
parser.add_argument(
"-V", "--version", action="version",
version=f"md2star {__version__}",
)
return parser
def _split_known(parser: argparse.ArgumentParser, argv: list[str]):
"""Parse known args, return ``(namespace, extras_for_pandoc)``."""
return parser.parse_known_args(argv)
# ─────────────────────────────────────────────────────────────────────
# Per-format entry points
# ─────────────────────────────────────────────────────────────────────
def _convert(fmt: str, argv: list[str]) -> int:
"""Run the full Markdown → .docx/.pptx/.pdf pipeline. Returns the exit code."""
parser = _make_format_parser(fmt)
args, pandoc_extras = _split_known(parser, argv)
# Wire the logging surface first, before any resolver/preprocess step can
# emit a diagnostic, so --verbose/--quiet take effect for the whole run.
configure_logging(verbose=args.verbose, quiet=args.quiet)
in_path = Path(args.input).expanduser().resolve()
if not in_path.exists():
raise InvalidInputError(
f"md2{fmt}: input file not found: {in_path}",
hint="md2star accepts a single .md / .markdown file as the first argument.",
)
out_path = (
Path(args.output).expanduser().resolve()
if args.output
else in_path.with_suffix(f".{fmt}")
)
# PDF detours through DOCX. The temp DOCX lives next to the requested
# PDF output so the user's source dir stays clean. We always use a
# sidecar `.md2star.tmp.docx` name when generating a PDF so we never
# stomp on a real .docx the user may already have. (An earlier
# version computed the sidecar only when `out_path.with_suffix(".docx")`
# already existed, but `Path.samefile(out_path)` raises FileNotFoundError
# when the PDF doesn't exist yet — a bug only the first invocation
# would hit.)
docx_fmt = "docx" if fmt == "pdf" else fmt
if fmt == "pdf":
docx_path = out_path.with_suffix(".md2star.tmp.docx")
else:
docx_path = out_path
# Build the pandoc metadata / flag list from our high-level options. Each
# is appended only when supplied, so unset options fall back to the
# template / metadata.yaml defaults rather than injecting empty values.
if args.author:
pandoc_extras.extend(["--metadata", f"author={args.author}"])
if args.lang:
pandoc_extras.extend(["--metadata", f"lang={args.lang}"])
if args.date:
# The Lua filter checks meta.date_override before falling back
# to the auto-localized date_format path; using our own key
# avoids colliding with Pandoc's own ``date`` metadata.
pandoc_extras.extend(["--metadata", f"date_override={args.date}"])
if args.bib:
pandoc_extras.extend(["--citeproc", "--bibliography", args.bib])
# Reference doc: explicit override > auto-detection.
# For PDF we look up the DOCX template since that is what feeds soffice.
reference_doc: Path | None
if args.reference_doc:
reference_doc = Path(args.reference_doc).expanduser().resolve()
else:
# Remote templates are the default since v2.5.0; --no-remote-templates
# (and the hard --offline switch, handled inside the resolver) opt out.
reference_doc = _resolve_reference_doc(
in_path, docx_fmt,
allow_remote_templates=not args.no_remote_templates,
offline=args.offline,
)
# 1. Preprocess
raw = in_path.read_text(encoding="utf-8")
processed = preprocess_markdown(
raw,
base_dir=str(in_path.parent),
lint_enabled=args.lint,
skip_phases=args.skip_phase,
allow_remote_images=args.allow_remote_images,
offline=args.offline,
)
# 2. Append the bibliography heading if requested. We do this on the
# preprocessed text rather than the source so the user's source file
# is never mutated. The heading text defaults to the localized
# "Bibliography" for the document's detected language (or the
# explicit --lang) — `args.bibliography_name` is None unless the
# user supplied it.
if args.bib:
heading = args.bibliography_name or _localized_bibliography_heading(
raw, args.lang,
)
processed = f"{processed}\n\n# {heading}\n"
# 3. Write the preprocessed Markdown to a temp file in the SAME dir as
# the input so any relative image paths still resolve. os_helper's
# temporary_filename(directory=...) owns creation + cleanup of the file.
with osh.temporary_filename(
suffix=".md", prefix=".preprocessed_", directory=str(in_path.parent)
) as temp_name:
temp_path = Path(temp_name)
Path(temp_path).write_text(processed, encoding="utf-8")
# 4. Run pandoc → DOCX (always, even for PDF).
rc = _run_pandoc(docx_fmt, temp_path, docx_path, reference_doc, pandoc_extras)
if rc != 0:
return rc
# 5. DOCX-only postprocess: re-inject MyTable / MyTableSmall styles.
if docx_fmt == "docx":
try:
inject_table_styles(str(docx_path))
except Exception as exc:
logger.warning(
f"md2star warning: postprocess failed ({exc}); "
"the .docx is otherwise complete."
)
# 6. PDF-only: shell out to headless LibreOffice. The intermediate
# DOCX is correct as-is for Word, but the bundled template's
# ``TableNormal0`` style triggers a soffice render bug where
# cells leak out of the table. We mutate the intermediate
# DOCX (zip-level) to drop ``TableNormal0`` so soffice falls
# back to its default table rendering. The user's actual DOCX
# output (md2docx) is never touched. Documented in
# CHANGELOG v1.1.1 + v2.0.0.
if fmt == "pdf":
try:
strip_table_normal_for_pdf(str(docx_path))
except Exception as exc:
logger.warning(
f"md2star warning: TableNormal0 strip failed ({exc}); "
"PDF tables may render with empty cells."
)
rc = _convert_docx_to_pdf(docx_path, out_path)
# Always remove the intermediate DOCX — the user asked for a PDF.
docx_path.unlink(missing_ok=True)
if rc != 0:
return rc
print(f"Wrote: {out_path}")
return 0
# ─────────────────────────────────────────────────────────────────────
# PDF helper — headless LibreOffice (soffice) bridge
# ─────────────────────────────────────────────────────────────────────
def _find_soffice() -> str | None:
"""Return the absolute path to ``soffice``, or None if not found.
Looks first on ``PATH`` (covers Homebrew / Linux package installs),
then in the standard macOS LibreOffice.app bundle location.
"""
path = shutil.which("soffice") or shutil.which("libreoffice")
if path:
return path
mac_app = "/Applications/LibreOffice.app/Contents/MacOS/soffice"
if os.path.exists(mac_app):
return mac_app
return None
def _convert_docx_to_pdf(docx_path: Path, pdf_path: Path) -> int:
"""Render *docx_path* to *pdf_path* via headless LibreOffice. Returns rc."""
soffice = _find_soffice()
if soffice is None:
raise MissingDependencyError(
"md2pdf: LibreOffice (`soffice`) not found",
hint=(
"Install it to enable PDF output:\n"
" macOS: brew install --cask libreoffice\n"
" Ubuntu: sudo apt install libreoffice\n"
" Windows: winget install --id TheDocumentFoundation.LibreOffice\n"
" other: https://www.libreoffice.org/download/"
),
)
# soffice writes a sibling file next to the input — we cannot control
# the exact output name, only its directory. We render into a tempdir
# and move the result to the user's requested path so the API stays
# "input → output" with no surprises.
with osh.temporary_folder(prefix="md2pdf-") as workdir:
cmd = [
soffice,
"--headless",
"--convert-to", "pdf",
"--outdir", workdir,
str(docx_path),
]
try:
proc = subprocess.run(
cmd, capture_output=True, timeout=120, check=False,
)
except subprocess.TimeoutExpired:
logger.error("md2pdf: LibreOffice timed out after 120s.")
return 1
if proc.returncode != 0:
stderr = proc.stderr.decode("utf-8", errors="replace").strip()
logger.error(
f"md2pdf: LibreOffice exited {proc.returncode}: {stderr}"
)
return proc.returncode
produced = Path(workdir) / (docx_path.stem + ".pdf")
if not produced.exists():
logger.error(
f"md2pdf: LibreOffice did not produce {produced.name}. "
f"stderr: {proc.stderr.decode(errors='replace')!r}"
)
return 1
osh.make_directory(str(pdf_path.parent))
shutil.move(str(produced), str(pdf_path))
return 0
def _render_error(exc: Md2starError) -> None:
"""Pretty-print a typed exception to stderr — headline + indented hint.
Replaces the raw Python traceback with a two-block message:
md2star: <headline error>
<hint, indented and word-wrapped at the natural newlines>
Called by the top-level handler in each ``md2{docx,pptx,pdf}_main``
so users see actionable text instead of a traceback when the
failure mode is something we already anticipated (see
:mod:`md2star.errors`).
"""
# Headline goes at error level; each hint line is emitted separately so
# the "%(message)s" formatter keeps the original two-block, indented
# layout (no per-line "md2star:" prefix on the wrapped hint).
logger.error(f"md2star: {exc}")
if exc.hint:
for line in exc.hint.splitlines():
logger.error(f" {line}")
def _run_format(fmt: str, argv: list[str] | None) -> int:
"""Top-level wrapper around :func:`_convert` for the four console entries.
Catches every :class:`Md2starError` subclass and renders it via
:func:`_render_error`, returning a non-zero exit code. Anything
that doesn't subclass :class:`Md2starError` is left to bubble up
as a Python traceback — that's a real bug we want users to file.
"""
try:
return _convert(fmt, list(sys.argv[1:] if argv is None else argv))
except KeyboardInterrupt:
# Ctrl-C during a long conversion (Pandoc, a Mermaid render, a
# LibreOffice PDF pass) should read as a clean stop, not a scary
# traceback. 130 = shell 128 + SIGINT(2), the conventional exit
# code so callers/scripts can distinguish an interrupt from a
# real failure.
logger.warning("md2star: interrupted.")
return 130
except Md2starError as exc:
_render_error(exc)
# Exit code conventions: 127 for missing system deps (mirrors
# the shell convention), 2 for invalid CLI input, 1 otherwise.
if isinstance(exc, MissingDependencyError):
return 127
if isinstance(exc, InvalidInputError):
return 2
return 1
[docs]
def md2docx_main(argv: list[str] | None = None) -> int:
"""Console entry point: ``md2docx``."""
return _run_format("docx", argv)
[docs]
def md2pptx_main(argv: list[str] | None = None) -> int:
"""Console entry point: ``md2pptx``."""
return _run_format("pptx", argv)
[docs]
def md2pdf_main(argv: list[str] | None = None) -> int:
"""Console entry point: ``md2pdf``."""
return _run_format("pdf", argv)
# ─────────────────────────────────────────────────────────────────────
# Top-level ``md2star`` entry point
# ─────────────────────────────────────────────────────────────────────
[docs]
def main(argv: list[str] | None = None) -> int:
"""Console entry point: ``md2star <subcommand>``.
Subcommands:
* ``docx`` / ``pptx`` / ``pdf`` — convert (same flags as the standalone
aliases ``md2docx`` / ``md2pptx`` / ``md2pdf``).
* ``gui`` — launch the local Overleaf-style Markdown → PDF editor.
* ``doctor`` — print an environment diagnostic.
* ``cache-dir`` — print the resolved cache directory path.
* ``clear-cache`` — wipe the cache directory.
"""
argv = list(sys.argv[1:] if argv is None else argv)
# Install the logging handler with defaults so the dispatch-level paths
# below (unknown subcommand, etc.) have a stderr sink. The conversion
# subcommands re-invoke configure() with their parsed --verbose/--quiet;
# configure() is idempotent, so that just updates the level.
configure_logging()
# No args or -h → help; -V → version. Both are terminal, exit 0.
if not argv or argv[0] in ("-h", "--help"):
_print_top_level_help()
return 0
if argv[0] in ("-V", "--version"):
# Version is program output → stdout via print, not the logger.
print(f"md2star {__version__}")
return 0
# First token selects the subcommand; the rest is its own argv.
sub, rest = argv[0], argv[1:]
# Conversion subcommands delegate to the same entry points the standalone
# md2docx/md2pptx/md2pdf console scripts use — one code path, no drift.
if sub == "docx":
return md2docx_main(rest)
if sub == "pptx":
return md2pptx_main(rest)
if sub == "pdf":
return md2pdf_main(rest)
# Non-conversion subcommands import lazily so a plain ``md2docx`` run never
# pays the import cost of doctor/templates it won't use.
if sub == "doctor":
from .doctor import main as doctor_main
return doctor_main(rest)
if sub == "templates":
from .templates import main as templates_main
return templates_main(rest)
if sub == "twin":
# Reverse direction: any document → an editable Markdown twin. Lazy
# import so a plain forward conversion never loads Kreuzberg/AI code.
from .twin_cli import main as twin_main
return twin_main(rest)
if sub == "gui":
# The GUI ships its ~4 MB vendored frontend (PDF.js, CodeMirror,
# Tailwind, fonts) inside md2star/data/gui/. Import is lazy so a
# plain md2docx run never pays for the http.server machinery.
from .gui_server import main as gui_main
return gui_main(rest)
# cache-dir / clear-cache both emit their result to stdout (script-friendly).
if sub == "cache-dir":
print(cache_dir())
return 0
if sub == "clear-cache":
freed = clear_cache()
print(f"md2star: cleared cache ({freed / 1024:.1f} KiB freed)")
return 0
logger.error(f"md2star: unknown subcommand {sub!r}")
# Help text (not a diagnostic) still goes straight to stderr via print.
_print_top_level_help(file=sys.stderr)
return 2
def _print_top_level_help(file=sys.stdout) -> None:
"""Print the top-level usage banner for the ``md2star`` dispatcher.
Parameters
----------
file : TextIO, optional
Stream the banner is written to. Defaults to ``sys.stdout``; the
error path passes ``sys.stderr`` so the help does not contaminate a
piped stdout payload.
"""
print(
f"md2star {__version__} — Markdown → DOCX/PPTX/PDF bridge\n\n"
"Usage:\n"
" md2star docx <input.md> [options...]\n"
" md2star pptx <input.md> [options...]\n"
" md2star pdf <input.md> [options...]\n"
" md2star gui [--port N] [--no-browser] [--bind ADDR]\n"
" md2star twin <input.(pdf|docx|pptx|...)> [--out DIR] [--diagrams]\n"
" md2star doctor [--json]\n"
" md2star templates {list,path} [...]\n"
" md2star cache-dir\n"
" md2star clear-cache\n\n"
"Shorthand aliases:\n"
" md2docx <input.md> [options...]\n"
" md2pptx <input.md> [options...]\n"
" md2pdf <input.md> [options...]\n\n"
"Run `md2docx --help` (or md2pptx/md2pdf) for full per-format options.",
file=file,
)
if __name__ == "__main__":
sys.exit(main())