From 7dd0037ae9e3bb4b68f3033788e0c85431d9a899 Mon Sep 17 00:00:00 2001 From: Nordflint Date: Thu, 26 Mar 2026 09:08:08 +0100 Subject: [PATCH 1/2] Add CLI-Anything python-docx harness with advanced table formatting --- agent-harness/.gitignore | 6 + agent-harness/PYTHON_DOCX.md | 31 ++ agent-harness/TEST.md | 23 + .../cli_anything/python_docx/README.md | 57 +++ .../cli_anything/python_docx/__init__.py | 5 + .../cli_anything/python_docx/__main__.py | 9 + .../cli_anything/python_docx/core/__init__.py | 3 + .../cli_anything/python_docx/core/session.py | 308 +++++++++++++ .../python_docx/python_docx_cli.py | 419 ++++++++++++++++++ .../python_docx/tests/test_core.py | 113 +++++ .../python_docx/tests/test_full_e2e.py | 151 +++++++ .../python_docx/utils/__init__.py | 3 + .../python_docx/utils/python_docx_backend.py | 32 ++ agent-harness/setup.py | 30 ++ 14 files changed, 1190 insertions(+) create mode 100644 agent-harness/.gitignore create mode 100644 agent-harness/PYTHON_DOCX.md create mode 100644 agent-harness/TEST.md create mode 100644 agent-harness/cli_anything/python_docx/README.md create mode 100644 agent-harness/cli_anything/python_docx/__init__.py create mode 100644 agent-harness/cli_anything/python_docx/__main__.py create mode 100644 agent-harness/cli_anything/python_docx/core/__init__.py create mode 100644 agent-harness/cli_anything/python_docx/core/session.py create mode 100644 agent-harness/cli_anything/python_docx/python_docx_cli.py create mode 100644 agent-harness/cli_anything/python_docx/tests/test_core.py create mode 100644 agent-harness/cli_anything/python_docx/tests/test_full_e2e.py create mode 100644 agent-harness/cli_anything/python_docx/utils/__init__.py create mode 100644 agent-harness/cli_anything/python_docx/utils/python_docx_backend.py create mode 100644 agent-harness/setup.py diff --git a/agent-harness/.gitignore b/agent-harness/.gitignore new file mode 100644 index 000000000..e6c864220 --- /dev/null +++ b/agent-harness/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.egg-info/ +.pytest_cache/ diff --git a/agent-harness/PYTHON_DOCX.md b/agent-harness/PYTHON_DOCX.md new file mode 100644 index 000000000..3cc305977 --- /dev/null +++ b/agent-harness/PYTHON_DOCX.md @@ -0,0 +1,31 @@ +# PYTHON_DOCX Harness + +Target software: `python-docx` +Source path: `python-docx/` + +## Scope + +This harness exposes high-value document operations from `python-docx` as a stateful CLI: + +- create/open/save documents +- add paragraph, heading, and table content +- update core metadata properties +- inspect summary and paragraph lists +- run with one-shot subcommands or interactive REPL +- emit machine-readable JSON output using `--json` +- support undo/redo in-session via in-memory `.docx` snapshots + +## Backend strategy + +The harness wraps the real `python-docx` APIs in `utils/python_docx_backend.py` and does not reimplement `.docx` behavior. + +## Session model + +A `DocxSession` instance tracks: + +- active in-memory document +- current path (if saved/opened) +- undo stack +- redo stack + +Undo and redo operate on serialized `.docx` bytes captured before each mutating operation. \ No newline at end of file diff --git a/agent-harness/TEST.md b/agent-harness/TEST.md new file mode 100644 index 000000000..c24b6c94c --- /dev/null +++ b/agent-harness/TEST.md @@ -0,0 +1,23 @@ +# Test Plan + +This harness includes two test layers: + +1. `test_core.py` +- validates `DocxSession` state transitions +- confirms mutating operations alter document state correctly +- verifies undo/redo stack behavior +- checks save/open round-trip and metadata writes + +2. `test_full_e2e.py` +- exercises the installed CLI command via subprocess +- verifies one-shot JSON output and persisted `.docx` edits +- verifies default REPL path and in-session undo + +## Planned validation commands + +From `python-docx/agent-harness`: + +```bash +python -m pip install -e . +python -m pytest -q cli_anything/python_docx/tests +``` \ No newline at end of file diff --git a/agent-harness/cli_anything/python_docx/README.md b/agent-harness/cli_anything/python_docx/README.md new file mode 100644 index 000000000..bdbc6f99f --- /dev/null +++ b/agent-harness/cli_anything/python_docx/README.md @@ -0,0 +1,57 @@ +# CLI-Anything python-docx Harness + +Stateful CLI harness for `python-docx` with one-shot commands, JSON output, and REPL-first workflow. + +## Install + +From `agent-harness/`: + +```bash +python -m pip install -e . +``` + +## Usage + +One-shot examples: + +```bash +cli-anything-python-docx --json new ./demo.docx --title "Demo" +cli-anything-python-docx add-paragraph --doc ./demo.docx "Hello from CLI" +cli-anything-python-docx --json summary --doc ./demo.docx +cli-anything-python-docx add-table --doc ./demo.docx --header Qty --header Id --header Desc --record "3|101|Spam" --record "7|422|Eggs" --record "4|631|Spam, spam, eggs, and spam" +cli-anything-python-docx add-table --doc ./demo.docx --header Qty --header Id --header Desc --record "3|101|Spam" --record "7|422|Eggs" --record "4|631|Spam, spam, eggs, and spam" --header-bold --header-bg-color D9E1F2 --row-lines --column-lines --outer-border --line-style single --line-size 8 --line-color 000000 +``` + +REPL (default when no subcommand is provided): + +```bash +cli-anything-python-docx +python-docx> new ./notes.docx --title "Notes" +python-docx> add-heading "Sprint" --level 2 +python-docx> add-paragraph "Action item" +python-docx> undo +python-docx> save +python-docx> exit +``` + +## Commands + +- `new [PATH] [--title TEXT]` +- `open PATH` +- `save [PATH]` +- `summary [--doc PATH]` (use global `--json` before the subcommand) +- `list-paragraphs [--doc PATH] [--limit N]` (use global `--json` before the subcommand) +- `add-paragraph [--doc PATH] [--style NAME] TEXT` +- `add-heading [--doc PATH] [--level N] TEXT` +- `add-table [--doc PATH] [--rows N] [--cols N] [--header TEXT ...] [--record TEXT ...] [--delimiter CHAR] [--header-bold] [--header-bg-color HEX] [--row-lines] [--column-lines] [--outer-border] [--line-style STYLE] [--line-size N] [--line-color COLOR]` +- `set-core [--doc PATH] KEY VALUE` +- `undo` +- `redo` +- `repl` + +`--doc` on mutating commands enables one-shot edits that auto-save back to that file. + +Structured table mode (`--header`/`--record`) is useful for record-style inserts. +Each `--record` line is parsed by `--delimiter` (default `|`). +Border formatting can be enabled independently for row separators (`--row-lines`) and column separators (`--column-lines`), and optional outside borders (`--outer-border`). +Header formatting can be enabled with `--header-bold` and `--header-bg-color D9E1F2`. diff --git a/agent-harness/cli_anything/python_docx/__init__.py b/agent-harness/cli_anything/python_docx/__init__.py new file mode 100644 index 000000000..2f5757a9c --- /dev/null +++ b/agent-harness/cli_anything/python_docx/__init__.py @@ -0,0 +1,5 @@ +"""CLI-Anything harness package for python-docx.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" \ No newline at end of file diff --git a/agent-harness/cli_anything/python_docx/__main__.py b/agent-harness/cli_anything/python_docx/__main__.py new file mode 100644 index 000000000..592696535 --- /dev/null +++ b/agent-harness/cli_anything/python_docx/__main__.py @@ -0,0 +1,9 @@ +from cli_anything.python_docx.python_docx_cli import cli + + +def main() -> None: + cli(prog_name="cli-anything-python-docx") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/agent-harness/cli_anything/python_docx/core/__init__.py b/agent-harness/cli_anything/python_docx/core/__init__.py new file mode 100644 index 000000000..4850b8683 --- /dev/null +++ b/agent-harness/cli_anything/python_docx/core/__init__.py @@ -0,0 +1,3 @@ +from cli_anything.python_docx.core.session import DocxSession, SessionError + +__all__ = ["DocxSession", "SessionError"] \ No newline at end of file diff --git a/agent-harness/cli_anything/python_docx/core/session.py b/agent-harness/cli_anything/python_docx/core/session.py new file mode 100644 index 000000000..a28c23f34 --- /dev/null +++ b/agent-harness/cli_anything/python_docx/core/session.py @@ -0,0 +1,308 @@ +"""Core session primitives for the python-docx harness.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from docx.oxml import OxmlElement +from docx.oxml.ns import qn + +from cli_anything.python_docx.utils import python_docx_backend as backend + + +class SessionError(RuntimeError): + """Raised when an operation requires unavailable session state.""" + + +class DocxSession: + def __init__(self) -> None: + self._document: Any | None = None + self._path: Path | None = None + self._undo_stack: list[bytes] = [] + self._redo_stack: list[bytes] = [] + + @property + def path(self) -> Path | None: + return self._path + + @property + def has_document(self) -> bool: + return self._document is not None + + def new_document(self, path: str | Path | None = None, title: str | None = None) -> None: + self._document = backend.new_document() + self._path = Path(path) if path is not None else None + self._undo_stack.clear() + self._redo_stack.clear() + if title: + self.set_core_property("title", title) + if path is not None: + self.save(path) + + def open_document(self, path: str | Path) -> None: + target = Path(path) + if not target.exists(): + raise SessionError(f"Document does not exist: {target}") + self._document = backend.load_document(target) + self._path = target + self._undo_stack.clear() + self._redo_stack.clear() + + def save(self, path: str | Path | None = None) -> Path: + self._ensure_document() + target = Path(path) if path is not None else self._path + if target is None: + raise SessionError("No target path set. Pass a path to save().") + target.parent.mkdir(parents=True, exist_ok=True) + backend.save_document(self._document, target) + self._path = target + return target + + def summary(self) -> dict[str, Any]: + doc = self._ensure_document() + paragraphs = doc.paragraphs + tables = doc.tables + heading_count = 0 + for para in paragraphs: + style = getattr(para, "style", None) + style_name = getattr(style, "name", "") + if isinstance(style_name, str) and style_name.startswith("Heading"): + heading_count += 1 + + return { + "path": str(self._path) if self._path is not None else None, + "paragraph_count": len(paragraphs), + "table_count": len(tables), + "heading_count": heading_count, + "undo_depth": len(self._undo_stack), + "redo_depth": len(self._redo_stack), + } + + def list_paragraphs(self, limit: int | None = None) -> list[dict[str, Any]]: + doc = self._ensure_document() + records: list[dict[str, Any]] = [] + for index, para in enumerate(doc.paragraphs): + style = getattr(para, "style", None) + style_name = getattr(style, "name", None) + records.append( + { + "index": index, + "text": para.text, + "style": style_name, + } + ) + if limit is None: + return records + return records[: max(limit, 0)] + + def add_paragraph(self, text: str, style: str | None = None) -> None: + doc = self._ensure_document() + self._checkpoint() + if style: + doc.add_paragraph(text, style=style) + else: + doc.add_paragraph(text) + + def add_heading(self, text: str, level: int = 1) -> None: + doc = self._ensure_document() + self._checkpoint() + doc.add_heading(text, level=level) + + def add_table( + self, + rows: int | None = None, + cols: int | None = None, + headers: list[str] | None = None, + records: list[list[str]] | None = None, + header_bold: bool = False, + header_bg_color: str | None = None, + row_lines: bool = False, + column_lines: bool = False, + outer_border: bool = False, + line_style: str = "single", + line_size: int = 8, + line_color: str = "auto", + ) -> dict[str, int]: + doc = self._ensure_document() + self._checkpoint() + headers = headers or [] + records = records or [] + + has_structured_data = bool(headers or records) + if not has_structured_data: + if rows is None or cols is None: + raise SessionError("rows and cols are required when no headers/records are provided.") + table = doc.add_table(rows=rows, cols=cols) + if row_lines or column_lines or outer_border: + self._apply_table_borders( + table=table, + row_lines=row_lines, + column_lines=column_lines, + outer_border=outer_border, + line_style=line_style, + line_size=line_size, + line_color=line_color, + ) + return {"rows": rows, "cols": cols} + + if cols is not None and cols < 1: + raise SessionError("cols must be >= 1 when provided.") + if rows is not None and rows < 1: + raise SessionError("rows must be >= 1 when provided.") + + inferred_cols = cols or 0 + if headers: + inferred_cols = max(inferred_cols, len(headers)) + for row in records: + inferred_cols = max(inferred_cols, len(row)) + + if inferred_cols < 1: + raise SessionError("Unable to infer table column count from headers/records.") + + total_rows = (1 if headers else 0) + len(records) + if rows is not None: + total_rows = max(total_rows, rows) + + table = doc.add_table(rows=total_rows, cols=inferred_cols) + + row_index = 0 + if headers: + normalized_header_bg_color = ( + self._normalize_hex_color(header_bg_color, "header_bg_color") + if header_bg_color + else None + ) + for col_index, value in enumerate(headers): + cell = table.rows[0].cells[col_index] + self._set_cell_text(cell, value, bold=header_bold) + if normalized_header_bg_color: + self._set_cell_background(cell, normalized_header_bg_color) + row_index = 1 + + for record in records: + cells = table.rows[row_index].cells + for col_index, value in enumerate(record): + cells[col_index].text = value + row_index += 1 + + if row_lines or column_lines or outer_border: + self._apply_table_borders( + table=table, + row_lines=row_lines, + column_lines=column_lines, + outer_border=outer_border, + line_style=line_style, + line_size=line_size, + line_color=line_color, + ) + + return {"rows": total_rows, "cols": inferred_cols} + + def _set_cell_text(self, cell: Any, value: str, bold: bool = False) -> None: + cell.text = value + if not bold: + return + for paragraph in cell.paragraphs: + if not paragraph.runs: + paragraph.add_run("") + for run in paragraph.runs: + run.bold = True + + def _set_cell_background(self, cell: Any, fill_color: str) -> None: + tc_pr = cell._tc.get_or_add_tcPr() + shd = tc_pr.find(qn("w:shd")) + if shd is None: + shd = OxmlElement("w:shd") + tc_pr.append(shd) + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), fill_color) + + def _normalize_hex_color(self, value: str, field_name: str) -> str: + normalized = value.strip().lstrip("#").upper() + if len(normalized) != 6 or any(ch not in "0123456789ABCDEF" for ch in normalized): + raise SessionError(f"{field_name} must be a 6-digit hex value like 'D9E1F2'.") + return normalized + + def _apply_table_borders( + self, + table: Any, + row_lines: bool, + column_lines: bool, + outer_border: bool, + line_style: str, + line_size: int, + line_color: str, + ) -> None: + color = line_color.strip() + if color.lower() == "auto": + color = "auto" + else: + color = self._normalize_hex_color(color, "line_color") + + tbl = table._tbl + tbl_pr = tbl.tblPr + if tbl_pr is None: + tbl_pr = OxmlElement("w:tblPr") + tbl.insert(0, tbl_pr) + + tbl_borders = tbl_pr.find(qn("w:tblBorders")) + if tbl_borders is None: + tbl_borders = OxmlElement("w:tblBorders") + tbl_pr.append(tbl_borders) + + border_edges: list[str] = [] + if outer_border: + border_edges.extend(["top", "left", "bottom", "right"]) + if row_lines: + border_edges.append("insideH") + if column_lines: + border_edges.append("insideV") + + for edge in border_edges: + edge_elm = tbl_borders.find(qn(f"w:{edge}")) + if edge_elm is None: + edge_elm = OxmlElement(f"w:{edge}") + tbl_borders.append(edge_elm) + edge_elm.set(qn("w:val"), line_style) + edge_elm.set(qn("w:sz"), str(line_size)) + edge_elm.set(qn("w:space"), "0") + edge_elm.set(qn("w:color"), color) + + def set_core_property(self, key: str, value: str) -> None: + doc = self._ensure_document() + core = doc.core_properties + if not hasattr(core, key): + raise SessionError(f"Unsupported core property: {key}") + self._checkpoint() + setattr(core, key, value) + + def undo(self) -> None: + self._ensure_document() + if not self._undo_stack: + raise SessionError("Nothing to undo.") + current = backend.serialize_document(self._document) + snapshot = self._undo_stack.pop() + self._redo_stack.append(current) + self._document = backend.load_document_from_bytes(snapshot) + + def redo(self) -> None: + self._ensure_document() + if not self._redo_stack: + raise SessionError("Nothing to redo.") + current = backend.serialize_document(self._document) + snapshot = self._redo_stack.pop() + self._undo_stack.append(current) + self._document = backend.load_document_from_bytes(snapshot) + + def _checkpoint(self) -> None: + self._ensure_document() + snapshot = backend.serialize_document(self._document) + self._undo_stack.append(snapshot) + self._redo_stack.clear() + + def _ensure_document(self): + if self._document is None: + raise SessionError("No active document. Use new/open first.") + return self._document diff --git a/agent-harness/cli_anything/python_docx/python_docx_cli.py b/agent-harness/cli_anything/python_docx/python_docx_cli.py new file mode 100644 index 000000000..9643404a7 --- /dev/null +++ b/agent-harness/cli_anything/python_docx/python_docx_cli.py @@ -0,0 +1,419 @@ +from __future__ import annotations + +import csv +import json +import shlex +from pathlib import Path +from typing import Any + +import click + +from cli_anything.python_docx.core import DocxSession, SessionError + + +def _format_summary(summary: dict[str, Any]) -> str: + lines = [ + f"path: {summary['path']}", + f"paragraph_count: {summary['paragraph_count']}", + f"table_count: {summary['table_count']}", + f"heading_count: {summary['heading_count']}", + f"undo_depth: {summary['undo_depth']}", + f"redo_depth: {summary['redo_depth']}", + ] + return "\n".join(lines) + + +def _emit(ctx: click.Context, payload: dict[str, Any], text: str | None = None) -> None: + if ctx.obj.get("json_output", False): + click.echo(json.dumps(payload, sort_keys=True)) + return + if text is not None: + click.echo(text) + return + click.echo(json.dumps(payload, indent=2, sort_keys=True)) + + +def _session_from_context(ctx: click.Context) -> DocxSession: + return ctx.obj["session"] + + +def _open_if_requested(session: DocxSession, doc: str | None) -> bool: + if not doc: + return False + session.open_document(Path(doc)) + return True + + +def _require_session_doc(session: DocxSession) -> None: + if not session.has_document: + raise click.UsageError("No active document. Use new/open or pass --doc.") + + +def _parse_records(record_values: tuple[str, ...], delimiter: str) -> list[list[str]]: + rows: list[list[str]] = [] + for value in record_values: + parsed = next(csv.reader([value], delimiter=delimiter)) + rows.append(parsed) + return rows + + +def _run_repl(root: click.Command, ctx_obj: dict[str, Any]) -> None: + click.echo("python-docx REPL. Type 'help' for commands, 'exit' to quit.") + while True: + try: + line = input("python-docx> ").strip() + except EOFError: + click.echo() + break + except KeyboardInterrupt: + click.echo() + continue + + if not line: + continue + + if line in {"exit", "quit"}: + break + + if line == "help": + click.echo( + "Commands: new, open, save, summary, list-paragraphs, add-paragraph, " + "add-heading, add-table, set-core, undo, redo, repl" + ) + click.echo("Use 'json on' or 'json off' to toggle JSON output.") + continue + + if line.startswith("json "): + mode = line.split(maxsplit=1)[1].strip().lower() + if mode not in {"on", "off"}: + click.echo("json mode must be 'on' or 'off'") + continue + ctx_obj["json_output"] = mode == "on" + click.echo(f"json output {'enabled' if ctx_obj['json_output'] else 'disabled'}") + continue + + try: + tokens = shlex.split(line) + except ValueError as err: + click.echo(f"Parse error: {err}") + continue + + if not tokens: + continue + + args = (["--json"] if ctx_obj.get("json_output", False) else []) + tokens + try: + root.main( + args=args, + prog_name="cli-anything-python-docx", + obj=ctx_obj, + standalone_mode=False, + ) + except click.ClickException as err: + err.show() + except SessionError as err: + click.echo(f"Error: {err}") + except Exception as err: # noqa: BLE001 + click.echo(f"Error: {err}") + + +@click.group(invoke_without_command=True) +@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.") +@click.pass_context +def cli(ctx: click.Context, json_output: bool) -> None: + """CLI-Anything harness for python-docx.""" + if ctx.obj is None: + ctx.obj = {} + if "session" not in ctx.obj: + ctx.obj["session"] = DocxSession() + if json_output: + ctx.obj["json_output"] = True + else: + ctx.obj.setdefault("json_output", False) + + if ctx.invoked_subcommand is None: + _run_repl(cli, ctx.obj) + + +@cli.command("repl") +@click.pass_context +def repl(ctx: click.Context) -> None: + """Start interactive mode.""" + _run_repl(cli, ctx.obj) + + +@cli.command("new") +@click.argument("path", required=False, type=click.Path(path_type=Path)) +@click.option("--title", default=None, help="Optional core title metadata.") +@click.pass_context +def new_command(ctx: click.Context, path: Path | None, title: str | None) -> None: + """Create a new document and optionally save it.""" + session = _session_from_context(ctx) + session.new_document(path=path, title=title) + payload = { + "ok": True, + "action": "new", + "path": str(session.path) if session.path else None, + "title": title, + } + _emit(ctx, payload, text=f"Created document at {session.path}" if session.path else "Created new in-memory document") + + +@cli.command("open") +@click.argument("path", type=click.Path(exists=True, path_type=Path)) +@click.pass_context +def open_command(ctx: click.Context, path: Path) -> None: + """Open an existing .docx document.""" + session = _session_from_context(ctx) + session.open_document(path) + summary = session.summary() + payload = {"ok": True, "action": "open", "summary": summary} + _emit(ctx, payload, text=f"Opened {path}\n{_format_summary(summary)}") + + +@cli.command("save") +@click.argument("path", required=False, type=click.Path(path_type=Path)) +@click.pass_context +def save_command(ctx: click.Context, path: Path | None) -> None: + """Save the active document.""" + session = _session_from_context(ctx) + saved = session.save(path) + payload = {"ok": True, "action": "save", "path": str(saved)} + _emit(ctx, payload, text=f"Saved document: {saved}") + + +@cli.command("summary") +@click.option("--doc", type=click.Path(exists=True, path_type=Path), default=None, help="Open this document before reporting summary.") +@click.pass_context +def summary_command(ctx: click.Context, doc: Path | None) -> None: + """Show document summary.""" + session = _session_from_context(ctx) + _open_if_requested(session, str(doc) if doc else None) + _require_session_doc(session) + summary = session.summary() + payload = {"ok": True, "action": "summary", "summary": summary} + _emit(ctx, payload, text=_format_summary(summary)) + + +@cli.command("list-paragraphs") +@click.option("--doc", type=click.Path(exists=True, path_type=Path), default=None, help="Open this document before listing paragraphs.") +@click.option("--limit", type=int, default=None, help="Max paragraphs to return.") +@click.pass_context +def list_paragraphs_command(ctx: click.Context, doc: Path | None, limit: int | None) -> None: + """List document paragraphs.""" + session = _session_from_context(ctx) + _open_if_requested(session, str(doc) if doc else None) + _require_session_doc(session) + rows = session.list_paragraphs(limit=limit) + payload = {"ok": True, "action": "list-paragraphs", "paragraphs": rows} + if ctx.obj.get("json_output", False): + _emit(ctx, payload) + return + + if not rows: + click.echo("No paragraphs.") + return + for row in rows: + click.echo(f"[{row['index']}] ({row['style']}) {row['text']}") + + +@cli.command("add-paragraph") +@click.argument("text") +@click.option("--doc", type=click.Path(exists=True, path_type=Path), default=None, help="Perform operation against this file and save it.") +@click.option("--style", default=None, help="Paragraph style name.") +@click.pass_context +def add_paragraph_command(ctx: click.Context, text: str, doc: Path | None, style: str | None) -> None: + """Add a paragraph.""" + session = _session_from_context(ctx) + used_doc = _open_if_requested(session, str(doc) if doc else None) + _require_session_doc(session) + session.add_paragraph(text=text, style=style) + if used_doc: + saved = session.save(doc) + else: + saved = session.path + payload = { + "ok": True, + "action": "add-paragraph", + "text": text, + "style": style, + "path": str(saved) if saved else None, + } + _emit(ctx, payload, text=f"Added paragraph. path={saved}") + + +@cli.command("add-heading") +@click.argument("text") +@click.option("--doc", type=click.Path(exists=True, path_type=Path), default=None, help="Perform operation against this file and save it.") +@click.option("--level", type=click.IntRange(0, 9), default=1, show_default=True) +@click.pass_context +def add_heading_command(ctx: click.Context, text: str, doc: Path | None, level: int) -> None: + """Add a heading paragraph.""" + session = _session_from_context(ctx) + used_doc = _open_if_requested(session, str(doc) if doc else None) + _require_session_doc(session) + session.add_heading(text=text, level=level) + if used_doc: + saved = session.save(doc) + else: + saved = session.path + payload = { + "ok": True, + "action": "add-heading", + "text": text, + "level": level, + "path": str(saved) if saved else None, + } + _emit(ctx, payload, text=f"Added heading. path={saved}") + + +@cli.command("add-table") +@click.option("--doc", type=click.Path(exists=True, path_type=Path), default=None, help="Perform operation against this file and save it.") +@click.option("--rows", type=click.IntRange(1, None), required=False, default=None, help="Row count for empty-table mode or minimum rows in structured mode.") +@click.option("--cols", type=click.IntRange(1, None), required=False, default=None, help="Column count for empty-table mode or column override in structured mode.") +@click.option("--header", "headers", multiple=True, help="Header cell value. Repeat for each header column.") +@click.option("--record", "record_values", multiple=True, help="Row values split by --delimiter (repeatable).") +@click.option("--delimiter", default="|", show_default=True, help="Delimiter used to parse each --record line.") +@click.option("--header-bold/--no-header-bold", default=False, help="Render header text in bold.") +@click.option("--header-bg-color", default=None, help="Header background color as 6-digit hex (e.g. D9E1F2).") +@click.option("--row-lines/--no-row-lines", default=False, help="Draw separator lines between rows.") +@click.option("--column-lines/--no-column-lines", default=False, help="Draw separator lines between columns.") +@click.option("--outer-border/--no-outer-border", default=False, help="Draw border around the table.") +@click.option( + "--line-style", + type=click.Choice(["single", "dashed", "dotted", "double", "thick"], case_sensitive=False), + default="single", + show_default=True, + help="Border line style.", +) +@click.option("--line-size", type=click.IntRange(2, 96), default=8, show_default=True, help="Border width in eighths of a point.") +@click.option("--line-color", default="auto", show_default=True, help="Border color: 'auto' or 6-digit hex (e.g. 000000).") +@click.pass_context +def add_table_command( + ctx: click.Context, + doc: Path | None, + rows: int | None, + cols: int | None, + headers: tuple[str, ...], + record_values: tuple[str, ...], + delimiter: str, + header_bold: bool, + header_bg_color: str | None, + row_lines: bool, + column_lines: bool, + outer_border: bool, + line_style: str, + line_size: int, + line_color: str, +) -> None: + """Add an empty or data-populated table.""" + if len(delimiter) != 1: + raise click.UsageError("--delimiter must be a single character.") + + records = _parse_records(record_values, delimiter) + has_structured_data = bool(headers or records) + if not has_structured_data and (rows is None or cols is None): + raise click.UsageError("Provide --rows and --cols for empty-table mode, or use --header/--record.") + if (header_bold or header_bg_color) and not headers: + raise click.UsageError("--header-bold/--header-bg-color require at least one --header.") + + session = _session_from_context(ctx) + used_doc = _open_if_requested(session, str(doc) if doc else None) + _require_session_doc(session) + table_shape = session.add_table( + rows=rows, + cols=cols, + headers=list(headers), + records=records, + header_bold=header_bold, + header_bg_color=header_bg_color, + row_lines=row_lines, + column_lines=column_lines, + outer_border=outer_border, + line_style=line_style.lower(), + line_size=line_size, + line_color=line_color, + ) + if used_doc: + saved = session.save(doc) + else: + saved = session.path + payload = { + "ok": True, + "action": "add-table", + "rows": table_shape["rows"], + "cols": table_shape["cols"], + "header_count": len(headers), + "record_count": len(records), + "header_bold": header_bold, + "header_bg_color": header_bg_color, + "row_lines": row_lines, + "column_lines": column_lines, + "outer_border": outer_border, + "line_style": line_style.lower(), + "line_size": line_size, + "line_color": line_color, + "path": str(saved) if saved else None, + } + _emit( + ctx, + payload, + text=( + f"Added table {table_shape['rows']}x{table_shape['cols']} " + f"(headers={len(headers)}, records={len(records)}, " + f"header_bold={header_bold}, header_bg_color={header_bg_color}, " + f"row_lines={row_lines}, column_lines={column_lines}, outer_border={outer_border}). " + f"path={saved}" + ), + ) + + +@cli.command("set-core") +@click.argument("key") +@click.argument("value") +@click.option("--doc", type=click.Path(exists=True, path_type=Path), default=None, help="Perform operation against this file and save it.") +@click.pass_context +def set_core_command(ctx: click.Context, key: str, value: str, doc: Path | None) -> None: + """Set a core document property.""" + session = _session_from_context(ctx) + used_doc = _open_if_requested(session, str(doc) if doc else None) + _require_session_doc(session) + session.set_core_property(key=key, value=value) + if used_doc: + saved = session.save(doc) + else: + saved = session.path + payload = { + "ok": True, + "action": "set-core", + "key": key, + "value": value, + "path": str(saved) if saved else None, + } + _emit(ctx, payload, text=f"Set core property {key}. path={saved}") + + +@cli.command("undo") +@click.pass_context +def undo_command(ctx: click.Context) -> None: + """Undo last mutation in current session.""" + session = _session_from_context(ctx) + session.undo() + summary = session.summary() + payload = {"ok": True, "action": "undo", "summary": summary} + _emit(ctx, payload, text=f"Undo complete\n{_format_summary(summary)}") + + +@cli.command("redo") +@click.pass_context +def redo_command(ctx: click.Context) -> None: + """Redo last undone mutation in current session.""" + session = _session_from_context(ctx) + session.redo() + summary = session.summary() + payload = {"ok": True, "action": "redo", "summary": summary} + _emit(ctx, payload, text=f"Redo complete\n{_format_summary(summary)}") + + +if __name__ == "__main__": + cli() diff --git a/agent-harness/cli_anything/python_docx/tests/test_core.py b/agent-harness/cli_anything/python_docx/tests/test_core.py new file mode 100644 index 000000000..22df7462f --- /dev/null +++ b/agent-harness/cli_anything/python_docx/tests/test_core.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from docx import Document +from docx.oxml.ns import qn + +from cli_anything.python_docx.core import DocxSession, SessionError + + +def it_handles_session_mutation_and_undo_redo() -> None: + session = DocxSession() + session.new_document() + + session.add_paragraph("first") + assert session.summary()["paragraph_count"] == 1 + + session.undo() + assert session.summary()["paragraph_count"] == 0 + + session.redo() + assert session.summary()["paragraph_count"] == 1 + + +def it_saves_opens_and_writes_core_property(tmp_path: Path) -> None: + target = tmp_path / "report.docx" + session = DocxSession() + session.new_document(path=target, title="Draft") + + session.add_heading("Plan", level=2) + session.add_table(rows=2, cols=2) + session.set_core_property("author", "CLI Harness") + session.save() + + reopened = DocxSession() + reopened.open_document(target) + + summary = reopened.summary() + assert summary["paragraph_count"] == 1 + assert summary["table_count"] == 1 + + +def it_raises_when_undo_has_no_history() -> None: + session = DocxSession() + session.new_document() + + with pytest.raises(SessionError): + session.undo() + + +def it_adds_structured_table_rows(tmp_path: Path) -> None: + target = tmp_path / "records.docx" + session = DocxSession() + session.new_document(path=target) + + session.add_table( + headers=["Qty", "Id", "Desc"], + records=[ + ["3", "101", "Spam"], + ["7", "422", "Eggs"], + ["4", "631", "Spam, spam, eggs, and spam"], + ], + header_bold=True, + header_bg_color="D9E1F2", + ) + session.save() + + doc = Document(str(target)) + table = doc.tables[0] + assert len(table.rows) == 4 + assert len(table.columns) == 3 + assert table.cell(0, 0).text == "Qty" + assert table.cell(0, 1).text == "Id" + assert table.cell(0, 2).text == "Desc" + assert table.cell(1, 0).text == "3" + assert table.cell(3, 2).text == "Spam, spam, eggs, and spam" + assert table.cell(0, 0).paragraphs[0].runs[0].bold is True + shd = table.cell(0, 0)._tc.get_or_add_tcPr().find(qn("w:shd")) + assert shd is not None + assert shd.get(qn("w:fill")) == "D9E1F2" + + +def it_applies_table_line_formatting(tmp_path: Path) -> None: + target = tmp_path / "bordered.docx" + session = DocxSession() + session.new_document(path=target) + session.add_table( + rows=2, + cols=2, + row_lines=True, + column_lines=True, + outer_border=True, + line_style="single", + line_size=8, + line_color="000000", + ) + session.save() + + doc = Document(str(target)) + table = doc.tables[0] + tbl_borders = table._tbl.tblPr.find(qn("w:tblBorders")) + assert tbl_borders is not None + + inside_h = tbl_borders.find(qn("w:insideH")) + inside_v = tbl_borders.find(qn("w:insideV")) + top = tbl_borders.find(qn("w:top")) + assert inside_h is not None + assert inside_v is not None + assert top is not None + assert inside_h.get(qn("w:val")) == "single" + assert inside_v.get(qn("w:val")) == "single" + assert inside_h.get(qn("w:color")) == "000000" diff --git a/agent-harness/cli_anything/python_docx/tests/test_full_e2e.py b/agent-harness/cli_anything/python_docx/tests/test_full_e2e.py new file mode 100644 index 000000000..48b4cd507 --- /dev/null +++ b/agent-harness/cli_anything/python_docx/tests/test_full_e2e.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +from docx import Document +from docx.oxml.ns import qn + + +def _cli_cmd() -> list[str]: + exe = shutil.which("cli-anything-python-docx") + if exe: + return [exe] + return [sys.executable, "-m", "cli_anything.python_docx"] + + +def _run(args: list[str], input_text: str | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + _cli_cmd() + args, + check=True, + capture_output=True, + text=True, + input=input_text, + ) + + +def _json_lines(output: str) -> list[dict]: + rows = [] + for line in output.splitlines(): + line = line.strip() + start = line.find("{") + if start < 0: + continue + candidate = line[start:] + if not candidate.endswith("}"): + continue + rows.append(json.loads(candidate)) + return rows + + +def it_runs_one_shot_json_workflow(tmp_path: Path) -> None: + doc_path = tmp_path / "workflow.docx" + + created = _run(["--json", "new", str(doc_path), "--title", "Workflow"]) + created_rows = _json_lines(created.stdout) + assert created_rows and created_rows[-1]["ok"] is True + + _run(["add-paragraph", "Hello from e2e", "--doc", str(doc_path)]) + _run(["add-heading", "Milestone", "--level", "2", "--doc", str(doc_path)]) + + summary = _run(["--json", "summary", "--doc", str(doc_path)]) + summary_rows = _json_lines(summary.stdout) + assert summary_rows + + payload = summary_rows[-1] + assert payload["summary"]["paragraph_count"] == 2 + assert payload["summary"]["heading_count"] == 1 + + +def it_runs_default_repl_mode_with_undo() -> None: + repl_script = "\n".join( + [ + "new", + "add-paragraph \"Temp line\"", + "undo", + "summary", + "exit", + "", + ] + ) + + completed = _run(["--json"], input_text=repl_script) + rows = _json_lines(completed.stdout) + + summary_rows = [row for row in rows if row.get("action") == "summary"] + assert summary_rows + assert summary_rows[-1]["summary"]["paragraph_count"] == 0 + + +def it_adds_table_records_from_cli(tmp_path: Path) -> None: + doc_path = tmp_path / "table-records.docx" + _run(["new", str(doc_path)]) + _run( + [ + "add-table", + "--doc", + str(doc_path), + "--header", + "Qty", + "--header", + "Id", + "--header", + "Desc", + "--header-bold", + "--header-bg-color", + "D9E1F2", + "--record", + "3|101|Spam", + "--record", + "7|422|Eggs", + "--record", + "4|631|Spam, spam, eggs, and spam", + ] + ) + + doc = Document(str(doc_path)) + table = doc.tables[0] + assert len(table.rows) == 4 + assert len(table.columns) == 3 + assert table.cell(0, 0).text == "Qty" + assert table.cell(1, 0).text == "3" + assert table.cell(3, 2).text == "Spam, spam, eggs, and spam" + assert table.cell(0, 0).paragraphs[0].runs[0].bold is True + shd = table.cell(0, 0)._tc.get_or_add_tcPr().find(qn("w:shd")) + assert shd is not None + assert shd.get(qn("w:fill")) == "D9E1F2" + + +def it_adds_row_and_column_lines_from_cli(tmp_path: Path) -> None: + doc_path = tmp_path / "table-lines.docx" + _run(["new", str(doc_path)]) + _run( + [ + "add-table", + "--doc", + str(doc_path), + "--rows", + "2", + "--cols", + "2", + "--row-lines", + "--column-lines", + "--outer-border", + "--line-style", + "single", + "--line-size", + "8", + "--line-color", + "000000", + ] + ) + + doc = Document(str(doc_path)) + table = doc.tables[0] + tbl_borders = table._tbl.tblPr.find(qn("w:tblBorders")) + assert tbl_borders is not None + assert tbl_borders.find(qn("w:insideH")) is not None + assert tbl_borders.find(qn("w:insideV")) is not None diff --git a/agent-harness/cli_anything/python_docx/utils/__init__.py b/agent-harness/cli_anything/python_docx/utils/__init__.py new file mode 100644 index 000000000..885231e22 --- /dev/null +++ b/agent-harness/cli_anything/python_docx/utils/__init__.py @@ -0,0 +1,3 @@ +from cli_anything.python_docx.utils import python_docx_backend + +__all__ = ["python_docx_backend"] \ No newline at end of file diff --git a/agent-harness/cli_anything/python_docx/utils/python_docx_backend.py b/agent-harness/cli_anything/python_docx/utils/python_docx_backend.py new file mode 100644 index 000000000..c46cdca0a --- /dev/null +++ b/agent-harness/cli_anything/python_docx/utils/python_docx_backend.py @@ -0,0 +1,32 @@ +"""Backend wrapper over python-docx APIs.""" + +from __future__ import annotations + +import io +from pathlib import Path +from typing import Any + +from docx import Document + + +def new_document() -> Any: + return Document() + + +def load_document(path: str | Path) -> Any: + return Document(str(path)) + + +def save_document(document: Any, path: str | Path) -> None: + document.save(str(path)) + + +def serialize_document(document: Any) -> bytes: + stream = io.BytesIO() + document.save(stream) + return stream.getvalue() + + +def load_document_from_bytes(data: bytes) -> Any: + stream = io.BytesIO(data) + return Document(stream) \ No newline at end of file diff --git a/agent-harness/setup.py b/agent-harness/setup.py new file mode 100644 index 000000000..74f332eec --- /dev/null +++ b/agent-harness/setup.py @@ -0,0 +1,30 @@ +from pathlib import Path + +from setuptools import find_namespace_packages, setup + +BASE_DIR = Path(__file__).parent +README = (BASE_DIR / "cli_anything" / "python_docx" / "README.md").read_text(encoding="utf-8") + +setup( + name="cli-anything-python-docx", + version="0.1.0", + description="CLI-Anything harness for python-docx", + long_description=README, + long_description_content_type="text/markdown", + author="CLI-Anything", + packages=find_namespace_packages(include=["cli_anything.*"]), + include_package_data=True, + install_requires=[ + "click>=8.1.7", + "python-docx>=1.1.2", + ], + extras_require={ + "dev": ["pytest>=7.4.0"], + }, + entry_points={ + "console_scripts": [ + "cli-anything-python-docx=cli_anything.python_docx.__main__:main", + ], + }, + python_requires=">=3.9", +) \ No newline at end of file From 712d74f821f4f8fa208d3c3966076c863614c847 Mon Sep 17 00:00:00 2001 From: Nordflint Date: Thu, 26 Mar 2026 09:57:37 +0100 Subject: [PATCH 2/2] Extend python-docx harness frontpage and table capabilities --- agent-harness/PYTHON_DOCX.md | 4 +- .../cli_anything/python_docx/README.md | 19 +- .../cli_anything/python_docx/core/session.py | 250 ++++++++++++++++-- .../python_docx/python_docx_cli.py | 92 ++++++- .../python_docx/tests/test_core.py | 42 ++- .../python_docx/tests/test_full_e2e.py | 71 ++++- 6 files changed, 448 insertions(+), 30 deletions(-) diff --git a/agent-harness/PYTHON_DOCX.md b/agent-harness/PYTHON_DOCX.md index 3cc305977..c2d7ead7a 100644 --- a/agent-harness/PYTHON_DOCX.md +++ b/agent-harness/PYTHON_DOCX.md @@ -9,11 +9,13 @@ This harness exposes high-value document operations from `python-docx` as a stat - create/open/save documents - add paragraph, heading, and table content +- insert template-driven frontpages/title pages - update core metadata properties - inspect summary and paragraph lists - run with one-shot subcommands or interactive REPL - emit machine-readable JSON output using `--json` - support undo/redo in-session via in-memory `.docx` snapshots +- enforce a fixed visual palette (`#05206E` main, `#357AE9` secondary) ## Backend strategy @@ -28,4 +30,4 @@ A `DocxSession` instance tracks: - undo stack - redo stack -Undo and redo operate on serialized `.docx` bytes captured before each mutating operation. \ No newline at end of file +Undo and redo operate on serialized `.docx` bytes captured before each mutating operation. diff --git a/agent-harness/cli_anything/python_docx/README.md b/agent-harness/cli_anything/python_docx/README.md index bdbc6f99f..6e80f19af 100644 --- a/agent-harness/cli_anything/python_docx/README.md +++ b/agent-harness/cli_anything/python_docx/README.md @@ -18,8 +18,10 @@ One-shot examples: cli-anything-python-docx --json new ./demo.docx --title "Demo" cli-anything-python-docx add-paragraph --doc ./demo.docx "Hello from CLI" cli-anything-python-docx --json summary --doc ./demo.docx +cli-anything-python-docx frontpage-templates +cli-anything-python-docx add-frontpage --doc ./demo.docx --template clean --title "Operations Review" --subtitle "Q1 2026" --author "CLI Agent" --organization "Nordflint" --date-text "2026-03-26" cli-anything-python-docx add-table --doc ./demo.docx --header Qty --header Id --header Desc --record "3|101|Spam" --record "7|422|Eggs" --record "4|631|Spam, spam, eggs, and spam" -cli-anything-python-docx add-table --doc ./demo.docx --header Qty --header Id --header Desc --record "3|101|Spam" --record "7|422|Eggs" --record "4|631|Spam, spam, eggs, and spam" --header-bold --header-bg-color D9E1F2 --row-lines --column-lines --outer-border --line-style single --line-size 8 --line-color 000000 +cli-anything-python-docx add-table --doc ./demo.docx --header Qty --header Id --header Desc --record "3|101|Spam" --record "7|422|Eggs" --record "4|631|Spam, spam, eggs, and spam" --header-bold --row-lines --column-lines --outer-border --line-style single --line-size 8 --line-color main ``` REPL (default when no subcommand is provided): @@ -43,6 +45,8 @@ python-docx> exit - `list-paragraphs [--doc PATH] [--limit N]` (use global `--json` before the subcommand) - `add-paragraph [--doc PATH] [--style NAME] TEXT` - `add-heading [--doc PATH] [--level N] TEXT` +- `frontpage-templates` +- `add-frontpage [--doc PATH] [--template NAME] --title TEXT [--subtitle TEXT] [--author TEXT] [--organization TEXT] [--date-text TEXT] [--page-break/--no-page-break] [--set-core-title/--no-set-core-title]` - `add-table [--doc PATH] [--rows N] [--cols N] [--header TEXT ...] [--record TEXT ...] [--delimiter CHAR] [--header-bold] [--header-bg-color HEX] [--row-lines] [--column-lines] [--outer-border] [--line-style STYLE] [--line-size N] [--line-color COLOR]` - `set-core [--doc PATH] KEY VALUE` - `undo` @@ -54,4 +58,15 @@ python-docx> exit Structured table mode (`--header`/`--record`) is useful for record-style inserts. Each `--record` line is parsed by `--delimiter` (default `|`). Border formatting can be enabled independently for row separators (`--row-lines`) and column separators (`--column-lines`), and optional outside borders (`--outer-border`). -Header formatting can be enabled with `--header-bold` and `--header-bg-color D9E1F2`. +Header formatting defaults to `main` background with white text, and can be customized with `--header-bold` and `--header-bg-color`. +Frontpage templates let the agent insert a predefined title-page layout with a single command. +The `corporate` frontpage template uses a blue (`main`) background with white text. + +## Palette + +This harness enforces a fixed palette: + +- `main`: `#05206E` +- `secondary`: `#357AE9` + +Title/header fonts and table styling use these colors by default. Color options only accept `main`, `secondary`, or those exact hex values. diff --git a/agent-harness/cli_anything/python_docx/core/session.py b/agent-harness/cli_anything/python_docx/core/session.py index a28c23f34..cb9ff134c 100644 --- a/agent-harness/cli_anything/python_docx/core/session.py +++ b/agent-harness/cli_anything/python_docx/core/session.py @@ -2,11 +2,14 @@ from __future__ import annotations +import datetime as dt from pathlib import Path from typing import Any +from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK from docx.oxml import OxmlElement from docx.oxml.ns import qn +from docx.shared import Pt, RGBColor from cli_anything.python_docx.utils import python_docx_backend as backend @@ -16,6 +19,11 @@ class SessionError(RuntimeError): class DocxSession: + PALETTE_MAIN = "05206E" + PALETTE_SECONDARY = "357AE9" + PALETTE_WHITE = "FFFFFF" + FRONTPAGE_TEMPLATES = ("clean", "corporate", "academic") + def __init__(self) -> None: self._document: Any | None = None self._path: Path | None = None @@ -107,7 +115,168 @@ def add_paragraph(self, text: str, style: str | None = None) -> None: def add_heading(self, text: str, level: int = 1) -> None: doc = self._ensure_document() self._checkpoint() - doc.add_heading(text, level=level) + paragraph = doc.add_heading(text, level=level) + self._apply_paragraph_color(paragraph, self.PALETTE_MAIN) + + def list_frontpage_templates(self) -> list[str]: + return list(self.FRONTPAGE_TEMPLATES) + + def add_frontpage( + self, + template: str, + title: str, + subtitle: str | None = None, + author: str | None = None, + organization: str | None = None, + date_text: str | None = None, + include_page_break: bool = True, + set_core_title: bool = True, + ) -> dict[str, Any]: + doc = self._ensure_document() + self._checkpoint() + + normalized_template = template.strip().lower() + if normalized_template not in self.FRONTPAGE_TEMPLATES: + raise SessionError( + f"Unsupported frontpage template: {template}. " + f"Available: {', '.join(self.FRONTPAGE_TEMPLATES)}" + ) + if not title.strip(): + raise SessionError("Frontpage title cannot be empty.") + + body = doc._body._element + existing_blocks = [child for child in body if child.tag != qn("w:sectPr")] + old_first_block = existing_blocks[0] if existing_blocks else None + + effective_date_text = date_text or dt.date.today().isoformat() + new_elements: list[Any] = [] + + if normalized_template == "clean": + self._append_frontpage_line(new_elements, doc, title, size=30, bold=True, space_after=18) + if subtitle: + self._append_frontpage_line( + new_elements, + doc, + subtitle, + size=16, + italic=True, + space_after=24, + color=self.PALETTE_SECONDARY, + ) + if author: + self._append_frontpage_line(new_elements, doc, author, size=12, space_after=6) + if organization: + self._append_frontpage_line(new_elements, doc, organization, size=12, space_after=6) + self._append_frontpage_line( + new_elements, + doc, + effective_date_text, + size=11, + space_after=0, + color=self.PALETTE_SECONDARY, + ) + elif normalized_template == "corporate": + if organization: + self._append_frontpage_line( + new_elements, + doc, + organization.upper(), + size=12, + bold=True, + space_after=24, + color=self.PALETTE_WHITE, + background_color=self.PALETTE_MAIN, + ) + self._append_frontpage_line( + new_elements, + doc, + title, + size=28, + bold=True, + space_after=12, + color=self.PALETTE_WHITE, + background_color=self.PALETTE_MAIN, + ) + if subtitle: + self._append_frontpage_line( + new_elements, + doc, + subtitle, + size=14, + space_after=24, + color=self.PALETTE_WHITE, + background_color=self.PALETTE_MAIN, + ) + if author: + self._append_frontpage_line( + new_elements, + doc, + f"Prepared by {author}", + size=12, + space_after=6, + color=self.PALETTE_WHITE, + background_color=self.PALETTE_MAIN, + ) + self._append_frontpage_line( + new_elements, + doc, + effective_date_text, + size=11, + space_after=0, + color=self.PALETTE_WHITE, + background_color=self.PALETTE_MAIN, + ) + else: + self._append_frontpage_line(new_elements, doc, title, size=26, bold=True, space_after=12) + if subtitle: + self._append_frontpage_line( + new_elements, + doc, + subtitle, + size=14, + italic=True, + space_after=18, + color=self.PALETTE_SECONDARY, + ) + if author: + self._append_frontpage_line(new_elements, doc, f"Author: {author}", size=12, space_after=6) + if organization: + self._append_frontpage_line(new_elements, doc, f"Institution: {organization}", size=12, space_after=6) + self._append_frontpage_line( + new_elements, + doc, + effective_date_text, + size=11, + space_after=0, + color=self.PALETTE_SECONDARY, + ) + + if include_page_break: + break_para = doc.add_paragraph() + break_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + break_para.add_run().add_break(WD_BREAK.PAGE) + new_elements.append(break_para._p) + + if old_first_block is not None: + for element in new_elements: + body.remove(element) + insert_at = body.index(old_first_block) + for offset, element in enumerate(new_elements): + body.insert(insert_at + offset, element) + + if set_core_title: + doc.core_properties.title = title + + return { + "template": normalized_template, + "title": title, + "subtitle": subtitle, + "author": author, + "organization": organization, + "date_text": effective_date_text, + "page_break": include_page_break, + "set_core_title": set_core_title, + } def add_table( self, @@ -122,7 +291,7 @@ def add_table( outer_border: bool = False, line_style: str = "single", line_size: int = 8, - line_color: str = "auto", + line_color: str = "main", ) -> dict[str, int]: doc = self._ensure_document() self._checkpoint() @@ -169,15 +338,14 @@ def add_table( row_index = 0 if headers: normalized_header_bg_color = ( - self._normalize_hex_color(header_bg_color, "header_bg_color") + self._resolve_palette_color(header_bg_color, "header_bg_color") if header_bg_color - else None + else self.PALETTE_MAIN ) for col_index, value in enumerate(headers): cell = table.rows[0].cells[col_index] - self._set_cell_text(cell, value, bold=header_bold) - if normalized_header_bg_color: - self._set_cell_background(cell, normalized_header_bg_color) + self._set_cell_text(cell, value, bold=header_bold, color=self.PALETTE_WHITE) + self._set_cell_background(cell, normalized_header_bg_color) row_index = 1 for record in records: @@ -199,15 +367,50 @@ def add_table( return {"rows": total_rows, "cols": inferred_cols} - def _set_cell_text(self, cell: Any, value: str, bold: bool = False) -> None: + def _append_frontpage_line( + self, + elements: list[Any], + doc: Any, + text: str, + *, + size: int, + bold: bool = False, + italic: bool = False, + space_after: int = 0, + color: str | None = None, + background_color: str | None = None, + ) -> None: + paragraph = doc.add_paragraph() + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + if space_after > 0: + paragraph.paragraph_format.space_after = Pt(space_after) + if background_color: + self._set_paragraph_background(paragraph, background_color) + run = paragraph.add_run(text) + run.bold = bold + run.italic = italic + run.font.size = Pt(size) + self._set_run_color(run, color or self.PALETTE_MAIN) + elements.append(paragraph._p) + + def _set_paragraph_background(self, paragraph: Any, fill_color: str) -> None: + p_pr = paragraph._p.get_or_add_pPr() + shd = p_pr.find(qn("w:shd")) + if shd is None: + shd = OxmlElement("w:shd") + p_pr.append(shd) + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), fill_color) + + def _set_cell_text(self, cell: Any, value: str, bold: bool = False, color: str | None = None) -> None: cell.text = value - if not bold: - return for paragraph in cell.paragraphs: if not paragraph.runs: paragraph.add_run("") for run in paragraph.runs: - run.bold = True + run.bold = bold + self._set_run_color(run, color or self.PALETTE_MAIN) def _set_cell_background(self, cell: Any, fill_color: str) -> None: tc_pr = cell._tc.get_or_add_tcPr() @@ -225,6 +428,25 @@ def _normalize_hex_color(self, value: str, field_name: str) -> str: raise SessionError(f"{field_name} must be a 6-digit hex value like 'D9E1F2'.") return normalized + def _resolve_palette_color(self, value: str, field_name: str) -> str: + normalized = value.strip().lower().lstrip("#") + if normalized in {"main", self.PALETTE_MAIN.lower()}: + return self.PALETTE_MAIN + if normalized in {"secondary", self.PALETTE_SECONDARY.lower()}: + return self.PALETTE_SECONDARY + raise SessionError( + f"{field_name} must be one of: main, secondary, {self.PALETTE_MAIN}, {self.PALETTE_SECONDARY}." + ) + + def _set_run_color(self, run: Any, color_hex: str) -> None: + run.font.color.rgb = RGBColor.from_string(color_hex) + + def _apply_paragraph_color(self, paragraph: Any, color_hex: str) -> None: + if not paragraph.runs: + paragraph.add_run("") + for run in paragraph.runs: + self._set_run_color(run, color_hex) + def _apply_table_borders( self, table: Any, @@ -235,11 +457,7 @@ def _apply_table_borders( line_size: int, line_color: str, ) -> None: - color = line_color.strip() - if color.lower() == "auto": - color = "auto" - else: - color = self._normalize_hex_color(color, "line_color") + color = self._resolve_palette_color(line_color, "line_color") tbl = table._tbl tbl_pr = tbl.tblPr diff --git a/agent-harness/cli_anything/python_docx/python_docx_cli.py b/agent-harness/cli_anything/python_docx/python_docx_cli.py index 9643404a7..3ccf921f1 100644 --- a/agent-harness/cli_anything/python_docx/python_docx_cli.py +++ b/agent-harness/cli_anything/python_docx/python_docx_cli.py @@ -78,7 +78,7 @@ def _run_repl(root: click.Command, ctx_obj: dict[str, Any]) -> None: if line == "help": click.echo( "Commands: new, open, save, summary, list-paragraphs, add-paragraph, " - "add-heading, add-table, set-core, undo, redo, repl" + "add-heading, add-table, frontpage-templates, add-frontpage, set-core, undo, redo, repl" ) click.echo("Use 'json on' or 'json off' to toggle JSON output.") continue @@ -275,7 +275,11 @@ def add_heading_command(ctx: click.Context, text: str, doc: Path | None, level: @click.option("--record", "record_values", multiple=True, help="Row values split by --delimiter (repeatable).") @click.option("--delimiter", default="|", show_default=True, help="Delimiter used to parse each --record line.") @click.option("--header-bold/--no-header-bold", default=False, help="Render header text in bold.") -@click.option("--header-bg-color", default=None, help="Header background color as 6-digit hex (e.g. D9E1F2).") +@click.option( + "--header-bg-color", + default=None, + help="Header background color from palette: main|secondary or exact palette hex.", +) @click.option("--row-lines/--no-row-lines", default=False, help="Draw separator lines between rows.") @click.option("--column-lines/--no-column-lines", default=False, help="Draw separator lines between columns.") @click.option("--outer-border/--no-outer-border", default=False, help="Draw border around the table.") @@ -287,7 +291,12 @@ def add_heading_command(ctx: click.Context, text: str, doc: Path | None, level: help="Border line style.", ) @click.option("--line-size", type=click.IntRange(2, 96), default=8, show_default=True, help="Border width in eighths of a point.") -@click.option("--line-color", default="auto", show_default=True, help="Border color: 'auto' or 6-digit hex (e.g. 000000).") +@click.option( + "--line-color", + default="main", + show_default=True, + help="Border color from palette: main|secondary or exact palette hex.", +) @click.pass_context def add_table_command( ctx: click.Context, @@ -368,6 +377,83 @@ def add_table_command( ) +@cli.command("frontpage-templates") +@click.pass_context +def frontpage_templates_command(ctx: click.Context) -> None: + """List available frontpage templates.""" + session = _session_from_context(ctx) + templates = session.list_frontpage_templates() + payload = {"ok": True, "action": "frontpage-templates", "templates": templates} + if ctx.obj.get("json_output", False): + _emit(ctx, payload) + return + click.echo("\n".join(templates)) + + +@cli.command("add-frontpage") +@click.option("--doc", type=click.Path(exists=True, path_type=Path), default=None, help="Perform operation against this file and save it.") +@click.option( + "--template", + "template_name", + type=click.Choice(list(DocxSession.FRONTPAGE_TEMPLATES), case_sensitive=False), + default="clean", + show_default=True, + help="Frontpage template preset.", +) +@click.option("--title", required=True, help="Frontpage title.") +@click.option("--subtitle", default=None, help="Optional subtitle.") +@click.option("--author", default=None, help="Optional author line.") +@click.option("--organization", default=None, help="Optional organization/institution line.") +@click.option("--date-text", default=None, help="Optional date text. Defaults to today's date.") +@click.option("--page-break/--no-page-break", default=True, help="Insert a page break after frontpage.") +@click.option("--set-core-title/--no-set-core-title", default=True, help="Update document core title metadata.") +@click.pass_context +def add_frontpage_command( + ctx: click.Context, + doc: Path | None, + template_name: str, + title: str, + subtitle: str | None, + author: str | None, + organization: str | None, + date_text: str | None, + page_break: bool, + set_core_title: bool, +) -> None: + """Insert a template-driven frontpage at the beginning of the document.""" + session = _session_from_context(ctx) + used_doc = _open_if_requested(session, str(doc) if doc else None) + _require_session_doc(session) + frontpage_meta = session.add_frontpage( + template=template_name, + title=title, + subtitle=subtitle, + author=author, + organization=organization, + date_text=date_text, + include_page_break=page_break, + set_core_title=set_core_title, + ) + if used_doc: + saved = session.save(doc) + else: + saved = session.path + payload = { + "ok": True, + "action": "add-frontpage", + "frontpage": frontpage_meta, + "path": str(saved) if saved else None, + } + _emit( + ctx, + payload, + text=( + f"Inserted frontpage template '{frontpage_meta['template']}' " + f"with title '{frontpage_meta['title']}'. path={saved}" + ), + ) + + @cli.command("set-core") @click.argument("key") @click.argument("value") diff --git a/agent-harness/cli_anything/python_docx/tests/test_core.py b/agent-harness/cli_anything/python_docx/tests/test_core.py index 22df7462f..dd192c712 100644 --- a/agent-harness/cli_anything/python_docx/tests/test_core.py +++ b/agent-harness/cli_anything/python_docx/tests/test_core.py @@ -39,6 +39,7 @@ def it_saves_opens_and_writes_core_property(tmp_path: Path) -> None: summary = reopened.summary() assert summary["paragraph_count"] == 1 assert summary["table_count"] == 1 + assert str(reopened._document.paragraphs[0].runs[0].font.color.rgb) == "05206E" def it_raises_when_undo_has_no_history() -> None: @@ -62,7 +63,6 @@ def it_adds_structured_table_rows(tmp_path: Path) -> None: ["4", "631", "Spam, spam, eggs, and spam"], ], header_bold=True, - header_bg_color="D9E1F2", ) session.save() @@ -78,7 +78,8 @@ def it_adds_structured_table_rows(tmp_path: Path) -> None: assert table.cell(0, 0).paragraphs[0].runs[0].bold is True shd = table.cell(0, 0)._tc.get_or_add_tcPr().find(qn("w:shd")) assert shd is not None - assert shd.get(qn("w:fill")) == "D9E1F2" + assert shd.get(qn("w:fill")) == "05206E" + assert str(table.cell(0, 0).paragraphs[0].runs[0].font.color.rgb) == "FFFFFF" def it_applies_table_line_formatting(tmp_path: Path) -> None: @@ -93,7 +94,7 @@ def it_applies_table_line_formatting(tmp_path: Path) -> None: outer_border=True, line_style="single", line_size=8, - line_color="000000", + line_color="main", ) session.save() @@ -110,4 +111,37 @@ def it_applies_table_line_formatting(tmp_path: Path) -> None: assert top is not None assert inside_h.get(qn("w:val")) == "single" assert inside_v.get(qn("w:val")) == "single" - assert inside_h.get(qn("w:color")) == "000000" + assert inside_h.get(qn("w:color")) == "05206E" + + +def it_inserts_frontpage_template_before_existing_content(tmp_path: Path) -> None: + target = tmp_path / "frontpage.docx" + session = DocxSession() + session.new_document(path=target) + session.add_paragraph("Existing body content") + + payload = session.add_frontpage( + template="corporate", + title="Quarterly Results", + subtitle="Q1 2026", + author="CLI Agent", + organization="Nordflint", + date_text="2026-03-26", + ) + session.save() + + assert payload["template"] == "corporate" + doc = Document(str(target)) + assert doc.paragraphs[0].text == "NORDFLINT" + assert doc.paragraphs[1].text == "Quarterly Results" + assert str(doc.paragraphs[0].runs[0].font.color.rgb) == "FFFFFF" + assert str(doc.paragraphs[1].runs[0].font.color.rgb) == "FFFFFF" + para0_shd = doc.paragraphs[0]._p.get_or_add_pPr().find(qn("w:shd")) + para1_shd = doc.paragraphs[1]._p.get_or_add_pPr().find(qn("w:shd")) + assert para0_shd is not None + assert para1_shd is not None + assert para0_shd.get(qn("w:fill")) == "05206E" + assert para1_shd.get(qn("w:fill")) == "05206E" + body_index = next(i for i, para in enumerate(doc.paragraphs) if para.text == "Existing body content") + assert body_index > 1 + assert doc.core_properties.title == "Quarterly Results" diff --git a/agent-harness/cli_anything/python_docx/tests/test_full_e2e.py b/agent-harness/cli_anything/python_docx/tests/test_full_e2e.py index 48b4cd507..dd002d904 100644 --- a/agent-harness/cli_anything/python_docx/tests/test_full_e2e.py +++ b/agent-harness/cli_anything/python_docx/tests/test_full_e2e.py @@ -95,8 +95,6 @@ def it_adds_table_records_from_cli(tmp_path: Path) -> None: "--header", "Desc", "--header-bold", - "--header-bg-color", - "D9E1F2", "--record", "3|101|Spam", "--record", @@ -116,7 +114,8 @@ def it_adds_table_records_from_cli(tmp_path: Path) -> None: assert table.cell(0, 0).paragraphs[0].runs[0].bold is True shd = table.cell(0, 0)._tc.get_or_add_tcPr().find(qn("w:shd")) assert shd is not None - assert shd.get(qn("w:fill")) == "D9E1F2" + assert shd.get(qn("w:fill")) == "05206E" + assert str(table.cell(0, 0).paragraphs[0].runs[0].font.color.rgb) == "FFFFFF" def it_adds_row_and_column_lines_from_cli(tmp_path: Path) -> None: @@ -139,7 +138,7 @@ def it_adds_row_and_column_lines_from_cli(tmp_path: Path) -> None: "--line-size", "8", "--line-color", - "000000", + "main", ] ) @@ -149,3 +148,67 @@ def it_adds_row_and_column_lines_from_cli(tmp_path: Path) -> None: assert tbl_borders is not None assert tbl_borders.find(qn("w:insideH")) is not None assert tbl_borders.find(qn("w:insideV")) is not None + assert tbl_borders.find(qn("w:insideH")).get(qn("w:color")) == "05206E" + + +def it_inserts_frontpage_template_from_cli(tmp_path: Path) -> None: + doc_path = tmp_path / "frontpage-cli.docx" + _run(["new", str(doc_path)]) + _run(["add-paragraph", "--doc", str(doc_path), "Existing body content"]) + _run( + [ + "add-frontpage", + "--doc", + str(doc_path), + "--template", + "clean", + "--title", + "Operations Review", + "--subtitle", + "Q1 2026", + "--author", + "CLI Agent", + "--organization", + "Nordflint", + "--date-text", + "2026-03-26", + ] + ) + + doc = Document(str(doc_path)) + assert doc.paragraphs[0].text == "Operations Review" + assert str(doc.paragraphs[0].runs[0].font.color.rgb) == "05206E" + body_index = next(i for i, para in enumerate(doc.paragraphs) if para.text == "Existing body content") + assert body_index > 0 + assert doc.core_properties.title == "Operations Review" + + +def it_uses_blue_background_and_white_font_for_corporate_frontpage(tmp_path: Path) -> None: + doc_path = tmp_path / "frontpage-corporate.docx" + _run(["new", str(doc_path)]) + _run( + [ + "add-frontpage", + "--doc", + str(doc_path), + "--template", + "corporate", + "--title", + "Corporate Report", + "--subtitle", + "Q2 2026", + "--author", + "CLI Agent", + "--organization", + "Nordflint", + "--date-text", + "2026-03-26", + ] + ) + + doc = Document(str(doc_path)) + assert doc.paragraphs[0].text == "NORDFLINT" + assert str(doc.paragraphs[0].runs[0].font.color.rgb) == "FFFFFF" + para0_shd = doc.paragraphs[0]._p.get_or_add_pPr().find(qn("w:shd")) + assert para0_shd is not None + assert para0_shd.get(qn("w:fill")) == "05206E"