diff --git a/plugins/format-handler/hwpx/manifest.json b/plugins/format-handler/hwpx/manifest.json new file mode 100644 index 000000000..7b7c50fd7 --- /dev/null +++ b/plugins/format-handler/hwpx/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "officecli-hwpx-py", + "version": "0.1.0", + "protocol": 1, + "kinds": ["format-handler"], + "extensions": [".hwpx"], + "runtime": "python", + "idle_timeout_seconds": { "default": 30, "verbs": { "save": 60, "dump": 30 } }, + "vocabulary": { + "addable_types": ["paragraph", "section"], + "settable_props": { + "paragraph": ["text", "style"] + }, + "path_segments": ["/section[N]", "/section[N]/paragraph[M]"] + }, + "description": "Minimal HWPX format handler (Python)", + "supports": ["paragraphs"] +} diff --git a/plugins/format-handler/hwpx/plugin.bat b/plugins/format-handler/hwpx/plugin.bat new file mode 100644 index 000000000..4c5609e98 --- /dev/null +++ b/plugins/format-handler/hwpx/plugin.bat @@ -0,0 +1,19 @@ +@echo off +REM OfficeCLI HWPX Plugin Wrapper +REM This script runs the Python plugin + +setlocal + +set PYTHON_SCRIPT=%~dp0plugin.py +set PYTHON=python + +if not exist "%PYTHON_SCRIPT%" ( + echo Error: plugin.py not found at %PYTHON_SCRIPT% + exit /b 1 +) + +REM Pass all arguments to the Python script +"%PYTHON%" "%PYTHON_SCRIPT%" %* + +endlocal +exit /b %ERRORLEVEL% diff --git a/plugins/format-handler/hwpx/plugin.py b/plugins/format-handler/hwpx/plugin.py new file mode 100644 index 000000000..631a864a2 --- /dev/null +++ b/plugins/format-handler/hwpx/plugin.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +"""OfficeCLI HWPX Format Handler Plugin (Python, protocol v1).""" + +import sys +import json +import io +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Dict, List, Optional + +HP = 'http://www.hancom.co.kr/hwpml/2011/paragraph' +HS = 'http://www.hancom.co.kr/hwpml/2011/section' +OPF = 'http://www.idpf.org/2007/opf' + + +def _emit(obj: Dict[str, Any]) -> None: + sys.stdout.write(json.dumps(obj, ensure_ascii=False) + '\n') + sys.stdout.flush() + + +def _result(value: Any = None) -> Dict[str, Any]: + return {'protocol': 1, 'msg_type': 'ok', 'result': value} + + +def _error(code: str, message: str, detail: str = '') -> Dict[str, Any]: + return { + 'protocol': 1, + 'msg_type': 'error', + 'error': {'code': code, 'message': message, 'detail': detail}, + } + + +class HwpxDocument: + def __init__(self, path: str): + self.path = Path(path) + self._raw_bytes = self.path.read_bytes() + self.zip = zipfile.ZipFile(io.BytesIO(self._raw_bytes), 'r') + self._sections: List[ET.Element] = [] + self._load() + + def _load(self) -> None: + content_data = self._read_content_hpf() + content_root = ET.fromstring(content_data) + section_files = self._collect_section_files(content_root) + if not section_files: + section_files = ['Contents/section0.xml'] + for sec_file in section_files: + try: + sec_data = self.zip.read(sec_file) + self._sections.append(ET.fromstring(sec_data)) + except KeyError: + continue + if not self._sections: + self._sections = [ + ET.fromstring(f'') + ] + + def _read_content_hpf(self) -> bytes: + for name in ['Contents/content.hpf', 'content.hpf', 'META-INF/manifest.xml']: + try: + return self.zip.read(name) + except KeyError: + continue + raise KeyError('HWPX content manifest not found') + + def _collect_section_files(self, content_root: ET.Element) -> List[str]: + section_files: List[str] = [] + for item in content_root.iter(f'{{{OPF}}}item'): + href = item.get('href', '') + if 'section' in href.lower() and href.endswith('.xml'): + section_files.append(href) + return section_files + + @property + def section_count(self) -> int: + return len(self._sections) + + def _collect_text(self, element: ET.Element) -> str: + return ''.join((t.text or '') for t in element.iter(f'{{{HP}}}t')) + + def get_paragraphs( + self, section_idx: int = 0 + ) -> List[Dict[str, Any]]: + if section_idx >= len(self._sections): + return [] + section = self._sections[section_idx] + result: List[Dict[str, Any]] = [] + for i, p in enumerate(section.iter(f'{{{HP}}}p')): + result.append( + { + 'tag': 'paragraph', + 'id': p.get('id', ''), + 'text': self._collect_text(p), + 'style': p.get('styleIDRef', ''), + 'path': f'/section[{section_idx}]/paragraph[{i}]', + } + ) + return result + + def get_node(self, path: str) -> Dict[str, Any]: + parts = path.strip('/').split('/') + section_idx = 0 + target_idx = None + target_tag = None + for part in parts: + if part.startswith('section['): + section_idx = int(part.split('[')[1].split(']')[0]) + elif part.startswith('paragraph['): + target_idx = int(part.split('[')[1].split(']')[0]) + target_tag = 'paragraph' + if target_tag == 'paragraph': + paras = self.get_paragraphs(section_idx) + if target_idx < len(paras): + return paras[target_idx] + raise KeyError('paragraph not found') + return { + 'tag': 'section', + 'id': str(section_idx), + 'path': f'/section[{section_idx}]', + 'section_index': section_idx, + } + + def set_paragraph_text( + self, section_idx: int, para_idx: int, text: str + ) -> bool: + if section_idx >= len(self._sections): + return False + paras = list(self._sections[section_idx].iter(f'{{{HP}}}p')) + if para_idx >= len(paras): + return False + para = paras[para_idx] + for run in list(para.iter(f'{{{HP}}}run')): + para.remove(run) + run = ET.SubElement(para, f'{{{HP}}}run') + t = ET.SubElement(run, f'{{{HP}}}t') + t.text = text + return True + + def add_paragraph( + self, + section_idx: int, + text: str, + style: str = '', + position: Optional[Dict[str, Any]] = None, + ) -> Optional[str]: + if section_idx >= len(self._sections): + return None + section = self._sections[section_idx] + para = ET.Element(f'{{{HP}}}p') + if style: + para.set('styleIDRef', style) + try: + next_id = max( + int(p.get('id', '0')) for p in section.iter(f'{{{HP}}}p') + ) + except ValueError: + next_id = -1 + para.set('id', str(next_id + 1)) + run = ET.SubElement(para, f'{{{HP}}}run') + t = ET.SubElement(run, f'{{{HP}}}t') + t.text = text + + if position and 'index' in position: + children = list(section) + idx = int(position['index']) + if idx < len(children): + section.insert(idx, para) + else: + section.append(para) + else: + section.append(para) + return para.get('id') + + def remove_element( + self, section_idx: int, para_idx: int + ) -> bool: + if section_idx >= len(self._sections): + return False + paras = list(self._sections[section_idx].iter(f'{{{HP}}}p')) + if para_idx >= len(paras): + return False + self._sections[section_idx].remove(paras[para_idx]) + return True + + def save(self, output_path: Optional[str] = None) -> None: + if output_path is None: + output_path = str(self.path) + with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf: + for item in self.zip.namelist(): + data = self.zip.read(item) + if item.lower().endswith('.xml') and 'section' in item.lower(): + data = self._encode_section(item) + zf.writestr(item, data) + + def _encode_section(self, href: str) -> bytes: + try: + if 'section' in href.lower() and href.lower().endswith('.xml'): + idx = int(''.join(ch for ch in href if ch.isdigit())) + if 0 <= idx < len(self._sections): + return ET.tostring(self._sections[idx], encoding='unicode').encode( + 'utf-8' + ) + except Exception: + pass + return self.zip.read(href) + + def close(self) -> None: + self.zip.close() + + +def _doc() -> HwpxDocument: + doc = globals().get('_doc_instance') + if doc is None: + raise RuntimeError('No document open') + return doc + + +def handle_get(doc: HwpxDocument, args: Dict[str, Any]) -> Dict[str, Any]: + path = args.get('path', '/') + try: + return _result(doc.get_node(path)) + except Exception as exc: + return _error('not_found', str(exc)) + + +def handle_set( + doc: HwpxDocument, args: Dict[str, Any], props: Dict[str, Any] +) -> Dict[str, Any]: + path = args.get('path', '') + parts = path.strip('/').split('/') + section_idx = 0 + para_idx = None + for part in parts: + if part.startswith('section['): + section_idx = int(part.split('[')[1].split(']')[0]) + elif part.startswith('paragraph['): + para_idx = int(part.split('[')[1].split(']')[0]) + unsupported = [k for k in props.keys() if k != 'text'] + if para_idx is not None and 'text' in props: + if not doc.set_paragraph_text(section_idx, para_idx, props['text']): + return _error('invalid_argument', 'failed to set text') + return _result({'unsupported_properties': unsupported}) + + +def handle_add( + doc: HwpxDocument, args: Dict[str, Any], props: Dict[str, Any] +) -> Dict[str, Any]: + parent = args.get('parent_path', args.get('parent', '/')) + add_type = args.get('type', 'paragraph') + position = args.get('position') + parts = parent.strip('/').split('/') + section_idx = 0 + for part in parts: + if part.startswith('section['): + section_idx = int(part.split('[')[1].split(']')[0]) + + if add_type == 'paragraph': + new_id = doc.add_paragraph( + section_idx, + props.get('text', ''), + props.get('style', ''), + position, + ) + if new_id is not None: + return _result( + { + 'path': f'/section[{section_idx}]/paragraph[{new_id}]', + 'unsupported_properties': [], + } + ) + return _error('invalid_argument', 'failed to add paragraph') + + return _error('unsupported_feature', f'Unsupported type: {add_type}') + + +def handle_query( + doc: HwpxDocument, args: Dict[str, Any] +) -> Dict[str, Any]: + selector = (args.get('selector') or '').lower() + results: List[Dict[str, Any]] = [] + for sec_idx in range(doc.section_count): + for para in doc.get_paragraphs(sec_idx): + text = para.get('text', '') + if not selector or selector in text.lower(): + results.append(para) + return _result(results) + + +def handle_remove( + doc: HwpxDocument, args: Dict[str, Any] +) -> Dict[str, Any]: + path = args.get('path', '') + parts = path.strip('/').split('/') + section_idx = 0 + para_idx = None + for part in parts: + if part.startswith('section['): + section_idx = int(part.split('[')[1].split(']')[0]) + elif part.startswith('paragraph['): + para_idx = int(part.split('[')[1].split(']')[0]) + if para_idx is not None: + return _result( + 'removed' if doc.remove_element(section_idx, para_idx) else None + ) + return _result(None) + + +def handle_info() -> Dict[str, Any]: + return { + 'name': 'officecli-hwpx-py', + 'version': '0.1.0', + 'protocol': 1, + 'kinds': ['format-handler'], + 'extensions': ['.hwpx'], + 'target': 'docx', + 'runtime': 'python', + 'idle_timeout_seconds': { + 'default': 30, + 'verbs': {'save': 60, 'dump': 30}, + }, + 'vocabulary': { + 'addable_types': ['paragraph', 'section'], + 'settable_props': {'paragraph': ['text', 'style']}, + 'path_segments': ['/section[N]', '/section[N]/paragraph[M]'], + }, + 'description': 'Minimal HWPX format handler', + 'supports': ['paragraphs'], + } + + +def main() -> None: + if len(sys.argv) > 1 and sys.argv[1] == '--info': + manifest = { + 'name': 'officecli-hwpx', + 'version': '0.1.0', + 'protocol': 1, + 'kinds': ['format-handler'], + 'extensions': ['.hwpx'], + 'runtime': 'python', + 'idle_timeout_seconds': {'default': 60, 'verbs': {'save': 120}}, + 'description': 'HWPX format-handler plugin for OfficeCLI.', + 'tier': 'community', + 'vocabulary': { + 'addable_types': ['paragraph'], + 'settable_props': { + 'paragraph': ['text'] + }, + 'path_segments': ['section', 'paragraph', 'run', 'chars'] + } + } + _emit(_result(manifest)) + return + + doc: Optional[HwpxDocument] = None + for raw in sys.stdin: + line = raw.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + _emit(_error('invalid_request', 'Invalid JSON on stdin')) + continue + + protocol = msg.get('protocol') + msg_type = msg.get('msg_type', '') + if protocol != 1: + _emit( + _error( + 'protocol_mismatch', + f'Expected protocol 1, got {protocol}', + ) + ) + continue + + if msg_type == 'open': + doc = HwpxDocument(msg.get('path', '')) + snapshot = { + 'capabilities': { + 'commands': [ + 'add', + 'set', + 'get', + 'query', + 'remove', + 'save', + 'view', + 'raw', + ], + 'features': ['save'], + }, + 'vocabulary': { + 'addable_types': ['paragraph', 'section'], + 'settable_props': {'paragraph': ['text', 'style']}, + 'path_segments': [ + '/section[N]', + '/section[N]/paragraph[M]', + ], + }, + } + _emit(_result(snapshot)) + continue + + if msg_type == 'command': + if doc is None: + _emit( + _error( + 'plugin_stream_closed', 'No document open' + ) + ) + continue + command = msg.get('command', '') + args = msg.get('args', {}) + props = msg.get('props', {}) + if command == 'get': + _emit(handle_get(doc, args)) + elif command == 'set': + _emit(handle_set(doc, args, props)) + elif command == 'add': + _emit(handle_add(doc, args, props)) + elif command == 'query': + _emit(handle_query(doc, args)) + elif command == 'remove': + _emit(handle_remove(doc, args)) + else: + _emit( + _error( + 'unsupported_command', + f'Unsupported command: {command}', + ) + ) + continue + + if msg_type == 'ping': + _emit(_result(None)) + continue + + if msg_type == 'save': + if doc: + doc.save() + _emit(_result(None)) + continue + + if msg_type == 'close': + if doc: + doc.close() + doc = None + _emit(_result(None)) + return + + _emit(_error('invalid_request', f'Unknown msg_type: {msg_type}')) + + +if __name__ == '__main__': + main() diff --git a/src/officecli/Core/Plugins/FormatHandlerSession.cs b/src/officecli/Core/Plugins/FormatHandlerSession.cs index 6f8015e04..085610f3a 100644 --- a/src/officecli/Core/Plugins/FormatHandlerSession.cs +++ b/src/officecli/Core/Plugins/FormatHandlerSession.cs @@ -65,8 +65,8 @@ public void Start(bool editable) var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); var psi = new ProcessStartInfo { - FileName = _plugin.ExecutablePath, - ArgumentList = { "open", _filePath }, + FileName = ResolveExecutableForLaunch(_plugin.ExecutablePath), + ArgumentList = { ResolveArgumentsForLaunch(_plugin.ExecutablePath, "open", Path.GetFullPath(_filePath)) }, UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, @@ -297,11 +297,11 @@ public void Start(bool editable) _broken = true; TryKill(); throw new CliException( - $"Format-handler plugin '{_plugin.Manifest.Name}' produced no activity for {idleTimeoutSec}s (command={verbForError}).") + $"Format-handler plugin '{_plugin.Manifest.Name}' produced no activity for {idleTimeoutSec}s (command={verbForError}).") { Code = "plugin_idle_timeout", Suggestion = $"Raise `idle_timeout_seconds.verbs.{verbForError}` in the plugin's manifest, " + - "emit periodic `{\"heartbeat\":true}` on stderr during long jobs, or pass --timeout 0 to disable.", + "emit periodic `{\"heartbeat\":true}` on stderr during long jobs, or pass --timeout 0 to disable.", }; } } @@ -376,4 +376,90 @@ private void TryKill() private static string Truncate(string s, int max) => s.Length <= max ? s : s.Substring(0, max) + "..."; + + /// + /// Resolve the executable to actually launch. On Windows, `.bat`/`.cmd` + /// require `cmd.exe /c` wrappers when `UseShellExecute` is false; `.py` + /// needs the interpreter from PATH (or `PY_PYTHON`); real executables + /// pass through unchanged. + /// + private static string ResolveExecutableForLaunch(string path) + { + if (OperatingSystem.IsWindows()) + { + if (path.EndsWith(".bat", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase)) + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe"); + } + if (path.EndsWith(".py", StringComparison.OrdinalIgnoreCase)) + { + var py = Environment.GetEnvironmentVariable("PY_PYTHON") + ?? Environment.GetEnvironmentVariable("PYTHON"); + if (!string.IsNullOrWhiteSpace(py)) + { + var pyPath = py!.Trim().Trim('"'); + if (!Path.IsPathRooted(pyPath)) + pyPath = ResolveOnPath(pyPath); + if (!string.IsNullOrEmpty(pyPath)) + return pyPath; + } + + if (TryResolve("python") is string pyExe) + return pyExe; + if (TryResolve("python3") is string pyExe3) + return pyExe3; + } + } + + return path; + } + + /// + /// Resolve the argument list to actually launch. For .bat wrappers we + /// emit a single quoted string after `cmd.exe /c`. For .py we emit + /// the script path followed by the original args. For executables we + /// pass the original args through unchanged. + /// + private static string[] ResolveArgumentsForLaunch(string executablePath, string verb, string filePath) + { + if (OperatingSystem.IsWindows()) + { + if (executablePath.EndsWith(".bat", StringComparison.OrdinalIgnoreCase) || + executablePath.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase)) + { + return new[] { "/c", $"\"{executablePath}\" {verb} \"{filePath}\"" }; + } + if (executablePath.EndsWith(".py", StringComparison.OrdinalIgnoreCase)) + { + return new[] { executablePath, verb, filePath }; + } + } + + return new[] { verb, filePath }; + } + + private static string? TryResolve(string exe) + { + var dirs = (Environment.GetEnvironmentVariable("PATH") ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + + var candidates = OperatingSystem.IsWindows() switch + { + true => new[] { exe + ".exe", exe, exe + ".cmd", exe + ".bat" }, + false => new[] { exe } + }; + + foreach (var dir in dirs) + { + foreach (var name in candidates) + { + var candidate = Path.Combine(dir, name); + if (File.Exists(candidate)) + return candidate; + } + } + + return null; + } } diff --git a/src/officecli/Core/Plugins/PluginRegistry.cs b/src/officecli/Core/Plugins/PluginRegistry.cs index 3da1b262a..9fc7ca141 100644 --- a/src/officecli/Core/Plugins/PluginRegistry.cs +++ b/src/officecli/Core/Plugins/PluginRegistry.cs @@ -143,6 +143,11 @@ private static IEnumerable PluginExeNames() { yield return "plugin.exe"; yield return "plugin"; + // Windows discovery also accepts non-.exe wrappers so Python/BAT + // plugins can live under the standard / plugin dirs + // without renaming them into an .exe shim. + yield return "plugin.bat"; + yield return "plugin.py"; } else { @@ -255,12 +260,23 @@ public static bool TryReadManifest(string executablePath, out PluginManifest man manifest = new PluginManifest(); try { + var fileName = executablePath; + var arguments = "--info"; + + // Windows: .bat files require cmd.exe /c when UseShellExecute is false. + if (OperatingSystem.IsWindows() + && fileName.EndsWith(".bat", StringComparison.OrdinalIgnoreCase)) + { + fileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe"); + arguments = "/c \"" + executablePath + "\" --info"; + } + using var p = new Process { StartInfo = new ProcessStartInfo { - FileName = executablePath, - Arguments = "--info", + FileName = fileName, + Arguments = arguments, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true,