From 4b6ae68b6a94b1215cfc8f346fdbf9c9aa3e17c6 Mon Sep 17 00:00:00 2001 From: ITSKVN Date: Mon, 6 Jul 2026 15:44:40 +0700 Subject: [PATCH 01/15] Add md-to-docx agent skill with MedKarute enhancements Vendored pickle-an skill under .agents/skills/md-to-docx with English docs, scripts/assets layout, cross-agent symlinks, front-matter/TOC and other generator improvements. Document upstream diff in README; ignore __pycache__, .venv, and leftover copilot reference folder. --- .agents/README.md | 62 + .agents/skills/md-to-docx/README.md | 138 ++ .agents/skills/md-to-docx/SKILL.md | 510 +++++++ .../skills/md-to-docx/assets/template.docx | Bin 0 -> 36640 bytes .../md-to-docx/assets/template_preview.docx | Bin 0 -> 38442 bytes .../md-to-docx/scripts/create_preview.py | 314 ++++ .../md-to-docx/scripts/create_template.py | 72 + .../md-to-docx/scripts/markdown_normalizer.py | 592 ++++++++ .../skills/md-to-docx/scripts/md_to_docx.py | 1307 +++++++++++++++++ .../md-to-docx/scripts/version_manager.py | 240 +++ .claude/skills/md-to-docx | 1 + .codex/skills/md-to-docx | 1 + .gitignore | 17 +- .grok/skills/md-to-docx | 1 + 14 files changed, 3250 insertions(+), 5 deletions(-) create mode 100644 .agents/README.md create mode 100644 .agents/skills/md-to-docx/README.md create mode 100644 .agents/skills/md-to-docx/SKILL.md create mode 100644 .agents/skills/md-to-docx/assets/template.docx create mode 100644 .agents/skills/md-to-docx/assets/template_preview.docx create mode 100644 .agents/skills/md-to-docx/scripts/create_preview.py create mode 100644 .agents/skills/md-to-docx/scripts/create_template.py create mode 100644 .agents/skills/md-to-docx/scripts/markdown_normalizer.py create mode 100644 .agents/skills/md-to-docx/scripts/md_to_docx.py create mode 100644 .agents/skills/md-to-docx/scripts/version_manager.py create mode 120000 .claude/skills/md-to-docx create mode 120000 .codex/skills/md-to-docx create mode 120000 .grok/skills/md-to-docx diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 0000000..267659e --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,62 @@ +# Agent Skills + +Project skills follow the [Agent Skills](https://agentskills.io) open standard. Each skill is a directory with a `SKILL.md` entrypoint and optional scripts, templates, and assets. + +## Canonical location + +``` +.agents/skills// +├── SKILL.md # Required: metadata + instructions +├── README.md # Optional: human notes, upstream link +├── scripts/ # Optional: executable code +└── assets/ # Optional: templates, binaries, static files +``` + +**Source of truth:** `.agents/skills/` — edit skills here only. + +## Agent discovery paths + +| Agent | Project path | Notes | +| ----- | ------------ | ----- | +| **Codex** | `.agents/skills/` | Scans from CWD up to repo root | +| **Cursor** | `.agents/skills/`, `.cursor/skills/` | Also reads `.claude/skills/`, `.codex/skills/` | +| **Claude Code** | `.claude/skills/` | Symlinked from `.agents/skills/` | +| **Grok** | `.grok/skills/` | Symlinked from `.agents/skills/` | + +Compatibility symlinks in this repo point to `.agents/skills/` so all agents share one skill tree: + +``` +.claude/skills/ → ../../.agents/skills/ +.codex/skills/ → ../../.agents/skills/ +.grok/skills/ → ../../.agents/skills/ +``` + +## Available skills + +| Skill | Description | Upstream | +| ----- | ----------- | -------- | +| `md-to-docx` | Convert Markdown to formatted Word (.docx) with versioning and normalization | [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) | + +Vendored skills in `.agents/skills/` may diverge from upstream (e.g. English `SKILL.md`, MedKarute symlink layout). To sync or report issues, use the upstream repository. + +## Adding a new skill + +For third-party skills, link the upstream repo in the skill table above and in `SKILL.md` / `README.md` under the skill folder (see [`md-to-docx`](./skills/md-to-docx/README.md) as reference). + +1. Create `.agents/skills//SKILL.md` with YAML frontmatter (`name`, `description`). +2. Add compatibility symlinks: + + ```bash + SKILL=your-skill-name + for dir in .claude/skills .codex/skills .grok/skills; do + mkdir -p "$dir" + ln -sfn "../../.agents/skills/$SKILL" "$dir/$SKILL" + done + ``` + +3. Restart the agent session (or wait for live reload) so discovery picks up the new skill. + +## Invoking skills + +- **Automatic:** Agents load skill name + description at startup; full `SKILL.md` loads when the task matches `description`. +- **Manual:** Type `/md-to-docx` (Claude Code, Cursor) or `$md-to-docx` (Codex CLI). \ No newline at end of file diff --git a/.agents/skills/md-to-docx/README.md b/.agents/skills/md-to-docx/README.md new file mode 100644 index 0000000..c22ec29 --- /dev/null +++ b/.agents/skills/md-to-docx/README.md @@ -0,0 +1,138 @@ +# md-to-docx + +Vendored copy of the [md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) Agent Skill for MedKarute. + +| | | +| --- | --- | +| **Upstream** | https://github.com/pickle-an/md-to-docx-skill | +| **Agent entrypoint** | [`SKILL.md`](./SKILL.md) | +| **Project layout** | `.agents/skills/md-to-docx/` (see [`.agents/README.md`](../../README.md)) | + +## Layout + +``` +md-to-docx/ +├── SKILL.md # Agent instructions (required) +├── README.md # This file +├── scripts/ +│ ├── md_to_docx.py # Main converter (CLI entry) +│ ├── markdown_normalizer.py +│ ├── version_manager.py +│ ├── create_template.py +│ └── create_preview.py +└── assets/ + ├── template.docx + └── template_preview.docx +``` + +## MedKarute enhancements + +Single Python pipeline. Extra behavior layered on upstream pickle-an (all in `scripts/md_to_docx.py`): + +| Feature | Implementation | +| ------- | -------------- | +| YAML front-matter (`title`, `date`, `version`, `audience`) | `extract_front_matter()` → cover page | +| Title / subtitle on `—` or `–` | `split_title_subtitle()` | +| Static table of contents | `add_table_of_contents()` after cover | +| Skip duplicate `#` title when front-matter title exists | `titles_match()` | +| Mermaid placeholder in Word | `add_code_block()` when `language == mermaid` | +| Alternating table row shading | `#F2F7FB` on odd data rows | +| Image-not-found placeholder | `[Image not found: ]` | + +After syncing `.py` files from pickle-an upstream, re-check these helpers still merge cleanly. + +## vs upstream (pickle-an) + +Still **one** Python converter — not a second pipeline. MedKarute adds steps inside the same run: + +```mermaid +flowchart LR + A[Input .md] --> B[Front-matter + version] + B --> C[Normalize] + C --> D[Parse] + D --> E[Generate .docx] +``` + +| Step | Upstream | MedKarute | +| ---- | -------- | --------- | +| Read input | `.md` as-is | Strip YAML `---` block first; metadata → cover | +| Versioning | `_V1`, `_V2`, `_normalized.md` | Same | +| Normalize / parse | `markdown_normalizer` + line parser | Same | +| Generate | Cover from first `#` only; Chinese labels | Cover from YAML or `#`; TOC; extras below | +| Layout | Scripts beside `SKILL.md` | `scripts/` + `assets/` | + +### Output labels (upstream CN → MedKarute EN) + +| Field | Upstream | MedKarute | +| ----- | -------- | --------- | +| Version on cover | `版本:V1` | `Version: V1` | +| Date on cover | `编制日期:2026年07月06日` | `Date: 2026-07-06` | +| Audience | — | `Audience: …` | +| TOC page title | — (no TOC) | `Table of Contents` | +| Missing image | `[图片加载失败: …]` | `[Image not found: …]` | +| CJK font id | `宋体` | `SimSun` (same family, EN locale name) | + +Typography unchanged: SimSun + Times New Roman, 0.74cm first-line indent, page break before `##`. + +### What is actually better + +| Improvement | Why it helps | +| ----------- | ------------ | +| YAML front-matter | Session/writing `.md` often already has `title`, `date`, `version`, `audience` — no extra CLI/chat metadata | +| Title — subtitle split | One YAML line → two-line cover | +| Static TOC | Long exports get a navigable page before body content | +| Skip duplicate `#` title | Front-matter title + body `#` no longer prints twice | +| Mermaid placeholder | Research diagrams stay in `.md`; Word gets a clear note instead of raw fences | +| Zebra tables + clearer image errors | Easier to read; agents/users see paths when assets are missing | +| English labels + ISO dates | Matches EN skill/docs and mixed-language research sessions | + +**Unchanged from upstream (already strong):** auto versioning, markdown normalizer (incl. Chinese numerals in headings), template support, formal document styles. + +### When you notice the difference + +- **Large win:** front-matter present, or many `##`–`####` headings (cover + TOC + no duplicate title). +- **Small win:** short `.md` without YAML — mostly label/date format and image error text; conversion logic is nearly the same. + +## Run + +```bash +cd .agents/skills/md-to-docx +pip install python-docx +python scripts/md_to_docx.py path/to/document.md +``` + +Optional front-matter example: + +```yaml +--- +title: Project Name — Executive Summary +date: 2026-07-06 +version: V2 +audience: Research team +--- +``` + +## Regenerate template assets + +```bash +cd .agents/skills/md-to-docx +python3 -m venv .venv && .venv/bin/pip install python-docx +.venv/bin/python scripts/create_template.py # assets/template.docx +.venv/bin/python scripts/create_preview.py # assets/template_preview.docx +``` + +`template_preview.docx` content is defined in `scripts/create_preview.py`. `template.docx` is a blank style-only base from `scripts/create_template.py`. + +**Language policy:** User-visible labels in generated `.docx` files and skill docs are English. `markdown_normalizer.py` keeps Chinese characters only as **input** tokens for numeral normalization (e.g. `一、` → `1.`). + +## Sync from upstream + +```bash +git clone --depth 1 https://github.com/pickle-an/md-to-docx-skill /tmp/md-to-docx-skill +cp /tmp/md-to-docx-skill/*.py .agents/skills/md-to-docx/scripts/ +cp /tmp/md-to-docx-skill/template.docx .agents/skills/md-to-docx/assets/ +# Keep local SKILL.md (English) unless intentionally re-merging upstream skill/SKILL.md +# Re-apply MedKarute enhancements in md_to_docx.py if the merge overwrote them +``` + +Report converter bugs to [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill). Report MedKarute integration issues in this repo. \ No newline at end of file diff --git a/.agents/skills/md-to-docx/SKILL.md b/.agents/skills/md-to-docx/SKILL.md new file mode 100644 index 0000000..f7b0ab2 --- /dev/null +++ b/.agents/skills/md-to-docx/SKILL.md @@ -0,0 +1,510 @@ +--- +name: md-to-docx +description: Convert Markdown files to formatted Word documents (.docx). Use when the user wants to convert .md to .docx, create a Word document from Markdown, or mentions document conversion. +metadata: + upstream: https://github.com/pickle-an/md-to-docx-skill +--- + +# Markdown to Word Document Converter + +This skill converts Markdown files into professionally formatted Word documents (.docx). + +**Upstream:** [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) — original Agent Skill and Python scripts. This copy lives under `.agents/skills/md-to-docx/` for MedKarute agent discovery; report bugs or request features upstream unless they are MedKarute-specific integration issues. + +## When to Invoke + +Invoke this skill when: + +- The user wants to convert a Markdown file to a Word document +- The user asks to create a Word document from Markdown content +- The user mentions `.md` to `.docx` conversion +- The user needs formatted document output + +## Processing Pipeline + +One converter (`scripts/md_to_docx.py`). Optional side output: `_normalized.md` when normalization is enabled. + +```mermaid +flowchart LR + A[Input .md] --> B[Version + front-matter] + B --> C[Normalize] + C --> D[Parse] + D --> E[Generate .docx] +``` + +## Features + +### 1. Automatic Version Management + +The skill automatically manages output file version numbers: + +| Scenario | Input file | Output files | +| -------- | ---------- | ------------ | +| No version in filename | `document.md` | `document_V1.docx`, `document_V1_normalized.md` | +| Version in filename | `document_V3.md` | `document_V4.docx`, `document_V4_normalized.md` | +| V1 already exists | `document.md` | `document_V2.docx`, `document_V2_normalized.md` | + +**Version rules:** + +- If the input filename contains a version (e.g. `_V3`), increment from that version +- If the filename has no version, scan the directory for existing versions and increment +- Version format: `_V{n}` where n is an integer (V1, V2, V3...) +- `.docx` and `_normalized.md` files share the same version number + +### 2. YAML Front-Matter + +Optional metadata block at the top of the Markdown file: + +```yaml +--- +title: Project Name — Executive Summary +date: 2026-07-06 +version: V2 +audience: Research team, reviewers +--- +``` + +- `title` — cover page; split on `—` or `–` into title + subtitle +- `date`, `version` — cover page (CLI args override when provided) +- `audience` — shown on cover page +- Body content is parsed after the front-matter block is stripped +- Duplicate `#` title matching front-matter `title` is skipped in the body + +### 3. Static Table of Contents + +When the document has `##` / `###` / `####` headings (mapped to heading levels 1–3), a **Table of Contents** page is inserted after the cover and before body content. + +### 4. Markdown Format Normalization + +Before conversion, the skill automatically normalizes common Markdown formatting issues: + +| Issue type | Example | Fix | +| ---------- | ------- | --- | +| Unclosed code fence | ` ```python` without closing fence | Auto-add closing ` ``` ` | +| Missing space after heading | `###Heading` | → `### Heading` | +| Chinese numerals in headings | `## 一、Core principles` | → `## 1. Core principles` | +| Chinese numerals in headings | `### (一) Technical details` | → `### (1) Technical details` | +| Unordered list without space | `-item` | → `- item` | +| Ordered list without space | `1.item` | → `1. item` | +| Horizontal rule variants | `--` or `----` | → `---` | +| Unmatched bold markers | `**only opening` | Remove invalid markers | +| Unmatched italic markers | `*only opening` | Remove invalid markers | +| Missing table separator row | Table without `\|---\|` | Auto-add separator row | +| Inconsistent table columns | Rows with different column counts | Auto-pad/truncate | +| Multiple consecutive blank lines | 3+ blank lines | Compress to 1 | +| Missing blank line before heading | Text directly before heading | Add blank line | +| Ordered list spacing | Blank lines between list items | **Preserve** (do not remove) | +| Paragraph first-line indent | ` Text with leading spaces` | Remove leading spaces | +| Blank lines between paragraphs | Single blank line between paragraphs | **Remove** (cleanup) | + +**Chinese numeral conversion:** +The skill automatically converts Chinese numeral sequences in headings to Arabic numerals: + +- Supported: 一, 二, 三, 四, 五, 六, 七, 八, 九, 十 (up to twenty) +- Pattern 1: `一、` → `1.` (Chinese punctuation) +- Pattern 2: `(一)` → `(1)` (parentheses form) +- Applies to all heading levels (# through ######) + +**Ordered list spacing:** +Blank lines between ordered list items are preserved for readability: + +- If a blank line exists between two numbered items, it is kept +- Example: `1. Item 1` → (blank line) → `2. Item 2` keeps the spacing + +**Blank line management:** +The skill manages blank lines based on context: + +- **Remove**: Blank lines between ordinary paragraphs (cleanup for better formatting) +- **Preserve**: Blank lines around special elements (headings, lists, code blocks, tables, horizontal rules) +- **Preserve**: Blank lines around indented content (list item details, nested items) +- **Compress**: Multiple consecutive blank lines reduced to one + +**Paragraph first-line whitespace:** +Leading whitespace on ordinary paragraphs is removed to prevent double indentation in Word: + +- Word applies first-line indent automatically +- Leading spaces in Markdown cause visual inconsistency +- List item indentation is preserved (unordered and ordered lists) +- Code blocks and blockquotes keep their formatting + +### 5. Supported Markdown Elements + +| Element | Syntax | Support | +| ------- | ------ | ------- | +| Headings | `#` through `######` | Full | +| Paragraphs | Plain text | Full | +| Bold | `**text**` | Full | +| Italic | `*text*` | Full | +| Bold + italic | `***text***` | Full | +| Unordered lists | `- item` / `* item` | Full | +| Ordered lists | `1. item` | Full | +| Tables | `\| col \|` | Full | +| Code blocks | ` ```code``` ` | Full | +| Inline code | `` `code` `` | Full | +| Links | `[text](url)` | Full | +| Images | `![alt](path)` | Full | +| Blockquotes | `> quote` | Full | +| Horizontal rules | `---` | Full | +| Strikethrough | `~~text~~` | Full | +| Line breaks | `
` or `\\` | Full | + +### 6. Document Formatting Specification + +#### Font Specification + +| Element | Chinese font | English font | Size | Notes | +| ------- | ------------ | ------------ | ---- | ----- | +| Body | SimSun | Times New Roman | 12pt | Standard body text | +| Heading 1 | SimSun | Times New Roman | 22pt | Major title | +| Heading 2 | SimSun | Times New Roman | 16pt | Section title | +| Heading 3 | SimSun | Times New Roman | 15pt | Subsection title | +| Heading 4 | SimSun | Times New Roman | 14pt | Item title | +| Heading 5 | SimSun | Times New Roman | 14pt | Sub-item title | +| Code block | Consolas | Consolas | 9pt | Slightly smaller than body | +| Inline code | Consolas | Consolas | 12pt | Same as body | + +#### Paragraph Specification + +| Property | Value | Notes | +| -------- | ----- | ----- | +| First-line indent | 0.74cm | ~two Chinese character widths | +| Line spacing | 1.5× | Improved readability | +| Space before | 0pt | Compact layout | +| Space after | 0pt | Compact layout | + +#### Heading Specification + +| Level | Markdown syntax | Size | Style | +| ----- | --------------- | ---- | ----- | +| Document title | `# Title` | 22pt | Bold, centered, can generate cover page | +| Level 1 | `## Title` | 22pt | Bold, page break before | +| Level 2 | `### Title` | 16pt | Bold, no page break | +| Level 3 | `#### Title` | 15pt | Bold, no page break | +| Level 4 | `##### Title` | 14pt | Bold, no page break | +| Level 5 | `###### Title` | 14pt | Bold, no page break | + +#### Table Specification + +| Property | Value | Notes | +| -------- | ----- | ----- | +| Table style | Table Grid | Standard bordered table | +| Alignment | Center | Table centered on page | +| Column width | Auto-calculated | Content-based distribution | +| Header background | #D9D9D9 | Light gray header row | +| Header alignment | Center | Header text centered | +| Cell alignment | Left | Data left-aligned | + +#### Code Block Specification + +| Property | Value | Notes | +| -------- | ----- | ----- | +| Font | Consolas | Monospace for clarity | +| Size | 9pt | Slightly smaller than body | +| Background | #F5F5F5 | Light gray background | +| Left indent | 0.5cm | Visual separation | +| Language label | Italic | e.g. `[python]` | + +#### Inline Code Specification + +| Property | Value | Notes | +| -------- | ----- | ----- | +| Font | Consolas | Monospace | +| Size | Same as body | Consistent line height | +| Background | #F0F0F0 | Light gray highlight | + +#### Blockquote Specification + +| Property | Value | Notes | +| -------- | ----- | ----- | +| Left border | #6366F1 | Purple vertical line | +| Border width | 1.5pt | Clearly visible | +| Left/right indent | 1cm | Emphasize quoted content | +| Font style | Italic | Distinguish quoted text | + +#### List Specification + +| Property | Value | Notes | +| -------- | ----- | ----- | +| Left indent | 0.74cm × level | Multi-level nesting | +| Line spacing | 1.5× | Matches body text | +| Unordered bullet | • | Solid circle | +| Ordered format | 1. 2. 3. | Numbered with period | + +#### Horizontal Rule Specification + +| Property | Value | Notes | +| -------- | ----- | ----- | +| Style | Bottom border | Line below paragraph | +| Color | #CCCCCC | Light gray | +| Space before/after | 6pt | Appropriate spacing | + +#### Cover Page Specification + +| Element | Value | Notes | +| ------- | ----- | ----- | +| Title size | 22pt | Matches level-1 heading | +| Title style | Bold, centered | Prominent document title | +| Version info | 12pt, centered | Format: Version: V1 | +| Date info | 12pt, centered | Format: Date: 2024-01-01 | +| Blank lines before title | 3 | Top margin | +| Blank lines after title | 14 | Space before version/date | + +#### Page Break Control + +| Rule | Description | +| ---- | ----------- | +| Page break before level-1 headings | Each `##` heading starts a new page | +| Other headings | No page break | +| After cover page | Automatic page break | + +## Usage + +### Basic Conversion + +Run from the skill root (`.agents/skills/md-to-docx/`): + +```bash +python scripts/md_to_docx.py [template.docx] [output.docx] [version] [date] +``` + +Or ask the agent: + +``` +Convert this Markdown file to Word: +[provide .md file path or content] +``` + +### Custom Template + +``` +Convert Markdown to Word using this template: +Markdown: [path or content] +Template: [.docx template path] +``` + +### With Cover Page + +``` +Convert to Word with cover page: +[Markdown content] +Title: [document title] +Version: [version number] +Date: [date] +``` + +## Parameters + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `markdown_content` | string | Yes | Markdown text or file path | +| `output_path` | string | No | Output .docx file path | +| `template_path` | string | No | Custom .docx template path | +| `version` | string | No | Cover page version (auto-generated if omitted) | +| `date` | string | No | Cover page date | +| `normalize` | boolean | No | Enable format normalization (default: true) | +| `save_normalized` | boolean | No | Save normalized MD file (default: true) | +| `use_versioning` | boolean | No | Enable automatic version numbering (default: true) | + +## Implementation + +The skill uses Python scripts with this pipeline: + +### Step 0: Version Management (`scripts/version_manager.py`) + +```python +# Automatically determine output file version +version_info = get_versioned_output_paths(input_path, output_dir) +# Returns: { 'version': 1, 'docx_path': 'document_V1.docx', 'normalized_md_path': 'document_V1_normalized.md' } +``` + +### Step 1: Format Normalization (`scripts/markdown_normalizer.py`) + +```python +# Auto-fix common Markdown formatting issues +normalizer = MarkdownNormalizer() +normalized_content = normalizer.normalize(content) +# Saved to: original_filename_normalized.md +``` + +### Step 2: Parse Markdown (`scripts/md_to_docx.py`) + +```python +# Convert normalized Markdown to structured elements +parser = MarkdownParser() +elements = parser.parse(normalized_content) +``` + +### Step 3: Generate Word Document + +```python +# Create formatted Word document +generator = DocxGenerator(template_path) +generator.create_document(output_path) +generator.generate(elements, version, date) +generator.save(output_path) +``` + +## Output Files + +After conversion, these files are produced: + +| File | Description | +| ---- | ----------- | +| `document_V{n}.docx` | Final versioned Word document | +| `document_V{n}_normalized.md` | Versioned normalized Markdown | + +**Version examples:** + +- First conversion: `document_V1.docx`, `document_V1_normalized.md` +- Second conversion: `document_V2.docx`, `document_V2_normalized.md` +- Versioned input: `document_V3.md` → `document_V4.docx` + +## Template Support + +### Custom Template + +When a template is provided: + +- Inherit page settings (margins, paper size) +- Inherit style definitions (Heading 1–6, Normal) +- Clear template content before adding new content + +### Built-in Styles (No Template) + +**This skill runs standalone without an external template file.** + +If no template is provided or the template file does not exist: + +- Create a blank document automatically +- Use default page settings (A4, 2.54cm margins) +- Create styles programmatically +- Apply consistent formatting + +The skill runs in any environment with Python and `python-docx`, without external template dependencies. + +## Examples + +### Example 1: Format Normalization + +**Input (with issues):** + +```markdown +# Test Document +## 1. Table Test +| Col1|Col2|Col3 +|Data1|Data2|Data3 +###Heading Without Space +-Item1 Without Space +``` + +**Normalized output:** + +```markdown +# Test Document + +## 1. Table Test + +|Col1|Col2|Col3| +|---|---|---| +|Data1|Data2|Data3| + +### Heading Without Space + +- Item1 Without Space +``` + +### Example 2: Simple Conversion + +Input: + +```markdown +# Project Document + +## Overview +This is the project overview. + +## Features +- Feature 1 +- Feature 2 +``` + +Output: Formatted Word document with correct heading hierarchy and bullet list. + +### Example 3: With Table + +Input: + +```markdown +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | /api/users | List all users | +| POST | /api/users | Create user | +``` + +Output: Word document with formatted table and gray header row. + +### Example 4: With Code Block + +Input: + +````markdown +## Installation + +```bash +npm install package-name +``` +```` + +Output: Word document with monospace code block. + +## Error Handling + +- **Invalid Markdown**: Log warning, continue with partial parsing +- **Missing template**: Fall back to built-in styles +- **File not found**: Return clear error message +- **Permission error**: Suggest alternative output path +- **Format issues**: Auto-fix during normalization phase + +## Dependencies + +- Python 3.x +- `python-docx` library + +Install: + +```bash +pip install python-docx +``` + +Optional: `requests`, `Pillow` (for remote/local image embedding). + +## Project Layout + +| Path | Description | +| ---- | ----------- | +| `SKILL.md` | Agent entrypoint (this file) | +| `scripts/md_to_docx.py` | Main conversion script | +| `scripts/markdown_normalizer.py` | Markdown format normalization | +| `scripts/version_manager.py` | Automatic version numbering | +| `scripts/create_template.py` | Regenerate default template (optional) | +| `scripts/create_preview.py` | Generate format preview doc (optional) | +| `assets/template.docx` | Default Word template (optional) | +| `assets/template_preview.docx` | Sample output for style reference | + +**Notes:** + +- `assets/template.docx` is optional; if present it is used, otherwise a blank document is created +- `scripts/create_template.py` can regenerate a template matching the formatting specification +- This skill runs fully standalone with no external dependencies beyond Python packages + +## Upstream repository + +| Resource | URL | +| -------- | --- | +| Repository | https://github.com/pickle-an/md-to-docx-skill | +| Upstream `SKILL.md` | https://github.com/pickle-an/md-to-docx-skill/blob/main/skill/SKILL.md | +| Releases | https://github.com/pickle-an/md-to-docx-skill/releases | + +Licensed MIT upstream. When updating this vendored copy, compare against upstream `skill/` and root Python modules. \ No newline at end of file diff --git a/.agents/skills/md-to-docx/assets/template.docx b/.agents/skills/md-to-docx/assets/template.docx new file mode 100644 index 0000000000000000000000000000000000000000..769f7b8cb629c922c4ad2bc609753f4abdb015d3 GIT binary patch literal 36640 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 z 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 IWfzDC0F27C)Bpeg literal 0 HcmV?d00001 diff --git a/.agents/skills/md-to-docx/assets/template_preview.docx b/.agents/skills/md-to-docx/assets/template_preview.docx new file mode 100644 index 0000000000000000000000000000000000000000..1d36e2bf63966f85b17b8cc2953004449ec33fa6 GIT binary patch literal 38442 zcmagFb6_Rgwl5raY;|m-V;ddYwmP=eanezDY}>YNCmq|)it$#Tz0bX8-}mnK{ZngY z&Kf_ASvBXV%3KQ4;1K8_ARy2n6kZ296-oth37{Y#qfj6qXuwu&5j$IFQ(I?!6%Tt; zCtU`28|#JyS@~5#$`b#uD+ z=P7p3FT2%!-1?}&m%`avfe9K0uUgTc^Be4~(IRe3WUIqjtODoQ8Al)JPR z`=bi3Z{vP$Bu^s#VwrEt3>0b^-_l@P12~hz4X#whLk#NU`<-fBCur*&%~c(0S9=N& z-3Q_9kZ1R7x*6QY)LBlTYD=Q-l(jZct%CQ??Y;$3RL>oOXR3eL@?@W`s?l1sYiUNS z_*kBNm%#CS5J5~o?GB`-tRvR_1(nL&9#i2R^w9V4KwS_+#{aY&ruQ&ECu=jV>OYJW z!lo)vqtqr8lQ_b{#qr^AB0}N=$PUt8;S!beX&2S!qY4o0_jb!^>S`v&FPL+-2HA>+kADnhSwBOFbH!)YtWG`fJdMFzo@v z?kYP$n%1e208&IjQ;G+6Gc_W9+6}Y;ZR)y<8KFvHQGFU43B=-{lAkDdaL!cfE6oq0 zZ3-6JW!DTx-$mq=_1wwspeEW;`#H_)d5dP6cfTIclaWrSdrq3UY;pYAqs@N#&jf{qz$se-FY_5-f>3}7GO;sOaI~{`Vl=jMH2rgtXT*=m^?gAW zd+-*UmsJbDMTQoWqyasNkdix*i=bkPl3TMH^H#*UlcU4tlq2kH z=rwKE4ZHq=nxXmmoi%zVa(@A5lV+A`w714FPRb-h-}Lny#3466^H7+KUk`WJmP~2C zB+-k6(jg>UC1&Y5CvuPK{64`CEd%(*SY%vWyF=RS>L* z`qMfh<=PVUu$v#}v0WNML)lzx;BO*SQ6saY308)7J$EQs8Ubq#vCa2&B|Nc+()WlE z&pg7*ub84VzePT2;m7`LOhNOj8MV7T5^tpyh0JGi zhF`2^th!8UhoPL9uX2!*ED`_3h!B_7nEFEf@K9t)OIJ?&2Syef4vH)JcCc>?=)N5# ztG0y&S9+IMxw?D0-CKilM?tR z)Hlg^LesHSy{zTqlKNLA1q$T9cC@E4zA_SA!K6Li;(jau&Itc`VHi#8tboCQfG8;c zb;G*ZIhrs6?^+id;NtY>$~f0qcie5n=)6?U9u4L|)82^@l}=4y`DT~ya!N)pCdt5@ zf+#9X@-5y#P(tS^cdqHkN1xnomhu=>P^q5mbhF;HVjl%YkW*l@X9^T?_+ir*(VLZo z?%raVYqsn+3Q3MEJ|muUv~On{uO&fF^6qpGKZ4|;h0IQ#-PHRHza`H+0TkIDG*yrT zPxzVKz&@|ll2y%lJjl>Ocirb7mRagHIP(@=mn->+3J3G>vP*lq{l&67*vqo}>4LG@S%tY+l7C@?wI@|yl!WGf(6)h~w5V63F$9hrw--gFk4Tq%l0 z%qE6B1rG6J#3%Ja{`XW@b%b+bU8;eFN##)5ViKzR#;`5uRfAzjU~^wwJ1hcM3+J<^ zC&K7dnzOA@j{)aV?{V7@3}}I6VVA{H0aaxBGKx!T)nP<&OB!N9L%b}K5kDAL;8RJO zzn@pXWzHC~<{a33J*R1(vg{G8My!|@7hBjl4?1q;3`9gkErD`_lU{_B)LDE8FH{I;BVYH&B%DN)FBwd00cc2JeEm9-A#YdERY?9aC){nMQ7pch6=auFmse=sU{7)K$}ws_I_WZ2pBdGXCEFXS~A$6M1ws# zQeGId9Ns)G=>;k}CuMr3;KUSF=Yx0GbN%zFNQJthR{k9WW+ z({}xKy=9+E$ER8G5R_}<`Mgf2C=yFUFJiJMPS|aaMW36e%V_E=)r@!q&NGzkHLqOHnHSvwWhWNu zUU2kPT>XjVI%0PF@5fsKiNt=CM$jrqhUwD*q2^7S91!<06jCNbQYBD(?@tcZ_PBgX zCr@lEwTOa2s3f+s&U=1Au#h3kEn-$zq+>5!!V zoruyRCzdxjODW{PbgQxakN(m%+1hig&RCmuL(~w*gyli zO=<<7%DjfY;=09IUg3dNYUOs-LfMgAkg(pml+STu2C$d@N|LhYTOShBvS2GMaANR(T!NWweg`q{UcS$ax~d><&CO31{a@put_!6f|A z2EqQJ>97Sqb!z;6Yx0px=pCoddQrvW!eY%6LJ_mhNhLZ-|71Phm262g9X3jVJuY&> z)GEsUD0S6+%Q*_z>jYzPQ+x!cb!$J|i}S4_B#Qc~o}mLG#<0$wOA{)0>^Uve4U% z-eR30ja)>v$Bs?X)y9s&b*nRq)reE;l;3Rg(2LcWQR`?)ZSLkfDgk5MY3Z)rJi$eRa7}!(=tF|)Hp5N72}$VuNqY_Y zo#Y_+75`4xe-HbO=F8JJQz5)N+O#@1XNg(5zF4?#xr6T~g*jib4{sGbp|d$rEO+Y02EDXYl^+O2M)mbqbz#>; z?;BGyyc~xJd`wdkT}sB5eft%6xYmM$@bl*$9B@>%zX@7v6bZW?Agr+ha$l6B+e9$M zemxe4aTjL|prOUILFZWvlUUF~Ub%X{*J+AnQQZ=NAfDx|Zu4L0W1t&k3*y@Eq(gaV zx5fGm4F@JB5)esF*(}-oyk_Zwu5jf zR;RMBeJly?+y~?`4MeCn11j>#rPs`bzqtdQ{R>NzzLiR@?WACgvFB|-rKz>3(O92$ zAiPQ$)x{rDGH3@gxKu>ge(3`bA$=HI6sG5TR2Jum5)ZnrSh6aBW9WPHq6EbJnp*#~ z;mxb(NVDVkjMA+ubPpi_@ZV-(dBT#XLT!_1g!`U?fT^$@trXY6z{~qI{ybX5mX$K3 zip<1Kf@t(5zyPqA&AfdUL1c|1;(o#2YEill6AE{IL7qlR7;|jj^u8qZg&*U)#~hXI zCH`?Q4-T?3!=?xciQBEQ>!&ls1>9Dyq05n44uBVhiF9h1-C^kIqE%-B+*@2f`z^_K z<)yO>wU(WnKzoV@$JG2ck48N_dX%j;t|~B8#%p6bxVpu#HXy#`_nvOD`NyY!7EHGOxTws) zN_q@fN#g-4>3TQ3#J83l%fJ{PQDippz_lyur%C!s3z zt%3-KySj(HW0_Z2cILb$!J_<7>X+;*Y)uP!e=RIoLyoOr3r6LS{3CJY_>fu-Eig^d znfPSG(5y^n&o+|liOS}ij`D9?0_&+xJzH{hYJt5d5H-sS2D+qd_rkp7)(Cua|9Q>TBGrn@7J{*FFrn;w)bsroA4j(j|a9L+UHs9ZBv(>PXObq^S!jTUCS0P zE!&4SK%3pW4q?~Rp}_m$rG6Jr=KES@jNA65+t8(++T}%-e9_V+0YP-xAzcWS4T*&0tTakR)!td3p z4ZX!S<=2{t zTIid@$MId$m0>(jL@2BuKVJU_pthsSOVj7!?Qs3Q+Iy{Xb3GJ%{eyIVZO(VULV$j= zvB>yts&6WEFKeW0=L7D(ZSMNw)NO3ns_qF}3gMIcN|13!)_HW=%hgZ7je9CS$Jkw{ zuOt2QE7vp0ZBp4TwTnlm$L}=(QQ;WBV8Zv#wa&Jt^09;gCO@0DHJ?r$?aR(LfXK6% zQW8O*!{$08(xz{+n1HuL<~i|4f7O%UK%jn7nJk_8({VlG8wsm4>yay(=XM91E91xG zKE2ZU&3V0iP&r5O~%gIFJzB#z0;k>vXx9Z^QF zwi?tH5v0RL(X?zq75qVY{QVL>(CR*6HP6Us0OGQ$_#6Zlth-5pk$>FZ^$op>+*m4nmoN?NpO1*QdZ%a4o0 zeZ!}xYHy#;$b-eeU-2mX?R=U2wl2l4WrT8}Zx>Dl*kej)yIt-4j4^4(4MbuKeo_8Y z>s^RxMN*2Sle~R|uZQQ34Za(8S&N}Ueh=GG%%$h0g&~COSy}RKgqgef zn$+X_R2^2YgzQ7gfUWeeB{(|6Aq+!|Pv1Y9eSf!i=%|qTi|c4B2C35bf<}bF6Wu_Q zCR{^aY(hp8u4wGN57F$xt$m1^dbfFcAIt0px2w{%56;%KE!#bW%G+sg-+f7p-YSEx z`5KG3bNVPOX+($LlXB<5p!BhXAuOB_o13mlZbDt80i$Wvq9z>cJo{$) z_Hy9f)1-93S6FSf|6>IYAS$O0oB@^+ef%ApZFhZQSg&^<3$sCaRMB`~-O4A3jpqdu9 zX6JZf!m&7-amYbG+aV#g$u56;L4BZX(i>p4|L!D$ru2Y={`-8liUleMV)qekK}@+D%ztCs6~+AtCH8=m5c(53 z4Md{@;{GG_FDfDlJ`Z)cp8rV@g9Fr~g#1TEJunXLkBWat{12%4pKcAv?SIbt4`EHK z5;vH!fwAq7`Pt-lzzqCPiJ(H9z$WBBs`E43%YwK5ob_K#WEr@*E{&^zjH{b@;@MKD zYe04Bsn-IA=@*{!+Z znZru$o*(1IMpf0=xg+fc+zw%Z>-%wQ z1?97YGn(57ROz0hcUAcOV;9oUm-+Zhc&MJ!`B zf>JwZ)YFJf(_-kvSZLF_$KFs`_idN*YEQZ-QWCz-wXrJI&ht@R=~8HKoWA0R?5Jzqi(`u~f#r3(jIV3m+CJ`_$&m1388!`IecL3nn03X;Rg0hC=SP<}qm;`w zU&x2|I^pIz&js0x>TUHX${kLs`nw11()Z2~bF6I{e&0odOjye&)OpuduJw1|X!2>&FPEc41G^Byy-_JO!M} z=e&lF3HUWlNiicnfz$u{APx+y)riU`+_QYkbz)RILf83Ib7)C+!5lCv>#>9 zTzsfSN7Ck3X7OZV&N^M?SFL0MOBi=D(wBw9{1N_38cO{8QOOJg=5#M!%88cziQjFP zB+dB~E|R!&I_pKuHryGJa3ExR#SYp(b>2hw{PRgr%HkGVY0VwT{8d#Jt zElCe0>v3O%B(-X}TkhlWhP@xWouHrW9HEcgd))Q{J>nm~?1& zssE(EUMY|+zx_-&u&Mn+xaHdnW|lQ zl|EM!Ac8neoxUE&_CQ{%=Y?#KFgJCP;CZ`c=I3&p*ZiK6LQ~u~eeK0FM-nw_kyvEt zt+gAoVZOEcKEh(lh8Kz^B!o4Sp}i)#RT{4y&alG}S{AuR5qg@0Z728@)7H0z3ui7x z6-}Ck_@o1ErwT#bF$mZE3HD1_Qmql*d1N zqkli9*d4`Y7h6UCtrm%IQ{e4$)9j>x-Ca-1i0eXLq3%61-YDwoiuI9f`%QfJ^eCO4 zO3c|fntehp&TsVw^`o#NabZ}4_8DjlP-0EqoDm>O9hvB8niMeb996-v4p+(ecu zX$UqC5eJXW^O91m#~OkhZ*u(~0e%^$wk-XzicZ)hOL(q~9DpGQ*&UX7yRoQW&WT|7co z8)&vwrR%+BZ)-A|VOksj%$d3LfUk-fsE&DidRWPo>+sw1YW2@6NvBX$V9KrOEX+FZ zg_V$p7$12)Ika$}r2xv3j((EFw7!D`7c zB~#c(by8$*>-zaf@^588<1d&;DHdVHl=T!3UERf7PKi?wJX?E;xSzVxC5N<4L$@C! z)=8pGJH_)6q-3#3>p9wvI#wf0@#I7JO=R!l9Fi<@ zQWKm)%)9w;X4d9)DfxUw1^4MBD^9;5y{wwIBV}VT$C|-SdrHgpxPOr`VPgJBzE@u7 za;H6ozG!aY7RLG7olMlV9nMsYQgoGwq%GrLb~&fES9n0L=D62F6Zdho8hXpv^@%?8 zQw?n-fUh&CQ{qs}kYPd8+{Rbx*(~N}flF!nW(zj*aHuvxDzaC)Zt_Jk(L-h8iAX%* zfvGYR)@L9At#Td{WMb`j(JOsdzKOB;WF+M182jMe26`GhRTEwuJTxz5?0H=0K+TP| zvom6SEdiqtR!L=HXyfdA@q95MpVypWsd7gxj*SUh?L63q2JWX3^}l0a@yqgd^U_T)ABNojitQ8cB}ME#9}{n+{lUY4Daw?Ay0( zOCQdz$Y>s8mFx*2SeuQ;6bc^}!7hw}mNR{QZd-rf_Lbm7jV|?&R@JU?A2ERH?g4{+X4bMjZ8C< z?|Kyk{aAH%CeG(NUNzs#=%@khn}qKt9iG|KXRDe@lhnZ$Nquk2p&J&_3m|-h(>Vau@)DX7gp1cOz z@Mj-_8z#9EPb9xHL*#Vu*~De)3D0Qxt$i0#LL_#l#_N+FzOno@?pySRcmNCj)H2MG zTKp-9XBxbYx!E-VdU`i)DEVFCE*|3+f`wDNKb^3hiKejKpg$Vp6~%iHj9mKoLT4_A z3M6%IQyjJ@Zc5n3*>-=*E8)See7fW&;u+J zD0Oxp7H05OCC|^6nm$WdekZj0GRt{27Wa$j{pZr%t|MUo*6;(FKcUXj zv#i}3mIGXc?|hTMuJS@DS){jpXe-JLo?b^1mN9-Jwed4_uiBkV_>`uKZL)B(TEk_) zhODw6zkG#8YnY9b+C(Gj5ld@JU*R2phwJ@2{Hy$*@H^xa)$_U>x1R*D zOUkEN47nTWs&y5#&rzP#TftzN3XBuPp3N#9#YxtoCOjDPW2ZL1#5=AA#e+OmUw$5~ zuR-qt=9x<(i#f~}tjA*b^YmcdbNngirJceE7*rWg$XAJ1fc8u+XBQN}L3yeT)13LdG$^KWZ@aT*`DkJ{JE_bXq_ zR7xCEe50rphdLR*Bg&=RsjNIkX1^BhOr@m5v?AGD2{H6G(!cM@iwb6xt*=pqJCAog*@AZRcv$H>AbxV zCxsPRntSH!cP#T-x?)`mN3(1EzGv_8H!}H|!79@MD0v*V#qNN?9r<=*Yi$#03A% zbIF?NjDqZogM~540psok1WfhV(NpO&emKynUgRz=dF3SWIw$Bs=C46ux8wcR@SUn`r2igng%aNnpUO@WqNj8U ze)X>?v2L7(Kvx3ku@U}|X>;46|ILP9DDh9WOy#ftUu@xlQCRM1evbHDUeT`4rG-F8 zKh#Px2l>_YQDVL2(6Y+%poe+NxO5?cvKC~-x_2<`xyx!iRW#%UXBWu$2o8f^nTTjs(&C%%$!_6uGkHJICgMQq7w!FQ++4gus z-!Qzk*t#8Z8##nM1k=CQQ+~QsyTKRryIq_lK=73Vy#>+HPD%u&X^@rK7hez-CyD9b4sqFOTKid?>cG15ShKU#w-;GmWlc@ZDy#Smy`_vC# z;vtnL(BE*hg1%lsOy`uA67_<%18mN{c{q;k?3m{Xn%LKeG;Z9T_G)92uWSWaCud13fIyXuN~7YO?a+W&}cb2@lEib zxKakG_$y`Pk<^)ZW5!HZx5nWE=(XExdUO=&aslkW(ZS-tQiO>dL6o6I;ll=o;Ycv& zFfc{pR^&_;A;BC!v6#l3ov=g_f92?&2_HD}+A&t96R%?V)L_Cikay`$2jvSwP?Ido zo(}bMC0%*WVY(1G41GG_CkR(88WpIKAf31#a!qn&HFm_fD@h##ax9M~iPwR|e`q8v zOVv^=T*{IB<9mhgkMEvP4H*ZYa}%a3JXdzSzkG|003m?~rfMIi*}$*?=imValSZ_Z zhV#gg#=%K;*~|Y>c_98rr7~eXP-WkzKPn+WYm$wIT8_QH9GHk;*}w)!fV#YZ4+qqL zfQJR(8xzroX~?K+z>Asj#A#wd4+HMUU472~8*!Djy#%6AebuT$LuS9y_tDI^s4G9% zOKWETJ|GmoATzVOR?$GSn{;-1D;53M8X1SsAu~cl-9($@Ad}K&DU5*Y@?A?YcUmX5 ziu-VRNpUAlxf6v*^kSp+K)tj&I<1S&Pv6ulikZ#$^4NDWuF1Yq!iz8Vq-97v6HSbNI7YlJ~F+V zz-dljN)GI=TP6C=S>)TQevqCpu}=>;xasE#cS1cp(~^o$E=RDwl1)b&d+g`>!SdO& z9rdtWtl>|;E3OIh;k^9AXnNfZ6^Qg3706EdzuM`^C1n<&8jUF@!%e++(g#!E`Sik_ z7IU@!^tT}}hdG@Td6`aXdf0z2@U;hbko#AAV!R1EQ_2!=7AIxmb+{9bIrE)6`J`r~ z)xVUm4--w6WL+e1neQ{@~m zi$6P#I2P-Cy6MlEr5~QF%>T@?@1Zgv(u3Gjx~TF=)7t1&q=xKAk zi|(UMpgk*C5nECx6xq^sjc$(cd8Fa!_V6fjT)#59Xnz0cz%nn&>{~74m!hH-JvL7~ zs7dFunv1?($%|I8A6%bm0eXiPMHBQ;Gn;tue4B>PS~lhr4GwvNseZ1Gw?rJk4B|(0gp>N$sL6dMfopu8i*0cKkRCL4f-r+3(@@M zQk%>3lkLc;IJtIfJcy+;FWr~}(Na8bkdeoCaqC-EZ|FeMuT7n11LU{zmDrP77x5>q zs|AnryVJ)8zyO4+=e>xL$xlJZ(U!;Q1Wp?oL~tNnpVw)7iL5BT`R*DjXY9!^Uxa5` zicUdTo0lHoQd-O^tA5qpu#Z^u)kmF>)Ov#@NrlR_#9$ns_v%BIJq3|Pv$!At$hsMJN@EQ$%se?#qe)Y1X;G)3pQ1?M1A z*N3NkY+R;612TOdP+7tXM^ZQ4k5njSe@1!bTH?&Gg07~NJ`Gx?-l;4oCUImGHf%J> zxuvSL1%?#{SZH}%3QBjl5zW*iPO}Ca=-)W$YvT_HW*2RCU?AjL)*f&Wa4$&Ck?m$$ zq>8i3cDoi@bvmR{@Tt1Y?;{jhoIcv&v?FCNb#o zlg7t1%XCr!IQx8?Rexo&S}u{f+{9z{c#~Ci_PJ zm`sQKV{(K2|IcK4VZ-L3u2ZU77tkckT>YPVe+@BaCKUDu{?J4<^H0ubmazBQ@YVgD zmGeW;<~2%s@Vi_oE!ccmI(o9Cz7vdF?G7V&Q?FVNwSvp4FD>4@0&GXccmX+E&=vknPZ+Axl&Fvxoyc=gfPdg8d1n%)di61(RWN1FMcL z@Gv|OVgE2tLx^S~$zMc_#gv>v6#$daIIwYJXxc9w*6f*|GMkec!Z8)>R1p~M+hWYF z!P;M_wu6lr>kN1ao!CdT$E!cN$bkP@CLx9cSm(gQK(`@*XzhQ{+=VJWX@y?@L1VPP zhng5?m)T=Q1rqL zPkK1f&q@EcsbZWj@KF{8VX zrI+L1UaPSnquS%9d`|~%YSoq)|I9jiTNesbJCKivEe8M^0qXOMb(?B;)V7euf=fndczf)rv* z5E0In+Rp~jQR1S24uX!R3Fj=$s6x8Ry|zh`q6T3%ezd()zxo`id|Xli2ENT#Lu6F@ zg;_ZU_pIJX1fa(^h#LSIRfW^lr9tWze5NFEd`&p>%T_wAgax;j<%C|0!j^s*f|F*M z>15IE1;NeVSkXHc=lMHu`*Kk*9`)K<0^}A%eT;P{Mh$ zGMlY@vmTuG;i`d`o&n?;q(}cl*d@y5+#q!SgTmR)#2bpaSe$KC)mtOZNxD2nf&UOB zkHni6N5+$C`?Rv1j_`OD__!StHUgepke0#$z+sgwt2OB!P6-~phzwKi4Ctp<*Vcm7y zEwf!K(k5R=wG}i^=!?UiT>PJ84{x8s>XOz&m<0lagy)~R(vs3 zckhFr*fu9i5e_(DTL{~FM?9sBd@203jePN0!I%Wl+eG;wcwI{(Qx>&Ne9xO&mCb!i zsUUe7yix4W|CmbPK&zimg8?c7xdpy*qHI`YMax0_^#lVCYv>Es2bP@GT}*U9$y-Tw zTZY#}IbIb!7-aKBU|=hWdxYDQ&pZS%lrbs$H9ju)gP-^2TR_O zE3WrXEj=oa)ZtH`L2SdHTxOvAU;hk~w?Os4oM+n0tCpR>kLoFT6yQJwU*4AKo))rn z-9T+7dVaS9=D@(W$Nhvm9oi*lT;4!Iq3`;{koaw&>}zlX8Wz5KqnGVTUR+y@9aYAtg!nH zKf@BiZu%pw*=@3w=!A^@OBjDR0z!_05osUlukqztNhy=F%;^$L#IMVH-P4751mRlJ zasJ8JH;Kj)hkGuaA+nvU_;mFbeps}UE>%^E)t|gHv{-}_mV~H^jN@{B z9G`-#0#I~Lk2Oq$SY1@v3fi*A?!vz=gx7P=hVYiTK*caOs#QbJ^aWt><;;0n1Li!7 z`@g~jN_pcmUN<;WL^DWizGfGZ5cdAcb??b6}}R<7_U@f3O!Z3kX>rg!gG+9 zX@qwwS0c&tPM;7>js0PLpFO*%mG$h>vtWG-F<+;hvODzzAq7;7m3yYp{#{0)o`1a`ct`8Tjs zQuO$B?h1BxU{>db-RQ3++{2>4tP)6pJG|m5EQKDVg=Lj17MEv57t)F7a>{=I$7%Fj zIx=%(ycTa28AfCW67JqsX1P_tZEy%2m28F14AOa(I=@crfW_yagmSK`(+gJZmMA87-<=R7xS^-N*VKDMY_%c4$Y(G*;}EEM~8zi&0~tzmB>`UvnMFV ztHKXiNCD*{LQ{@ZMzi$bT)GQE$S%ZZwIw*;VtxkYtJ}m-Mgi;SBH;wa=PibJ2#sZ2 zq*UrN^17NEnGFM*>hF-5La&sE-G?HD3A9`mv8SFjbmWLkPDXRF($ny@7*I#Qic6(I zcaK3`r0+62?}2YX>Y;>w!S7d4!Y`-xvlS;EB#)($%JM0CWS0g`AR+p*Wv?mlakrL8 zfnc08(GZ96cy#gakzS6q0i-R^ASj`u9mE*|?7_BmA+{5ZKQVa4f%aUn8#JFs2U2^b zVS!#_g5`sH-F=GSC>YoCchE7V>Av2HTY%(5KiB2aDdNQ3j31Li$D5?Eu_=1~K#kF%&$NM9e8? z-yUhdcDE2zKn$gY z?slFtK%J#x&HZ2D@OUx14|CeQ_Q@cy7Oyu?>-%sDbiP6q^aj^>M3PEsIC1qsy$P*d zhxbwx@(I7`G!r*GzN;0N;xz<*qE9*LF-~L`5wm=qunZ|>y%*_WPp9z?$)75zMB!-AyvDIs_05b?04Mk#`=CR>USY$g53N@S8CfriW zf@i>D@w91SBIs_8a>A8qvVj9oT2dj9c~kxbL8)#T0GBh3jJFKsw````CzF~hv5N*DIdPe{l9Aq#XF)c(nt_ zy6dP8=rotI{?R>6&6+Oyn1r6yHB?#=Gt-t0)fAZFfZAFezu)88?6iPf1ULgzusl@S zxs-$-!g_<1O{Tc8uF(lF{R}m=(Y3X40t72(x1cFDtlO91UJSE_O+IRkSG;xt{GviXUm1~o$QA~|SAoO~a!*5WgYkX#=McX2~DVG6Z5Y|Yc zgB8O~&`239r@I0lGM7(Y;jqXsHmy~KH8Nc{G<9;@LtLJYTnSCqND=j%pyuEp*I`&1 z8^!tZrG`19JYaA%u;p-kC4)%=%_yy$E*UuaI?V#zmdgYsh*2_MmuLWYP5A1%x}v?E z+=0se;CQQa*`em^9I=AzkoEC0t1Ch~NZc66H7ieV%YomaKI87%_(9@$K-0y&$VN;? z7dB23A;=1aUn*d}N(VKg-y>tXOK446=TH4g>Y3-d@6K;`b_hYmkr*C1D{FssUffU!l-219cPMpHjc5tj`n z1Q`emfw70=jv!p!?MvP>s%j$~-2JD7eN{URf+ermm{kDXIQ)?9)=Sh02wA&$KbimxGE$7z`;-nt~j#_8e59 zU|+%DgS5Rn1e&O~R;dKQl8uaHcs47Io*)S?sV0OJL)I+1H6~e)BSoZhI zU;fg4L<-cc%MbmR?v@-o1>)+#dw(A(7^h(Ga|L2Ik^MAIXc}adX-FCk6iuv2)rv~h zNt1gT zk7K%Ds7CSl$zS%!QMp|w`{=13z_EwyJD<+&5tAxd*z(%1>}3b*Lx53n-DiUQhH)n# z$Oy^$#os+ofl8s{GxKxevzbIMR1m^RKa|yKFz~0Gw)mV+;Xu!v36zX4R!7`+fk9rcqutDf2qOewNKn&P-3;vXt z;LA2NiJW)yMQCan{MCk)>aZ4<1&jRY?!Fy-T>SAtmV2;oi9jIqzksa&1*G<0Kxuz~ z0EA9egi6I9DQv>9#%@+CBuJ}}e<4hrcx=e5!L5qI0w27?(4}033**4NDanW-g<%^= zAPYK#&xwPdV}33oQUA+Fu;P}?(AhfGI02S8X)qK4=H+nrZ~3oFw?uAY`3kit%s_Sc6U7L z^g2HdruHw=KAtW=p5IU1+TOCJ#@2OjUH}(uRaHorms$5OOP3oRt9-ol`j~B99pwa# zY3m1b?{-LONp5NKS!zgQZu%UnLvE7>oSd&+OXmlMEvw{%2a$lw$o039mLvrIZ;X+l zw;wK0-z9Gztb8nY@1xnP##|h4=FDDdmnr>h1t`0geLcM1*e!2eYV>_{_^R&x3T>W} z%zXX)J{ooX+^lw2$9(d;o)}sJE`9wx`1RXMTJPQSiaxkLjK-XP4rK+Ug*GMad-^qZ zxUKFxIrgOiE-V3_=Nv1G@jvAc#c2pRZ>#n7LoQ5>j;jwJV|2GAw!8JF)V%UZI8C)a^i?}u?Xu&rt6ExAv#NM&es{E_)r1oL&HY31 ze#X^`<96HfI#nh+DfH6!wSB`Hi!kdakCf*tgV^4i-^bFrZY?jLEkW;o(ZK4hZxR6) z-s*cTA3hHOHz6aX`2I4a-&i)muDQ0kDPL6doiLHHj8H8mW(@H1vi;Nxm^*QJ`p8T?^}9@rS=#-; zB|P{j83>(N_4M=3mj1+dNsuQ%kXUt`o32x zRH@FUSvfOnT`>C6oCA9IWd$Mo(bx4mVQ1EQcQCN&{}A>TP;qQqw=izOgS)%C1qtr% z1b252?(XjH?(XhRaCd?S2=bbobML$7{@?rl(Szzi*Y3IIoV%)e)vnsLj<*3HVE{g2 zez@oJP{VoadfE|#W$mtdm>&OeZzWcnk@b+;u7kEVcyzIQe?oTv&R%qy_H^}NH|;Vn z=$HOhvt_ODplL8}2W2TtUo9KHVXkTusn6jht-iUj)m%QT;gKKrS~=85KOG)?Fn5)6=kaS!_1D3w{(%iB}J_`$6&IQSs}yzF>SenrBkbUZefuMQa_K-2L1Z8e6z3 z9gq#IPw#5HRh!yVUj}<)J(K&Q6m5nTYvbWzru!ojB5ip>tMy56J+sI$sj+ul<3j6@ znm0q%sZi;fbRG|P)vTMQ*{9PliknR?m5pfFkLaBr@kFeD=tS67(i*gKW@`rgG)6!O z6&wRKT3l4?NjR;e(G4^Hj>aHXFZT(9M6DLQVw85{UE{6mM<#UhW6Mvbj^+onjW~ED zeWv%{O{>fChQVLk9_>1Hs$9modE@E8Jti*-K>THE3SrcUS2V#ZkSyy!mQY+Yy(ER! zs(b|eUR8UrdPQh-BW*dx~vHrBpAl&$YvjM^Z zJNJR}pAG&)bB#y?U;vnC6VMD9(CkARyoG(a_%r;&?M>A0zyrbqO|qA2@%RoPFV6*U zT0Yw5@q5tWGg=gt>1it6q+Pf>-TEi{;a}oDntnKp9_7)~<&!&wC44rp5f)pz??*3} z!3PJKV|~9qM_`NdxsH2nI&(NbUvjm2z8_i4omxDwRu1p6e=2qvM?imjbbW%lu=hB5 zUC-=7<;iT{{Cz@K1=-YY7?xFq2fN$K=d^bQhZnK1wR)f}^M%21Yw{kusr)rM+_2)U zm9BH={wDhS$>`DI^Z3*L>fMW3?WC4HZpdw*V(TSG#*`;g>dU=RtLH0cYqv|F0bk|P z__v4c`~K1BaoKdMx$m3%K2!593CXONw)clt*ui!!*3cDuaBpL^YYp{e2vD~1Ha|G|BZ`tv7xu=K7w z#|FkcC+1OvA#Zjjii{y|R;HoH*@7U9u5(%MxhYsZG1%eDvkB0MY&N8WpXUl;jK{Sh zMt~6ky>EjKyjeEdoHc8~x5yah-(Y6NHZeuM#j0?!vcx=J2wk5Tn)AReJ)ZJ>b*_kRtS6mDocsjH4mvbzKht^lGkrBFi2oWNW>Il z=Bt|q+(D@bh3s$mgU~0rr8W!;;a5SBszE^}cvRn1#i-Kp;)h^Z#fc}mxX3b?4neys z6~0+s>0YI4mS7a=``V)Rey7D-8Ry*#KHj68^q1E?u1q%_+1{?bc%HY~5jZFNRnMoF zmuO+$^aHP3jo10TvAUNAK5aG}sWQ5kmerr^^fe2pHB4}WI5$J$MKsv<(SWE zdUkKFnj~$VLXgl!8PIPGou?3vLe9SFvD2J4O%#^dd*IsJRI+ATOj#Q`K`A0K%X~4$ zv7g}hG9>jINkAZ=1oeBePAq+Q8bpwZ|%ByF8Vt z5=#~FxZp@0xLx~^#zcqqD*fi3*6m&bMYk!)HVty(`u7vAKhIVYQ^O~}U7PAK>jJxQ zQ27?`CHBoSr-~e`D3MM)UP-}@SPvfE=E;hilo|78WJApoLCcXyBkt3 zo)VB3rRh9hkLT0~EZ#7scEPYkd*dO`F}G8G@t9DZ-V$8pbhPf|#pt{JVbn!$Qm@AW z{p3`a+wUHoY5aZci;0u$9Iae(?%w6f4ZM=C%qaHhGC7)?Xy&~S&d*$S(Clz*r)RG` z!d%uZ1gA`Cgc__JDZV}lZH#W-h7sz$3i5qd+Kx)_Fag}E_LCZ|JnYl>YD;H2H(u>5n%0nfgf*m>sT)r&$ zmNv`th9x6Xi=*6ep`^*8c1wuezAIVNh<@{QH7d*#ltout%*;<}uQwmVuQv4><5t|t zO!}OehIg-dIa@~QeL6c-+@TR2rFk~%Hg{+Ks)7v8>3DL0T;J9(=43c`ALKgU+Fo|} z`h>M-+$C4cRxoAE-DrNQ!?oBjO21GFIkv@L{bq1ovyl+KNq=&J4841!i0daWt#gTv z@6zhT!fE?v_rv#uxy#ylw25!ZQZZ2#K6usE;l6Osa-{7>VHnr7BUz?SP}{BaEYcN! zT+&t1OSAPMQ%s3{lJueJ8!mXQV8ewwNj1VZEs*ckPpVM%Zc0o7-&+DWfb23wG!mg zvv}_0-pCl2>pMTW^=QR=@@Un*2Z`q#e?}ZgTV~=td}AQ04Gi} zdlDTK9E=XD-U2yqPOq!JmOV05g$`oQj#MZKr69$vUcpX=0h)Mg$sj#PN~9-ky@MYF z-&LZ|`I^G5iZVksV`P)7QtY7le77+FctXi-6vIpz{VKH~7#Jn!9Ji-4R-U>OHwq_q zS}QT^Yabtgor!Xm$tW2AyXD+Xy)y=_ykn-axF5~%c~sZly}d5#v~&zixIy*TjkG#; zpOxf+heEmU_sVp|!1FU@$M2VO_fK@Imf4?AbvAQvdrh|!9ZlcjPn-L^#9*${HoItz zg;q9xv{F6EDS6RVn`F$j$)LK4F&Sk;i4IxW7>>26u_li59UZwVPIyhA6;NG>i7?U2 zdv|2gq&1(=6|Z_7xU-+cU0?kuK8bBG{U)AVRE!{eG3LRNPF7pCB?2N~{`}$)9JD`A z36XxWXRqmsei|cFJ2Z)DxSrv;n@%jiOQxx05wLtvW_FlSBwv}h$c6di072;DrD$%7 z^1GqldDl-PuZgu%?~-N@s$unqER&*znrD#hPmfM%Xg)7PL$7}0PutQUSrbtzO+7d` z*WOlL5P&ny;6Wfb)gPanXw>85pa-viAPf@~EVy?Y+rh(iThdp0YFN4a z1_Rto2tOhf*T=#cuJ?4l>giC#U=em(#1CyFzSF+T!p)-zpMel~_<+E!y9l$KUg^^5 z-o;rBHv`_0u*t7SPTRfT9486iL*;yNy-n|So;jle-g5+g1^Ln#+zq&F$usi>m}Wq3 zt7SGC8WiKLY5nr2xSSSbLz0iJ7E=}-&DD-u>k3+{=gm>^;;q28`1`jQTzvS$Rdx4s z3c_|fz2U^3H9bVH70dTNf@6Fz)UAWHRq@`cUwW5O$tsC^mqEhABT zP=@`5zLJ74>Q)5`=kPT7iMAF#y-kcz%SFo-T#o%5VK5bKBa7SXYCN!Dm(pflpMyLU zV-E|kFDDlqzAsaItmSuK!X0Tc%(XN8)MT@@+1`{BRKadUX$KuIAzjDTt+9gb>pWqt zjsiSZPe)xnN%bhYud~(>18O~ePRbwlR}s;UF8g|VEuYgmrw6;O$N8p|KLLjH__Odv z=Z+=*CEPl;{fK#flD>;BW@CT{C~Q-tKnwfp;i4BxATe3BvxDf)rWzN3TO|4RrXh2* z^IRw9;HD#w!B2?g9GUm^^wFMQ#xm;NCGG4P7yX^S9)j@dRttZmCfVBjk@~A>xq~aB zUz|nn#+=>-gVdOI%ZHSh$5hU{1m!pwX(A^d{4 zgyS@9l$WgKu8Gj->jZ{a8zNXw*Se;L%7 zEk%ewS~n;6W=FU3)OYl@X4w?8AdCN6WNk~t+wJ!rlz(e+eY|mNcav<&^k56#UiWKB zz6JSFXU34&Je|c27oFZ5y?k<6UP++T+STq zDJJR8<38^CIqrTO21odYi$Y)wT2R47&2p)p%=;yZI-6toz-HsZ<{(bpV;s{pO;836 zh@i4&l~h@tvr2iA&%w<}ofX$gTf6hqF+#g_iU@9FJC&>_m8e%q)0HqbmC!cQyur;; zup4kkK*idQJC*7um3r_PT#!E=mVhFXd~;7Lm5W5D-7QdBXLl-%=q-30S|9J2N$A_o z7X*JexmrlOTYz^QC=T{%TJ&kg%ZgVK@3{6;PL`s7bG1-$w@3jEn(}SxWChAM(k*ef zNL<~l-^3Cwb8)p;9mkR#pt~lj!!+A8D#oiD>DRQlYj5P)yh*hIP~52;HBnoRtLnsWSsyOJmO#5#PXSCVo>UA9NWKFw}~Wj_|nmMZWMWxh@R~gWLTM;=`I$A zU{fh>Q|Z&}Nu&dy=xT8Tz2{WmZ^N;}bD#IgW>Ufi&Y@?9bEc^oLbm}HmeS_p=h~t{ zVzM(w^<{?9=lC8@L9Vx?`D=VEdZnY8`4=0kv|vfIEXmeL_On+yE013_8n0>ao4SF9hs`$# zuw?j!;xSs!m?x`W_ZO553RL&$ih4rjmMb#AI8bl2GI}U-{jfoI2q*ZCJwuHJiiAKw zg3Js9DDyLLdlYReW6PD3co#AZKu`-`9k31*nD8kY-9}$UazJ0DKF8gK{(vwl=tN}jcTt-JEG_#8$vZy_w z8vun!1cX~-M|+|a0@O1D3(I)V5+=xQi0(Lwe<2g{pCJ)}{}u9V#RL%2>HJSfu7{w7 zTELS#{)EhcKqTT1>4BH-$x;|ABm{9Vr?f!DSfjj;L=*^DOraOjokkggwbp|m7Sg>Y z_J?0-PjaLZ0@5{w2+DCk6_w`zs4XkDzBG0D3dWy3xzHmFrD z(v-1?5SeowR_CY~ZmO-IkQ@zRZUdptQ~OeSX+J!m@iLqN_E?US34{WIo$NtWljX_+kmii;_b(*jA2Igrs`Go=>_N$q7M2lSzeG!q612qVpxuq2CXmS&3FusdxG zur>T=*DPv)0kz%}EaMVgxe0>tegz{7g4(@a_$4uOfc^q@jY&7xHt~XHrlap1&O(n$ zgvd_N;%bZa-+Uwl!Su--=`=OKMcx|W2>qg@^tupzzbIlbw*+EFAo>nQ!*c2V)R-$p z1op|r#Lz!A!x7QuX!+i=;txb_}>rwV|aZ)hk?LU zJU{z=`+c%|k?`NB;eQVg_9suTO=|W9-zKej)XUm)v2--BbhWT_c8HAy2TDSf+-_g# z_;TU@)JY}AMnz^&P8g0je~DP29BC#BoySg8qHRrXeIj@r);g>kjzH~|TA|W75&Rli zm^oT`IFPOVu;+LsK z>((7f+xtg37D4<6)RoW7FG0R(B|33Y1x>P;f7gyo~eLqUg^did4z z&OQ6?{kUxC5PB+FQtBZ(mcj9wCj*wNW%ZW4iTTwtXNT9suaUQt9*uQh0Lg5 z@)wxutq$#gce}JFV3#7L$0sB+}W}cr;;s+*a-_o)rK<0RSE@*n_Fm zuj)j1?$eh6Oy}_)20Gpvu(>sRgf!c)W!ulSC)YeQbBFAor`W$QTwY~M*86!7m$F%q5%WCyd>U0&nRcJ2Xq z182$E$dU9+v^30<^ejU&&0k~tJe{OmUjeSAq<5yKUp=q=dakT?v+8To^OiRX?|h4! z>on5l69(3CeO(CPiVB?6@c+cS_jqHZ(kCWg1xrSXN{9AFI72JIb(*G3MNM5%`F zSwJWhIsDSNpUZ4dZ)$vWvkWZe^tEr6dk%4O0x6jwZ4ngK5GxN;PB+1HNL6Pwx1t($mc|29i1`EK zxwS8xu?G*eF3{ANUWzbHfTiBy=?tiVi*V-{*DS>NFhmk@@)Q_?32qjafESOTOalfN2{rCYF?FVN0r2^V55Yp1rWd$eAQDmB zHT*#O0y06E7P^a)hl%-@82kUH7*kc2-)4>Fl&PuO0t#fzzcnw-}aP0%tscO)IFUU|$5uBMS|+cAqI0t*D0s=q&kqYCj;}#b?)F^0-HeM*>2bci=>Gn;89C`9)c7R^B>R90 z+by}Oq9B#qo5=Q zd_b#ZigiG1STjXSjR$;iV2PiAp75dKhKdRIFBNXU0mj;vA^qY?FK;(5a?T2W>rl)& z0_b>i_?M2i(e*keqW^`2Q=WQ;_u&1rT#|zJ_diyAPWiCnLE&Ez4x71=q#jn0u1Ajg4QgVht{vcj>`4fAIoxu8eu5NjINKn=t2;h-k2~Y6WZWp4#|{ zqBF7j%DPo6!zOm99B0avg~ek7Y;G>2_v*HdSta3bDR0;_fM{Tab>gh>j?FY}AsUIN z@`~XQB)U2hb0+gM*i4&ie`MBs?W}2Eh(unD5vp+Ygi;y);Th#+QkM2SVRjBf`vG>{@WW^qbZXRRzwT28|iQdT){f^ z@i+^T@gTJi)LIc5+G^B>x-E0VGOrlvRNigV_CBt(KU?eMi$4b> zL#b|52V-fn;{N350NOz%s>)5!`Rh_ZCvVvogr`PPhi`*@WptHn-)ZjFJ%?2i6C9z{ z^V@_X37^j6R$YZXUyOq)?egPz;eYL?!OnSS#9ORqHP5_(Qr7TSrR9jSiK%}^X<+cc zgr>#$NcI|(P&gTtn8*d;02x?}AgPDKT})3Nfj~kdR9H!O)@Lz&2tGp+dA%rKGA!}W zCVK?%Pvj|ZLh^gWrHX^6VTNvHV)C)DN{k+u(zw;GI1!Te_*{$OIZEGPQ5_T#yOdzZ z63pdm$%Rp&XF-r1cl^7#T_EM+6^jhYGeH_22RF(xt z6@|mxDRRPA$HhwYx2KNc<0d4n?};b`Q4=Rd<%CK7k(J~}vrv>tYAD1|jcEO-PN$9F z%2Ij}FqM^3406Im6b9Weg6cVcfz|s`0gePtWons`Uu01!>uUX}jOp_!InX~cweE9e zdOrnlEu{(Z!~#V|3PnY+!GEF_K>rh!06vHmf1!pf1y1R23$$tg7Q}u+`>L+V~ac(CXn_QVzlP=5%T-sv-|iaHgLtK`vQ?)*jEB~3)bW{ z`K{5cN#s>}QuD&~&}Oj2*4K3ku37@o5mDHOEUMmiDSe8+UrivqU)uw#;!2J&ZKfDOg|%tHmm{+0i%_9xs9#CB{o z3a(Io9Q%Y-qV@SGp9IyWZM>b^Q&9HLa?tuf`SVD?BW=RqjE2<2IAUzaZd2ezK&uVq z?O-AVh!!jP&lFOvz<(r*8&c17Xl-iff##W+Kf&_%od~IDd?w9sMGll7+0coxCD_pl z+O!?UL57b3fiC{JZP!Nt`}pJojvV#Htyi2B$C`wv9z0F&?YFd5}O+O z_vj=zVjRi2?CXrd0iXo{N}R59G|`0ILcRN-Xkr`>3ugq0wk@MA9RdB*zo5I$EWA(v zH&NOuso7CbSMc}w1}L8H$wG7`i-RX>Ds_MuAST-iX(Y$m3jIiqu@y>7jqCWO$N{8$-&Ef!x*bv63DP68jyf2D-#gqB-AF zwKzZ~#}E3D*a*@-1y2K!WXLF~GRyac_N_!0ufKu=%$hPfhI5%9+ySDDv2};fvPA=w znV5#8iLpH|wGmJM6r3C}F3IuYnN5H$bNg3VqEbQiSd>3uxl;E^eDOQ~ziWIM| zZ-9a9hM59%aQfeIfl?-;^^Zt#qHs7+N^Oc#=PxMOHs(2RjyWfJ$UF@j;RQY^xCiIJ zVNMFp1gtAi8wokq9H6Iidgz=`ih&y{ zTV0)ou$6z#jU%#*e)2vV1^ePd?th10TyK>FkD!DdW!`BFTfF+iIQgs3&Rx`T4DpPu zm+0Md9T5?H2nWHgPl#YM5qvK85NYWOk^>O8;6yLKxpK)`W_EoD7s2Vww`3RxG`nCl zG9~X__~&0qS$!z6kdh2xE1(pC&QQ}s0ysDA8zH7s*N_AF zh~7CYevkgZ*TEF<8!@khxaB20y9nDRA$$!|RfqnwJqEwG;Q6A<=0X@uki2^`8cn8% z-h3YY;W&g}fUhCv8N(g{QQp>9t}S^Cl7AYuZiMgw0`dJ7h$l2@>Kf8e>nosbF!um{ z8?4{kNU#UXqI zZ@OF}NZvq&Gx%RW_1g~7X${!71OBf<@@6pbDjmk>@qH9zsLBP}QN!mR-+BhkJcn zLKO++fOZWzq3&@=^FeIINAW`v18zP*w!(4sI|Ga;5_iDRKec%JKKe)hTgmLBAYjl- zJcncY3LuzI@TC{Z9yR*o6t4Tb7oKt&dKft==F0WVOBD?0}=+HHLFTCWBBmY2Q>e$`KQ- z-N!W}-N?V+J5aqz=dE@cuE;pCg*0x8OQ$gvmi2=Wk zK8$f1@Ap-MtJYkMM$(nh$XzwAu$5^&0t_%P3dRqGPv&qt3=j75IsAUS;Kjbm!2KmU zH9ZXG7$WN3k9P{2HA8&7-7zjswA&Q@@=XKs54)obx~dgH<*=%lRdqDB6&u00(~;jF zJd$mbF!xr>OjhqjuM;xCz%K2l*ALigfPz~kL7|gZvwW2t({N>qSAFLJyA)fo8!rYN zPt*LlVen@e<_u$w$?q$n--oYpnhk}0=GW%-jDN z?2Ak>=h(_9ykF);({HDjggd9p)69 zwR@o#v8yWzL=~d$om+|6?KKQwa{Q?JHCA5l@aKO{;c+j);K&dz%+QNG)cEwF@c0#a zLd=P=<|)@51Q=Smj^_`Os|z%aS;4ZgXO>0NNV-zOSLz9xKy0aQ?My>r=BF~7NnPN= zbTBsH_Dn8B4yJuId-?IO!zZJC@LLFhgw6xXlOpYH&yxVA(5q95lK$7rfxX=g9m9L2 zr`3xJig4`dn7*)GKwS@pQe0o)7e#TH`!4%R#bXkp3rPx6qDevOlT;PtA(;soa)`zN z8x=uXUk&|ijopdPrqU~3 zi6f+G=GFZ43BW;GWIY<}U;y@@prAu5)&8rz5&W3=PG1%!IZ@>}1^GuvazIGVlVp2j z**pfSaue5f=&zi<_8Ru{^r>td{6*4A;1~yuf+`x`C-KY z$sIBKb}5f(v^^67;28fdMl=Q;DI%w!P%A~8Q&IsHnPZ!4hvgfBO;d#wLV?JH2&?U|i>s&A`KbHW~MG*u*y2#`X#e#59pfe_p zGUPR}xqRE0lam2OO}-u;_hkWXOk6eLv&s4N&^Z)lJrqtt{J5E7V*KBn*qGfLd^jn{ z?PDy+^F==a&1TK^BlL%4GDi;9Sjx#5=i8#xg3~P#3ve zQB_9B5fk%3sNg#^VBtnV{;D9eOYuO&raIak3fM~2ICr!Y&U2gOWZLpFz}`miYoNc> zBNP0S1zKV}z?lP~e>-E344I2wHHh3b>B9$ zO(;$}112;;g~)f@T)b4#U_~ggoy!sSn!hNP+hH~R3-kA7PwOlfb=M%AgD&wrRZpOc zF5?rq+ojkh0fucmDmP&=g=W9a5)$RWY56CaN5CbL*31?qQfenJRzf8V559}%k{OunCZrk8^kP+3>iWBmA5}S;hZ&PYlx0i@DLEmq%B^)uszs>T? z2y4YOaA7crBDozm`9rGg7QrXOFM|pboO4k?CR53De^btqpm>KSOh5TALsnrw_#^@l z{uok}1Pb6IzMLMw?93CHIZ z^KpKT6Qbrm8E@a)-75$SgC!SQOFq%1b;rbn*O8@Nu1}G}5?$E8b|g>C&H^zf<#NB^ z)8hh;wU%L0AqzALJgM3D-%K^;v@f%$B9zr-Gbz0Jd(dFVSuJd3Ph{G-Fbm!}OrV9m z6gGt9*FZ~Wgr*8)P6`vmGkdTtF{}@Xc84$tzbx$NH^BWEUu0wjz}6= z&2Z;y%}7^`*hvCPQ~k(H^t#WhKJ{5|FA>~{kcImGN;Vgrtd6g6)0}FX`aTbAKjynu zt*T!o>Odt8jsdlR)z@wRok!y?-i2{bf z>lI-c;_Dw$g_~vpNQQ`z{c+PKY7T=CXr1RFp-vF-|EjZu z1*qeI>hyU%Mg2FJ;r_EjtYh(CK-OrOkhiN5(yjj@#YA&+;~k&*^8R17O!N6fbh<%- zfQ|sw|2|4}{wZYRVC3j%W^M9ko>imDhW!r?l&-B}-Ig6==q5Xgs_xT;{(!TzSxN!W5PN-G~_r4OWZZZ^qG%#Xp_DtIs4Kqt_&>o#o+RougI_P&CM-g zX^6rlZj`dcyk7Ie*ENZXZAL+Jp#>P!+LUz1p|*gSOHX%4=gK0lJ;)G*Dps0-%T|rH zeqk7?8OreT$Clq%9#Xhlf$gC;ywV0C(Au9C3orKfgAF-LeaiM@MNeWZQ&F-g{V5a} z|5V;fHoP28UB~oxB`k5K?7z*HHCcpPi}QoHmqn&;pCk@!)X@$Gn%-aC(*q{7D(+hzt3@qA$4mmO ziZ!=PhDdIl1ZTdgmM4L4y}jR8&$#?*Q2om(=3*$KPm zE#CLaM6&$vrACz+j@6;&Cwt0$9G5huH3$hFt)=1Xn$YXEqH?rl)7%?(r8d*Njbh!# zu=ryA8zSffj~169kOM2&f}@^~2$)-Z&W&<571-+H4m_waJ5>=X2w99_qVBq48FBFRWGPCK-a> zjyH{lQkS{U<-^{HPUGv+zoe}Sbw)QJ*e;AWk-^8Ew}|nLsE_f0m{$s~^lK+kdC5WZ zO$SFmX`3TXX57l8vwHlFtm@CGb3e-^N_u>~8fr-m^*-3$1dLO7--6OVA06b;hRgeh zOYKM+7Crz{OSB<1N4Mbo;9?QHK>=iN@kf|uflH3n9)($LJ1npbpXYAVv^=tkVo<$@*Ts+Hq%f> z-l;cl_=X90VjMv{c=c8d3U(bdOC!e#nChJ}Xr0HbEM@_7YDX`190vnH8ROqDKtRamLj_LqETVQ6y(bSjIP^AW;nkHH^b!djNgLsX<$dz2I-1`#r^N8O?A}Bi?>z72|$E zJwXEO2ZiYXcPIoT&qj|!)C$8V?+(EVj(`hAfYFrzhW$@cq9G<;;& zMg(dRthh5Fr>WsUEp-mqZXw}Nf><%CL}C~_T%B8qBur6$<&2SwV^;1VYf2+}G{XD| zOq#~>hrrnAxk4ot#>`J)3e*nVU?(BMO@n@i=}73v2t>PULnGEYYARDx zX>6RvMQ>h15JVD%N)e3cVf^{Bw)<;2`v&c2tGyVK!gua$RHlT+ENPV_8K&}x24()& z66w0)$>lW1VNnrjCBQ2cy;D>(%{$KE7Zas6s(uT_wcmULIZymGt%r(W6|?r9;VlMmq%xNn2$4a zWv4=KqyIdYJ!pu~AF~;&=VZ_a$3FwKY!nIo4u<%pnX#QCkIYP2;H)q*WNsvr+_Z+q zG}Bsq2Htd}8|H`pn*-x}V@tQrM0Yl?wG?dV%k&KyvUq647&e0&cg%*_O%h*|_O_C) z?S604EpL+Ia7E3o4LAt>WbMy`@1avpFUehGXT|Q)RU&2Ium7uxbAkX-%MhS|d^;5o z5bD1+Nk>y7Doa^uwGt_19GLe>$IgO|!;rfR{qIZX7#xOCqS3?uZEy)|8{`?DWI^B*sk`W%tO>Cqjygy{z5^pG1a;%~I8+~6Ne z8?#VrP_QV4P8T3R4sUp|_+8uPG&qgWYmNPx@nY0z2&x<&iBF1;6U7~p7%RaAR->=D z?hO3FeIQ`=gycU|IA7`|7)8a2V^M@?@}y?&`}s2I1P~?E7Ee}OF>aNg@9)Rc&CJTu zSirBetCug7=i144W||6xsh?2py~^jr#bZDQS-|5Q$0O}G#Nrc+l4R>QPaLt%$kH@| zin3(qso2l3l|M^=5uB%}kv7R+vr03?!b~&fm43f*ueUBRBQw?Vd+qc@Mr}OdpC>?O z$lEt&l$5M%P?)XGUl7Z1wj+uvp+_^!gTz-@NoBikTH3Cl%VFb1`C;I^&N*_T|a86>-*E*TJm&nxhTE%m*<1Uz2R!SS1qt^=vO=B zx5MA`Z(h%ok7rLVHd&sHKqK)zTi(y-%{tv$K8WH2XVuxS*So7{HoWh*!Q{d%a1g=b z>p&hM{@lkv=W?=CIt`>)b)>nV=({A2{rC>Yg5Y$1_W|X-mr{hj!aneGxVG7&`kEmv zjBX{Ajc0YCV#7&6EeGyc`r!(5xfLrw%?TLdYtk>*AgwDT&?~;S-EtMyQ~~bs{D)X{ z>jh^-(ur0?`7lwj(O_~8jtIF#FQ5^u@0NP$@-m_zJUuy}cD00%t`rl;=_~P3S!iQ) zf)e$|OW$X53vBzP8>$5j+rBEaKog~*Tla+eXxFOD5Q~pkQLKLzb}pV+!Wj;PEn*}> zxx*Q7BYdPwH>cW&YqS$Ew2Cb)7EI~!2#i^anIkcbzjXR`3_j?twBd9@7udev_+E@OzawkfMtKiuMww-}35hmYhj~{Nb+&|4s zN{VSKB+7ppx-W-adf&Eh%sNeLI<8^seRUp8$XU~}@zF{fIvHQhpEc##52w%brS3NW zkr1I4io=Wn4Vo^7cPmrUvnN9nhN~h$m?f4ZXDG45(_3oD_ z7e0#SLg>=s*kNF9-tBkA-K2z$R;q=CCGtX%iyO02`A`g5v1p0C6!-nd3!M|B$bvqp z%;)`7*(5Pa>3S?P@_Jy$Nt+dogvUtd-`aA~_5iUKl5yGLB*hF^?S5(G!I!0RFsBE^Vv7nLoOF8^_ z6miFt+2Pt;P28Q&Sdi4X-mm{ zWvTORMqM>Fy&a~2rooBLck5+|Mb{jtPb-%3Qh^gczN%9&q?m*}AYqp1Zz@^W`Ec9F zthSZ!IDoLRRxB^=w7hqBA}?(nl*%dQdc_5AyU!|9*&%B3(OSvwPGrj!xJxxFV}lD{ zcH;vLZ8ph&rS(h;RY+>3fHmqCt@tlD16ez_=J|%#%?H7cd;Y1T_0B zl#JN!kv>n1U})BWzmFc0 zc9QDhJgLD|fF~7v@|D^7;MGg^sCMCAu5JzY`%Wqiw0$(kYxTFs|0=qg<-@gH4EVu= z0A#P90q+J*4vscfYF3u?W{yTye}48VBWhv#7*K$Ic?plkjTUi*pv)^Ek|X89REab+H{jw32=U!Dr!;X%-fDu!Zrs5Rt+Eb$iHvix_woC;*vJg=ng1 zBC77x;tEbE3yMxPI*PKV#fY{5rV;>RVp|G#Tp0rWI%12_;P<&~&@bFlHOYglkp)YX z{?LrHd(~S77efx|nXU?gL+_%JHfw`4u(|~%-OxT@arM7+@s1rVb^`Q}19aj2L)Tvw zW&f@2PdVAh=~_U3jtH^cDNER3T4rQIk*oHA46^#wmasf#x+$dabDN~tW>=mfC??Y; z?)xtXD{qAI76G#8*^;8NM4w~ZJ3F>hlNE34M1m%}25J$8Q{aN<%lt3B=B1}UwPu+y=eQrR^$wugk9>|4gnM+ohy;0D|=a z!ZH3K{6n&wy^XEIpI*(3skQcJKoNH7>buz;Wy}FHMF|lDqO@3)_=fB1LuyI9CKOrr z<{{0+YB20l<#@Tsjl*{kV69s*Lq$GIMgpZ&&Yc^S(b3JyLOjA%#l>p|&e`fuI!wgj z=)7e&TsaRx2TyW?caS=HeZ1abGSNgVX3g#nanP@gJ(|fk+2gnsjz+aXhn!$whe1~H ztwYjl1e*9K8emqb{fjcYU7C^n*E5H?8K%|CW62a17OUNF>e;gLjkMi%NCXWs&ljG7 zn^pUvm79Yh@9-UAXC!yJk35@<9xQdmV~~%xNkotTb(R)06C7#*7?TMFWOx5Q=dgCN z(l@dPjLLjucav%x*1H@Som<8Hu_9WHq46wWEDd@5D&HbSh#PN#$S`V7vqu;i)-NS5 z3?`{7GWklvn5B@N$0I*`Z*jhP%DjL-zFhgoZNi75IIvy%YZh+_(i}=oEbYDhNnWpxeWsqyN>D*@{rQ) zuum|BZKxK3h}CQRB6ehQyWnJMcr2=Nv*$xgDmC)Gc8ZI+jKs=4rlR7h6KD3fU&C{S zLL|c`2$}J0DD|1Br_n-6{5U1qIXF+35~RV|NyRUMWZBwfeYU3VQhhvFaiHlby;J=u zMyab!z^1^XOv7O8#G!4LX{b5BKR!$raH6_w$gJ^!Z;^?ztw&vicB*KVeSU#g-I+aE znsXlHvC++gM~oDewHN}|LR5}b=qSz>JJW6?ZxJi>seNfH>|}S59j`qwq@)>?L|qeL zYRWrjPSI$JnxTz-{PctH#$HS=H6NXfB7JI}L|P0p!@Va}_B(rYOeVZ8Yr_9&>&nBS zYWwh58q3JiphQwKy`=2Ld^IGk5~WgP8#D~bnq@3w?8{_dvc{|WkeO8SS+h;AkfoG{ zjA$^7bx37i<~u6MxqALM=bGm__x+snyRPRszx#JzbDr2Dgp&OoA(rx6eR*@E1Lxxu z_eeCrmd;a9A^q4DGVl*vM}_%udRB|z1-9}F^^>KxMa-~$l%ou7jsD>wa)-pQTor?RNN1!F1{D54BQq~x;CX?tDc9E+V|m!p|(U(nTvvN0m%wrq&B zQ0?iH=)IjRz2em8A1|hp>Q4LiE;I@_*LDp}*Up}`G9cE@>JRcYSejYBpoim*t3X|I zB#?cz@VbU*QO#T2LiP3&M&?iNQqn~CbSf>+&jhbA+5 zQ)d`BEnVl4 zIQ`F}*l2J4bo-;Do0V0YYPP?x7xHwr$(Mp+ZH{TQ>x5JHa`I^$*v1vBpyeH=b`t~= zCI^9R1y(Fzv+wWA6rj9-`RQuwH?Cb`=B#x@a9a6mUO7yS&;(U$O>h*>D z_4jcfbn3gGaSArd38)J3IQSl2 zcv{!yT26A)E;%zPanK9GsHkilf;e+K3bhtI=|P)Uxm2DWgek`nG+QDx(=7zI>c~jo zL$O0u?&JKb;v&X7KXAzMuYgq$g4pEOB3~(;SOJ;k^0|`2<%Q|8>QatOWX>4m?m`n< zUZwPw)MT)bsVbK=wa)GH$c$~6^7Kon*p+uV-JWbbj1_Ke`AdR8VEAM?s>q;Qm?HeA z$7#)hv45+HMw1UgXS|B?{khP>r2~7@bnI%HrXF9C&x;BOzVgh3ZBN%dz8xEzV7$pD z(vOm4eyNkzKT&d(a2Q_sk4nEYdhwl&r9&z*eaeM@zuOB<_CS&Z4*DrEk0r1usI^o0 zuzneF5%z7Hb)m9k-oEtA;u~|?r6}in+auv-RjoT(o+Ogt$2yd>FeFp{6y)2lA83yz zqekjK^YNrgV9%mO_uM-{Il#uY_Eg%Z#i^XnF-*fPD=cQz?UylG+&y~YS&SIw##zrU zg}yUywdn-WheQhfr2jR_(+Z7*&QMAmjF?W&bnygz)x>XflzP1Q z-{lGIQW?xs=6NLVTXG#rQ8pKgErilOQxmhB{ilWnpR?3$6zV_w}Ed<^$@qV(x!h8*?$o?pJT+Y@KhV4pi^8r@KiJV+XxNpx%mX5Q z3a;>qP&U5*<rCNA5Y zGwlC+s#(Kc%44%bgd#)g$Sf_X*N{XJX`7XrnfT~jRAs)flz+8I?*dXL>}~HP=e>)1 z1bquztNIy!dgOolm^Tv4nEJx3;(a|M6RnEzV-K5FoEijtVwq)B9z z+4v-avNr_S8(IB`V`=^@w%d9p*~stuyo;$=P%&@v5k{`sE4m=>*%(9hXu_T-BLW86 zHFMYBz}ib`@DO5NuMwfbo56jh46KWn_-rjxs9K$g{13 zB-DKKn;?6^a*ZAobjSKyjUVE^MiV8j3`h~JfPLSLxhI7U*m}>(zt%!vTVI>*ynt7w z*1w_pcG{f~ncafm6LD+3iZ2h6Q^Ps9Og(;oQ-UgdkYtm~_&A(>sjwSvbB`nI>f9w) zAsre$4Yd=DRrH7xyCUBtb%!ClEw++XEVp9oaOL(aGO#tFM^k)qo1k)&yYi54u8mVW z4}%X)k0iG3f4$k7{yN?|HL>D)(`br7+SGu2c4UW9I~m>|S$O?AQ6}|>6DuXV`hG_Z z?%gDz5%-YtEa|m6&d=LuY8coHaX_}gVrHJ4LO*5d**5VmQZ|U*UCxxn1$?^yAU9}~ zI+h!p@7eY)-73hTzl2=-?Op$WXnqyWW2AT#ZT=-KkgdBs+16zJSw_eI;TPY*@tYk1 z?13-Km|*STp<(iK%cbRbT~2|wTK(n}l%wT)-Ux*-d4Kb*O$SXw5@$bocsIpJj$4*Q z#CKXGC)rXT`Qg4K2z=}Cl%HrmR&)oBSu{c#8=5LrmpArtOdWW_6(*3v<1((=@|du1 zZX;)ey&j3&CqTD5-VOQVfuj+%*r;n_$i*$rvsxbKhahmH*GezkOH2ha)K;M8wkkW; z8Xo9_KrD^{#b5_lFYAkbZVsN7tJxWtzz<=l_Z7I52Go&ZYiQt%=XwE9{K)aYr}kiA zR0Pb_3Am&i0Ir&IYYope2K4-T_t@R^_+E(y!x#Gdw=w~^3oisBx`qYLc&=ce=NH`8 z&FxQjjqbWe0)t^~&b{6WbzG@fhM(G6hG=Ow{B zV4rlIC+XH2&u=CQ7!EdG*5P>npKy>317-jl5$g<(aX%S0*b-noxN5wPKOXcG53V7D zDZu)BodOm9M>P&601Mr90?CNq3H~H|!CYYNxXvXK`8(G}B?)E$ORaSlMAXlyY!F^x jJUAa*$2Y|OHpMn(hbD%bfElzptHdC-!1Q>Mxcck=qAV?? literal 0 HcmV?d00001 diff --git a/.agents/skills/md-to-docx/scripts/create_preview.py b/.agents/skills/md-to-docx/scripts/create_preview.py new file mode 100644 index 0000000..d2f6133 --- /dev/null +++ b/.agents/skills/md-to-docx/scripts/create_preview.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from docx import Document +from docx.shared import Pt, Cm +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +import os + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ASSETS_DIR = os.path.join(os.path.dirname(SCRIPT_DIR), 'assets') +TEMPLATE_PATH = os.path.join(ASSETS_DIR, 'template.docx') +OUTPUT_PATH = os.path.join(ASSETS_DIR, 'template_preview.docx') +CJK_FONT = 'SimSun' + +def set_run_font(run, font_cn=CJK_FONT, font_en='Times New Roman', size=12, bold=False, italic=False): + run.font.size = Pt(size) + run.font.bold = bold + run.font.italic = italic + run.font.name = font_en + run._element.rPr.rFonts.set(qn('w:eastAsia'), font_cn) + +def add_shading(paragraph, color): + shading = OxmlElement('w:shd') + shading.set(qn('w:fill'), color) + paragraph._p.get_or_add_pPr().append(shading) + +def set_cell_shading(cell, color): + shading = OxmlElement('w:shd') + shading.set(qn('w:fill'), color) + cell._tc.get_or_add_tcPr().append(shading) + +def create_preview(): + doc = Document(TEMPLATE_PATH) + + doc.add_heading('Formatting Specification Preview', level=1) + p = doc.add_paragraph() + run = p.add_run('This document demonstrates every format supported by the md-to-docx skill.') + set_run_font(run) + + doc.add_heading('1. Heading Styles', level=1) + + doc.add_heading('Level 1 Heading (Heading 1)', level=1) + p = doc.add_paragraph() + run = p.add_run('Size: 22pt, bold, page break before each level-1 heading') + set_run_font(run, size=10, italic=True) + + doc.add_heading('Level 2 Heading (Heading 2)', level=2) + p = doc.add_paragraph() + run = p.add_run('Size: 16pt, bold, no page break') + set_run_font(run, size=10, italic=True) + + doc.add_heading('Level 3 Heading (Heading 3)', level=3) + p = doc.add_paragraph() + run = p.add_run('Size: 15pt, bold, no page break') + set_run_font(run, size=10, italic=True) + + doc.add_heading('Level 4 Heading (Heading 4)', level=4) + p = doc.add_paragraph() + run = p.add_run('Size: 14pt, bold, no page break') + set_run_font(run, size=10, italic=True) + + doc.add_heading('Level 5 Heading (Heading 5)', level=5) + p = doc.add_paragraph() + run = p.add_run('Size: 14pt, bold, no page break') + set_run_font(run, size=10, italic=True) + + doc.add_heading('Level 6 Heading (Heading 6)', level=6) + p = doc.add_paragraph() + run = p.add_run('Size: 12pt, bold, no page break') + set_run_font(run, size=10, italic=True) + + doc.add_heading('2. Body Text Styles', level=1) + + p = doc.add_paragraph() + run = p.add_run('Body: SimSun + Times New Roman, 12pt, first-line indent 0.74cm, 1.5× line spacing.') + set_run_font(run) + + p = doc.add_paragraph() + run = p.add_run('Bold text: ') + set_run_font(run) + run = p.add_run('This is bold text') + set_run_font(run, bold=True) + + p = doc.add_paragraph() + run = p.add_run('Italic text: ') + set_run_font(run) + run = p.add_run('This is italic text') + set_run_font(run, italic=True) + + p = doc.add_paragraph() + run = p.add_run('Bold + italic: ') + set_run_font(run) + run = p.add_run('This is bold italic text') + set_run_font(run, bold=True, italic=True) + + doc.add_heading('3. List Styles', level=1) + + doc.add_heading('3.1 Unordered List', level=2) + for item in ['Unordered item 1', 'Unordered item 2', 'Unordered item 3']: + p = doc.add_paragraph() + run = p.add_run(f'• {item}') + set_run_font(run) + p.paragraph_format.left_indent = Cm(0.74) + + doc.add_heading('3.2 Ordered List', level=2) + for i, item in enumerate(['Ordered item 1', 'Ordered item 2', 'Ordered item 3'], 1): + p = doc.add_paragraph() + run = p.add_run(f'{i}. {item}') + set_run_font(run) + p.paragraph_format.left_indent = Cm(0.74) + + doc.add_heading('3.3 Nested List', level=2) + p = doc.add_paragraph() + run = p.add_run('• Level 1 item') + set_run_font(run) + p.paragraph_format.left_indent = Cm(0.74) + + p = doc.add_paragraph() + run = p.add_run('• Level 2 item') + set_run_font(run) + p.paragraph_format.left_indent = Cm(1.48) + + p = doc.add_paragraph() + run = p.add_run('• Level 3 item') + set_run_font(run) + p.paragraph_format.left_indent = Cm(2.22) + + doc.add_heading('4. Table Styles', level=1) + + p = doc.add_paragraph() + run = p.add_run('Table Grid, centered, header background #D9D9D9, header centered, data left-aligned.') + set_run_font(run, size=10, italic=True) + + table = doc.add_table(rows=4, cols=3) + table.style = 'Table Grid' + table.alignment = WD_TABLE_ALIGNMENT.CENTER + + headers = ['Column 1', 'Column 2', 'Column 3'] + for i, header in enumerate(headers): + cell = table.rows[0].cells[i] + cell.text = header + for paragraph in cell.paragraphs: + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + for run in paragraph.runs: + run.font.bold = True + set_cell_shading(cell, 'D9D9D9') + + data = [ + ['Value 1', 'Value 2', 'Value 3'], + ['Value 4', 'Value 5', 'Value 6'], + ['Value 7', 'Value 8', 'Value 9'], + ] + for row_idx, row_data in enumerate(data): + for col_idx, cell_text in enumerate(row_data): + cell = table.rows[row_idx + 1].cells[col_idx] + cell.text = cell_text + + doc.add_heading('5. Code Block Styles', level=1) + + p = doc.add_paragraph() + run = p.add_run('Code block: Consolas 9pt, background #F5F5F5, left indent 0.5cm') + set_run_font(run, size=10, italic=True) + + p = doc.add_paragraph() + run = p.add_run('[python]') + set_run_font(run, font_en='Consolas', size=9, italic=True) + + code_lines = [ + 'def hello_world():', + ' print("Hello, World!")', + ' return True', + ] + for line in code_lines: + p = doc.add_paragraph() + run = p.add_run(line) + set_run_font(run, font_cn='Consolas', font_en='Consolas', size=9) + p.paragraph_format.left_indent = Cm(0.5) + add_shading(p, 'F5F5F5') + + doc.add_heading('6. Inline Code Styles', level=1) + + p = doc.add_paragraph() + run = p.add_run('Inline code: ') + set_run_font(run) + run = p.add_run('print("Hello")') + set_run_font(run, font_cn='Consolas', font_en='Consolas', size=12) + + p = doc.add_paragraph() + run = p.add_run('Inline code: Consolas 12pt, background #F0F0F0') + set_run_font(run, size=10, italic=True) + + doc.add_heading('7. Blockquote Styles', level=1) + + p = doc.add_paragraph() + run = p.add_run('Blockquote: left border #6366F1, 1.5pt width, 1cm indent, italic') + set_run_font(run, size=10, italic=True) + + p = doc.add_paragraph() + run = p.add_run('This is quoted text in italic with a purple left border.') + set_run_font(run, italic=True) + p.paragraph_format.left_indent = Cm(1) + p.paragraph_format.right_indent = Cm(1) + + doc.add_heading('8. Horizontal Rule Styles', level=1) + + p = doc.add_paragraph() + run = p.add_run('Horizontal rule: bottom border #CCCCCC, 6pt spacing before/after') + set_run_font(run, size=10, italic=True) + + p = doc.add_paragraph() + pBdr = OxmlElement('w:pBdr') + bottom = OxmlElement('w:bottom') + bottom.set(qn('w:val'), 'single') + bottom.set(qn('w:sz'), '6') + bottom.set(qn('w:color'), 'CCCCCC') + pBdr.append(bottom) + p._p.get_or_add_pPr().append(pBdr) + p.paragraph_format.space_before = Pt(6) + p.paragraph_format.space_after = Pt(6) + + doc.add_paragraph('Content above the rule') + doc.add_paragraph('Content below the rule') + + doc.add_heading('9. Font Specification Summary', level=1) + + table = doc.add_table(rows=8, cols=4) + table.style = 'Table Grid' + table.alignment = WD_TABLE_ALIGNMENT.CENTER + + headers = ['Element', 'CJK font', 'Latin font', 'Size'] + for i, header in enumerate(headers): + cell = table.rows[0].cells[i] + cell.text = header + for paragraph in cell.paragraphs: + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + for run in paragraph.runs: + run.font.bold = True + set_cell_shading(cell, 'D9D9D9') + + data = [ + ['Body', 'SimSun', 'Times New Roman', '12pt'], + ['Heading 1', 'SimSun', 'Times New Roman', '22pt'], + ['Heading 2', 'SimSun', 'Times New Roman', '16pt'], + ['Heading 3', 'SimSun', 'Times New Roman', '15pt'], + ['Heading 4', 'SimSun', 'Times New Roman', '14pt'], + ['Code block', 'Consolas', 'Consolas', '9pt'], + ['Inline code', 'Consolas', 'Consolas', '12pt'], + ] + for row_idx, row_data in enumerate(data): + for col_idx, cell_text in enumerate(row_data): + cell = table.rows[row_idx + 1].cells[col_idx] + cell.text = cell_text + + doc.add_heading('10. Paragraph Specification Summary', level=1) + + table = doc.add_table(rows=5, cols=3) + table.style = 'Table Grid' + table.alignment = WD_TABLE_ALIGNMENT.CENTER + + headers = ['Property', 'Value', 'Notes'] + for i, header in enumerate(headers): + cell = table.rows[0].cells[i] + cell.text = header + for paragraph in cell.paragraphs: + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + for run in paragraph.runs: + run.font.bold = True + set_cell_shading(cell, 'D9D9D9') + + data = [ + ['First-line indent', '0.74cm', '~two CJK character widths'], + ['Line spacing', '1.5×', 'Improved readability'], + ['Space before', '0pt', 'Compact layout'], + ['Space after', '0pt', 'Compact layout'], + ] + for row_idx, row_data in enumerate(data): + for col_idx, cell_text in enumerate(row_data): + cell = table.rows[row_idx + 1].cells[col_idx] + cell.text = cell_text + + doc.add_heading('11. Page Break Rules', level=1) + + table = doc.add_table(rows=4, cols=2) + table.style = 'Table Grid' + table.alignment = WD_TABLE_ALIGNMENT.CENTER + + headers = ['Rule', 'Description'] + for i, header in enumerate(headers): + cell = table.rows[0].cells[i] + cell.text = header + for paragraph in cell.paragraphs: + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + for run in paragraph.runs: + run.font.bold = True + set_cell_shading(cell, 'D9D9D9') + + data = [ + ['Page break before level-1 headings', 'Each level-1 heading starts a new page'], + ['Other headings', 'Level 2 and below stay on the same page'], + ['After cover page', 'Automatic page break after the cover'], + ] + for row_idx, row_data in enumerate(data): + for col_idx, cell_text in enumerate(row_data): + cell = table.rows[row_idx + 1].cells[col_idx] + cell.text = cell_text + + doc.save(OUTPUT_PATH) + print(f'Preview document created: {OUTPUT_PATH}') + +if __name__ == '__main__': + create_preview() \ No newline at end of file diff --git a/.agents/skills/md-to-docx/scripts/create_template.py b/.agents/skills/md-to-docx/scripts/create_template.py new file mode 100644 index 0000000..1bc6c06 --- /dev/null +++ b/.agents/skills/md-to-docx/scripts/create_template.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from docx import Document +import shutil +import os + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ASSETS_DIR = os.path.join(os.path.dirname(SCRIPT_DIR), 'assets') +CJK_FONT = 'SimSun' +SOURCE_TEMPLATE = "" +OUTPUT_TEMPLATE = os.path.join(ASSETS_DIR, 'template.docx') + +def create_template_from_source(): + if not os.path.exists(SOURCE_TEMPLATE): + print(f"Source template not found: {SOURCE_TEMPLATE}") + print("Creating new template with default styles...") + create_new_template() + return + + shutil.copy(SOURCE_TEMPLATE, OUTPUT_TEMPLATE) + print(f"Copied: {SOURCE_TEMPLATE} -> {OUTPUT_TEMPLATE}") + + doc = Document(OUTPUT_TEMPLATE) + + for element in doc.element.body[:]: + if element.tag.endswith('p') or element.tag.endswith('tbl'): + doc.element.body.remove(element) + + doc.save(OUTPUT_TEMPLATE) + print(f"Template created: {OUTPUT_TEMPLATE}") + print("Content cleared, styles preserved.") + +def create_new_template(): + from docx.shared import Pt, Cm + from docx.oxml.ns import qn + + doc = Document() + + for section in doc.sections: + section.page_height = Cm(29.7) + section.page_width = Cm(21.0) + section.left_margin = Cm(2.54) + section.right_margin = Cm(2.54) + section.top_margin = Cm(2.54) + section.bottom_margin = Cm(2.54) + + styles_config = { + 'Heading 1': {'size': 22, 'bold': True, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + 'Heading 2': {'size': 16, 'bold': True, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + 'Heading 3': {'size': 15, 'bold': True, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + 'Heading 4': {'size': 14, 'bold': True, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + 'Heading 5': {'size': 14, 'bold': True, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + 'Heading 6': {'size': 12, 'bold': True, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + 'Normal': {'size': 12, 'bold': False, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + } + + for style_name, config in styles_config.items(): + try: + style = doc.styles[style_name] + style.font.size = Pt(config['size']) + style.font.bold = config['bold'] + style.font.name = config['font_en'] + style._element.rPr.rFonts.set(qn('w:eastAsia'), config['font_cn']) + except KeyError: + pass + + doc.save(OUTPUT_TEMPLATE) + print(f"New template created: {OUTPUT_TEMPLATE}") + +if __name__ == '__main__': + create_template_from_source() diff --git a/.agents/skills/md-to-docx/scripts/markdown_normalizer.py b/.agents/skills/md-to-docx/scripts/markdown_normalizer.py new file mode 100644 index 0000000..1d85af2 --- /dev/null +++ b/.agents/skills/md-to-docx/scripts/markdown_normalizer.py @@ -0,0 +1,592 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import re +from typing import List, Tuple, Optional + + +class MarkdownNormalizer: + + def __init__(self, verbose: bool = False): + self.verbose = verbose + self.fixes_applied = [] + # Source-character map for normalizing Chinese numerals in headings (not UI strings). + self.chinese_num_map = { + '零': '0', '〇': '0', + '一': '1', '二': '2', '三': '3', '四': '4', + '五': '5', '六': '6', '七': '7', '八': '8', + '九': '9', '十': '10', '十一': '11', '十二': '12', + '十三': '13', '十四': '14', '十五': '15', '十六': '16', + '十七': '17', '十八': '18', '十九': '19', '二十': '20' + } + + def normalize(self, content: str) -> str: + self.fixes_applied = [] + + lines = content.split('\n') + lines = self._fix_code_blocks(lines) + lines = self._fix_tables(lines) + lines = self._adjust_heading_levels(lines) + lines = self._fix_headings(lines) + lines = self._fix_lists(lines) + lines = self._fix_horizontal_rules(lines) + lines = self._fix_inline_formatting(lines) + lines = self._fix_empty_lines(lines) + lines = self._fix_paragraph_spacing(lines) + lines = self._fix_leading_whitespace(lines) + + result = '\n'.join(lines) + result = self._fix_trailing_whitespace(result) + + if self.verbose and self.fixes_applied: + print(f"[Normalizer] Applied {len(self.fixes_applied)} fixes:") + for fix in self.fixes_applied[:10]: + print(f" - {fix}") + if len(self.fixes_applied) > 10: + print(f" ... and {len(self.fixes_applied) - 10} more") + + return result + + def _log_fix(self, message: str): + self.fixes_applied.append(message) + + def _convert_chinese_number_in_heading(self, text: str) -> str: + patterns = [ + (r'^([一二三四五六七八九十]+)[、..]\s*', lambda m: self._replace_chinese_num(m)), + (r'^\(([一二三四五六七八九十]+)\)\s*', lambda m: ''), + ] + + for pattern, replacement in patterns: + match = re.match(pattern, text) + if match: + chinese_num = match.group(1) + if chinese_num in self.chinese_num_map: + arabic_num = self.chinese_num_map[chinese_num] + if pattern == patterns[0][0]: + new_text = re.sub(pattern, f'{arabic_num}. ', text) + else: + new_text = re.sub(pattern, '', text) + self._log_fix(f"Removed Chinese number in heading: '({chinese_num})'") + return new_text + + return text + + def _replace_chinese_num(self, match) -> str: + chinese_num = match.group(1) + return self.chinese_num_map.get(chinese_num, chinese_num) + + def _fix_code_blocks(self, lines: List[str]) -> List[str]: + result = [] + in_code_block = False + code_block_start = -1 + + for i, line in enumerate(lines): + if line.strip().startswith('```'): + if not in_code_block: + in_code_block = True + code_block_start = i + result.append(line) + else: + in_code_block = False + result.append(line) + else: + result.append(line) + + if in_code_block: + result.append('```') + self._log_fix(f"Closed unclosed code block at line {code_block_start + 1}") + + return result + + def _fix_tables(self, lines: List[str]) -> List[str]: + result = [] + i = 0 + + while i < len(lines): + line = lines[i] + + if self._is_table_row(line): + table_lines = [line] + j = i + 1 + + while j < len(lines) and self._is_table_row(lines[j]): + table_lines.append(lines[j]) + j += 1 + + table_lines = self._normalize_table(table_lines) + result.extend(table_lines) + i = j + else: + result.append(line) + i += 1 + + return result + + def _is_table_row(self, line: str) -> bool: + stripped = line.strip() + return '|' in stripped and (stripped.startswith('|') or stripped.endswith('|')) + + def _normalize_table_row(self, line: str, num_cols: int) -> str: + cells = [c.strip() for c in line.split('|') if c.strip()] + while len(cells) < num_cols: + cells.append('') + if len(cells) > num_cols: + cells = cells[:num_cols] + return '|' + '|'.join(cells) + '|' + + def _normalize_table(self, table_lines: List[str]) -> List[str]: + if len(table_lines) < 1: + return table_lines + + header_cells = [c.strip() for c in table_lines[0].split('|') if c.strip()] + num_cols = len(header_cells) + + if num_cols == 0: + return table_lines + + has_separator = False + separator_index = -1 + + for i, line in enumerate(table_lines[1:], 1): + cells = [c.strip() for c in line.split('|') if c.strip()] + if all(c.replace('-', '').replace(':', '') == '' for c in cells if c): + has_separator = True + separator_index = i + break + + result = [] + result.append(self._normalize_table_row(table_lines[0], num_cols)) + + if not has_separator: + separator = '|' + '|'.join(['---'] * num_cols) + '|' + result.append(separator) + self._log_fix(f"Added missing table separator row") + + for i, line in enumerate(table_lines): + if has_separator and i == separator_index: + normalized_sep = '|' + '|'.join(['---'] * num_cols) + '|' + result.append(normalized_sep) + continue + + if i == 0: + continue + + if has_separator and i == separator_index: + continue + + normalized_row = self._normalize_table_row(line, num_cols) + result.append(normalized_row) + + return result + + def _adjust_heading_levels(self, lines: List[str]) -> List[str]: + h1_count = 0 + in_code_block = False + + for line in lines: + if line.strip().startswith('```'): + in_code_block = not in_code_block + continue + if not in_code_block and re.match(r'^#\s+', line): + h1_count += 1 + + has_multiple_h1 = h1_count > 1 + + heading_levels = [] + first_h1_found = False + in_code_block = False + + for line in lines: + if line.strip().startswith('```'): + in_code_block = not in_code_block + continue + if in_code_block: + continue + heading_match = re.match(r'^(#{1,6})\s+', line) + if heading_match: + level = len(heading_match.group(1)) + if level == 1 and not first_h1_found: + first_h1_found = True + continue + if has_multiple_h1: + heading_levels.append(level + 1) + else: + heading_levels.append(level) + + if not heading_levels: + return lines + + min_level = min(heading_levels) + + if has_multiple_h1: + if min_level <= 2: + offset = 0 + else: + offset = min_level - 2 + else: + if min_level <= 2: + offset = 0 + else: + offset = min_level - 2 + + result = [] + first_h1_processed = False + in_code_block = False + + for line in lines: + if line.strip().startswith('```'): + in_code_block = not in_code_block + result.append(line) + continue + + if in_code_block: + result.append(line) + continue + + heading_match = re.match(r'^(#{1,6})\s+(.+)$', line) + if heading_match: + level = len(heading_match.group(1)) + text = heading_match.group(2) + + if level == 1 and not first_h1_processed: + result.append(line) + first_h1_processed = True + continue + + if has_multiple_h1: + effective_level = level + 1 + else: + effective_level = level + + new_level = effective_level - offset + if new_level < 1: + new_level = 1 + if new_level > 6: + new_level = 6 + new_line = '#' * new_level + ' ' + text + result.append(new_line) + if new_level != level: + self._log_fix(f"Adjusted heading level: H{level} -> H{new_level}: '{text[:30]}...'") + else: + result.append(line) + + return result + + def _fix_headings(self, lines: List[str]) -> List[str]: + result = [] + + for i, line in enumerate(lines): + heading_match = re.match(r'^(#{1,6})\s*(.+)$', line) + if heading_match: + level = len(heading_match.group(1)) + text = heading_match.group(2).strip() + text = self._convert_chinese_number_in_heading(text) + normalized = '#' * level + ' ' + text + if normalized != line: + self._log_fix(f"Normalized heading: '{line.strip()}' -> '{normalized}'") + result.append(normalized) + else: + result.append(line) + + return result + + def _fix_lists(self, lines: List[str]) -> List[str]: + result = [] + + for line in lines: + stripped = line.strip() + + if re.match(r'^[-]{2,}$', stripped) or re.match(r'^[*]{2,}$', stripped) or re.match(r'^[_]{2,}$', stripped): + result.append(line) + continue + + if stripped.startswith('**'): + result.append(line) + continue + + bullet_match = re.match(r'^(\s*)[-*+](\s*)(.*)$', line) + if bullet_match and bullet_match.group(3).strip(): + indent = bullet_match.group(1) + space = bullet_match.group(2) + text = bullet_match.group(3).strip() + normalized = indent + '- ' + text + if normalized != line: + if not space: + self._log_fix(f"Added missing space in bullet list: '{line.strip()}'") + else: + self._log_fix(f"Normalized bullet list: '{line.strip()}'") + result.append(normalized) + continue + + ordered_match = re.match(r'^(\s*)(\d+)[.)](\s*)(.*)$', line) + if ordered_match and ordered_match.group(4).strip(): + indent = ordered_match.group(1) + number = ordered_match.group(2) + space = ordered_match.group(3) + text = ordered_match.group(4).strip() + normalized = f"{indent}{number}. {text}" + if normalized != line: + if not space: + self._log_fix(f"Added missing space in ordered list: '{line.strip()}'") + else: + self._log_fix(f"Normalized ordered list: '{line.strip()}'") + result.append(normalized) + continue + + result.append(line) + + return result + + def _fix_horizontal_rules(self, lines: List[str]) -> List[str]: + result = [] + + for line in lines: + stripped = line.strip() + if re.match(r'^[-_*]{2,}$', stripped) and stripped != '---': + result.append('---') + self._log_fix(f"Normalized horizontal rule: '{stripped}' -> '---'") + else: + result.append(line) + + return result + + def _fix_inline_formatting(self, lines: List[str]) -> List[str]: + result = [] + + for line in lines: + if line.strip().startswith('```'): + result.append(line) + continue + + bold_count = line.count('**') + if bold_count % 2 != 0: + line = re.sub(r'\*\*(?!\*)', '', line) + self._log_fix(f"Removed unmatched bold markers") + + italic_count = line.count('*') - line.count('**') * 2 + if italic_count % 2 != 0: + line = re.sub(r'(? bool: + stripped = line.strip() + return bool(re.match(r'^\d+[.)]\s+', stripped)) + + def _is_heading(self, line: str) -> bool: + return bool(re.match(r'^#{1,6}\s', line)) + + def _is_list_item(self, line: str) -> bool: + stripped = line.strip() + return bool(re.match(r'^[-*+]\s+', stripped)) or bool(re.match(r'^\d+[.)]\s+', stripped)) + + def _is_special_line(self, line: str) -> bool: + if line.strip() == '': + return False + return (self._is_heading(line) or + line.strip().startswith('>') or + line.strip().startswith('```') or + line.strip().startswith('|') or + line.strip() == '---') + + def _fix_empty_lines(self, lines: List[str]) -> List[str]: + result = [] + prev_empty = False + empty_count = 0 + i = 0 + + while i < len(lines): + line = lines[i] + + if line.strip() == '': + if not prev_empty: + if i > 0 and i < len(lines) - 1: + prev_line = lines[i - 1] + next_line = lines[i + 1] + + if self._is_heading(prev_line) or self._is_heading(next_line): + result.append('') + prev_empty = True + empty_count = 1 + i += 1 + continue + + if self._is_special_line(prev_line) or self._is_special_line(next_line): + result.append('') + prev_empty = True + empty_count = 1 + i += 1 + continue + + if prev_line.strip() == '---' or next_line.strip() == '---': + result.append('') + prev_empty = True + empty_count = 1 + i += 1 + continue + + self._log_fix(f"Removed empty line between paragraphs") + prev_empty = True + empty_count = 1 + else: + empty_count += 1 + else: + result.append(line) + prev_empty = False + + i += 1 + + if empty_count > 2: + self._log_fix(f"Compressed {empty_count} consecutive empty lines to 1") + + return result + + def _fix_paragraph_spacing(self, lines: List[str]) -> List[str]: + result = [] + + for i, line in enumerate(lines): + is_heading = bool(re.match(r'^#{1,6}\s', line)) + + if is_heading and i > 0: + if result and result[-1].strip() != '': + result.append('') + self._log_fix(f"Added empty line before heading: '{line.strip()[:30]}...'") + + result.append(line) + + if is_heading and i < len(lines) - 1: + if i + 1 < len(lines) and lines[i + 1].strip() != '': + pass + + return result + + def _fix_trailing_whitespace(self, content: str) -> str: + lines = content.split('\n') + fixed_lines = [] + trailing_count = 0 + br_converted = 0 + i = 0 + + while i < len(lines): + line = lines[i] + + if line.endswith(' ') or line.endswith('\t'): + stripped = line.rstrip() + merged_line = stripped + + while i + 1 < len(lines): + next_line = lines[i + 1] + if next_line.strip() == '': + break + if next_line.endswith(' ') or next_line.endswith('\t'): + merged_line += '
' + next_line.rstrip() + br_converted += 1 + i += 1 + else: + merged_line += '
' + next_line.rstrip() + br_converted += 1 + i += 1 + break + + fixed_lines.append(merged_line) + br_converted += 1 + else: + stripped = line.rstrip() + if stripped != line: + trailing_count += 1 + fixed_lines.append(stripped) + + i += 1 + + if trailing_count > 0: + self._log_fix(f"Removed trailing whitespace from {trailing_count} lines") + + if br_converted > 0: + self._log_fix(f"Converted {br_converted} Markdown line breaks to
") + + return '\n'.join(fixed_lines) + + def _fix_leading_whitespace(self, lines: List[str]) -> List[str]: + result = [] + in_code_block = False + + for i, line in enumerate(lines): + if line.strip().startswith('```'): + in_code_block = not in_code_block + result.append(line) + continue + + if in_code_block: + result.append(line) + continue + + if re.match(r'^#{1,6}\s', line): + result.append(line) + continue + + if re.match(r'^\s*>', line): + result.append(line) + continue + + if re.match(r'^\s*[-*+]\s+', line): + result.append(line) + continue + + if re.match(r'^\s*\d+[.)]\s+', line): + result.append(line) + continue + + if line.strip() == '': + result.append(line) + continue + + stripped = line.lstrip() + if stripped != line: + self._log_fix(f"Removed leading whitespace from paragraph") + result.append(stripped) + else: + result.append(line) + + return result + + def get_fixes(self) -> List[str]: + return self.fixes_applied + + +def normalize_markdown(content: str, verbose: bool = False) -> Tuple[str, List[str]]: + normalizer = MarkdownNormalizer(verbose=verbose) + normalized = normalizer.normalize(content) + return normalized, normalizer.get_fixes() + + +def normalize_markdown_file(input_path: str, output_path: Optional[str] = None, verbose: bool = False) -> Tuple[str, List[str]]: + with open(input_path, 'r', encoding='utf-8') as f: + content = f.read() + + normalized, fixes = normalize_markdown(content, verbose=verbose) + + if output_path: + with open(output_path, 'w', encoding='utf-8') as f: + f.write(normalized) + if verbose: + print(f"[Normalizer] Saved normalized file to: {output_path}") + + return normalized, fixes + + +if __name__ == '__main__': + import sys + + if len(sys.argv) < 2: + print("Usage: python markdown_normalizer.py [output.md]") + sys.exit(1) + + input_file = sys.argv[1] + output_file = sys.argv[2] if len(sys.argv) > 2 else None + + normalized, fixes = normalize_markdown_file(input_file, output_file, verbose=True) + + print(f"\nTotal fixes applied: {len(fixes)}") diff --git a/.agents/skills/md-to-docx/scripts/md_to_docx.py b/.agents/skills/md-to-docx/scripts/md_to_docx.py new file mode 100644 index 0000000..25585e3 --- /dev/null +++ b/.agents/skills/md-to-docx/scripts/md_to_docx.py @@ -0,0 +1,1307 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import re +import os +import sys +import shutil +from docx import Document +from docx.shared import Pt, Inches, Cm, Twips, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +from datetime import datetime +from typing import Optional, List, Dict, Any, Tuple +from io import BytesIO + +try: + import requests + REQUESTS_AVAILABLE = True +except ImportError: + REQUESTS_AVAILABLE = False + +try: + from PIL import Image + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + +try: + from markdown_normalizer import MarkdownNormalizer + NORMALIZER_AVAILABLE = True +except ImportError: + NORMALIZER_AVAILABLE = False + +try: + from version_manager import get_versioned_output_paths + VERSION_MANAGER_AVAILABLE = True +except ImportError: + VERSION_MANAGER_AVAILABLE = False + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +SKILL_DIR = os.path.dirname(SCRIPT_DIR) +ASSETS_DIR = os.path.join(SKILL_DIR, 'assets') + +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) + +FRONT_MATTER_KEYS = ('title', 'date', 'version', 'audience') +CJK_FONT = 'SimSun' +DEFAULT_DATE_FMT = '%Y-%m-%d' + + +def extract_front_matter(content: str) -> Tuple[Dict[str, str], str]: + """Parse YAML-like front matter and return metadata plus body.""" + meta: Dict[str, str] = {} + match = re.match(r'^---\r?\n([\s\S]*?)\r?\n---\r?\n*', content) + if not match: + return meta, content + + fm = match.group(1) + for key in FRONT_MATTER_KEYS: + field = re.search(rf'^{re.escape(key)}:\s*(.+)$', fm, re.M) + if field: + meta[key] = field.group(1).strip().strip('"').strip("'") + + return meta, content[match.end():] + + +def split_title_subtitle(title: str) -> Tuple[str, str]: + """Split title on em/en dash into main title and subtitle.""" + parts = re.split(r'\s*[—–]\s*', title, maxsplit=1) + if len(parts) == 2: + return parts[0].strip(), parts[1].strip() + return title.strip(), '' + + +def titles_match(left: str, right: str) -> bool: + """Loose match for skipping duplicate document titles.""" + left = left.strip() + right = right.strip() + if not left or not right: + return False + return left == right or left in right or right in left + + +def get_default_template_path() -> str: + """Return absolute path to the default template in assets/.""" + return os.path.join(ASSETS_DIR, 'template.docx') + +def resolve_template_path(template_path: str) -> str: + """Resolve template path with relative paths and default asset fallback.""" + if template_path is None: + return get_default_template_path() + + if os.path.isabs(template_path): + return template_path + + if os.path.exists(template_path): + return os.path.abspath(template_path) + + default_template = get_default_template_path() + if os.path.exists(default_template): + return default_template + + return template_path + +class MarkdownParser: + def __init__(self, enable_code_blocks: bool = True, md_file_path: str = None): + self.elements = [] + self.current_table = None + self.in_code_block = False + self.code_content = [] + self.code_language = '' + self.code_block_indent = '' + self.skip_empty = False + self.enable_code_blocks = enable_code_blocks + self.md_file_path = md_file_path + self.md_file_dir = os.path.dirname(md_file_path) if md_file_path else None + + def _parse_image(self, line: str) -> List[Dict[str, Any]]: + """Parse a line containing images and return element dicts.""" + elements = [] + img_pattern = r'!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)' + last_end = 0 + + for match in re.finditer(img_pattern, line): + if match.start() > last_end: + text_before = line[last_end:match.start()].strip() + if text_before: + elements.append({'type': 'paragraph', 'text': text_before}) + + alt_text = match.group(1) + img_path = match.group(2) + img_title = match.group(3) if match.group(3) else '' + + if self.md_file_dir and not os.path.isabs(img_path) and not img_path.startswith('http'): + img_path = os.path.join(self.md_file_dir, img_path) + + elements.append({ + 'type': 'image', + 'alt': alt_text, + 'path': img_path, + 'title': img_title + }) + last_end = match.end() + + if last_end < len(line): + text_after = line[last_end:].strip() + if text_after: + elements.append({'type': 'paragraph', 'text': text_after}) + + return elements if elements else [{'type': 'paragraph', 'text': line}] + + def parse(self, content: str) -> List[Dict[str, Any]]: + lines = content.split('\n') + i = 0 + + while i < len(lines): + line = lines[i] + + stripped_line = line.lstrip() + if self.enable_code_blocks and stripped_line.startswith('```'): + if self.in_code_block: + self.elements.append({ + 'type': 'code_block', + 'content': '\n'.join(self.code_content), + 'language': self.code_language + }) + self.code_content = [] + self.code_language = '' + self.code_block_indent = '' + self.in_code_block = False + else: + self.in_code_block = True + self.code_language = stripped_line[3:].strip() + self.code_block_indent = line[:len(line) - len(stripped_line)] + i += 1 + self.skip_empty = False + continue + + if self.in_code_block: + if self.code_block_indent and line.startswith(self.code_block_indent): + self.code_content.append(line[len(self.code_block_indent):]) + elif line.strip() == '': + self.code_content.append('') + else: + self.code_content.append(line) + i += 1 + self.skip_empty = False + continue + + if self.current_table is not None: + if line.strip().startswith('|'): + if '---' not in line: + self.current_table['rows'].append(line) + i += 1 + continue + else: + self._flush_table() + self.skip_empty = False + + if line.startswith('# '): + self.elements.append({'type': 'title', 'text': line[2:].strip()}) + self.skip_empty = True + elif line.startswith('## '): + self.elements.append({'type': 'heading1', 'text': line[3:].strip()}) + self.skip_empty = True + elif line.startswith('### '): + self.elements.append({'type': 'heading2', 'text': line[4:].strip()}) + self.skip_empty = True + elif line.startswith('#### '): + self.elements.append({'type': 'heading3', 'text': line[5:].strip()}) + self.skip_empty = True + elif line.startswith('##### '): + self.elements.append({'type': 'heading4', 'text': line[6:].strip()}) + self.skip_empty = True + elif line.startswith('###### '): + self.elements.append({'type': 'heading5', 'text': line[7:].strip()}) + self.skip_empty = True + elif re.match(r'^(\s*)[-*]\s', line): + match = re.match(r'^(\s*)[-*]\s(.*)$', line) + if match: + indent = match.group(1) + level = len(indent) // 2 + 1 + text = match.group(2).strip() + self.elements.append({'type': 'bullet', 'text': text, 'level': level}) + self.skip_empty = False + elif re.match(r'^(\s*)\d+\.\s', line): + match = re.match(r'^(\s*)(\d+)\.\s(.*)$', line) + if match: + indent = match.group(1) + level = len(indent) // 2 + 1 + number = int(match.group(2)) + text = match.group(3).strip() + self.elements.append({'type': 'ordered_item', 'text': text, 'number': number, 'level': level}) + self.skip_empty = False + elif line.strip().startswith('|'): + self.current_table = {'rows': [line]} + self.skip_empty = False + elif line.strip().startswith('>'): + text = line.strip().lstrip('>').strip() + self.elements.append({'type': 'blockquote', 'text': text}) + self.skip_empty = False + elif line.strip() == '---' or line.strip() == '***' or line.strip() == '___': + self.elements.append({'type': 'horizontal_rule'}) + self.skip_empty = False + elif line.strip() == '': + if not self.skip_empty: + self.elements.append({'type': 'empty'}) + else: + if '![' in line and '](' in line: + img_elements = self._parse_image(line) + self.elements.extend(img_elements) + else: + self.elements.append({'type': 'paragraph', 'text': line}) + self.skip_empty = False + + i += 1 + + if self.current_table: + self._flush_table() + + return self.elements + + def _parse_cell_content(self, cell_text: str) -> List[Dict[str, Any]]: + """Parse table cell content that may mix text and images.""" + content = [] + img_pattern = r'!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)' + last_end = 0 + + for match in re.finditer(img_pattern, cell_text): + if match.start() > last_end: + text_before = cell_text[last_end:match.start()].strip() + if text_before: + content.append({'type': 'text', 'value': text_before}) + + alt_text = match.group(1) + img_path = match.group(2) + img_title = match.group(3) if match.group(3) else '' + + if self.md_file_dir and not os.path.isabs(img_path) and not img_path.startswith('http'): + img_path = os.path.join(self.md_file_dir, img_path) + + content.append({ + 'type': 'image', + 'alt': alt_text, + 'path': img_path, + 'title': img_title + }) + last_end = match.end() + + if last_end < len(cell_text): + text_after = cell_text[last_end:].strip() + if text_after: + content.append({'type': 'text', 'value': text_after}) + + return content if content else [{'type': 'text', 'value': cell_text}] + + def _flush_table(self): + if self.current_table and self.current_table['rows']: + rows = self.current_table['rows'] + headers = [self._parse_cell_content(c.strip()) for c in rows[0].split('|') if c.strip()] + data = [] + for row in rows[1:]: + cells = [self._parse_cell_content(c.strip()) for c in row.split('|') if c.strip()] + if cells: + data.append(cells) + self.elements.append({'type': 'table', 'headers': headers, 'data': data}) + self.current_table = None + + +class TextFormatter: + @staticmethod + def parse_inline(text: str) -> List[Dict[str, Any]]: + parts = [] + i = 0 + + while i < len(text): + if text[i] == '`' and (i == 0 or text[i-1] != '\\'): + end_idx = text.find('`', i + 1) + if end_idx != -1: + parts.append({'type': 'code', 'content': text[i+1:end_idx]}) + i = end_idx + 1 + else: + parts.append({'type': 'text', 'content': text[i]}) + i += 1 + elif text[i] == '[': + link_match = re.match(r'\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)', text[i:]) + if link_match: + link_text = link_match.group(1) + link_url = link_match.group(2) + link_title = link_match.group(3) if link_match.group(3) else '' + inner_parts = TextFormatter.parse_inline(link_text) + parts.append({'type': 'link', 'content': inner_parts, 'url': link_url, 'title': link_title}) + i += len(link_match.group(0)) + else: + parts.append({'type': 'text', 'content': text[i]}) + i += 1 + elif text[i:i+3] == '***': + end_idx = text.find('***', i + 3) + if end_idx != -1: + inner_content = text[i+3:end_idx] + inner_parts = TextFormatter.parse_inline(inner_content) + parts.append({'type': 'bold_italic', 'content': inner_parts}) + i = end_idx + 3 + else: + parts.append({'type': 'text', 'content': text[i:i+3]}) + i += 3 + elif text[i:i+2] == '~~': + end_idx = text.find('~~', i + 2) + if end_idx != -1: + inner_content = text[i+2:end_idx] + inner_parts = TextFormatter.parse_inline(inner_content) + parts.append({'type': 'strikethrough', 'content': inner_parts}) + i = end_idx + 2 + else: + parts.append({'type': 'text', 'content': text[i:i+2]}) + i += 2 + elif text[i:i+2] == '**': + end_idx = text.find('**', i + 2) + if end_idx != -1: + inner_content = text[i+2:end_idx] + inner_parts = TextFormatter.parse_inline(inner_content) + parts.append({'type': 'bold', 'content': inner_parts}) + i = end_idx + 2 + else: + parts.append({'type': 'text', 'content': text[i:i+2]}) + i += 2 + elif text[i] == '*' and (i == 0 or text[i-1] != '*'): + end_idx = text.find('*', i + 1) + if end_idx != -1 and text[end_idx-1] != '*': + inner_content = text[i+1:end_idx] + inner_parts = TextFormatter.parse_inline(inner_content) + parts.append({'type': 'italic', 'content': inner_parts}) + i = end_idx + 1 + else: + parts.append({'type': 'text', 'content': text[i]}) + i += 1 + elif text[i:i+4] == '
': + parts.append({'type': 'br'}) + i += 4 + else: + next_code = text.find('`', i) + next_link = text.find('[', i) + next_bold_italic = text.find('***', i) + next_strikethrough = text.find('~~', i) + next_bold = text.find('**', i) + next_italic = text.find('*', i) + next_br = text.find('
', i) + + candidates = [] + if next_code != -1: + candidates.append(('code', next_code)) + if next_link != -1: + candidates.append(('link', next_link)) + if next_bold_italic != -1: + candidates.append(('bold_italic', next_bold_italic)) + if next_strikethrough != -1: + candidates.append(('strikethrough', next_strikethrough)) + if next_bold != -1: + candidates.append(('bold', next_bold)) + if next_italic != -1: + candidates.append(('italic', next_italic)) + if next_br != -1: + candidates.append(('br', next_br)) + + if not candidates: + parts.append({'type': 'text', 'content': text[i:]}) + break + + candidates.sort(key=lambda x: x[1]) + next_type, next_pos = candidates[0] + + if next_pos > i: + parts.append({'type': 'text', 'content': text[i:next_pos]}) + i = next_pos + + return parts + + @staticmethod + def parse_link(text: str) -> Optional[Dict[str, Any]]: + match = re.match(r'\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)', text) + if match: + return { + 'text': match.group(1), + 'url': match.group(2), + 'title': match.group(3) if match.group(3) else '' + } + return None + + +class DocxGenerator: + def __init__(self, template_path: str): + self.template_path = template_path + self.doc = None + self.first_heading1 = True + + def create_document(self, output_path: str): + if self.template_path and os.path.exists(self.template_path): + shutil.copy(self.template_path, output_path) + self.doc = Document(output_path) + for element in self.doc.element.body[:]: + if element.tag.endswith('p') or element.tag.endswith('tbl'): + self.doc.element.body.remove(element) + else: + self.doc = Document() + for section in self.doc.sections: + section.page_height = Cm(29.7) + section.page_width = Cm(21.0) + section.left_margin = Cm(2.54) + section.right_margin = Cm(2.54) + section.top_margin = Cm(2.54) + section.bottom_margin = Cm(2.54) + self._setup_styles() + + def _setup_styles(self): + styles_config = { + 'Heading 1': {'size': 22, 'bold': True, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + 'Heading 2': {'size': 16, 'bold': True, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + 'Heading 3': {'size': 15, 'bold': True, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + 'Heading 4': {'size': 14, 'bold': True, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + 'Heading 5': {'size': 14, 'bold': True, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + 'Heading 6': {'size': 12, 'bold': True, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + 'Normal': {'size': 12, 'bold': False, 'font_cn': CJK_FONT, 'font_en': 'Times New Roman'}, + } + for style_name, config in styles_config.items(): + try: + style = self.doc.styles[style_name] + style.font.size = Pt(config['size']) + style.font.bold = config['bold'] + style.font.name = config['font_en'] + style._element.rPr.rFonts.set(qn('w:eastAsia'), config['font_cn']) + except KeyError: + pass + + def _set_run_font(self, run, font_cn: str = CJK_FONT, font_en: str = 'Times New Roman', size: float = 12, bold: bool = False): + run.font.size = Pt(size) + run.font.name = font_en + run._element.rPr.rFonts.set(qn('w:eastAsia'), font_cn) + run.bold = bold + + def _add_formatted_text(self, paragraph, text: str, base_size: float = 12): + parts = TextFormatter.parse_inline(text) + for part in parts: + if part['type'] == 'text': + run = paragraph.add_run(part['content']) + self._set_run_font(run, size=base_size) + elif part['type'] == 'bold': + content = part['content'] + if isinstance(content, list): + for sub_part in content: + self._render_part(paragraph, sub_part, base_size, bold=True) + else: + run = paragraph.add_run(content) + self._set_run_font(run, size=base_size, bold=True) + elif part['type'] == 'italic': + content = part['content'] + if isinstance(content, list): + for sub_part in content: + self._render_part(paragraph, sub_part, base_size, italic=True) + else: + run = paragraph.add_run(content) + self._set_run_font(run, size=base_size) + run.italic = True + elif part['type'] == 'bold_italic': + content = part['content'] + if isinstance(content, list): + for sub_part in content: + self._render_part(paragraph, sub_part, base_size, bold=True, italic=True) + else: + run = paragraph.add_run(content) + self._set_run_font(run, size=base_size, bold=True) + run.italic = True + elif part['type'] == 'strikethrough': + content = part['content'] + if isinstance(content, list): + for sub_part in content: + self._render_part(paragraph, sub_part, base_size, strike=True) + else: + run = paragraph.add_run(content) + self._set_run_font(run, size=base_size) + run.font.strike = True + elif part['type'] == 'code': + run = paragraph.add_run(part['content']) + run.font.size = Pt(base_size) + run.font.name = 'Consolas' + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'Consolas') + shading = OxmlElement('w:shd') + shading.set(qn('w:fill'), 'F0F0F0') + run._r.get_or_add_rPr().append(shading) + elif part['type'] == 'link': + link_url = part.get('url', '') + link_content = part.get('content', []) + link_text = self._extract_text_from_parts(link_content) + if link_text: + self._add_hyperlink(paragraph, link_url, link_text, base_size) + elif part['type'] == 'br': + paragraph.add_run().add_break() + + def _render_part(self, paragraph, part: Dict[str, Any], base_size: float = 12, bold: bool = False, italic: bool = False, strike: bool = False): + if part['type'] == 'text': + run = paragraph.add_run(part['content']) + self._set_run_font(run, size=base_size, bold=bold, italic=italic, strike=strike) + elif part['type'] == 'bold': + content = part['content'] + if isinstance(content, list): + for sub_part in content: + self._render_part(paragraph, sub_part, base_size, bold=True, italic=italic, strike=strike) + else: + run = paragraph.add_run(content) + self._set_run_font(run, size=base_size, bold=True) + if italic: + run.italic = True + if strike: + run.font.strike = True + elif part['type'] == 'italic': + content = part['content'] + if isinstance(content, list): + for sub_part in content: + self._render_part(paragraph, sub_part, base_size, bold=bold, italic=True, strike=strike) + else: + run = paragraph.add_run(content) + self._set_run_font(run, size=base_size) + run.italic = True + if bold: + run.bold = True + if strike: + run.font.strike = True + elif part['type'] == 'code': + run = paragraph.add_run(part['content']) + run.font.size = Pt(base_size) + run.font.name = 'Consolas' + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'Consolas') + shading = OxmlElement('w:shd') + shading.set(qn('w:fill'), 'F0F0F0') + run._r.get_or_add_rPr().append(shading) + if bold: + run.bold = True + if italic: + run.italic = True + elif part['type'] == 'link': + link_url = part.get('url', '') + link_content = part.get('content', []) + link_text = self._extract_text_from_parts(link_content) + if link_text: + self._add_hyperlink(paragraph, link_url, link_text, base_size) + + def _set_run_font(self, run, font_cn: str = CJK_FONT, font_en: str = 'Times New Roman', size: float = 12, bold: bool = False, italic: bool = False, strike: bool = False): + run.font.size = Pt(size) + run.font.name = font_en + run._element.rPr.rFonts.set(qn('w:eastAsia'), font_cn) + run.bold = bold + if italic: + run.italic = True + if strike: + run.font.strike = True + + def _add_hyperlink(self, paragraph, url: str, text: str, base_size: float = 12): + part = paragraph.part + r_id = part.relate_to(url, 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', is_external=True) + + hyperlink = OxmlElement('w:hyperlink') + hyperlink.set(qn('r:id'), r_id) + + new_run = OxmlElement('w:r') + rPr = OxmlElement('w:rPr') + + rFonts = OxmlElement('w:rFonts') + rFonts.set(qn('w:ascii'), 'Times New Roman') + rFonts.set(qn('w:hAnsi'), 'Times New Roman') + rFonts.set(qn('w:eastAsia'), 'SimSun') + rPr.append(rFonts) + + sz = OxmlElement('w:sz') + sz.set(qn('w:val'), str(int(base_size * 2))) + rPr.append(sz) + + color = OxmlElement('w:color') + color.set(qn('w:val'), '0563C1') + rPr.append(color) + + u = OxmlElement('w:u') + u.set(qn('w:val'), 'single') + rPr.append(u) + + new_run.append(rPr) + + text_elem = OxmlElement('w:t') + text_elem.text = text + new_run.append(text_elem) + + hyperlink.append(new_run) + paragraph._p.append(hyperlink) + + def _extract_text_from_parts(self, parts: List[Dict[str, Any]]) -> str: + text_parts = [] + for part in parts: + if part.get('type') == 'text': + text_parts.append(part.get('content', '')) + elif part.get('type') in ('bold', 'italic', 'bold_italic', 'strikethrough', 'code'): + content = part.get('content') + if isinstance(content, list): + text_parts.append(self._extract_text_from_parts(content)) + elif isinstance(content, str): + text_parts.append(content) + return ''.join(text_parts) + + def add_cover_page( + self, + title: str, + version: str = '', + date: str = '', + subtitle: str = '', + audience: str = '', + ): + main_title, derived_subtitle = split_title_subtitle(title) + if not subtitle: + subtitle = derived_subtitle + + for _ in range(3): + p = self.doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + + p = self.doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run(main_title) + run.bold = True + run.font.size = Pt(22) + run.font.name = 'SimSun' + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + if subtitle: + p_sub = self.doc.add_paragraph() + p_sub.alignment = WD_ALIGN_PARAGRAPH.CENTER + run_sub = p_sub.add_run(subtitle) + run_sub.font.size = Pt(16) + run_sub.font.name = 'SimSun' + run_sub._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + for _ in range(14 if not subtitle else 10): + self.doc.add_paragraph() + + p = self.doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + + if version: + run1 = p.add_run(f'Version: {version}') + run1.font.size = Pt(12) + run1.font.name = 'SimSun' + run1._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + p.add_run('\n') + + if date: + run2 = p.add_run(f'Date: {date}') + run2.font.size = Pt(12) + run2.font.name = 'SimSun' + run2._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + if audience: + p.add_run('\n') + run3 = p.add_run(f'Audience: {audience}') + run3.font.size = Pt(12) + run3.font.name = 'SimSun' + run3._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + self.doc.add_paragraph() + self.doc.add_page_break() + + def add_table_of_contents(self, entries: List[Tuple[int, str]]): + """Insert a static table of contents from heading levels 1-3.""" + if not entries: + return + + p = self.doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run('Table of Contents') + run.bold = True + run.font.size = Pt(16) + run.font.name = 'SimSun' + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + for level, text in entries: + p = self.doc.add_paragraph() + p.paragraph_format.left_indent = Cm(0.74 * (level - 1)) + p.paragraph_format.line_spacing = 1.5 + run = p.add_run(text) + run.bold = level <= 2 + run.font.size = Pt(14 if level == 1 else 12) + run.font.name = 'SimSun' + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + self.doc.add_page_break() + + def add_title(self, text: str, is_first: bool = False, version: str = '', date: str = ''): + if is_first: + self.add_cover_page(text, version, date) + else: + p = self.doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run(text) + run.bold = True + run.font.size = Pt(22) + run.font.name = 'SimSun' + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + def add_heading(self, level: int, text: str): + if level == 1: + if not self.first_heading1: + self.doc.add_page_break() + self.first_heading1 = False + + p = self.doc.add_paragraph() + + # Disable page break before paragraph (template styles may enable it). + p.paragraph_format.page_break_before = False + + try: + style = self.doc.styles[f'Heading {level}'] + p.style = style + # Re-apply after style assignment to override template defaults. + p.paragraph_format.page_break_before = False + except KeyError: + pass + + pPr = p._p.get_or_add_pPr() + for numPr in pPr.findall(qn('w:numPr')): + pPr.remove(numPr) + + text = re.sub(r'^[\d\.]+\s*', '', text).strip() + + run = p.add_run(text) + run.bold = True + run.font.name = 'Times New Roman' + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + if level == 1: + run.font.size = Pt(22) + elif level == 2: + run.font.size = Pt(16) + elif level == 3: + run.font.size = Pt(15) + elif level == 4: + run.font.size = Pt(14) + elif level == 5: + run.font.size = Pt(14) + + def add_paragraph(self, text: str): + p = self.doc.add_paragraph() + p.paragraph_format.first_line_indent = Cm(0.74) + p.paragraph_format.line_spacing = 1.5 + self._add_formatted_text(p, text) + + def add_paragraph_with_hanging_indent(self, text: str): + p = self.doc.add_paragraph() + p.paragraph_format.first_line_indent = Cm(0) + p.paragraph_format.left_indent = Cm(0.74) + p.paragraph_format.line_spacing = 1.5 + self._add_formatted_text(p, text) + + def add_bullet(self, text: str, level: int = 1): + p = self.doc.add_paragraph() + indent_per_level = Cm(0.74) + p.paragraph_format.left_indent = indent_per_level * level + p.paragraph_format.line_spacing = 1.5 + run = p.add_run('• ') + self._set_run_font(run, size=12) + self._add_formatted_text(p, text) + + def add_ordered_item(self, text: str, number: int, level: int = 1): + p = self.doc.add_paragraph() + indent_per_level = Cm(0.74) + p.paragraph_format.left_indent = indent_per_level * level + p.paragraph_format.line_spacing = 1.5 + run = p.add_run(f'{number}. ') + self._set_run_font(run, size=12) + self._add_formatted_text(p, text) + + def add_table(self, headers: List[List[Dict[str, Any]]], data: List[List[List[Dict[str, Any]]]]): + if not headers: + return + + num_cols = len(headers) + + def get_cell_text_length(cell_content: List[Dict[str, Any]]) -> float: + total_len = 0 + for item in cell_content: + if item.get('type') == 'text': + text = item.get('value', '') + char_count = len(text) + chinese_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff') + total_len += char_count + chinese_count * 0.5 + elif item.get('type') == 'image': + total_len += 20 + return total_len + + col_widths = [] + for col_idx in range(num_cols): + max_len = get_cell_text_length(headers[col_idx]) if col_idx < len(headers) else 0 + for row in data: + if col_idx < len(row): + max_len = max(max_len, get_cell_text_length(row[col_idx])) + col_widths.append(max(max_len, 5)) + + total_width = sum(col_widths) + page_width = 8464 + if total_width > 0: + col_widths = [int(w * page_width / total_width) for w in col_widths] + else: + col_widths = [int(page_width / num_cols)] * num_cols + + table = self.doc.add_table(rows=1, cols=num_cols) + table.style = 'Table Grid' + table.alignment = WD_TABLE_ALIGNMENT.CENTER + + for idx, width in enumerate(col_widths): + for cell in table.columns[idx].cells: + cell.width = Twips(width) + + def render_cell_content(cell, content: List[Dict[str, Any]], is_header: bool = False): + cell.paragraphs[0].clear() + p = cell.paragraphs[0] + p.alignment = WD_ALIGN_PARAGRAPH.CENTER if is_header else WD_ALIGN_PARAGRAPH.LEFT + p.paragraph_format.first_line_indent = Cm(0) + + for item in content: + if item.get('type') == 'text': + text = item.get('value', '') + if is_header: + self._add_formatted_text(p, text, base_size=10) + else: + self._add_formatted_text(p, text) + elif item.get('type') == 'image': + img_path = item.get('path', '') + alt_text = item.get('alt', '') + + try: + if not PIL_AVAILABLE: + raise ImportError("PIL not available") + + if img_path.startswith('http://') or img_path.startswith('https://'): + if not REQUESTS_AVAILABLE: + raise ImportError("requests not available") + response = requests.get(img_path, timeout=10) + response.raise_for_status() + image_data = BytesIO(response.content) + img = Image.open(image_data) + image_stream = BytesIO() + img_format = img.format if img.format else 'PNG' + img.save(image_stream, format=img_format) + image_stream.seek(0) + else: + if not os.path.exists(img_path): + raise FileNotFoundError(f"Image file not found: {img_path}") + image_stream = img_path + + run = p.add_run() + run.add_picture(image_stream, width=Cm(10)) + except Exception as e: + run = p.add_run(f"[Image: {alt_text}]") + run.font.size = Pt(9) + run.italic = True + + header_cells = table.rows[0].cells + for i, header_content in enumerate(headers): + render_cell_content(header_cells[i], header_content, is_header=True) + shading = OxmlElement('w:shd') + shading.set(qn('w:fill'), 'D9D9D9') + header_cells[i]._tc.get_or_add_tcPr().append(shading) + + for row_idx, row_data in enumerate(data): + row = table.add_row() + for i, cell_content in enumerate(row_data): + if i < num_cols: + render_cell_content(row.cells[i], cell_content, is_header=False) + if row_idx % 2 == 1: + for cell in row.cells: + shading = OxmlElement('w:shd') + shading.set(qn('w:fill'), 'F2F7FB') + cell._tc.get_or_add_tcPr().append(shading) + + self.doc.add_paragraph() + + def add_code_block(self, code: str, language: str = ''): + if language and language.lower() == 'mermaid': + p = self.doc.add_paragraph() + p.paragraph_format.left_indent = Cm(0.5) + p.paragraph_format.space_before = Pt(6) + p.paragraph_format.space_after = Pt(6) + shading = OxmlElement('w:shd') + shading.set(qn('w:fill'), 'F5F5F5') + p._p.get_or_add_pPr().append(shading) + run = p.add_run('[Diagram: see source .md for interactive Mermaid diagram]') + run.font.size = Pt(9) + run.font.name = 'Consolas' + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'Consolas') + run.italic = True + return + + p = self.doc.add_paragraph() + p.paragraph_format.left_indent = Cm(0.5) + p.paragraph_format.space_before = Pt(6) + p.paragraph_format.space_after = Pt(6) + + shading = OxmlElement('w:shd') + shading.set(qn('w:fill'), 'F5F5F5') + p._p.get_or_add_pPr().append(shading) + + if language: + lang_run = p.add_run(f'[{language}]\n') + lang_run.font.size = Pt(9) + lang_run.font.name = 'Consolas' + lang_run._element.rPr.rFonts.set(qn('w:eastAsia'), 'Consolas') + lang_run.italic = True + + code_run = p.add_run(code) + code_run.font.size = Pt(9) + code_run.font.name = 'Consolas' + code_run._element.rPr.rFonts.set(qn('w:eastAsia'), 'Consolas') + + def add_blockquote(self, text: str): + p = self.doc.add_paragraph() + p.paragraph_format.left_indent = Cm(1) + p.paragraph_format.right_indent = Cm(1) + p.paragraph_format.space_before = Pt(6) + p.paragraph_format.space_after = Pt(6) + + border = OxmlElement('w:pBdr') + left_border = OxmlElement('w:left') + left_border.set(qn('w:val'), 'single') + left_border.set(qn('w:sz'), '12') + left_border.set(qn('w:color'), '6366F1') + border.append(left_border) + p._p.get_or_add_pPr().append(border) + + self._add_formatted_text(p, text) + + def add_horizontal_rule(self): + p = self.doc.add_paragraph() + p.paragraph_format.space_before = Pt(6) + p.paragraph_format.space_after = Pt(6) + + pBdr = OxmlElement('w:pBdr') + bottom = OxmlElement('w:bottom') + bottom.set(qn('w:val'), 'single') + bottom.set(qn('w:sz'), '6') + bottom.set(qn('w:color'), 'CCCCCC') + pBdr.append(bottom) + p._p.get_or_add_pPr().append(pBdr) + + def add_image(self, img_path: str, alt_text: str = '', max_width: float = 15.0): + """Add an image to the document. + + Args: + img_path: Local path or URL + alt_text: Alt text for caption / fallback + max_width: Maximum width in centimeters + """ + p = self.doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + p.paragraph_format.space_before = Pt(6) + p.paragraph_format.space_after = Pt(6) + + try: + if not PIL_AVAILABLE: + raise ImportError("PIL not available") + + if img_path.startswith('http://') or img_path.startswith('https://'): + if not REQUESTS_AVAILABLE: + raise ImportError("requests not available") + response = requests.get(img_path, timeout=10) + response.raise_for_status() + image_data = BytesIO(response.content) + img = Image.open(image_data) + image_stream = BytesIO() + img_format = img.format if img.format else 'PNG' + img.save(image_stream, format=img_format) + image_stream.seek(0) + else: + if not os.path.exists(img_path): + raise FileNotFoundError(f"Image file not found: {img_path}") + image_stream = img_path + + run = p.add_run() + inline_shape = run.add_picture(image_stream, width=Cm(max_width)) + + if alt_text: + p_caption = self.doc.add_paragraph() + p_caption.alignment = WD_ALIGN_PARAGRAPH.CENTER + run_caption = p_caption.add_run(alt_text) + run_caption.font.size = Pt(9) + run_caption.font.name = 'SimSun' + run_caption._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + run_caption.italic = True + + except Exception: + error_msg = f"[Image not found: {img_path}]" + run = p.add_run(error_msg) + run.font.size = Pt(10) + run.font.color.rgb = RGBColor(255, 0, 0) + run.italic = True + if alt_text: + run_alt = p.add_run(f" (alt text: {alt_text})") + run_alt.font.size = Pt(10) + run_alt.italic = True + + def add_empty(self): + self.doc.add_paragraph() + + def generate( + self, + elements: List[Dict[str, Any]], + version: str = '', + date: str = '', + enable_code_blocks: bool = False, + metadata: Optional[Dict[str, str]] = None, + include_toc: bool = True, + ): + metadata = metadata or {} + fm_title = metadata.get('title', '').strip() + fm_version = metadata.get('version', '') or version + fm_date = metadata.get('date', '') or date + audience = metadata.get('audience', '') + + cover_from_front_matter = False + skip_title_text = '' + + if fm_title: + if not fm_date: + fm_date = datetime.now().strftime(DEFAULT_DATE_FMT) + self.add_cover_page(fm_title, fm_version, fm_date, audience=audience) + cover_from_front_matter = True + skip_title_text, _ = split_title_subtitle(fm_title) + + toc_entries: List[Tuple[int, str]] = [] + for elem in elements: + if elem['type'] == 'heading1': + toc_entries.append((1, elem['text'])) + elif elem['type'] == 'heading2': + toc_entries.append((2, elem['text'])) + elif elem['type'] == 'heading3': + toc_entries.append((3, elem['text'])) + + if include_toc and toc_entries: + self.add_table_of_contents(toc_entries) + + is_first_title = not cover_from_front_matter + effective_version = fm_version or version + effective_date = fm_date or date or datetime.now().strftime(DEFAULT_DATE_FMT) + + for elem in elements: + if elem['type'] == 'title': + if skip_title_text and titles_match(elem['text'], skip_title_text): + continue + self.add_title(elem['text'], is_first_title, effective_version, effective_date) + is_first_title = False + + elif elem['type'] == 'heading1': + self.add_heading(1, elem['text']) + + elif elem['type'] == 'heading2': + self.add_heading(2, elem['text']) + + elif elem['type'] == 'heading3': + self.add_heading(3, elem['text']) + + elif elem['type'] == 'heading4': + self.add_heading(4, elem['text']) + + elif elem['type'] == 'heading5': + self.add_heading(5, elem['text']) + + elif elem['type'] == 'paragraph': + if '
' in elem['text']: + self.add_paragraph_with_hanging_indent(elem['text']) + else: + self.add_paragraph(elem['text']) + + elif elem['type'] == 'bullet': + self.add_bullet(elem['text'], elem.get('level', 1)) + + elif elem['type'] == 'ordered_item': + self.add_ordered_item(elem['text'], elem['number'], elem.get('level', 1)) + + elif elem['type'] == 'table': + self.add_table(elem['headers'], elem['data']) + + elif elem['type'] == 'code_block' and enable_code_blocks: + self.add_code_block(elem['content'], elem.get('language', '')) + + elif elem['type'] == 'blockquote': + self.add_blockquote(elem['text']) + + elif elem['type'] == 'horizontal_rule': + self.add_horizontal_rule() + + elif elem['type'] == 'image': + self.add_image(elem['path'], elem.get('alt', '')) + + elif elem['type'] == 'empty': + self.add_empty() + + def save(self, output_path: str): + self.doc.save(output_path) + + +def convert_markdown_to_docx( + markdown_content: str, + output_path: str, + template_path: str = None, + version: str = '', + date: str = '', + normalize: bool = True, + save_normalized: bool = True, + normalized_output_path: Optional[str] = None, + enable_code_blocks: bool = True, + md_file_path: str = None, + verbose: bool = False +) -> Tuple[str, List[str]]: + fixes_applied = [] + + resolved_template_path = resolve_template_path(template_path) + if verbose: + print(f"[Template] Resolved template path: {resolved_template_path}") + + metadata, markdown_body = extract_front_matter(markdown_content) + if metadata.get('version') and not version: + version = metadata['version'] + if metadata.get('date') and not date: + date = metadata['date'] + + if normalize and NORMALIZER_AVAILABLE: + normalizer = MarkdownNormalizer(verbose=verbose) + markdown_body = normalizer.normalize(markdown_body) + fixes_applied = normalizer.get_fixes() + + if save_normalized and normalized_output_path: + with open(normalized_output_path, 'w', encoding='utf-8') as f: + f.write(markdown_body) + if verbose: + print(f"[Normalizer] Saved normalized file to: {normalized_output_path}") + + parser = MarkdownParser(enable_code_blocks=enable_code_blocks, md_file_path=md_file_path) + elements = parser.parse(markdown_body) + + generator = DocxGenerator(resolved_template_path) + generator.create_document(output_path) + generator.generate( + elements, + version, + date, + enable_code_blocks, + metadata=metadata, + include_toc=True, + ) + generator.save(output_path) + + return output_path, fixes_applied + + +def convert_markdown_file( + input_path: str, + template_path: str = None, + output_path: Optional[str] = None, + version: str = '', + date: str = '', + normalize: bool = True, + save_normalized: bool = True, + enable_code_blocks: bool = True, + verbose: bool = False, + use_versioning: bool = True +) -> Tuple[str, str, List[str]]: + with open(input_path, 'r', encoding='utf-8') as f: + content = f.read() + + normalized_output_path = None + actual_version = version + + if use_versioning and VERSION_MANAGER_AVAILABLE: + version_info = get_versioned_output_paths(input_path, None, verbose) + + if output_path is None: + output_path = version_info['docx_path'] + + normalized_output_path = version_info['normalized_md_path'] + + if not version: + actual_version = f"V{version_info['version']}" + + if verbose: + print(f"[VersionManager] Using version: {actual_version}") + else: + if output_path is None: + output_path = os.path.splitext(input_path)[0] + '.docx' + + if normalize and save_normalized: + base_name = os.path.splitext(input_path)[0] + normalized_output_path = f"{base_name}_normalized.md" + + output_path, fixes_applied = convert_markdown_to_docx( + content, output_path, template_path, + actual_version, date, normalize, save_normalized, + normalized_output_path, enable_code_blocks, input_path, verbose + ) + + return output_path, normalized_output_path, fixes_applied + + +if __name__ == '__main__': + import sys + + if len(sys.argv) < 2: + print("Usage: python md_to_docx.py [template.docx] [output.docx] [version] [date]") + print("Options:") + print(" --no-normalize Skip markdown normalization") + print(" --no-save-norm Don't save normalized markdown file") + print(" --enable-code-blocks Enable code block rendering") + print(" --no-versioning Disable automatic version numbering") + print(" --verbose Show detailed processing info") + print("") + print("Note: If template.docx is not specified, the default template will be used.") + sys.exit(1) + + args = sys.argv[1:] + normalize = True + save_normalized = True + enable_code_blocks = True + verbose = False + use_versioning = True + + if '--no-normalize' in args: + normalize = False + args.remove('--no-normalize') + + if '--no-save-norm' in args: + save_normalized = False + args.remove('--no-save-norm') + + if '--enable-code-blocks' in args: + enable_code_blocks = True + args.remove('--enable-code-blocks') + + if '--verbose' in args: + verbose = True + args.remove('--verbose') + + if '--no-versioning' in args: + use_versioning = False + args.remove('--no-versioning') + + input_file = args[0] if len(args) > 0 else None + template_file = args[1] if len(args) > 1 else None + output_file = args[2] if len(args) > 2 else None + version = args[3] if len(args) > 3 else '' + date = args[4] if len(args) > 4 else '' + + if input_file: + result, normalized_path, fixes = convert_markdown_file( + input_file, template_file, output_file, + version, date, normalize, save_normalized, + enable_code_blocks, verbose, use_versioning + ) + print(f"Document created: {result}") + if normalized_path: + print(f"Normalized MD: {normalized_path}") + if fixes: + print(f"Markdown fixes applied: {len(fixes)}") diff --git a/.agents/skills/md-to-docx/scripts/version_manager.py b/.agents/skills/md-to-docx/scripts/version_manager.py new file mode 100644 index 0000000..b7fd690 --- /dev/null +++ b/.agents/skills/md-to-docx/scripts/version_manager.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import re +import os +from typing import Tuple, Optional + + +class VersionManager: + VERSION_PATTERN = re.compile(r'(.+?)[-_]?V(\d+)$', re.IGNORECASE) + + def __init__(self, verbose: bool = False): + self.verbose = verbose + + def extract_version(self, filename: str) -> Tuple[str, Optional[int]]: + base_name = os.path.splitext(filename)[0] + match = self.VERSION_PATTERN.match(base_name) + + if match: + name_part = match.group(1).rstrip('_-') + version_num = int(match.group(2)) + return name_part, version_num + + return base_name, None + + def get_next_version(self, filename: str) -> int: + _, current_version = self.extract_version(filename) + + if current_version is None: + return 1 + + return current_version + 1 + + def generate_versioned_filename( + self, + base_path: str, + extension: str, + version: Optional[int] = None, + suffix: str = '' + ) -> str: + dir_path = os.path.dirname(base_path) + filename = os.path.basename(base_path) + name_without_ext = os.path.splitext(filename)[0] + + name_part, existing_version = self.extract_version(filename) + + if version is not None: + final_version = version + elif existing_version is not None: + final_version = existing_version + else: + final_version = 1 + + if suffix: + new_name = f"{name_part}_V{final_version}_{suffix}.{extension}" + else: + new_name = f"{name_part}_V{final_version}.{extension}" + + if dir_path: + return os.path.join(dir_path, new_name) + return new_name + + def generate_next_versioned_filename( + self, + base_path: str, + extension: str, + suffix: str = '' + ) -> Tuple[str, int]: + dir_path = os.path.dirname(base_path) + filename = os.path.basename(base_path) + name_without_ext = os.path.splitext(filename)[0] + + name_part, existing_version = self.extract_version(filename) + + if existing_version is not None: + next_version = existing_version + 1 + else: + next_version = 1 + + if suffix: + new_name = f"{name_part}_V{next_version}_{suffix}.{extension}" + else: + new_name = f"{name_part}_V{next_version}.{extension}" + + if dir_path: + return os.path.join(dir_path, new_name), next_version + return new_name, next_version + + def find_latest_version_file( + self, + base_path: str, + extension: str + ) -> Tuple[Optional[str], int]: + dir_path = os.path.dirname(base_path) + filename = os.path.basename(base_path) + name_without_ext = os.path.splitext(filename)[0] + + name_part, existing_version = self.extract_version(filename) + + if dir_path: + search_dir = dir_path + else: + search_dir = '.' + + if not os.path.exists(search_dir): + return None, 0 + + max_version = 0 + latest_file = None + + pattern = re.compile( + re.escape(name_part) + r'[-_]?V(\d+)' + re.escape('.' + extension) + '$', + re.IGNORECASE + ) + + for f in os.listdir(search_dir): + match = pattern.match(f) + if match: + version_num = int(match.group(1)) + if version_num > max_version: + max_version = version_num + latest_file = os.path.join(search_dir, f) + + if existing_version is not None and existing_version > max_version: + return base_path, existing_version + + return latest_file, max_version + + def get_next_version_from_directory( + self, + base_path: str, + extension: str + ) -> Tuple[str, int]: + latest_file, max_version = self.find_latest_version_file(base_path, extension) + + next_version = max_version + 1 + + dir_path = os.path.dirname(base_path) + filename = os.path.basename(base_path) + name_without_ext = os.path.splitext(filename)[0] + + name_part, _ = self.extract_version(filename) + + new_name = f"{name_part}_V{next_version}.{extension}" + + if dir_path: + return os.path.join(dir_path, new_name), next_version + return new_name, next_version + + +def get_versioned_output_paths( + input_path: str, + output_dir: Optional[str] = None, + verbose: bool = False +) -> dict: + manager = VersionManager(verbose=verbose) + + input_dir = os.path.dirname(input_path) + input_filename = os.path.basename(input_path) + name_without_ext = os.path.splitext(input_filename)[0] + + name_part, existing_version = manager.extract_version(input_filename) + + if output_dir is None: + output_dir = input_dir if input_dir else '.' + + md_pattern = re.compile( + re.escape(name_part) + r'[-_]?V(\d+)_normalized\.md$', + re.IGNORECASE + ) + docx_pattern = re.compile( + re.escape(name_part) + r'[-_]?V(\d+)\.docx$', + re.IGNORECASE + ) + + max_md_version = 0 + max_docx_version = 0 + + if os.path.exists(output_dir): + for f in os.listdir(output_dir): + md_match = md_pattern.match(f) + if md_match: + version_num = int(md_match.group(1)) + max_md_version = max(max_md_version, version_num) + + docx_match = docx_pattern.match(f) + if docx_match: + version_num = int(docx_match.group(1)) + max_docx_version = max(max_docx_version, version_num) + + if existing_version is not None: + max_md_version = max(max_md_version, existing_version) + max_docx_version = max(max_docx_version, existing_version) + + next_version = max(max_md_version, max_docx_version) + 1 + + if existing_version is None and max_md_version == 0 and max_docx_version == 0: + next_version = 1 + + normalized_md_path = os.path.join(output_dir, f"{name_part}_V{next_version}_normalized.md") + docx_path = os.path.join(output_dir, f"{name_part}_V{next_version}.docx") + + if verbose: + print(f"[VersionManager] Input file: {input_path}") + print(f"[VersionManager] Name part: {name_part}") + print(f"[VersionManager] Existing version in filename: {existing_version}") + print(f"[VersionManager] Max MD version in directory: {max_md_version}") + print(f"[VersionManager] Max DOCX version in directory: {max_docx_version}") + print(f"[VersionManager] Next version: {next_version}") + print(f"[VersionManager] Normalized MD path: {normalized_md_path}") + print(f"[VersionManager] DOCX path: {docx_path}") + + return { + 'name_part': name_part, + 'version': next_version, + 'normalized_md_path': normalized_md_path, + 'docx_path': docx_path + } + + +if __name__ == '__main__': + import sys + + if len(sys.argv) < 2: + print("Usage: python version_manager.py [output_dir]") + print("\nExamples:") + print(" python version_manager.py document.md") + print(" python version_manager.py document_V2.md") + print(" python version_manager.py document.md ./output") + sys.exit(1) + + input_file = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + result = get_versioned_output_paths(input_file, output_dir, verbose=True) + + print(f"\nResult:") + print(f" Version: V{result['version']}") + print(f" Normalized MD: {result['normalized_md_path']}") + print(f" DOCX: {result['docx_path']}") diff --git a/.claude/skills/md-to-docx b/.claude/skills/md-to-docx new file mode 120000 index 0000000..1b32cbe --- /dev/null +++ b/.claude/skills/md-to-docx @@ -0,0 +1 @@ +../../.agents/skills/md-to-docx \ No newline at end of file diff --git a/.codex/skills/md-to-docx b/.codex/skills/md-to-docx new file mode 120000 index 0000000..1b32cbe --- /dev/null +++ b/.codex/skills/md-to-docx @@ -0,0 +1 @@ +../../.agents/skills/md-to-docx \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4619743..e38795e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,12 @@ -/research/ -.local/ -share/out/ -.mcp.json -/docs/raws +/research/ +.local/ +share/out/ +.mcp.json +/docs/raws + +# Python +__pycache__/ +*.py[cod] +*$py.class +.agents/skills/**/.venv/ +.agents/skills/md-to-docx copilot/ diff --git a/.grok/skills/md-to-docx b/.grok/skills/md-to-docx new file mode 120000 index 0000000..1b32cbe --- /dev/null +++ b/.grok/skills/md-to-docx @@ -0,0 +1 @@ +../../.agents/skills/md-to-docx \ No newline at end of file From 9966190ce34e306b9e6a2c1e54a43a64cfdabd2f Mon Sep 17 00:00:00 2001 From: ITSKVN Date: Mon, 6 Jul 2026 15:49:02 +0700 Subject: [PATCH 02/15] Set default document font to Times New Roman Replace SimSun/CJK_FONT with DEFAULT_FONT across converter, templates, preview script, and docs. Code blocks remain Consolas. --- .agents/skills/md-to-docx/README.md | 4 +- .agents/skills/md-to-docx/SKILL.md | 20 +++--- .../skills/md-to-docx/assets/template.docx | Bin 36640 -> 36636 bytes .../md-to-docx/assets/template_preview.docx | Bin 38442 -> 38440 bytes .../md-to-docx/scripts/create_preview.py | 24 +++---- .../md-to-docx/scripts/create_template.py | 16 ++--- .../skills/md-to-docx/scripts/md_to_docx.py | 60 +++++++++--------- 7 files changed, 62 insertions(+), 62 deletions(-) diff --git a/.agents/skills/md-to-docx/README.md b/.agents/skills/md-to-docx/README.md index c22ec29..23270f6 100644 --- a/.agents/skills/md-to-docx/README.md +++ b/.agents/skills/md-to-docx/README.md @@ -70,9 +70,9 @@ flowchart LR | Audience | — | `Audience: …` | | TOC page title | — (no TOC) | `Table of Contents` | | Missing image | `[图片加载失败: …]` | `[Image not found: …]` | -| CJK font id | `宋体` | `SimSun` (same family, EN locale name) | +| Body font | `宋体` / SimSun | `Times New Roman` (default) | -Typography unchanged: SimSun + Times New Roman, 0.74cm first-line indent, page break before `##`. +Typography: Times New Roman throughout (code: Consolas), 0.74cm first-line indent, page break before `##`. ### What is actually better diff --git a/.agents/skills/md-to-docx/SKILL.md b/.agents/skills/md-to-docx/SKILL.md index f7b0ab2..49e8393 100644 --- a/.agents/skills/md-to-docx/SKILL.md +++ b/.agents/skills/md-to-docx/SKILL.md @@ -152,16 +152,16 @@ Leading whitespace on ordinary paragraphs is removed to prevent double indentati #### Font Specification -| Element | Chinese font | English font | Size | Notes | -| ------- | ------------ | ------------ | ---- | ----- | -| Body | SimSun | Times New Roman | 12pt | Standard body text | -| Heading 1 | SimSun | Times New Roman | 22pt | Major title | -| Heading 2 | SimSun | Times New Roman | 16pt | Section title | -| Heading 3 | SimSun | Times New Roman | 15pt | Subsection title | -| Heading 4 | SimSun | Times New Roman | 14pt | Item title | -| Heading 5 | SimSun | Times New Roman | 14pt | Sub-item title | -| Code block | Consolas | Consolas | 9pt | Slightly smaller than body | -| Inline code | Consolas | Consolas | 12pt | Same as body | +| Element | Font | Size | Notes | +| ------- | ---- | ---- | ----- | +| Body | Times New Roman | 12pt | Default for all prose | +| Heading 1 | Times New Roman | 22pt | Major title | +| Heading 2 | Times New Roman | 16pt | Section title | +| Heading 3 | Times New Roman | 15pt | Subsection title | +| Heading 4 | Times New Roman | 14pt | Item title | +| Heading 5 | Times New Roman | 14pt | Sub-item title | +| Code block | Consolas | 9pt | Monospace | +| Inline code | Consolas | 12pt | Monospace | #### Paragraph Specification diff --git a/.agents/skills/md-to-docx/assets/template.docx b/.agents/skills/md-to-docx/assets/template.docx index 769f7b8cb629c922c4ad2bc609753f4abdb015d3..5b5c4e0a33eafb6cec5d2b708203ae015c901c51 100644 GIT binary patch delta 11102 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 Date: Mon, 6 Jul 2026 15:52:21 +0700 Subject: [PATCH 03/15] Document cross-agent skill discovery as manual repo setup Clarify that Grok, Claude Code, and Codex are external tools and that .claude/.codex/.grok symlinks are created and committed once, not auto-generated by agents when adding skills. --- .agents/README.md | 28 +++++++++ .agents/skills/md-to-docx/README.md | 1 + .context/GLOBAL.md | 96 +++++++++++++++-------------- 3 files changed, 78 insertions(+), 47 deletions(-) diff --git a/.agents/README.md b/.agents/README.md index 267659e..4f83822 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -14,6 +14,18 @@ Project skills follow the [Agent Skills](https://agentskills.io) open standard. **Source of truth:** `.agents/skills/` — edit skills here only. +## What are Grok, Claude Code, and Codex? + +They are **external AI coding assistants** from different vendors — not created by MedKarute: + +| Name | Vendor | Typical interface | +| ---- | ------ | ----------------- | +| **Grok** | xAI | Grok in Cursor, Grok CLI | +| **Claude Code** | Anthropic | Claude Code CLI / desktop | +| **Codex** | OpenAI | Codex CLI | + +Each product scans its own skill directory when you open a repo. MedKarute does **not** ship or install these tools; it only lays out paths so they can find shared skills. + ## Agent discovery paths | Agent | Project path | Notes | @@ -31,6 +43,22 @@ Compatibility symlinks in this repo point to `.agents/skills/` so all agents sha .grok/skills/ → ../../.agents/skills/ ``` +### Manual setup — agents do not create this for you + +Folders `.claude/`, `.codex/`, `.grok/` and their `skills/` symlinks are **repo setup**, done once by a human or an agent in a setup session — then **committed to git**. + +- Agents **read** skills from the paths above; they **do not** auto-create symlinks when you add a new skill. +- If a skill exists only under `.agents/skills/` with no symlinks, Claude Code / Grok / Codex may not see it until you run the symlink step in [Adding a new skill](#adding-a-new-skill). +- After adding symlinks, restart the agent session (or wait for live reload). + +```mermaid +flowchart LR + setup[Human or setup agent] --> canonical[".agents/skills/"] + canonical --> symlinks[".claude / .codex / .grok symlinks"] + symlinks --> commit[Commit to git] + commit --> discover[Each agent scans its own path] +``` + ## Available skills | Skill | Description | Upstream | diff --git a/.agents/skills/md-to-docx/README.md b/.agents/skills/md-to-docx/README.md index 23270f6..59c6da1 100644 --- a/.agents/skills/md-to-docx/README.md +++ b/.agents/skills/md-to-docx/README.md @@ -7,6 +7,7 @@ Vendored copy of the [md-to-docx-skill](https://github.com/pickle-an/md-to-docx- | **Upstream** | https://github.com/pickle-an/md-to-docx-skill | | **Agent entrypoint** | [`SKILL.md`](./SKILL.md) | | **Project layout** | `.agents/skills/md-to-docx/` (see [`.agents/README.md`](../../README.md)) | +| **Cross-agent discovery** | Symlinks in `.claude/skills/`, `.codex/skills/`, `.grok/skills/` — manual repo setup, not auto-created by agents (see [`.agents/README.md`](../../README.md)) | ## Layout diff --git a/.context/GLOBAL.md b/.context/GLOBAL.md index 13c8c7c..7c88729 100644 --- a/.context/GLOBAL.md +++ b/.context/GLOBAL.md @@ -1,54 +1,56 @@ -# GLOBAL — MedKarute - -> AI memory dùng chung (commit). Cập nhật khi có invariant mới hoặc promote từ phiên chat. - -## Invariants (tóm tắt) - -- Chat ≠ storage — nội dung durable ghi vào `research/{slug}/` hoặc `docs/` -- `research/{slug}/` = git repo riêng; root ignore `research/` -- Agent auto-commit sau mỗi lượt ghi có nghĩa — báo *"Đã lưu tự động."* -- INDEX phân tầng — load **một** sub-INDEX theo task -- EndNote MCP: orchestrator-only; read-heavy; write library qua user -- Mermaid bắt buộc cho diagram trong session/workflow doc -- Governance (`CLAUDE.md`, `AGENTS.md`, `docs/decisions/`) chỉ sửa khi phiên **đề cập** rõ -- **Tagline cố định đầu mọi README** (root + `docs/readme/*`), copy nguyên văn, không dịch/đổi thêm: - ``` - # MedKarute (メドカルテ) - research-helper - - **AI Orchestrator + Amanuensis for Research** - 研究を支えるAIオーケストレーターと書記 - ``` - Chốt 2026-07-03 (user) — thêm locale README mới cũng phải có khối này ở đầu - -## Module index - -| Module | Guide | Ghi chú | -|--------|-------|---------| -| Overview | `docs/guides/research/00-overview.md` | Phiên mới / orientation | -| README | `docs/guides/research/readme.md` | Identity project | -| INDEX | `docs/guides/research/index-routing.md` | Router + load map | -| Papers | `docs/guides/research/papers.md` | PDF, paper note, EndNote | -| Sessions | `docs/guides/research/sessions.md` | "docs lại", subagent | -| Insights | `docs/guides/research/insights.md` | Mental model | -| Writing | `docs/guides/research/writing.md` | Prose, citation | -| EndNote MCP | `docs/guides/mcp/endnote-mcp-tools.md` | 12 tools | -| MarkItDown | `docs/guides/mcp/markitdown-mcp.md` | PDF convert (stub) | +# GLOBAL — MedKarute + +> AI memory dùng chung (commit). Cập nhật khi có invariant mới hoặc promote từ phiên chat. + +## Invariants (tóm tắt) + +- Chat ≠ storage — nội dung durable ghi vào `research/{slug}/` hoặc `docs/` +- `research/{slug}/` = git repo riêng; root ignore `research/` +- Agent auto-commit sau mỗi lượt ghi có nghĩa — báo *"Đã lưu tự động."* +- INDEX phân tầng — load **một** sub-INDEX theo task +- EndNote MCP: orchestrator-only; read-heavy; write library qua user +- Mermaid bắt buộc cho diagram trong session/workflow doc +- Governance (`CLAUDE.md`, `AGENTS.md`, `docs/decisions/`) chỉ sửa khi phiên **đề cập** rõ +- **Tagline cố định đầu mọi README** (root + `docs/readme/*`), copy nguyên văn, không dịch/đổi thêm: + ``` + # MedKarute (メドカルテ) - research-helper + + **AI Orchestrator + Amanuensis for Research** + 研究を支えるAIオーケストレーターと書記 + ``` + Chốt 2026-07-03 (user) — thêm locale README mới cũng phải có khối này ở đầu + +## Module index + +| Module | Guide | Ghi chú | +|--------|-------|---------| +| Overview | `docs/guides/research/00-overview.md` | Phiên mới / orientation | +| README | `docs/guides/research/readme.md` | Identity project | +| INDEX | `docs/guides/research/index-routing.md` | Router + load map | +| Papers | `docs/guides/research/papers.md` | PDF, paper note, EndNote | +| Sessions | `docs/guides/research/sessions.md` | "docs lại", subagent | +| Insights | `docs/guides/research/insights.md` | Mental model | +| Writing | `docs/guides/research/writing.md` | Prose, citation | +| EndNote MCP | `docs/guides/mcp/endnote-mcp-tools.md` | 12 tools | +| MarkItDown | `docs/guides/mcp/markitdown-mcp.md` | PDF convert (stub) | | EndNote decision | `docs/decisions/endnote-workflow.md` | Canonical workflow | +| Agent skills | `.agents/README.md` | Shared skills; cross-agent symlinks (manual setup) | ## Active docs (bootstrap) - -- `docs/guides/research/*.md` (7) -- `docs/guides/mcp/endnote-mcp-tools.md`, `markitdown-mcp.md` -- `docs/decisions/endnote-workflow.md` + +- `docs/guides/research/*.md` (7) +- `docs/guides/mcp/endnote-mcp-tools.md`, `markitdown-mcp.md` +- `docs/decisions/endnote-workflow.md` - `docs/templates/*.tpl` (7) +- `.agents/README.md`, `.agents/skills/` (vendored agent skills) **Không** trong active docs: `docs/raws/`, `docs/readme/` (README locale cho human — root `README.md` EN là canonical). - -## `modules/` — cách dùng - -Thư mục `.context/modules/` rỗng lúc bootstrap. Tạo file khi có invariant **theo module**, ví dụ: - -- `modules/papers.md` — quy tắc đặc thù papers (ngoài guide chung) -- `modules/mcp-endnote.md` — state machine index nếu phình - + +## `modules/` — cách dùng + +Thư mục `.context/modules/` rỗng lúc bootstrap. Tạo file khi có invariant **theo module**, ví dụ: + +- `modules/papers.md` — quy tắc đặc thù papers (ngoài guide chung) +- `modules/mcp-endnote.md` — state machine index nếu phình + Không duplicate nội dung đã có trong `docs/guides/` — module chỉ ghi delta/invariant ngắn. \ No newline at end of file From 689cdc68451898d4c6586a98dd393df021bdad82 Mon Sep 17 00:00:00 2001 From: ITSKVN Date: Mon, 6 Jul 2026 15:56:20 +0700 Subject: [PATCH 04/15] Add agent skills symlink guidance to AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestrator now has §7 with symlink bash loop and pointer to .agents/README.md when vendoring or adding skills. --- AGENTS.md | 163 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 92 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fdca12f..2dd72db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,73 +1,94 @@ -# AGENTS.md — MedKarute - -> Startup order, invariants, routing. Chi tiết workflow → `CLAUDE.md` + `docs/guides/`. - -## §0 Milestone - -**0.0.1 Bootstrap** — xem `.context/MILESTONES.md` cho acceptance criteria. - -## §1 Startup order - -Mỗi phiên mới, đọc theo thứ tự: - -1. `.local/ENVIRONMENT.md` — `os_profile`, paths (nếu tồn tại) -2. `.context/GLOBAL.md` -3. `.context/MILESTONES.md` -4. `.context/TENSIONS_OPEN.md` -5. `.context/TENSIONS_ACTIVE.md` -6. `.context/modules/*.md` — chỉ module liên quan task -7. `.local/session.md` — `active_project`, `pending_actions` -8. `.local/claude-agent-summary.md` — nếu stale thì refresh (xem CLAUDE §F) - -Không load mặc định: `docs/raws/`, `docs/readme/` (README locale — human-only), `docs/ideations/` scratch, `*HISTORY*`. - -## §2 Invariants - -| # | Invariant | -|---|-----------| -| 1 | **Chat ≠ storage** — quyết định, insight, paper note ghi vào file; chat chỉ điều phối | -| 2 | **Load một sub-INDEX** — không load đồng thời mọi `papers/` + `sessions/` + `insights/` + `writing/` | -| 3 | **MCP orchestrator-only** — subagent không gọi MCP; mọi `index`/`rebuild_index` qua orchestrator | -| 4 | **Docs protocol** — "docs lại" → session note; governance chỉ khi phiên tag `[governance:path]` | -| 5 | **Mermaid mandatory** — diagram trong file = Mermaid, không ASCII thay thế | -| 6 | **Governance-only-when-mentioned** — không sửa `CLAUDE.md`/`AGENTS.md`/`docs/decisions/` nếu phiên không bàn | -| 7 | **Per-project git** — `git -C research/{slug}/`; root không track `research/` | -| 8 | **Auto-commit** — sau ghi file có nghĩa; báo user một dòng: *"Đã lưu tự động."* | -| 9 | **Semi-tech** — nói thẳng subagent, MCP, orchestrator, INDEX trong chat | -| 10 | **Promote memory** — durable insight → đúng file dự án (`.context/`, `docs/decisions/`, guides); không `AGENT_SHARED.md` | - -## §3 Tension format - -- **OPEN** → `.context/TENSIONS_OPEN.md` — chưa resolve -- **ACTIVE** → `.context/TENSIONS_ACTIVE.md` — đã resolve, đang hiệu lực -- **HISTORY** — archive khi milestone đóng (không load mặc định) - -Mỗi entry: `ID`, mô tả ngắn, options, quyết định (nếu ACTIVE), ngày. - -## §4 Routing - -| Mode | Khi nào | -|------|---------| -| **CODE_NOW** | User yêu cầu ghi file, ingest paper, docs lại — làm ngay | -| **ASK_ARCHITECTURE** | Thay đổi cấu trúc governance / invariant — hỏi user trước | -| **EXPLAIN** | User hỏi hiểu workflow — trích từ guide, không suy diễn | - -## §5 Self-check (trước khi ghi file) - -- [ ] Đã đọc guide đúng area (`docs/guides/research/{area}.md`)? -- [ ] Load đúng INDEX (một sub-INDEX)? -- [ ] Nội dung durable — không chỉ trong chat? -- [ ] Diagram = Mermaid? -- [ ] Governance file — phiên có đề cập rõ? -- [ ] Git commit trong `research/{slug}/` (không root)? - -## §6 Promote path - -``` -docs/ideations/ (scratch, gitignored) - → docs/decisions/ (quyết định chốt) - → .context/modules/ (invariant module) - → .context/GLOBAL.md (index active docs) +# AGENTS.md — MedKarute + +> Startup order, invariants, routing. Chi tiết workflow → `CLAUDE.md` + `docs/guides/`. + +## §0 Milestone + +**0.0.1 Bootstrap** — xem `.context/MILESTONES.md` cho acceptance criteria. + +## §1 Startup order + +Mỗi phiên mới, đọc theo thứ tự: + +1. `.local/ENVIRONMENT.md` — `os_profile`, paths (nếu tồn tại) +2. `.context/GLOBAL.md` +3. `.context/MILESTONES.md` +4. `.context/TENSIONS_OPEN.md` +5. `.context/TENSIONS_ACTIVE.md` +6. `.context/modules/*.md` — chỉ module liên quan task +7. `.local/session.md` — `active_project`, `pending_actions` +8. `.local/claude-agent-summary.md` — nếu stale thì refresh (xem CLAUDE §F) + +Không load mặc định: `docs/raws/`, `docs/readme/` (README locale — human-only), `docs/ideations/` scratch, `*HISTORY*`. + +## §2 Invariants + +| # | Invariant | +|---|-----------| +| 1 | **Chat ≠ storage** — quyết định, insight, paper note ghi vào file; chat chỉ điều phối | +| 2 | **Load một sub-INDEX** — không load đồng thời mọi `papers/` + `sessions/` + `insights/` + `writing/` | +| 3 | **MCP orchestrator-only** — subagent không gọi MCP; mọi `index`/`rebuild_index` qua orchestrator | +| 4 | **Docs protocol** — "docs lại" → session note; governance chỉ khi phiên tag `[governance:path]` | +| 5 | **Mermaid mandatory** — diagram trong file = Mermaid, không ASCII thay thế | +| 6 | **Governance-only-when-mentioned** — không sửa `CLAUDE.md`/`AGENTS.md`/`docs/decisions/` nếu phiên không bàn | +| 7 | **Per-project git** — `git -C research/{slug}/`; root không track `research/` | +| 8 | **Auto-commit** — sau ghi file có nghĩa; báo user một dòng: *"Đã lưu tự động."* | +| 9 | **Semi-tech** — nói thẳng subagent, MCP, orchestrator, INDEX trong chat | +| 10 | **Promote memory** — durable insight → đúng file dự án (`.context/`, `docs/decisions/`, guides); không `AGENT_SHARED.md` | + +## §3 Tension format + +- **OPEN** → `.context/TENSIONS_OPEN.md` — chưa resolve +- **ACTIVE** → `.context/TENSIONS_ACTIVE.md` — đã resolve, đang hiệu lực +- **HISTORY** — archive khi milestone đóng (không load mặc định) + +Mỗi entry: `ID`, mô tả ngắn, options, quyết định (nếu ACTIVE), ngày. + +## §4 Routing + +| Mode | Khi nào | +|------|---------| +| **CODE_NOW** | User yêu cầu ghi file, ingest paper, docs lại — làm ngay | +| **ASK_ARCHITECTURE** | Thay đổi cấu trúc governance / invariant — hỏi user trước | +| **EXPLAIN** | User hỏi hiểu workflow — trích từ guide, không suy diễn | + +## §5 Self-check (trước khi ghi file) + +- [ ] Đã đọc guide đúng area (`docs/guides/research/{area}.md`)? +- [ ] Load đúng INDEX (một sub-INDEX)? +- [ ] Nội dung durable — không chỉ trong chat? +- [ ] Diagram = Mermaid? +- [ ] Governance file — phiên có đề cập rõ? +- [ ] Git commit trong `research/{slug}/` (không root)? + +## §6 Promote path + +``` +docs/ideations/ (scratch, gitignored) + → docs/decisions/ (quyết định chốt) + → .context/modules/ (invariant module) + → .context/GLOBAL.md (index active docs) +``` + +Brainstorm chưa chốt → giữ `docs/raws/` hoặc `docs/ideations/`; không promote. + +## §7 Agent skills (symlinks) + +Khi task **thêm hoặc vendored skill mới** — đọc `.agents/README.md` trước khi ghi. + +| Quy tắc | Chi tiết | +|---------|----------| +| Source of truth | `.agents/skills//` — chỉ sửa ở đây | +| Symlink bắt buộc | Sau skill mới, tạo symlink `.claude/skills/`, `.codex/skills/`, `.grok/skills/` → cùng skill | +| Không tự sinh | Agent **không** auto-tạo symlink; orchestrator chạy bước symlink + commit root | +| Grok / Claude / Codex | Sản phẩm AI bên ngoài — repo chỉ layout path discovery | + +```bash +SKILL=your-skill-name +for dir in .claude/skills .codex/skills .grok/skills; do + mkdir -p "$dir" + ln -sfn "../../.agents/skills/$SKILL" "$dir/$SKILL" +done ``` -Brainstorm chưa chốt → giữ `docs/raws/` hoặc `docs/ideations/`; không promote. \ No newline at end of file +Chi tiết discovery paths và diagram → `.agents/README.md`. \ No newline at end of file From 544b45c25d24af954a36661d7b391dbb530217d6 Mon Sep 17 00:00:00 2001 From: ITSKVN Date: Mon, 6 Jul 2026 15:57:07 +0700 Subject: [PATCH 05/15] Update OCR pipeline --- .claude/skills/md-to-docx | 1 - .codex/skills/md-to-docx | 1 - .grok/skills/md-to-docx | 1 - docs/guides/research/papers.md | 27 +++++++++++++++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) delete mode 120000 .claude/skills/md-to-docx delete mode 120000 .codex/skills/md-to-docx delete mode 120000 .grok/skills/md-to-docx diff --git a/.claude/skills/md-to-docx b/.claude/skills/md-to-docx deleted file mode 120000 index 1b32cbe..0000000 --- a/.claude/skills/md-to-docx +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/md-to-docx \ No newline at end of file diff --git a/.codex/skills/md-to-docx b/.codex/skills/md-to-docx deleted file mode 120000 index 1b32cbe..0000000 --- a/.codex/skills/md-to-docx +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/md-to-docx \ No newline at end of file diff --git a/.grok/skills/md-to-docx b/.grok/skills/md-to-docx deleted file mode 120000 index 1b32cbe..0000000 --- a/.grok/skills/md-to-docx +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/md-to-docx \ No newline at end of file diff --git a/docs/guides/research/papers.md b/docs/guides/research/papers.md index beddd21..198b379 100644 --- a/docs/guides/research/papers.md +++ b/docs/guides/research/papers.md @@ -45,6 +45,33 @@ flowchart TD **Invariant**: Mọi thay đổi library = re-export XML + `index`. Chi tiết → `docs/decisions/endnote-workflow.md`. +## OCR — khi `raw.md` là bản scan/ảnh chụp + +Bước 2 (MarkItDown) giả định PDF có text layer. Bản scan/ảnh chụp màn hình ra `raw.md` rỗng hoặc quá ngắn so với số trang — cần OCR trước khi viết paper note. + +```mermaid +flowchart TD + B["MarkItDown → .raw.md"] --> C{"raw.md rỗng/quá ngắn
so với số trang?"} + C -->|Không| D["Paper note bình thường"] + C -->|Có, nghi scan| ASK1{"Hỏi user: raw.md thiếu text,
OCR không?"} + ASK1 -->|Không| D2["Paper note, ghi chú
text có thể thiếu do scan"] + ASK1 -->|Có| ASK2{"Hỏi user: input là gì?
PDF nhiều trang hay ảnh rời?"} + ASK2 -->|PDF nhiều trang| E1["Auto-spawn Agent model:haiku
OCR từng trang"] + ASK2 -->|Ảnh chụp rời| E2["Auto-spawn Agent model:haiku
OCR từng ảnh"] + E1 --> F["Orchestrator nhận text,
làm sạch, ghi vào raw.md"] + E2 --> F + F --> D +``` + +1. Orchestrator phát hiện `raw.md` nghi là bản scan (rỗng/quá ngắn so với số trang PDF) → **hỏi user xác nhận có muốn OCR không** — không tự động chạy +2. Nếu có → **hỏi user cách input**: PDF nhiều trang (Haiku đọc từng trang) hay ảnh chụp màn hình rời (user cung cấp path/paste ảnh) +3. Orchestrator auto-spawn `Agent` (`model: haiku`, `subagent_type: general-purpose`) — OCR ra text thuần, **không ghi file**, chỉ trả kết quả +4. Orchestrator nhận kết quả, làm sạch (subagent có thể escape ký tự lạ — xem bài học ở `chien-luoc-erp-helper.md` project khác), ghi đè/append vào `papers/{slug}.raw.md` +5. Ghi 1 dòng log trong paper note: model dùng (haiku), số trang/ảnh OCR, thời gian — phục vụ audit +6. Tiếp tục luồng paper note bình thường (bước 3 trở đi ở trên) + +**Không tự động OCR mà không hỏi** — quyết định tốn subagent-call thuộc về user mỗi lần, không suy đoán. + ## Paper đã có trong EndNote Không bắt buộc copy PDF vào `papers/` — `read_pdf_section` đọc attachment trong library. From 911033404ae6829cb7cbc44188b046e917607dfe Mon Sep 17 00:00:00 2001 From: ITSKVN Date: Mon, 6 Jul 2026 15:59:42 +0700 Subject: [PATCH 06/15] Add md-to-docx skill references to EN and VI README Link vendored skill and .agents/README in Tools and Quick links sections of root README.md and docs/readme/README.vi.md. --- README.md | 117 ++++++++++++++++++++------------------- docs/readme/README.vi.md | 115 +++++++++++++++++++------------------- 2 files changed, 119 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 9be04ef..7874379 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,63 @@ -# MedKarute (メドカルテ) - research-helper - -**AI Orchestrator + Amanuensis for Research** -研究を支えるAIオーケストレーターと書記 - -> **Languages**: [Tiếng Việt](docs/readme/README.vi.md) — more in [docs/readme/](docs/readme/) (human-only, not in agent load map) - -**MedKarute** (formerly `research-helper`) is a chat-driven research assistant: an agent (orchestrator) runs the research workflow via chat, writes results to Markdown under `research/{slug}/`, and calls two MCP servers — **MarkItDown** (new PDFs) and **endnote-mcp** (curated EndNote library). Governance (`AGENTS.md`, `CLAUDE.md`, `docs/`) defines how the agent behaves; research data lives in separate per-project folders. - -## Getting started - -1. Clone or open this repo. -2. Use an **agent coding tool** (Claude Code, Grok CLI, Cursor, …) **or** a plain CLI (`claude`, `grok`, …) — from the repo root, say **"bắt đầu"** (or "start"). -3. The agent reads `AGENTS.md` / `CLAUDE.md` → runs onboarding (slug + purpose) → creates `research/{slug}/` if you have no project yet. - -**Nothing fancy.** A coding tool or IDE only makes files, diffs, and the tree easier to see — the agent still works from committed `.md` files. Plain CLI without an IDE **works the same**; no specific tool is required. - -> End-user guide (distilled, for the researcher — not the agent): [`docs/guides/huong-dan-su-dung.md`](docs/guides/huong-dan-su-dung.md). - -## Workflow (overview) - -``` - +------------------+ - | EndNote via user | - +--------+---------+ - ^ - | - +---------+ +------+--------+ +-------------+ - | New PDF |------------->| paper note |------------->| insight | - +---------+ +---------------+ +------+------+ - | - +--------------+ +---------------+ | - | chat session |-------->| session note |---------------------+ - +--------------+ +---------------+ | - v - +-------------+ - | writing | - +-------------+ -``` - -Artifacts, INDEX routing, per-project git → [docs/guides/research/00-overview.md](docs/guides/research/00-overview.md). - -## Tools & roles - -| Tool | Role in MedKarute | -|------|-------------------------| -| **MarkItDown MCP** | Convert new PDFs → token-efficient Markdown for the agent (papers not yet in EndNote) | -| **endnote-mcp** | Read curated EndNote library — search, deep PDF read, citation/bibliography. Read-only (writes via EndNote desktop) | -| **[WhySchools / context-mapping](https://github.com/WhySchools/context-mapping)** (context-gen) | **Same lineage** — [WhySchools](https://github.com/WhySchools) *Human is the brain* agentic workflow: shared memory in `.context/` (GLOBAL, MILESTONES, TENSIONS, modules). This repo adopts that governance layout → [`.context/GLOBAL.md`](.context/GLOBAL.md). Early MCP brainstorm archived in [`docs/raws/research-helper.md`](docs/raws/research-helper.md). **Not a runtime dependency** for research sessions | +# MedKarute (メドカルテ) - research-helper + +**AI Orchestrator + Amanuensis for Research** +研究を支えるAIオーケストレーターと書記 + +> **Languages**: [Tiếng Việt](docs/readme/README.vi.md) — more in [docs/readme/](docs/readme/) (human-only, not in agent load map) + +**MedKarute** (formerly `research-helper`) is a chat-driven research assistant: an agent (orchestrator) runs the research workflow via chat, writes results to Markdown under `research/{slug}/`, and calls two MCP servers — **MarkItDown** (new PDFs) and **endnote-mcp** (curated EndNote library). Governance (`AGENTS.md`, `CLAUDE.md`, `docs/`) defines how the agent behaves; research data lives in separate per-project folders. + +## Getting started + +1. Clone or open this repo. +2. Use an **agent coding tool** (Claude Code, Grok CLI, Cursor, …) **or** a plain CLI (`claude`, `grok`, …) — from the repo root, say **"bắt đầu"** (or "start"). +3. The agent reads `AGENTS.md` / `CLAUDE.md` → runs onboarding (slug + purpose) → creates `research/{slug}/` if you have no project yet. + +**Nothing fancy.** A coding tool or IDE only makes files, diffs, and the tree easier to see — the agent still works from committed `.md` files. Plain CLI without an IDE **works the same**; no specific tool is required. + +> End-user guide (distilled, for the researcher — not the agent): [`docs/guides/huong-dan-su-dung.md`](docs/guides/huong-dan-su-dung.md). + +## Workflow (overview) + +``` + +------------------+ + | EndNote via user | + +--------+---------+ + ^ + | + +---------+ +------+--------+ +-------------+ + | New PDF |------------->| paper note |------------->| insight | + +---------+ +---------------+ +------+------+ + | + +--------------+ +---------------+ | + | chat session |-------->| session note |---------------------+ + +--------------+ +---------------+ | + v + +-------------+ + | writing | + +-------------+ +``` + +Artifacts, INDEX routing, per-project git → [docs/guides/research/00-overview.md](docs/guides/research/00-overview.md). + +## Tools & roles + +| Tool | Role in MedKarute | +|------|-------------------------| +| **MarkItDown MCP** | Convert new PDFs → token-efficient Markdown for the agent (papers not yet in EndNote) | +| **endnote-mcp** | Read curated EndNote library — search, deep PDF read, citation/bibliography. Read-only (writes via EndNote desktop) | +| **[WhySchools / context-mapping](https://github.com/WhySchools/context-mapping)** (context-gen) | **Same lineage** — [WhySchools](https://github.com/WhySchools) *Human is the brain* agentic workflow: shared memory in `.context/` (GLOBAL, MILESTONES, TENSIONS, modules). This repo adopts that governance layout → [`.context/GLOBAL.md`](.context/GLOBAL.md). Early MCP brainstorm archived in [`docs/raws/research-helper.md`](docs/raws/research-helper.md). **Not a runtime dependency** for research sessions | | **Markpad** | Local `.md` viewer for the user — not an MCP, viewer only | +| **md-to-docx** (agent skill) | Convert `writing/*.md` → formatted Word `.docx` (versioning, TOC, front-matter). Vendored from [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) → [`.agents/skills/md-to-docx/`](.agents/skills/md-to-docx/) | ## Quick links - -| File | Purpose | -|------|---------| -| [AGENTS.md](AGENTS.md) | Invariants, startup order | -| [CLAUDE.md](CLAUDE.md) | Orchestrator playbook | -| [docs/guides/research/00-overview.md](docs/guides/research/00-overview.md) | `research/` workflow detail | -| [docs/decisions/endnote-workflow.md](docs/decisions/endnote-workflow.md) | EndNote workflow (canonical) | -| [CONTRIBUTING.md](CONTRIBUTING.md) | Đóng góp — test thật, báo lỗi, sửa governance | \ No newline at end of file + +| File | Purpose | +|------|---------| +| [AGENTS.md](AGENTS.md) | Invariants, startup order | +| [CLAUDE.md](CLAUDE.md) | Orchestrator playbook | +| [docs/guides/research/00-overview.md](docs/guides/research/00-overview.md) | `research/` workflow detail | +| [docs/decisions/endnote-workflow.md](docs/decisions/endnote-workflow.md) | EndNote workflow (canonical) | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Đóng góp — test thật, báo lỗi, sửa governance | +| [.agents/skills/md-to-docx/](.agents/skills/md-to-docx/) | Markdown → Word skill (`SKILL.md`, upstream link) | +| [.agents/README.md](.agents/README.md) | Agent skills layout, cross-agent symlinks | \ No newline at end of file diff --git a/docs/readme/README.vi.md b/docs/readme/README.vi.md index 419c9e3..0d19f5a 100644 --- a/docs/readme/README.vi.md +++ b/docs/readme/README.vi.md @@ -1,59 +1,62 @@ -# MedKarute (メドカルテ) - research-helper - -**AI Orchestrator + Amanuensis for Research** -研究を支えるAIオーケストレーターと書記 - -> **English**: [README.md](../../README.md) (canonical) - -**MedKarute** (trước gọi `research-helper`) là chat-driven research assistant: agent (orchestrator) điều phối workflow nghiên cứu qua chat, ghi kết quả vào file Markdown trong `research/{slug}/`, và gọi hai MCP — **MarkItDown** (PDF mới) và **endnote-mcp** (thư viện EndNote đã curate). Governance (`AGENTS.md`, `CLAUDE.md`, `docs/`) mô tả cách agent hoạt động; dữ liệu nghiên cứu tách riêng per-project. - -## Bắt đầu - -1. Clone/mở repo này. -2. Dùng **agent coding tool** (Claude Code, Grok CLI, Cursor, …) **hoặc** CLI thuần (`claude`, `grok`, …) — trong thư mục repo, nói **"bắt đầu"**. -3. Agent đọc `AGENTS.md` / `CLAUDE.md` → chạy onboarding (slug + purpose) → tạo `research/{slug}/` nếu chưa có project. - -**Không có gì phức tạp.** Coding tool/IDE chỉ giúp nhìn file, diff, tree rõ hơn — bản chất vẫn là agent đọc các file `.md` đã commit. Dùng thuần CLI, không mở IDE, **vẫn chạy y hệt**; không phụ thuộc tool cụ thể. - -> Hướng dẫn sử dụng cho người nghiên cứu (không phải cho agent): [`docs/guides/huong-dan-su-dung.md`](../guides/huong-dan-su-dung.md). - -## Workflow (sơ lược) - -``` - +------------------+ - | EndNote via user | - +--------+---------+ - ^ - | - +---------+ +------+--------+ +-------------+ - | PDF mới |------------->| paper note |------------->| insight | - +---------+ +---------------+ +------+------+ - | - +--------------+ +---------------+ | - | phiên chat |-------->| session note |---------------------+ - +--------------+ +---------------+ | - v - +-------------+ - | writing | - +-------------+ -``` - -Chi tiết artifact, INDEX, git per-project → [00-overview.md](../guides/research/00-overview.md). - -## Công cụ & vai trò - -| Công cụ | Vai trò trong MedKarute | -|---------|-------------------------------| -| **MarkItDown MCP** | Convert PDF mới → Markdown token-efficient cho agent đọc (paper chưa vào EndNote) | -| **endnote-mcp** | Đọc thư viện EndNote đã curate — search, đọc PDF sâu, format citation/bibliography. Read-only (write qua EndNote desktop) | -| **[WhySchools / context-mapping](https://github.com/WhySchools/context-mapping)** (context-gen) | **Cùng một nguồn** — workflow agentic *Human is the brain* của [WhySchools](https://github.com/WhySchools): bộ nhớ dự án trong `.context/` (GLOBAL, MILESTONES, TENSIONS, modules). Repo này áp dụng layout governance đó → [`.context/GLOBAL.md`](../../.context/GLOBAL.md). Brainstorm MCP sớm lưu tại [`research-helper.md`](../raws/research-helper.md). **Không phải dependency chạy** khi làm nghiên cứu hằng ngày | +# MedKarute (メドカルテ) - research-helper + +**AI Orchestrator + Amanuensis for Research** +研究を支えるAIオーケストレーターと書記 + +> **English**: [README.md](../../README.md) (canonical) + +**MedKarute** (trước gọi `research-helper`) là chat-driven research assistant: agent (orchestrator) điều phối workflow nghiên cứu qua chat, ghi kết quả vào file Markdown trong `research/{slug}/`, và gọi hai MCP — **MarkItDown** (PDF mới) và **endnote-mcp** (thư viện EndNote đã curate). Governance (`AGENTS.md`, `CLAUDE.md`, `docs/`) mô tả cách agent hoạt động; dữ liệu nghiên cứu tách riêng per-project. + +## Bắt đầu + +1. Clone/mở repo này. +2. Dùng **agent coding tool** (Claude Code, Grok CLI, Cursor, …) **hoặc** CLI thuần (`claude`, `grok`, …) — trong thư mục repo, nói **"bắt đầu"**. +3. Agent đọc `AGENTS.md` / `CLAUDE.md` → chạy onboarding (slug + purpose) → tạo `research/{slug}/` nếu chưa có project. + +**Không có gì phức tạp.** Coding tool/IDE chỉ giúp nhìn file, diff, tree rõ hơn — bản chất vẫn là agent đọc các file `.md` đã commit. Dùng thuần CLI, không mở IDE, **vẫn chạy y hệt**; không phụ thuộc tool cụ thể. + +> Hướng dẫn sử dụng cho người nghiên cứu (không phải cho agent): [`docs/guides/huong-dan-su-dung.md`](../guides/huong-dan-su-dung.md). + +## Workflow (sơ lược) + +``` + +------------------+ + | EndNote via user | + +--------+---------+ + ^ + | + +---------+ +------+--------+ +-------------+ + | PDF mới |------------->| paper note |------------->| insight | + +---------+ +---------------+ +------+------+ + | + +--------------+ +---------------+ | + | phiên chat |-------->| session note |---------------------+ + +--------------+ +---------------+ | + v + +-------------+ + | writing | + +-------------+ +``` + +Chi tiết artifact, INDEX, git per-project → [00-overview.md](../guides/research/00-overview.md). + +## Công cụ & vai trò + +| Công cụ | Vai trò trong MedKarute | +|---------|-------------------------------| +| **MarkItDown MCP** | Convert PDF mới → Markdown token-efficient cho agent đọc (paper chưa vào EndNote) | +| **endnote-mcp** | Đọc thư viện EndNote đã curate — search, đọc PDF sâu, format citation/bibliography. Read-only (write qua EndNote desktop) | +| **[WhySchools / context-mapping](https://github.com/WhySchools/context-mapping)** (context-gen) | **Cùng một nguồn** — workflow agentic *Human is the brain* của [WhySchools](https://github.com/WhySchools): bộ nhớ dự án trong `.context/` (GLOBAL, MILESTONES, TENSIONS, modules). Repo này áp dụng layout governance đó → [`.context/GLOBAL.md`](../../.context/GLOBAL.md). Brainstorm MCP sớm lưu tại [`research-helper.md`](../raws/research-helper.md). **Không phải dependency chạy** khi làm nghiên cứu hằng ngày | | **Markpad** | App mở file `.md` local cho user xem note — không phải MCP, chỉ viewer | +| **md-to-docx** (agent skill) | Convert `writing/*.md` → Word `.docx` có format (version, TOC, front-matter). Vendored từ [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) → [`.agents/skills/md-to-docx/`](../../.agents/skills/md-to-docx/) | ## Link nhanh - -| File | Mục đích | -|------|----------| -| [AGENTS.md](../../AGENTS.md) | Invariants, startup order | -| [CLAUDE.md](../../CLAUDE.md) | Orchestrator playbook | -| [00-overview.md](../guides/research/00-overview.md) | Chi tiết workflow `research/` | -| [endnote-workflow.md](../decisions/endnote-workflow.md) | EndNote workflow (canonical) | \ No newline at end of file + +| File | Mục đích | +|------|----------| +| [AGENTS.md](../../AGENTS.md) | Invariants, startup order | +| [CLAUDE.md](../../CLAUDE.md) | Orchestrator playbook | +| [00-overview.md](../guides/research/00-overview.md) | Chi tiết workflow `research/` | +| [endnote-workflow.md](../decisions/endnote-workflow.md) | EndNote workflow (canonical) | +| [md-to-docx](../../.agents/skills/md-to-docx/) | Skill Markdown → Word (`SKILL.md`, link upstream) | +| [.agents/README.md](../../.agents/README.md) | Layout agent skills, symlink cross-agent | \ No newline at end of file From bcecf5629cbf94268ff03adb0c042ab0a5d10964 Mon Sep 17 00:00:00 2001 From: ITSKVN Date: Mon, 6 Jul 2026 16:07:39 +0700 Subject: [PATCH 07/15] Link MarkItDown and endnote-mcp upstream GitHub in README Tools Add microsoft/markitdown and gokmengokhan/endnote-mcp references to Tools & roles in EN and VI README. --- README.md | 4 ++-- docs/readme/README.vi.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 590ae25..c6eb4fc 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ Artifacts, INDEX routing, per-project git → [docs/guides/research/00-overview. | Tool | Role in MedKarute | |------|-------------------------| -| **MarkItDown MCP** | Convert new PDFs → token-efficient Markdown for the agent (papers not yet in EndNote) | -| **endnote-mcp** | Read curated EndNote library — search, deep PDF read, citation/bibliography. Read-only (writes via EndNote desktop) | +| **[MarkItDown MCP](https://github.com/microsoft/markitdown/tree/main/packages/markitdown-mcp)** | Convert new PDFs → token-efficient Markdown for the agent (papers not yet in EndNote). `uvx markitdown-mcp` | +| **[endnote-mcp](https://github.com/gokmengokhan/endnote-mcp)** | Read curated EndNote library — search, deep PDF read, citation/bibliography. Read-only (writes via EndNote desktop). `uvx endnote-mcp serve` | | **[WhySchools / context-mapping](https://github.com/WhySchools/context-mapping)** (context-gen) | **Same lineage** — [WhySchools](https://github.com/WhySchools) *Human is the brain* agentic workflow: shared memory in `.context/` (GLOBAL, MILESTONES, TENSIONS, modules). This repo adopts that governance layout → [`.context/GLOBAL.md`](.context/GLOBAL.md). Early MCP brainstorm archived in [`docs/raws/research-helper.md`](docs/raws/research-helper.md). **Not a runtime dependency** for research sessions | | **Markpad** | Local `.md` viewer for the user — not an MCP, viewer only | | **md-to-docx** (agent skill) | Convert `writing/*.md` → formatted Word `.docx` (versioning, TOC, front-matter). Vendored from [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) → [`.agents/skills/md-to-docx/`](.agents/skills/md-to-docx/) | diff --git a/docs/readme/README.vi.md b/docs/readme/README.vi.md index 0d19f5a..ef8742a 100644 --- a/docs/readme/README.vi.md +++ b/docs/readme/README.vi.md @@ -44,8 +44,8 @@ Chi tiết artifact, INDEX, git per-project → [00-overview.md](../guides/resea | Công cụ | Vai trò trong MedKarute | |---------|-------------------------------| -| **MarkItDown MCP** | Convert PDF mới → Markdown token-efficient cho agent đọc (paper chưa vào EndNote) | -| **endnote-mcp** | Đọc thư viện EndNote đã curate — search, đọc PDF sâu, format citation/bibliography. Read-only (write qua EndNote desktop) | +| **[MarkItDown MCP](https://github.com/microsoft/markitdown/tree/main/packages/markitdown-mcp)** | Convert PDF mới → Markdown token-efficient cho agent đọc (paper chưa vào EndNote). `uvx markitdown-mcp` | +| **[endnote-mcp](https://github.com/gokmengokhan/endnote-mcp)** | Đọc thư viện EndNote đã curate — search, đọc PDF sâu, format citation/bibliography. Read-only (write qua EndNote desktop). `uvx endnote-mcp serve` | | **[WhySchools / context-mapping](https://github.com/WhySchools/context-mapping)** (context-gen) | **Cùng một nguồn** — workflow agentic *Human is the brain* của [WhySchools](https://github.com/WhySchools): bộ nhớ dự án trong `.context/` (GLOBAL, MILESTONES, TENSIONS, modules). Repo này áp dụng layout governance đó → [`.context/GLOBAL.md`](../../.context/GLOBAL.md). Brainstorm MCP sớm lưu tại [`research-helper.md`](../raws/research-helper.md). **Không phải dependency chạy** khi làm nghiên cứu hằng ngày | | **Markpad** | App mở file `.md` local cho user xem note — không phải MCP, chỉ viewer | | **md-to-docx** (agent skill) | Convert `writing/*.md` → Word `.docx` có format (version, TOC, front-matter). Vendored từ [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) → [`.agents/skills/md-to-docx/`](../../.agents/skills/md-to-docx/) | From c69eb84983fd081d40a9594ce20f693a16ad7958 Mon Sep 17 00:00:00 2001 From: ITSKVN Date: Mon, 6 Jul 2026 16:08:27 +0700 Subject: [PATCH 08/15] Align md-to-docx Tools row with upstream GitHub link style Link skill name to pickle-an/md-to-docx-skill like MCP rows; clarify vendored path and that it is an agent skill, not uvx MCP. --- README.md | 2 +- docs/readme/README.vi.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c6eb4fc..a25c14c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Artifacts, INDEX routing, per-project git → [docs/guides/research/00-overview. | **[endnote-mcp](https://github.com/gokmengokhan/endnote-mcp)** | Read curated EndNote library — search, deep PDF read, citation/bibliography. Read-only (writes via EndNote desktop). `uvx endnote-mcp serve` | | **[WhySchools / context-mapping](https://github.com/WhySchools/context-mapping)** (context-gen) | **Same lineage** — [WhySchools](https://github.com/WhySchools) *Human is the brain* agentic workflow: shared memory in `.context/` (GLOBAL, MILESTONES, TENSIONS, modules). This repo adopts that governance layout → [`.context/GLOBAL.md`](.context/GLOBAL.md). Early MCP brainstorm archived in [`docs/raws/research-helper.md`](docs/raws/research-helper.md). **Not a runtime dependency** for research sessions | | **Markpad** | Local `.md` viewer for the user — not an MCP, viewer only | -| **md-to-docx** (agent skill) | Convert `writing/*.md` → formatted Word `.docx` (versioning, TOC, front-matter). Vendored from [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) → [`.agents/skills/md-to-docx/`](.agents/skills/md-to-docx/) | +| **[md-to-docx](https://github.com/pickle-an/md-to-docx-skill)** (agent skill) | Convert `writing/*.md` → formatted Word `.docx` (versioning, TOC, front-matter). Vendored copy → [`.agents/skills/md-to-docx/`](.agents/skills/md-to-docx/) (`SKILL.md` + scripts). Not an MCP — agent invokes via skill discovery | ## Quick links diff --git a/docs/readme/README.vi.md b/docs/readme/README.vi.md index ef8742a..774175e 100644 --- a/docs/readme/README.vi.md +++ b/docs/readme/README.vi.md @@ -48,7 +48,7 @@ Chi tiết artifact, INDEX, git per-project → [00-overview.md](../guides/resea | **[endnote-mcp](https://github.com/gokmengokhan/endnote-mcp)** | Đọc thư viện EndNote đã curate — search, đọc PDF sâu, format citation/bibliography. Read-only (write qua EndNote desktop). `uvx endnote-mcp serve` | | **[WhySchools / context-mapping](https://github.com/WhySchools/context-mapping)** (context-gen) | **Cùng một nguồn** — workflow agentic *Human is the brain* của [WhySchools](https://github.com/WhySchools): bộ nhớ dự án trong `.context/` (GLOBAL, MILESTONES, TENSIONS, modules). Repo này áp dụng layout governance đó → [`.context/GLOBAL.md`](../../.context/GLOBAL.md). Brainstorm MCP sớm lưu tại [`research-helper.md`](../raws/research-helper.md). **Không phải dependency chạy** khi làm nghiên cứu hằng ngày | | **Markpad** | App mở file `.md` local cho user xem note — không phải MCP, chỉ viewer | -| **md-to-docx** (agent skill) | Convert `writing/*.md` → Word `.docx` có format (version, TOC, front-matter). Vendored từ [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) → [`.agents/skills/md-to-docx/`](../../.agents/skills/md-to-docx/) | +| **[md-to-docx](https://github.com/pickle-an/md-to-docx-skill)** (agent skill) | Convert `writing/*.md` → Word `.docx` có format (version, TOC, front-matter). Bản vendored → [`.agents/skills/md-to-docx/`](../../.agents/skills/md-to-docx/) (`SKILL.md` + scripts). Không phải MCP — agent gọi qua skill discovery | ## Link nhanh From b31898548b67044530f33517f55619da59a4d1ad Mon Sep 17 00:00:00 2001 From: ITSKVN Date: Mon, 6 Jul 2026 16:12:28 +0700 Subject: [PATCH 09/15] Document awesome-copilot md-to-docx as secondary update reference Link GitHub awesome-copilot SKILL.md for doc and feature parity when refreshing the vendored skill; runtime stays pickle-an Python pipeline. --- .agents/README.md | 4 ++-- .agents/skills/md-to-docx/README.md | 19 +++++++++++++++++-- .agents/skills/md-to-docx/SKILL.md | 20 ++++++++++++-------- README.md | 2 +- docs/readme/README.vi.md | 2 +- 5 files changed, 33 insertions(+), 14 deletions(-) diff --git a/.agents/README.md b/.agents/README.md index 4f83822..3f16717 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -63,9 +63,9 @@ flowchart LR | Skill | Description | Upstream | | ----- | ----------- | -------- | -| `md-to-docx` | Convert Markdown to formatted Word (.docx) with versioning and normalization | [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) | +| `md-to-docx` | Convert Markdown to formatted Word (.docx) with versioning and normalization | [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) (Python); also [awesome-copilot SKILL.md](https://github.com/github/awesome-copilot/blob/main/skills/md-to-docx/SKILL.md) for doc/feature updates | -Vendored skills in `.agents/skills/` may diverge from upstream (e.g. English `SKILL.md`, MedKarute symlink layout). To sync or report issues, use the upstream repository. +Vendored skills in `.agents/skills/` may diverge from upstream (e.g. English `SKILL.md`, MedKarute symlink layout). Sync Python from pickle-an; diff awesome-copilot `SKILL.md` when refreshing instructions — see [`md-to-docx/README.md`](./skills/md-to-docx/README.md). ## Adding a new skill diff --git a/.agents/skills/md-to-docx/README.md b/.agents/skills/md-to-docx/README.md index 59c6da1..ba73c17 100644 --- a/.agents/skills/md-to-docx/README.md +++ b/.agents/skills/md-to-docx/README.md @@ -4,7 +4,8 @@ Vendored copy of the [md-to-docx-skill](https://github.com/pickle-an/md-to-docx- | | | | --- | --- | -| **Upstream** | https://github.com/pickle-an/md-to-docx-skill | +| **Upstream (runtime)** | https://github.com/pickle-an/md-to-docx-skill — Python `scripts/` | +| **Reference (updates)** | https://github.com/github/awesome-copilot/blob/main/skills/md-to-docx/SKILL.md — GitHub Copilot skill (Node.js); compare docs/features, do not swap pipeline | | **Agent entrypoint** | [`SKILL.md`](./SKILL.md) | | **Project layout** | `.agents/skills/md-to-docx/` (see [`.agents/README.md`](../../README.md)) | | **Cross-agent discovery** | Symlinks in `.claude/skills/`, `.codex/skills/`, `.grok/skills/` — manual repo setup, not auto-created by agents (see [`.agents/README.md`](../../README.md)) | @@ -128,12 +129,26 @@ python3 -m venv .venv && .venv/bin/pip install python-docx ## Sync from upstream +**1. Python (pickle-an)** — scripts and template: + ```bash git clone --depth 1 https://github.com/pickle-an/md-to-docx-skill /tmp/md-to-docx-skill cp /tmp/md-to-docx-skill/*.py .agents/skills/md-to-docx/scripts/ cp /tmp/md-to-docx-skill/template.docx .agents/skills/md-to-docx/assets/ -# Keep local SKILL.md (English) unless intentionally re-merging upstream skill/SKILL.md # Re-apply MedKarute enhancements in md_to_docx.py if the merge overwrote them ``` +**2. awesome-copilot (reference)** — skill docs / feature checklist: + +```bash +curl -sL -o /tmp/awesome-md-to-docx-SKILL.md \ + https://raw.githubusercontent.com/github/awesome-copilot/main/skills/md-to-docx/SKILL.md +# Diff against .agents/skills/md-to-docx/SKILL.md — port doc/UX ideas only (runtime stays Python) +``` + +| Source | Stack | Use for | +| ------ | ----- | ------- | +| [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) | Python + `python-docx` | Converter code, template, bug reports | +| [awesome-copilot `md-to-docx`](https://github.com/github/awesome-copilot/blob/main/skills/md-to-docx/SKILL.md) | Node.js + `docx`/`marked` | Updating `SKILL.md`, missing features (e.g. PNG embed sizing notes) | + Report converter bugs to [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill). Report MedKarute integration issues in this repo. \ No newline at end of file diff --git a/.agents/skills/md-to-docx/SKILL.md b/.agents/skills/md-to-docx/SKILL.md index 49e8393..4831bbb 100644 --- a/.agents/skills/md-to-docx/SKILL.md +++ b/.agents/skills/md-to-docx/SKILL.md @@ -3,13 +3,16 @@ name: md-to-docx description: Convert Markdown files to formatted Word documents (.docx). Use when the user wants to convert .md to .docx, create a Word document from Markdown, or mentions document conversion. metadata: upstream: https://github.com/pickle-an/md-to-docx-skill + reference: https://github.com/github/awesome-copilot/blob/main/skills/md-to-docx/SKILL.md --- # Markdown to Word Document Converter This skill converts Markdown files into professionally formatted Word documents (.docx). -**Upstream:** [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) — original Agent Skill and Python scripts. This copy lives under `.agents/skills/md-to-docx/` for MedKarute agent discovery; report bugs or request features upstream unless they are MedKarute-specific integration issues. +**Upstream (runtime):** [pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) — Python scripts in `scripts/`. This copy lives under `.agents/skills/md-to-docx/` for MedKarute agent discovery; report converter bugs upstream unless MedKarute-specific. + +**Reference (updates):** [awesome-copilot `md-to-docx` SKILL.md](https://github.com/github/awesome-copilot/blob/main/skills/md-to-docx/SKILL.md) — alternate Node.js skill (GitHub Copilot). Use when refreshing docs or checking feature parity; **do not replace** the Python pipeline with it. ## When to Invoke @@ -499,12 +502,13 @@ Optional: `requests`, `Pillow` (for remote/local image embedding). - `scripts/create_template.py` can regenerate a template matching the formatting specification - This skill runs fully standalone with no external dependencies beyond Python packages -## Upstream repository +## Upstream & reference -| Resource | URL | -| -------- | --- | -| Repository | https://github.com/pickle-an/md-to-docx-skill | -| Upstream `SKILL.md` | https://github.com/pickle-an/md-to-docx-skill/blob/main/skill/SKILL.md | -| Releases | https://github.com/pickle-an/md-to-docx-skill/releases | +| Resource | URL | Role | +| -------- | --- | ---- | +| pickle-an repository | https://github.com/pickle-an/md-to-docx-skill | **Sync Python** — `*.py`, `template.docx` | +| pickle-an `SKILL.md` | https://github.com/pickle-an/md-to-docx-skill/blob/main/skill/SKILL.md | Compare skill instructions | +| pickle-an releases | https://github.com/pickle-an/md-to-docx-skill/releases | Version notes | +| awesome-copilot `SKILL.md` | https://github.com/github/awesome-copilot/blob/main/skills/md-to-docx/SKILL.md | **Reference only** — Node.js skill; doc/feature parity when updating MedKarute `SKILL.md` / enhancements | -Licensed MIT upstream. When updating this vendored copy, compare against upstream `skill/` and root Python modules. \ No newline at end of file +Licensed MIT (pickle-an). When updating: sync `.py` from pickle-an, re-apply MedKarute helpers in `md_to_docx.py`, then diff awesome-copilot `SKILL.md` for missing UX/docs ideas (PNG embed notes, prerequisites, etc.). \ No newline at end of file diff --git a/README.md b/README.md index a25c14c..4970383 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Artifacts, INDEX routing, per-project git → [docs/guides/research/00-overview. | **[endnote-mcp](https://github.com/gokmengokhan/endnote-mcp)** | Read curated EndNote library — search, deep PDF read, citation/bibliography. Read-only (writes via EndNote desktop). `uvx endnote-mcp serve` | | **[WhySchools / context-mapping](https://github.com/WhySchools/context-mapping)** (context-gen) | **Same lineage** — [WhySchools](https://github.com/WhySchools) *Human is the brain* agentic workflow: shared memory in `.context/` (GLOBAL, MILESTONES, TENSIONS, modules). This repo adopts that governance layout → [`.context/GLOBAL.md`](.context/GLOBAL.md). Early MCP brainstorm archived in [`docs/raws/research-helper.md`](docs/raws/research-helper.md). **Not a runtime dependency** for research sessions | | **Markpad** | Local `.md` viewer for the user — not an MCP, viewer only | -| **[md-to-docx](https://github.com/pickle-an/md-to-docx-skill)** (agent skill) | Convert `writing/*.md` → formatted Word `.docx` (versioning, TOC, front-matter). Vendored copy → [`.agents/skills/md-to-docx/`](.agents/skills/md-to-docx/) (`SKILL.md` + scripts). Not an MCP — agent invokes via skill discovery | +| **[md-to-docx](https://github.com/pickle-an/md-to-docx-skill)** (agent skill) | Convert `writing/*.md` → formatted Word `.docx` (versioning, TOC, front-matter). Vendored copy → [`.agents/skills/md-to-docx/`](.agents/skills/md-to-docx/) (Python). Update reference: [awesome-copilot `SKILL.md`](https://github.com/github/awesome-copilot/blob/main/skills/md-to-docx/SKILL.md). Not an MCP | ## Quick links diff --git a/docs/readme/README.vi.md b/docs/readme/README.vi.md index 774175e..ab9481b 100644 --- a/docs/readme/README.vi.md +++ b/docs/readme/README.vi.md @@ -48,7 +48,7 @@ Chi tiết artifact, INDEX, git per-project → [00-overview.md](../guides/resea | **[endnote-mcp](https://github.com/gokmengokhan/endnote-mcp)** | Đọc thư viện EndNote đã curate — search, đọc PDF sâu, format citation/bibliography. Read-only (write qua EndNote desktop). `uvx endnote-mcp serve` | | **[WhySchools / context-mapping](https://github.com/WhySchools/context-mapping)** (context-gen) | **Cùng một nguồn** — workflow agentic *Human is the brain* của [WhySchools](https://github.com/WhySchools): bộ nhớ dự án trong `.context/` (GLOBAL, MILESTONES, TENSIONS, modules). Repo này áp dụng layout governance đó → [`.context/GLOBAL.md`](../../.context/GLOBAL.md). Brainstorm MCP sớm lưu tại [`research-helper.md`](../raws/research-helper.md). **Không phải dependency chạy** khi làm nghiên cứu hằng ngày | | **Markpad** | App mở file `.md` local cho user xem note — không phải MCP, chỉ viewer | -| **[md-to-docx](https://github.com/pickle-an/md-to-docx-skill)** (agent skill) | Convert `writing/*.md` → Word `.docx` có format (version, TOC, front-matter). Bản vendored → [`.agents/skills/md-to-docx/`](../../.agents/skills/md-to-docx/) (`SKILL.md` + scripts). Không phải MCP — agent gọi qua skill discovery | +| **[md-to-docx](https://github.com/pickle-an/md-to-docx-skill)** (agent skill) | Convert `writing/*.md` → Word `.docx` có format (version, TOC, front-matter). Bản vendored → [`.agents/skills/md-to-docx/`](../../.agents/skills/md-to-docx/) (Python). Tham chiếu cập nhật: [awesome-copilot `SKILL.md`](https://github.com/github/awesome-copilot/blob/main/skills/md-to-docx/SKILL.md). Không phải MCP | ## Link nhanh From 6d4e823269f16cc27a5c9e4431c4311fe4b6f2e8 Mon Sep 17 00:00:00 2001 From: ITSKVN Date: Mon, 6 Jul 2026 16:14:46 +0700 Subject: [PATCH 10/15] Update user guide with MCP tools and md-to-docx pipeline Add daily Word export task, tool flow diagram, front-matter tip, vocabulary, and deep-dive links in huong-dan-su-dung.md. --- docs/guides/huong-dan-su-dung.md | 193 +++++++++++++++++++------------ 1 file changed, 117 insertions(+), 76 deletions(-) diff --git a/docs/guides/huong-dan-su-dung.md b/docs/guides/huong-dan-su-dung.md index 00e6dd7..3c273ca 100644 --- a/docs/guides/huong-dan-su-dung.md +++ b/docs/guides/huong-dan-su-dung.md @@ -1,95 +1,136 @@ -# Hướng dẫn sử dụng — cho bác sĩ / người nghiên cứu - -> Đọc file này **một lần** trước khi bắt đầu. Không cần đọc `CLAUDE.md`/`AGENTS.md`/`docs/decisions/` — đó là sổ tay vận hành cho AI, không phải cho bạn. Chi tiết sâu hơn khi cần → xem mục **10. Đọc thêm** cuối file. - ---- - -## 1. MedKarute là gì - -Bạn chat với AI (Claude) như bình thường — nhưng thay vì để mọi thứ trôi trong lịch sử chat, AI **ghi lại có cấu trúc**: bài đã đọc, buổi thảo luận, cách hiểu gộp, đoạn đang viết. Tuần sau mở lại, AI đọc file cũ, không hỏi lại từ đầu. - -Ba việc AI làm giúp bạn: - -1. **Đọc PDF** — bài scan/PDF dài, AI chuyển thành text đọc được, tóm tắt -2. **Tra thư viện EndNote** — hỏi "bài nào về X" thay vì tự lục tay +# Hướng dẫn sử dụng — cho bác sĩ / người nghiên cứu + +> Đọc file này **một lần** trước khi bắt đầu. Không cần đọc `CLAUDE.md`/`AGENTS.md`/`docs/decisions/` — đó là sổ tay vận hành cho AI, không phải cho bạn. Chi tiết sâu hơn khi cần → xem mục **10. Đọc thêm** cuối file. + +--- + +## 1. MedKarute là gì + +Bạn chat với AI (Claude, Grok, Cursor, …) như bình thường — nhưng thay vì để mọi thứ trôi trong lịch sử chat, AI **ghi lại có cấu trúc**: bài đã đọc, buổi thảo luận, cách hiểu gộp, đoạn đang viết. Tuần sau mở lại, AI đọc file cũ, không hỏi lại từ đầu. + +Bốn việc AI làm giúp bạn: + +1. **Đọc PDF** — bài scan/PDF dài, AI chuyển thành text đọc được, tóm tắt (công cụ **MarkItDown**) +2. **Tra thư viện EndNote** — hỏi "bài nào về X" thay vì tự lục tay (công cụ **endnote-mcp**) 3. **Ghi chép có chỗ** — mọi kết luận quan trọng nằm trong file, không chỉ trong đầu - -## 2. Cài đặt lần đầu (một lần duy nhất) - -**Bước 1 — cài `uv`/`uvx`** (công cụ chạy AI cần, chưa có sẵn trên máy nào): - -- **Mac**: mở Terminal, gõ `brew install uv` (nếu đã có Homebrew) — hoặc `curl -LsSf https://astral.sh/uv/install.sh | sh` -- Nếu không quen Terminal — nhờ người hướng dẫn bạn làm hộ bước này, chỉ cần 1 lần - -**Bước 2 — mở chat với AI** (Claude Code hoặc app tương đương), gõ **"bắt đầu"**. - -**Bước 3 — AI sẽ hỏi bạn vài câu**: - -- Tên đề tài nghiên cứu (bắt buộc) + mục đích nghiên cứu (bắt buộc) -- Cách kết nối thư viện EndNote — AI đưa 2 lựa chọn, giải thích ngắn lợi/hại, bạn chọn 1: - - **Tự làm (native)**: bạn tự gõ 1 lệnh trong Terminal (`endnote-mcp setup`), công cụ tự tìm file — chịu khó 1 lần nhưng chắc chắn hơn - - **Nhờ AI làm** (agent): bạn chỉ cần cho AI biết file XML/thư mục PDF của EndNote nằm đâu, AI tự cấu hình — nhàn hơn nhưng bạn cần biết đúng đường dẫn - -Sau bước này, AI **không hỏi lại** — nó nhớ lựa chọn của bạn. - -## 3. Việc bạn làm hằng ngày - -| Bạn muốn | Bạn gõ trong chat | AI làm gì | -|---|---|---| -| Đọc 1 bài PDF mới | Gửi/nhắc tên file PDF | Tóm tắt, tạo phiếu đọc bài | -| Tìm bài trong thư viện | "Bài nào về [chủ đề]?" | Tra EndNote, trả lời kèm nguồn | -| Ghi lại buổi thảo luận hôm nay | "docs lại" hoặc "tổng kết" | Tự viết biên bản, không cần bạn tóm tắt tay | +4. **Xuất Word** — từ bản viết Markdown, AI tạo file `.docx` có bìa, mục lục, phiên bản V1/V2… (skill **md-to-docx**) + +## 2. Cài đặt lần đầu (một lần duy nhất) + +**Bước 1 — cài `uv`/`uvx`** (công cụ chạy AI cần, chưa có sẵn trên máy nào): + +- **Mac**: mở Terminal, gõ `brew install uv` (nếu đã có Homebrew) — hoặc `curl -LsSf https://astral.sh/uv/install.sh | sh` +- Nếu không quen Terminal — nhờ người hướng dẫn bạn làm hộ bước này, chỉ cần 1 lần + +**Bước 2 — mở chat với AI** (Claude Code, Grok, Cursor, hoặc app tương đương), gõ **"bắt đầu"**. + +**Bước 3 — AI sẽ hỏi bạn vài câu**: + +- Tên đề tài nghiên cứu (bắt buộc) + mục đích nghiên cứu (bắt buộc) +- Cách kết nối thư viện EndNote — AI đưa 2 lựa chọn, giải thích ngắn lợi/hại, bạn chọn 1: + - **Tự làm (native)**: bạn tự gõ 1 lệnh trong Terminal (`endnote-mcp setup`), công cụ tự tìm file — chịu khó 1 lần nhưng chắc chắn hơn + - **Nhờ AI làm** (agent): bạn chỉ cần cho AI biết file XML/thư mục PDF của EndNote nằm đâu, AI tự cấu hình — nhàn hơn nhưng bạn cần biết đúng đường dẫn + +Sau bước này, AI **không hỏi lại** — nó nhớ lựa chọn của bạn. + +## 3. Việc bạn làm hằng ngày + +| Bạn muốn | Bạn gõ trong chat | AI làm gì | +|---|---|---| +| Đọc 1 bài PDF mới | Gửi/nhắc tên file PDF | Tóm tắt, tạo phiếu đọc bài | +| Tìm bài trong thư viện | "Bài nào về [chủ đề]?" | Tra EndNote, trả lời kèm nguồn | +| Ghi lại buổi thảo luận hôm nay | "docs lại" hoặc "tổng kết" | Tự viết biên bản, không cần bạn tóm tắt tay | | Soạn đoạn viết có trích dẫn | "Viết đoạn về..." | Soạn nháp, trích dẫn tạm — chốt citation chuẩn khi bạn duyệt | +| Xuất bản viết sang Word | "Xuất Word file …" / "convert sang docx" | Tạo file `.docx` cạnh file `.md` trong `writing/` (tự tăng V1, V2, V3…) | -## 4. Việc của bạn vs việc của AI - -| Bạn làm | AI làm | -|---|---| -| Quyết định mục tiêu nghiên cứu | Gợi ý bước tiếp theo | -| Import/export file EndNote (AI không tự sửa thư viện của bạn) | Đọc, tìm, trích dẫn từ thư viện | -| Duyệt nội dung AI viết | Viết nháp, ghi chép, tự lưu file | - -**Lưu ý duy nhất dễ quên**: sau khi bạn thêm tài liệu mới vào EndNote, nhớ **export lại file XML** — AI nhắc bạn khi cần, nhưng thao tác export là bạn tự làm (AI không có quyền sửa thư viện EndNote của bạn). - -## 5. Nếu có gì đó không chạy đúng - -- AI sẽ **tự thử sửa trước** (không làm phiền bạn với chi tiết kỹ thuật) -- Nếu thử vài lần vẫn chưa xong, AI sẽ **báo bạn bằng lời thường**, giải thích đang vướng gì -- Đôi khi AI hỏi bạn có đồng ý "gửi bản sửa lại cho dự án gốc" không (gọi là **PR** — không phải quảng cáo, chỉ là cách chia sẻ bản sửa cho người dùng khác) — bạn chỉ cần trả lời có/không, AI làm hết phần kỹ thuật -- Bạn từ chối cũng không sao — máy bạn vẫn dùng bản đã sửa bình thường +## 3b. Luồng công cụ (sơ lược) -## 6. Điều AI sẽ không tự ý làm +Hai công cụ ngoài (**MCP**) AI gọi khi cần đọc PDF hoặc tra EndNote. Một **agent skill** (`md-to-docx`) AI dùng khi bạn muốn file Word — không cần bạn cài thêm app convert. -- Không tự sửa thư viện EndNote của bạn -- Không tự đổi quy tắc vận hành khi bạn không yêu cầu -- Không tự gửi thay đổi lên GitHub khi chưa hỏi bạn trước +```mermaid +flowchart LR + PDF[PDF mới] --> PN[Phiếu đọc bài] + EN[EndNote XML] --> LIB[Tra thư viện] + CHAT[Phiên chat] --> SES[Biên bản phiên] + PN --> INS[Cách hiểu gộp] + SES --> INS + INS --> WRT[Bản viết .md] + WRT --> DOCX[Word .docx] +``` -## 7. Muốn góp ý / báo lỗi - -Không cần biết Git. Cứ nói với AI trong chat bạn thấy gì chưa ổn — AI sẽ tự lo phần còn lại (xem mục 5). Chi tiết đầy đủ cho ai tò mò → `CONTRIBUTING.md`. +| Công cụ | Bạn thấy gì | Ghi chú | +|---|---|---| +| **MarkItDown** | AI đọc PDF mới nhanh hơn | Chỉ paper chưa có trong EndNote | +| **endnote-mcp** | AI trả lời kèm nguồn từ thư viện | Bạn vẫn import/export EndNote bằng tay | +| **md-to-docx** | File Word mở được trong Microsoft Word | Input thường là `writing/…md`; output `…_V1.docx`, `…_V2.docx` | -## 8. Ví dụ một vòng làm việc +**Mẹo bìa đẹp khi xuất Word** — thêm đầu file `.md` (AI có thể giúp viết): -> Bạn nhận 1 bài guideline mới qua email → gửi PDF cho AI → AI tóm tắt, hỏi bạn có muốn thêm vào EndNote không → bạn đồng ý → AI soạn sẵn để import → bạn import vào EndNote + export XML → AI cập nhật lại thư viện tìm kiếm → sau đó bạn hỏi "bài này liên quan gì tới 3 bài tuần trước" → AI tra thư viện, trả lời kèm trích dẫn. +```yaml +--- +title: Tên đề tài — Tóm tắt ngắn +date: 2026-07-06 +version: V1 +audience: Nhóm nghiên cứu, hội đồng +--- +``` -## 9. Từ vựng nhanh (không cần nhớ, chỉ tra khi thấy lạ) +Dòng `title` có dấu `—` sẽ tách thành tiêu đề + phụ đề trên bìa Word. -| Từ | Nghĩa đơn giản | -|---|---| +## 4. Việc của bạn vs việc của AI + +| Bạn làm | AI làm | +|---|---| +| Quyết định mục tiêu nghiên cứu | Gợi ý bước tiếp theo | +| Import/export file EndNote (AI không tự sửa thư viện của bạn) | Đọc, tìm, trích dẫn từ thư viện | +| Duyệt nội dung AI viết | Viết nháp, ghi chép, tự lưu file | + +**Lưu ý duy nhất dễ quên**: sau khi bạn thêm tài liệu mới vào EndNote, nhớ **export lại file XML** — AI nhắc bạn khi cần, nhưng thao tác export là bạn tự làm (AI không có quyền sửa thư viện EndNote của bạn). + +## 5. Nếu có gì đó không chạy đúng + +- AI sẽ **tự thử sửa trước** (không làm phiền bạn với chi tiết kỹ thuật) +- Nếu thử vài lần vẫn chưa xong, AI sẽ **báo bạn bằng lời thường**, giải thích đang vướng gì +- Đôi khi AI hỏi bạn có đồng ý "gửi bản sửa lại cho dự án gốc" không (gọi là **PR** — không phải quảng cáo, chỉ là cách chia sẻ bản sửa cho người dùng khác) — bạn chỉ cần trả lời có/không, AI làm hết phần kỹ thuật +- Bạn từ chối cũng không sao — máy bạn vẫn dùng bản đã sửa bình thường + +## 6. Điều AI sẽ không tự ý làm + +- Không tự sửa thư viện EndNote của bạn +- Không tự đổi quy tắc vận hành khi bạn không yêu cầu +- Không tự gửi thay đổi lên GitHub khi chưa hỏi bạn trước + +## 7. Muốn góp ý / báo lỗi + +Không cần biết Git. Cứ nói với AI trong chat bạn thấy gì chưa ổn — AI sẽ tự lo phần còn lại (xem mục 5). Chi tiết đầy đủ cho ai tò mò → `CONTRIBUTING.md`. + +## 8. Ví dụ một vòng làm việc + +> Bạn nhận 1 bài guideline mới qua email → gửi PDF cho AI → AI tóm tắt, hỏi bạn có muốn thêm vào EndNote không → bạn đồng ý → AI soạn sẵn để import → bạn import vào EndNote + export XML → AI cập nhật lại thư viện tìm kiếm → sau đó bạn hỏi "bài này liên quan gì tới 3 bài tuần trước" → AI tra thư viện, trả lời kèm trích dẫn → cuối tuần bạn nói "xuất Word bản viết case-01" → AI tạo `case-01_V1.docx` trong `writing/` → bạn mở Word, gửi đồng nghiệp duyệt. + +## 9. Từ vựng nhanh (không cần nhớ, chỉ tra khi thấy lạ) + +| Từ | Nghĩa đơn giản | +|---|---| | MCP | Cách AI "gọi" công cụ ngoài (đọc PDF, tra EndNote) | +| agent skill | Gói hướng dẫn + script AI dùng cho một việc cụ thể (vd. xuất Word) | +| md-to-docx | Skill xuất file Markdown → Word có format | | orchestrator | AI chính bạn đang chat cùng | | session note | Biên bản 1 buổi làm việc | | insight | Cách hiểu gộp từ nhiều bài, không phải tóm tắt 1 bài | | paper note | Phiếu đọc 1 bài | - -## 10. Đọc thêm (khi cần, không bắt buộc) - -| Muốn hiểu sâu hơn về | Xem | -|---|---| -| Bức tranh tổng thể + ví dụ y khoa chi tiết | `docs/raws/2026-07-03-GIOI-THIEU-VA-WORKFLOW.md` | -| Cách góp ý/báo lỗi đầy đủ | `CONTRIBUTING.md` | +| `_V1.docx`, `_V2.docx` | Phiên bản Word tự tăng — mỗi lần xuất lại không ghi đè bản cũ | + +## 10. Đọc thêm (khi cần, không bắt buộc) + +| Muốn hiểu sâu hơn về | Xem | +|---|---| +| Bức tranh tổng thể + ví dụ y khoa chi tiết | `docs/raws/2026-07-03-GIOI-THIEU-VA-WORKFLOW.md` | +| Cách góp ý/báo lỗi đầy đủ | `CONTRIBUTING.md` | | Quy tắc EndNote chi tiết | `docs/decisions/endnote-workflow.md` | +| Workflow bản viết + citation | `docs/guides/research/writing.md` | +| Xuất Word (chi tiết kỹ thuật) | `.agents/skills/md-to-docx/README.md` | --- -*Hướng dẫn sử dụng — MedKarute. Cập nhật khi workflow đổi thật, không đoán trước.* +*Hướng dẫn sử dụng — MedKarute. Cập nhật 2026-07-06: thêm md-to-docx, luồng MCP + skill.* From 2bae2d5e21d93902e5d771343880eaf5fe246de0 Mon Sep 17 00:00:00 2001 From: ITSKVN Date: Mon, 6 Jul 2026 16:27:07 +0700 Subject: [PATCH 11/15] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e38795e..c0b3ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ *$py.class .agents/skills/**/.venv/ .agents/skills/md-to-docx copilot/ +tools/pack-share.sh From e816c521dd0269a60c6de74b9670b318eb5167b1 Mon Sep 17 00:00:00 2001 From: ITSKVN Date: Mon, 6 Jul 2026 16:28:17 +0700 Subject: [PATCH 12/15] Add pack-share.sh for ZIP distribution without git Build share/out/medkarute-share-YYYY-MM-DD.zip excluding .git, .local, research, and skill .venv; materialize agent skill paths for Windows unzip. Document in 00-overview.md. --- .gitignore | 1 - docs/guides/research/00-overview.md | 122 +++++++++++++++------------- tools/pack-share.sh | 80 ++++++++++++++++++ 3 files changed, 144 insertions(+), 59 deletions(-) create mode 100644 tools/pack-share.sh diff --git a/.gitignore b/.gitignore index c0b3ae6..e38795e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,3 @@ __pycache__/ *$py.class .agents/skills/**/.venv/ .agents/skills/md-to-docx copilot/ -tools/pack-share.sh diff --git a/docs/guides/research/00-overview.md b/docs/guides/research/00-overview.md index e30e1da..0cb338b 100644 --- a/docs/guides/research/00-overview.md +++ b/docs/guides/research/00-overview.md @@ -1,62 +1,68 @@ -# Tổng quan — `research/` +# Tổng quan — `research/` + +> Governance guide. Agent đọc file này khi orientation hoặc phiên mới. + +## Mục đích + +`research/` chứa **dữ liệu nghiên cứu** theo từng project. Tách khỏi governance (root repo: `CLAUDE.md`, `docs/`, `.context/`). + +- Root `.gitignore` ignore toàn bộ `research/` +- Mỗi `research/{slug}/` là **git repo riêng** (local, offline) +- Agent **tự commit** sau mỗi lượt ghi có nghĩa + +## Bốn loại artifact + +| Tên | Chỗ | Một câu | +|-----|-----|---------| +| **Paper note** | `papers/{slug}.md` | *Bài này nói gì?* | +| **Session note** | `sessions/YYYY-MM-DD-….md` | *Hôm nay trao đổi gì?* | +| **Insight** | `insights/{topic-slug}.md` | *Mình hiểu / mental model thế nào?* | +| **Writing** | `writing/{slug}.md` | *Mình viết ra sao?* | + +```mermaid +flowchart LR + PDF[PDF] --> PaperNote[paper note] + PaperNote --> Insight[insight] + Chat[phiên chat] --> Session[session note] + Session --> Insight + Insight --> Writing[writing] +``` + +## Cấu trúc project + +``` +research/{slug}/ +├── README.md ← identity (purpose, topic, scope) +├── INDEX.md ← router mỏng +├── papers/ ← PDF + paper note +├── sessions/ ← log phiên chat +├── insights/ ← mental model cross-paper +└── writing/ ← prose, lit review +``` + +## Quy tắc agent + +1. Trước task trong một area → đọc `docs/guides/research/{area}.md` +2. Guide = source of truth — không suy diễn từ memory +3. User hỏi "hướng dẫn" → trích guide + ví dụ từ project hiện tại + +## Guides theo thư mục + +| File | Mô tả | +|------|-------| +| [readme.md](readme.md) | `README.md` project | +| [index-routing.md](index-routing.md) | INDEX phân tầng | +| [papers.md](papers.md) | PDF, MarkItDown, EndNote | +| [sessions.md](sessions.md) | "docs lại" | +| [insights.md](insights.md) | Mental model | +| [writing.md](writing.md) | Prose, citation | + +## Pack / share (ZIP, không git) -> Governance guide. Agent đọc file này khi orientation hoặc phiên mới. +Khi cần gửi bản cập nhật cho máy không dùng git: -## Mục đích - -`research/` chứa **dữ liệu nghiên cứu** theo từng project. Tách khỏi governance (root repo: `CLAUDE.md`, `docs/`, `.context/`). - -- Root `.gitignore` ignore toàn bộ `research/` -- Mỗi `research/{slug}/` là **git repo riêng** (local, offline) -- Agent **tự commit** sau mỗi lượt ghi có nghĩa - -## Bốn loại artifact - -| Tên | Chỗ | Một câu | -|-----|-----|---------| -| **Paper note** | `papers/{slug}.md` | *Bài này nói gì?* | -| **Session note** | `sessions/YYYY-MM-DD-….md` | *Hôm nay trao đổi gì?* | -| **Insight** | `insights/{topic-slug}.md` | *Mình hiểu / mental model thế nào?* | -| **Writing** | `writing/{slug}.md` | *Mình viết ra sao?* | - -```mermaid -flowchart LR - PDF[PDF] --> PaperNote[paper note] - PaperNote --> Insight[insight] - Chat[phiên chat] --> Session[session note] - Session --> Insight - Insight --> Writing[writing] -``` - -## Cấu trúc project - -``` -research/{slug}/ -├── README.md ← identity (purpose, topic, scope) -├── INDEX.md ← router mỏng -├── papers/ ← PDF + paper note -├── sessions/ ← log phiên chat -├── insights/ ← mental model cross-paper -└── writing/ ← prose, lit review +```bash +./tools/pack-share.sh ``` -## Quy tắc agent - -1. Trước task trong một area → đọc `docs/guides/research/{area}.md` -2. Guide = source of truth — không suy diễn từ memory -3. User hỏi "hướng dẫn" → trích guide + ví dụ từ project hiện tại - -## Guides theo thư mục - -| File | Mô tả | -|------|-------| -| [readme.md](readme.md) | `README.md` project | -| [index-routing.md](index-routing.md) | INDEX phân tầng | -| [papers.md](papers.md) | PDF, MarkItDown, EndNote | -| [sessions.md](sessions.md) | "docs lại" | -| [insights.md](insights.md) | Mental model | -| [writing.md](writing.md) | Prose, citation | - -## Pack / share (defer) - -Khi cần chuyển máy không qua git remote: `tools/pack-share.sh` (chưa code — xem `docs/raws/pack-share-script-plan.md`). \ No newline at end of file +Output: `share/out/medkarute-share-YYYY-MM-DD.zip` — kèm `SHARE-README.txt` hướng dẫn giải nén và *bắt đầu*. \ No newline at end of file diff --git a/tools/pack-share.sh b/tools/pack-share.sh new file mode 100644 index 0000000..7029f74 --- /dev/null +++ b/tools/pack-share.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Build a ZIP snapshot for users without git. +# Output: share/out/medkarute-share-YYYY-MM-DD.zip + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DATE_TAG="$(date +%Y-%m-%d)" +STAGING="$(mktemp -d)" +OUT_DIR="$ROOT/share/out" +ZIP_NAME="medkarute-share-${DATE_TAG}.zip" +ZIP_PATH="$OUT_DIR/$ZIP_NAME" + +cleanup() { rm -rf "$STAGING"; } +trap cleanup EXIT + +mkdir -p "$OUT_DIR" + +rsync -a \ + --exclude '.git/' \ + --exclude 'research/' \ + --exclude '.local/' \ + --exclude 'share/out/' \ + --exclude '.mcp.json' \ + --exclude 'docs/raws/' \ + --exclude '__pycache__/' \ + --exclude '*.py[cod]' \ + --exclude '.agents/skills/**/.venv/' \ + --exclude '.agents/skills/md-to-docx copilot/' \ + --exclude '.claude/settings.local.json' \ + "$ROOT/" "$STAGING/medkarute/" + +# ZIP on Windows often breaks symlinks — copy skills from staged .agents (no .venv). +for agent_dir in .claude/skills .codex/skills .grok/skills; do + rm -rf "$STAGING/medkarute/$agent_dir" + mkdir -p "$STAGING/medkarute/$agent_dir" + if [ -d "$STAGING/medkarute/.agents/skills" ]; then + for skill in "$STAGING/medkarute/.agents/skills/"*/; do + [ -d "$skill" ] || continue + cp -a "$skill" "$STAGING/medkarute/$agent_dir/$(basename "$skill")" + done + fi +done + +COMMIT="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)" +cat > "$STAGING/medkarute/SHARE-README.txt" < Date: Mon, 6 Jul 2026 16:30:59 +0700 Subject: [PATCH 13/15] Add share update intro BAN-CAP-NHAT-2026-07-06.md for ZIP recipients Vietnamese changelog for non-git users; pack-share includes it as BAN-CAP-NHAT.md inside the distributed ZIP. --- share/BAN-CAP-NHAT-2026-07-06.md | 118 +++++++++++++++++++++++++++++++ tools/pack-share.sh | 8 +++ 2 files changed, 126 insertions(+) create mode 100644 share/BAN-CAP-NHAT-2026-07-06.md diff --git a/share/BAN-CAP-NHAT-2026-07-06.md b/share/BAN-CAP-NHAT-2026-07-06.md new file mode 100644 index 0000000..6c1a239 --- /dev/null +++ b/share/BAN-CAP-NHAT-2026-07-06.md @@ -0,0 +1,118 @@ +# MedKarute — Giới thiệu bản cập nhật (2026-07-06) + +> Dành cho người nhận file **`medkarute-share-2026-07-06.zip`** — không cần Git. +> So với bản share trước (~4 Jul 2026). + +--- + +## Tóm tắt một dòng + +Bản này thêm **xuất Word từ bản viết Markdown** (skill `md-to-docx`), cập nhật **hướng dẫn sử dụng** và **README**, đồng thời bổ sung quy trình **OCR PDF scan** trong guide papers. + +--- + +## Bạn cần làm gì? + +1. **Giải nén** ZIP vào thư mục làm việc (vd. `D:\medkarute`) — có thể **thay thế** bản cũ hoặc giải nén sang folder mới. +2. **Không** copy đè `research/` hoặc `.local/` từ máy cũ nếu bạn đã có dữ liệu nghiên cứu — hai thư mục đó **không nằm trong ZIP** (riêng từng máy). +3. Mở folder trong **Claude Code / Grok / Cursor**, gõ **`bắt đầu`** như trước. +4. Đọc nhanh: `docs/guides/huong-dan-su-dung.md` (đã cập nhật mục xuất Word). + +--- + +## Các phần mới (chưa có ở bản share cũ) + +| Phần | Đường dẫn | Là gì | +|------|-----------|--------| +| **Skill xuất Word** | `.agents/skills/md-to-docx/` | Convert `writing/*.md` → `.docx` (bìa, mục lục, V1/V2…). Font mặc định Times New Roman | +| **Agent skills — hướng dẫn** | `.agents/README.md` | Cách các AI (Claude, Grok, Codex) tìm skill trong project | +| **Script đóng gói ZIP** | `tools/pack-share.sh` | Người maintain repo dùng để tạo ZIP share (bạn có thể bỏ qua) | +| **Symlink skill (đã copy thật)** | `.claude/skills/`, `.codex/skills/`, `.grok/skills/` | Cùng nội dung `md-to-docx` — ZIP đã copy sẵn, không cần Git/symlink | + +--- + +## Các file đã cập nhật nội dung + +### Cho người nghiên cứu (đọc trước) + +| File | Thay đổi chính | +|------|----------------| +| **`docs/guides/huong-dan-su-dung.md`** | Thêm việc **xuất Word**; bảng công cụ MarkItDown / endnote-mcp / md-to-docx; diagram luồng; mẹo YAML front-matter cho bìa Word | +| **`README.md`** (EN) | Bảng Tools & roles: link GitHub MCP + skill md-to-docx | +| **`docs/readme/README.vi.md`** | Bản tiếng Việt tương ứng | + +### Cho AI (orchestrator) — không bắt buộc đọc + +| File | Thay đổi chính | +|------|----------------| +| **`AGENTS.md`** | Mục **§7 Agent skills** — khi thêm skill mới phải tạo symlink (đã làm sẵn trong ZIP) | +| **`.context/GLOBAL.md`** | Index thêm mục Agent skills | +| **`docs/guides/research/papers.md`** | Thêm luồng **OCR** khi PDF là bản scan (MarkItDown ra text rỗng) | +| **`docs/guides/research/00-overview.md`** | Ghi chú cách tạo ZIP share | +| **`.gitignore`** | Bỏ qua `__pycache__`, `.venv` của skill Python | + +### Trong skill `md-to-docx` + +| File | Ghi chú | +|------|---------| +| `SKILL.md` | Hướng dẫn agent khi user muốn `.md` → `.docx` | +| `README.md` | Chi tiết kỹ thuật, so sánh upstream | +| `scripts/md_to_docx.py` | Converter chính (Python) | +| `assets/template.docx` | Template Word | + +**Upstream tham khảo khi cập nhật sau:** +[pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) (code) · +[awesome-copilot SKILL.md](https://github.com/github/awesome-copilot/blob/main/skills/md-to-docx/SKILL.md) (docs) + +--- + +## Cách dùng tính năng mới — xuất Word + +Trong chat, ví dụ: + +- *"Xuất Word file writing/case-01-….md"* +- *"Convert sang docx"* + +AI sẽ chạy skill và tạo file cạnh bản `.md`, ví dụ: + +- `case-01_….md` → `case-01_…_V1.docx` (lần đầu) +- Lần sau → `_V2.docx`, `_V3.docx`… (không ghi đè bản cũ) + +**Tùy chọn** — thêm đầu file `.md` để bìa đẹp: + +```yaml +--- +title: Tên đề tài — Tóm tắt +date: 2026-07-06 +version: V1 +audience: Nhóm nghiên cứu +--- +``` + +--- + +## Không đổi / không nằm trong ZIP + +| Mục | Ghi chú | +|-----|---------| +| **`research/`** | Dữ liệu đề tài của bạn — giữ trên máy cũ | +| **`.local/`** | Cấu hình máy (EndNote path, MCP…) — giữ trên máy cũ | +| **`.mcp.json`** | Copy từ `.mcp.json.tpl` hoặc để AI hướng dẫn lại | +| **EndNote workflow** | Vẫn như cũ: bạn export XML, AI `index` | + +--- + +## Câu hỏi thường gặp + +**Có cần cài thêm gì để xuất Word?** +Không trên máy bạn — AI chạy script Python trong skill. Người maintain cần `python-docx` trong môi trường agent; bạn chỉ cần **Word** để mở file `.docx`. + +**Bản cũ và bản mới chạy song song được không?** +Được, nhưng nên dùng **một folder** để tránh lẫn governance cũ/mới. Dữ liệu `research/` copy sang folder mới nếu cần. + +**Gặp lỗi?** +Nói trong chat — xem mục 5 trong `huong-dan-su-dung.md`. + +--- + +*MedKarute share ZIP · build 2026-07-06 · commit nguồn `6d4e823` + `e816c52`* \ No newline at end of file diff --git a/tools/pack-share.sh b/tools/pack-share.sh index 7029f74..2a97867 100644 --- a/tools/pack-share.sh +++ b/tools/pack-share.sh @@ -43,6 +43,13 @@ for agent_dir in .claude/skills .codex/skills .grok/skills; do done COMMIT="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)" + +if [ -f "$ROOT/share/BAN-CAP-NHAT-${DATE_TAG}.md" ]; then + cp "$ROOT/share/BAN-CAP-NHAT-${DATE_TAG}.md" "$STAGING/medkarute/BAN-CAP-NHAT.md" +elif ls "$ROOT"/share/BAN-CAP-NHAT-*.md >/dev/null 2>&1; then + cp "$(ls -t "$ROOT"/share/BAN-CAP-NHAT-*.md | head -1)" "$STAGING/medkarute/BAN-CAP-NHAT.md" +fi + cat > "$STAGING/medkarute/SHARE-README.txt" < Date: Mon, 6 Jul 2026 16:36:36 +0700 Subject: [PATCH 14/15] Gitignore share/ and tools/pack-share.sh ZIP distribution artifacts and pack script stay local on maintainer machine; remove from tracking and note in 00-overview.md. --- .gitignore | 5 +- docs/guides/research/00-overview.md | 8 +- share/BAN-CAP-NHAT-2026-07-06.md | 118 ---------------------------- tools/pack-share.sh | 88 --------------------- 4 files changed, 4 insertions(+), 215 deletions(-) delete mode 100644 share/BAN-CAP-NHAT-2026-07-06.md delete mode 100644 tools/pack-share.sh diff --git a/.gitignore b/.gitignore index e38795e..fa2abd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ /research/ .local/ -share/out/ -.mcp.json +share/ +tools/pack-share.sh +.mcp.json /docs/raws # Python diff --git a/docs/guides/research/00-overview.md b/docs/guides/research/00-overview.md index 0cb338b..f736d04 100644 --- a/docs/guides/research/00-overview.md +++ b/docs/guides/research/00-overview.md @@ -59,10 +59,4 @@ research/{slug}/ ## Pack / share (ZIP, không git) -Khi cần gửi bản cập nhật cho máy không dùng git: - -```bash -./tools/pack-share.sh -``` - -Output: `share/out/medkarute-share-YYYY-MM-DD.zip` — kèm `SHARE-README.txt` hướng dẫn giải nén và *bắt đầu*. \ No newline at end of file +Gói ZIP cho máy không dùng git: script `tools/pack-share.sh` + ghi chú `share/BAN-CAP-NHAT-*.md` — **gitignore**, chỉ trên máy maintain, không commit. \ No newline at end of file diff --git a/share/BAN-CAP-NHAT-2026-07-06.md b/share/BAN-CAP-NHAT-2026-07-06.md deleted file mode 100644 index 6c1a239..0000000 --- a/share/BAN-CAP-NHAT-2026-07-06.md +++ /dev/null @@ -1,118 +0,0 @@ -# MedKarute — Giới thiệu bản cập nhật (2026-07-06) - -> Dành cho người nhận file **`medkarute-share-2026-07-06.zip`** — không cần Git. -> So với bản share trước (~4 Jul 2026). - ---- - -## Tóm tắt một dòng - -Bản này thêm **xuất Word từ bản viết Markdown** (skill `md-to-docx`), cập nhật **hướng dẫn sử dụng** và **README**, đồng thời bổ sung quy trình **OCR PDF scan** trong guide papers. - ---- - -## Bạn cần làm gì? - -1. **Giải nén** ZIP vào thư mục làm việc (vd. `D:\medkarute`) — có thể **thay thế** bản cũ hoặc giải nén sang folder mới. -2. **Không** copy đè `research/` hoặc `.local/` từ máy cũ nếu bạn đã có dữ liệu nghiên cứu — hai thư mục đó **không nằm trong ZIP** (riêng từng máy). -3. Mở folder trong **Claude Code / Grok / Cursor**, gõ **`bắt đầu`** như trước. -4. Đọc nhanh: `docs/guides/huong-dan-su-dung.md` (đã cập nhật mục xuất Word). - ---- - -## Các phần mới (chưa có ở bản share cũ) - -| Phần | Đường dẫn | Là gì | -|------|-----------|--------| -| **Skill xuất Word** | `.agents/skills/md-to-docx/` | Convert `writing/*.md` → `.docx` (bìa, mục lục, V1/V2…). Font mặc định Times New Roman | -| **Agent skills — hướng dẫn** | `.agents/README.md` | Cách các AI (Claude, Grok, Codex) tìm skill trong project | -| **Script đóng gói ZIP** | `tools/pack-share.sh` | Người maintain repo dùng để tạo ZIP share (bạn có thể bỏ qua) | -| **Symlink skill (đã copy thật)** | `.claude/skills/`, `.codex/skills/`, `.grok/skills/` | Cùng nội dung `md-to-docx` — ZIP đã copy sẵn, không cần Git/symlink | - ---- - -## Các file đã cập nhật nội dung - -### Cho người nghiên cứu (đọc trước) - -| File | Thay đổi chính | -|------|----------------| -| **`docs/guides/huong-dan-su-dung.md`** | Thêm việc **xuất Word**; bảng công cụ MarkItDown / endnote-mcp / md-to-docx; diagram luồng; mẹo YAML front-matter cho bìa Word | -| **`README.md`** (EN) | Bảng Tools & roles: link GitHub MCP + skill md-to-docx | -| **`docs/readme/README.vi.md`** | Bản tiếng Việt tương ứng | - -### Cho AI (orchestrator) — không bắt buộc đọc - -| File | Thay đổi chính | -|------|----------------| -| **`AGENTS.md`** | Mục **§7 Agent skills** — khi thêm skill mới phải tạo symlink (đã làm sẵn trong ZIP) | -| **`.context/GLOBAL.md`** | Index thêm mục Agent skills | -| **`docs/guides/research/papers.md`** | Thêm luồng **OCR** khi PDF là bản scan (MarkItDown ra text rỗng) | -| **`docs/guides/research/00-overview.md`** | Ghi chú cách tạo ZIP share | -| **`.gitignore`** | Bỏ qua `__pycache__`, `.venv` của skill Python | - -### Trong skill `md-to-docx` - -| File | Ghi chú | -|------|---------| -| `SKILL.md` | Hướng dẫn agent khi user muốn `.md` → `.docx` | -| `README.md` | Chi tiết kỹ thuật, so sánh upstream | -| `scripts/md_to_docx.py` | Converter chính (Python) | -| `assets/template.docx` | Template Word | - -**Upstream tham khảo khi cập nhật sau:** -[pickle-an/md-to-docx-skill](https://github.com/pickle-an/md-to-docx-skill) (code) · -[awesome-copilot SKILL.md](https://github.com/github/awesome-copilot/blob/main/skills/md-to-docx/SKILL.md) (docs) - ---- - -## Cách dùng tính năng mới — xuất Word - -Trong chat, ví dụ: - -- *"Xuất Word file writing/case-01-….md"* -- *"Convert sang docx"* - -AI sẽ chạy skill và tạo file cạnh bản `.md`, ví dụ: - -- `case-01_….md` → `case-01_…_V1.docx` (lần đầu) -- Lần sau → `_V2.docx`, `_V3.docx`… (không ghi đè bản cũ) - -**Tùy chọn** — thêm đầu file `.md` để bìa đẹp: - -```yaml ---- -title: Tên đề tài — Tóm tắt -date: 2026-07-06 -version: V1 -audience: Nhóm nghiên cứu ---- -``` - ---- - -## Không đổi / không nằm trong ZIP - -| Mục | Ghi chú | -|-----|---------| -| **`research/`** | Dữ liệu đề tài của bạn — giữ trên máy cũ | -| **`.local/`** | Cấu hình máy (EndNote path, MCP…) — giữ trên máy cũ | -| **`.mcp.json`** | Copy từ `.mcp.json.tpl` hoặc để AI hướng dẫn lại | -| **EndNote workflow** | Vẫn như cũ: bạn export XML, AI `index` | - ---- - -## Câu hỏi thường gặp - -**Có cần cài thêm gì để xuất Word?** -Không trên máy bạn — AI chạy script Python trong skill. Người maintain cần `python-docx` trong môi trường agent; bạn chỉ cần **Word** để mở file `.docx`. - -**Bản cũ và bản mới chạy song song được không?** -Được, nhưng nên dùng **một folder** để tránh lẫn governance cũ/mới. Dữ liệu `research/` copy sang folder mới nếu cần. - -**Gặp lỗi?** -Nói trong chat — xem mục 5 trong `huong-dan-su-dung.md`. - ---- - -*MedKarute share ZIP · build 2026-07-06 · commit nguồn `6d4e823` + `e816c52`* \ No newline at end of file diff --git a/tools/pack-share.sh b/tools/pack-share.sh deleted file mode 100644 index 2a97867..0000000 --- a/tools/pack-share.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env bash -# Build a ZIP snapshot for users without git. -# Output: share/out/medkarute-share-YYYY-MM-DD.zip - -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -DATE_TAG="$(date +%Y-%m-%d)" -STAGING="$(mktemp -d)" -OUT_DIR="$ROOT/share/out" -ZIP_NAME="medkarute-share-${DATE_TAG}.zip" -ZIP_PATH="$OUT_DIR/$ZIP_NAME" - -cleanup() { rm -rf "$STAGING"; } -trap cleanup EXIT - -mkdir -p "$OUT_DIR" - -rsync -a \ - --exclude '.git/' \ - --exclude 'research/' \ - --exclude '.local/' \ - --exclude 'share/out/' \ - --exclude '.mcp.json' \ - --exclude 'docs/raws/' \ - --exclude '__pycache__/' \ - --exclude '*.py[cod]' \ - --exclude '.agents/skills/**/.venv/' \ - --exclude '.agents/skills/md-to-docx copilot/' \ - --exclude '.claude/settings.local.json' \ - "$ROOT/" "$STAGING/medkarute/" - -# ZIP on Windows often breaks symlinks — copy skills from staged .agents (no .venv). -for agent_dir in .claude/skills .codex/skills .grok/skills; do - rm -rf "$STAGING/medkarute/$agent_dir" - mkdir -p "$STAGING/medkarute/$agent_dir" - if [ -d "$STAGING/medkarute/.agents/skills" ]; then - for skill in "$STAGING/medkarute/.agents/skills/"*/; do - [ -d "$skill" ] || continue - cp -a "$skill" "$STAGING/medkarute/$agent_dir/$(basename "$skill")" - done - fi -done - -COMMIT="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)" - -if [ -f "$ROOT/share/BAN-CAP-NHAT-${DATE_TAG}.md" ]; then - cp "$ROOT/share/BAN-CAP-NHAT-${DATE_TAG}.md" "$STAGING/medkarute/BAN-CAP-NHAT.md" -elif ls "$ROOT"/share/BAN-CAP-NHAT-*.md >/dev/null 2>&1; then - cp "$(ls -t "$ROOT"/share/BAN-CAP-NHAT-*.md | head -1)" "$STAGING/medkarute/BAN-CAP-NHAT.md" -fi - -cat > "$STAGING/medkarute/SHARE-README.txt" < Date: Tue, 7 Jul 2026 08:21:27 +0700 Subject: [PATCH 15/15] Update readme --- README.md | 15 +++++++++++++++ docs/readme/README.vi.md | 31 +++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4970383..84e0125 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,21 @@ **MedKarute** (formerly `research-helper`) is a chat-driven research assistant: an agent (orchestrator) runs the research workflow via chat, writes results to Markdown under `research/{slug}/`, and calls two MCP servers — **MarkItDown** (new PDFs) and **endnote-mcp** (curated EndNote library). Governance (`AGENTS.md`, `CLAUDE.md`, `docs/`) defines how the agent behaves; research data lives in separate per-project folders. +## MedKarute vs NotebookLM + +NotebookLM is a hosted app, ready to use. MedKarute is a **harness** — you wire it onto an agent coding tool, and output lives in files (`research/{slug}/`), not a ready-made app. Different category, not a head-to-head. + +| | **NotebookLM** | **MedKarute** | +|---|---|---| +| Setup | Low | Higher (MCP, per-project git) | +| Storage | Cloud notebook | Git-tracked Markdown | +| Best fit | Quick Q&A, skim summaries | Longitudinal research, citations, deliverables | +| Token model | Google pricing | Savings **conditional** — only if you follow the load map (one sub-INDEX at a time) | + +**Note:** careless use (dumping all of `papers/`+`sessions/`+`insights/`) costs *more* than NotebookLM. MCP is orchestrator-only — subagents don't touch tools — so the token efficiency is discipline, not automatic. + +**Pick:** NotebookLM for fast reading with no setup. MedKarute when research must accumulate in files, survive across sessions, and connect to EndNote + Word. + ## Getting started 1. Clone or open this repo. diff --git a/docs/readme/README.vi.md b/docs/readme/README.vi.md index ab9481b..6a2c3e6 100644 --- a/docs/readme/README.vi.md +++ b/docs/readme/README.vi.md @@ -7,6 +7,21 @@ **MedKarute** (trước gọi `research-helper`) là chat-driven research assistant: agent (orchestrator) điều phối workflow nghiên cứu qua chat, ghi kết quả vào file Markdown trong `research/{slug}/`, và gọi hai MCP — **MarkItDown** (PDF mới) và **endnote-mcp** (thư viện EndNote đã curate). Governance (`AGENTS.md`, `CLAUDE.md`, `docs/`) mô tả cách agent hoạt động; dữ liệu nghiên cứu tách riêng per-project. +## MedKarute vs NotebookLM + +NotebookLM là app hosted, dùng ngay. MedKarute là **harness** — bạn tự lắp trên agent coding tool, kết quả nằm trong file (`research/{slug}/`), không phải app dùng liền. Hai loại khác nhau, không ngang hàng. + +| | **NotebookLM** | **MedKarute** | +|---|---|---| +| Setup | Thấp | Cao hơn (MCP, git per-project) | +| Lưu trữ | Cloud notebook | Markdown git-tracked | +| Hợp nhất khi | Hỏi nhanh, tóm tắt lướt | Nghiên cứu dài hạn, citation, deliverable | +| Token | Giá Google | Tiết kiệm **có điều kiện** — chỉ khi tuân load map (1 sub-INDEX/lần) | + +**Lưu ý:** dùng ẩu (dump cả `papers/`+`sessions/`+`insights/`) tốn *hơn* NotebookLM. MCP chỉ orchestrator gọi — subagent không đụng tool, nên hiệu quả token là kỷ luật, không phải tự động. + +**Chọn:** NotebookLM cho đọc nhanh không setup. MedKarute khi cần tích luỹ trong file, sống qua nhiều phiên, nối EndNote + Word. + ## Bắt đầu 1. Clone/mở repo này. @@ -44,19 +59,19 @@ Chi tiết artifact, INDEX, git per-project → [00-overview.md](../guides/resea | Công cụ | Vai trò trong MedKarute | |---------|-------------------------------| -| **[MarkItDown MCP](https://github.com/microsoft/markitdown/tree/main/packages/markitdown-mcp)** | Convert PDF mới → Markdown token-efficient cho agent đọc (paper chưa vào EndNote). `uvx markitdown-mcp` | -| **[endnote-mcp](https://github.com/gokmengokhan/endnote-mcp)** | Đọc thư viện EndNote đã curate — search, đọc PDF sâu, format citation/bibliography. Read-only (write qua EndNote desktop). `uvx endnote-mcp serve` | +| **[MarkItDown MCP](https://github.com/microsoft/markitdown/tree/main/packages/markitdown-mcp)** | Convert PDF mới → Markdown token-efficient cho agent đọc (paper chưa vào EndNote). `uvx markitdown-mcp` | +| **[endnote-mcp](https://github.com/gokmengokhan/endnote-mcp)** | Đọc thư viện EndNote đã curate — search, đọc PDF sâu, format citation/bibliography. Read-only (write qua EndNote desktop). `uvx endnote-mcp serve` | | **[WhySchools / context-mapping](https://github.com/WhySchools/context-mapping)** (context-gen) | **Cùng một nguồn** — workflow agentic *Human is the brain* của [WhySchools](https://github.com/WhySchools): bộ nhớ dự án trong `.context/` (GLOBAL, MILESTONES, TENSIONS, modules). Repo này áp dụng layout governance đó → [`.context/GLOBAL.md`](../../.context/GLOBAL.md). Brainstorm MCP sớm lưu tại [`research-helper.md`](../raws/research-helper.md). **Không phải dependency chạy** khi làm nghiên cứu hằng ngày | -| **Markpad** | App mở file `.md` local cho user xem note — không phải MCP, chỉ viewer | -| **[md-to-docx](https://github.com/pickle-an/md-to-docx-skill)** (agent skill) | Convert `writing/*.md` → Word `.docx` có format (version, TOC, front-matter). Bản vendored → [`.agents/skills/md-to-docx/`](../../.agents/skills/md-to-docx/) (Python). Tham chiếu cập nhật: [awesome-copilot `SKILL.md`](https://github.com/github/awesome-copilot/blob/main/skills/md-to-docx/SKILL.md). Không phải MCP | - -## Link nhanh +| **Markpad** | App mở file `.md` local cho user xem note — không phải MCP, chỉ viewer | +| **[md-to-docx](https://github.com/pickle-an/md-to-docx-skill)** (agent skill) | Convert `writing/*.md` → Word `.docx` có format (version, TOC, front-matter). Bản vendored → [`.agents/skills/md-to-docx/`](../../.agents/skills/md-to-docx/) (Python). Tham chiếu cập nhật: [awesome-copilot `SKILL.md`](https://github.com/github/awesome-copilot/blob/main/skills/md-to-docx/SKILL.md). Không phải MCP | + +## Link nhanh | File | Mục đích | |------|----------| | [AGENTS.md](../../AGENTS.md) | Invariants, startup order | | [CLAUDE.md](../../CLAUDE.md) | Orchestrator playbook | | [00-overview.md](../guides/research/00-overview.md) | Chi tiết workflow `research/` | -| [endnote-workflow.md](../decisions/endnote-workflow.md) | EndNote workflow (canonical) | -| [md-to-docx](../../.agents/skills/md-to-docx/) | Skill Markdown → Word (`SKILL.md`, link upstream) | +| [endnote-workflow.md](../decisions/endnote-workflow.md) | EndNote workflow (canonical) | +| [md-to-docx](../../.agents/skills/md-to-docx/) | Skill Markdown → Word (`SKILL.md`, link upstream) | | [.agents/README.md](../../.agents/README.md) | Layout agent skills, symlink cross-agent | \ No newline at end of file