Source code for md2star.twin_cli

"""``md2star twin`` — read any document back into an editable Markdown twin.

The forward path turns Markdown into a polished DOCX/PPTX/PDF. This is the
inverse *as a document*, not just as text: given a PDF (or anything LibreOffice
can convert to one), it recovers prose + GFM tables, scrapes every embedded
raster back out as PNGs, and — with ``--diagrams`` — re-authors node-and-edge
figures as Mermaid via the target-matching eyeball loop. The result is a
``<stem>.md`` plus an ``assets/`` folder you can edit and re-render.

Thin argparse adapter over :func:`md2star.reverse.to_markdown_twin` and
:func:`md2star.reverse_diagrams.make_diagram_handler`; kept out of
:mod:`md2star.cli` so the reverse feature imports lazily and the dispatcher
stays small.


Author
------
[Warith HARCHAOUI](https://linkedin.com/in/warith-harchaoui/)
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

from .errors import Md2starError
from .logging import configure as configure_logging
from .logging import get_logger

# Module logger — child of the root "md2star" logger (configured by the CLI).
logger = get_logger(__name__)


def _build_parser() -> argparse.ArgumentParser:
    """Construct the ``md2star twin`` argument parser."""
    parser = argparse.ArgumentParser(
        prog="md2star twin",
        description="Read a document back into an editable Markdown twin "
        "(prose + tables + scraped images; optional Mermaid diagrams).",
    )
    # The single positional: any PDF, or a format LibreOffice can print to PDF.
    parser.add_argument("input", help="Document to recover (PDF, or convertible to one).")
    # Output folder rather than a file: the twin is a .md *plus* an assets/ dir,
    # so callers point at a directory and we own the two names inside it.
    parser.add_argument(
        "--out",
        metavar="DIR",
        default=None,
        help="Output folder for <stem>.md + assets/ (default: the input's folder).",
    )
    # Escape hatch to the classic text-only recovery (no rasters written).
    parser.add_argument(
        "--no-images",
        action="store_true",
        help="Recover prose + tables only; drop scraped rasters.",
    )
    # The one flag that pulls in the AI stack; off by default so the twin stays
    # deterministic and dependency-light unless the user asks for reconstruction.
    parser.add_argument(
        "--diagrams",
        action="store_true",
        help="Re-author figures via the local VLM eyeball loop — node-and-edge "
        "diagrams as Mermaid, other vector figures as SVG (needs the [ai] stack "
        "+ a running Ollama).",
    )
    # Model override; when absent the suite picker (best-engine) chooses per host.
    parser.add_argument(
        "--model",
        metavar="TAG",
        default=None,
        help="Vision model tag for --diagrams (default: the suite picker's choice).",
    )
    # Cap on render→compare→revise cycles so a stubborn diagram can't spin forever.
    parser.add_argument(
        "--max-iterations",
        type=int,
        default=3,
        metavar="N",
        help="Eyeball-loop budget per diagram (default: 3).",
    )
    # Standard verbosity pair, wired through the shared logging configuration.
    parser.add_argument("-v", "--verbose", action="store_true", help="Debug logging.")
    parser.add_argument("-q", "--quiet", action="store_true", help="Warnings and errors only.")
    return parser


[docs] def main(argv: list[str] | None = None) -> int: """Console entry point for ``md2star twin``. Returns a process exit code.""" args = _build_parser().parse_args(sys.argv[1:] if argv is None else argv) configure_logging(verbose=args.verbose, quiet=args.quiet) src = Path(args.input) # Default the output next to the input so a bare ``md2star twin report.pdf`` # drops ``report.md`` + ``assets/`` right where the source lives. out_dir = Path(args.out) if args.out else (src.parent if src.parent != Path("") else Path(".")) # Only build the AI handler when --diagrams is requested; without it the twin # is fully deterministic and needs neither Ollama nor best-engine. image_handler = None if args.diagrams: from .reverse_diagrams import diagrams_available, make_diagram_handler if not diagrams_available(args.model): # Don't fail the whole run — warn and continue with plain PNGs, since # the deterministic twin is still valuable without reconstruction. logger.warning( "md2star twin: --diagrams requested but the AI stack is unavailable " "(Ollama not installed/running, or the model isn't pulled); " "keeping scraped images as PNGs." ) else: image_handler = make_diagram_handler( model=args.model, max_iterations=args.max_iterations ) try: from .reverse import to_markdown_twin md_path = to_markdown_twin( src, out_dir, extract_images=not args.no_images, image_handler=image_handler, ) except FileNotFoundError as exc: logger.error("md2star twin: %s", exc) return 2 except Md2starError as exc: # Typed errors (e.g. LibreOffice missing for a non-native input) carry a # headline + hint; surface both without a traceback. logger.error("md2star twin: %s", exc) if getattr(exc, "hint", None): logger.error(" %s", exc.hint) return 127 except RuntimeError as exc: logger.error("md2star twin: %s", exc) return 1 else: # Program output → stdout so the path is pipeable; logs went to stderr. print(md_path) return 0
if __name__ == "__main__": # pragma: no cover - module executed via md2star twin sys.exit(main())