Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .claude/rules/native-macos-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 原生 macOS 能力優先原則

## 原則

macdoc 必須優先使用 macOS 原生 framework 處理文件,不引入外部 CLI 工具或跨平台函式庫來做系統已經能做的事。
每個 converter 的基礎層應對映到一個原生能力,macdoc 在其上加結構化 heuristics。

## 原生能力對映表

| 功能 | 原生方法 | macdoc 用法 | 加值 |
|------|---------|------------|------|
| PDF 文字提取 | `PDFPage.string` / `PDFSelection.selectionsByLine()` | `pdf-to-md-swift` | heading/list/block heuristics |
| PDF metadata | `PDFDocument.documentAttributes` | `pdf detect-source` | 來源格式推斷 |
| PDF 頁面渲染 | `CGContext` + `PDFPage.draw()` | `pdf-to-latex-swift` Phase 1 | block segmentation |
| OCR | `Vision.framework` (`VNRecognizeTextRequest`) | `che-pdf-mcp`, `surya-swift` | 多語言、表格、數學式 |
| Word 解析 | `ZIPFoundation` + XML parsing | `ooxml-swift` | 語義標註、樣式繼承 |
| 圖片處理 | `CoreGraphics`, `AppKit` (`NSImage`) | `marker-swift` | 分類、格式轉換 |
| HTML 解析 | 第三方 `SwiftSoup`(例外) | `html-to-md-swift` | — |
| Markdown 解析 | `apple/swift-markdown` | `md-to-html-swift`, `md-to-word-swift` | — |

## 設計規則

1. **原生 framework 優先** — 如果 macOS 內建 framework 能做,不引入外部依賴
2. **PDFKit 是 PDF 的基礎層** — 所有 PDF 文字提取必須從 `PDFKit` 開始,不用 poppler/pdftotext
3. **Vision 是 OCR 的唯一後端** — 不引入 Tesseract 或其他 OCR engine
4. **CoreGraphics 處理圖片** — 不引入 ImageMagick 或 libvips
5. **允許的例外** — `SwiftSoup`(HTML parsing,Apple 沒有原生 HTML parser)、`ZIPFoundation`(ZIP 操作)、`swift-markdown`(Apple 官方但非系統內建)
6. **外部 AI CLI 是委派,不是依賴** — `codex`/`claude`/`gemini` 是 transcription 的外部工具,macdoc 不直接呼叫 LLM API

## textutil 能力對照

`textutil` 是 macOS 內建的文件轉換 CLI,macdoc 的轉換指令語法與其相容(見 `cli-textutil-compat.md`)。

| textutil 支援 | macdoc 對應 | 差異 |
|--------------|------------|------|
| `.docx` → `.html` | `convert --to html file.docx` | macdoc 多了 APA styling、SRT 等格式 |
| `.docx` → `.txt` | `convert --to md file.docx` | macdoc 輸出結構化 Markdown |
| `.html` → `.docx` | `convert --to docx file.html` | macdoc 透過 OOXML 直接生成 |
| `.pdf` → (不支援) | `convert --to md file.pdf` | macdoc 用 PDFKit 提取 |

## 新增 converter 的檢查清單

新增格式轉換器時,確認:

- [ ] 基礎提取用原生 framework(PDFKit / Vision / CoreGraphics)
- [ ] 不引入可用原生替代的外部依賴
- [ ] 如果需要外部依賴,記錄在上方「允許的例外」
- [ ] CLI 語法遵循 `cli-textutil-compat.md`
- [ ] 在 `CONVERSIONS.md` 更新轉換矩陣
17 changes: 14 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ che-pdf-mcp
# 建構主專案(在 repo 根目錄)
swift build

# 執行 CLI — 統一轉換入口(textutil-compatible)
swift run macdoc convert --to md file.docx
swift run macdoc convert --to html file.md [--full]
swift run macdoc convert --to html file.srt
swift run macdoc convert --to html file.bib [--full] [--css minimal|web]
swift run macdoc convert --to md file.bib
swift run macdoc convert --to json file.bib
swift run macdoc convert --to md file.html

# 執行 CLI — Word
swift run macdoc word input.docx -o output.md

Expand Down Expand Up @@ -172,11 +181,12 @@ swift package clean && swift build

#### macdoc (CLI)
- **用途**:CLI 工具,整合各套件功能
- **Convert**:統一轉換入口(`macdoc convert --to <format> <file>`),textutil-compatible 語法
- **Word**:標準模式(`.md`)、Marker 模式(`.md` + `_meta.json` + `images/`)
- **PDF**:Phase 1(init → segment → render → blocks → transcribe → chapters → assemble)+ Phase 2(normalize → fix-envs → compile-check → consolidate)
- **Bib**:BibLaTeX → APA 7 HTML/Markdown(to-html, to-md, list)
- **Config**:AI 後端設定管理
- **依賴**:word-to-md-swift + marker-swift + pdf-to-latex-swift + bib-apa-to-html-swift + bib-apa-to-md-swift + ArgumentParser
- **依賴**:word-to-md-swift + marker-swift + pdf-to-latex-swift + html-to-md-swift + md-to-html-swift + srt-to-html-swift + bib-apa-to-html-swift + bib-apa-to-json-swift + bib-apa-to-md-swift + ArgumentParser

#### che-word-mcp(145 工具)
- **用途**:Word 文件處理 MCP,讓 Claude 能讀取和分析 Word 文件
Expand Down Expand Up @@ -267,7 +277,7 @@ swift build
| 目錄 | Git Remote | 說明 |
|------|-----------|------|
| `.` (root) | https://github.com/PsychQuant/macdoc.git | 主專案 CLI |
| `packages/common-converter-swift` | https://github.com/PsychQuant/common-converter-swift.git | 轉換器協議 |
| `packages/common-converter-swift` | https://github.com/PsychQuant/doc-converter-swift.git | 轉換器協議(remote 名 doc-converter-swift) |
| `packages/word-to-md-swift` | https://github.com/PsychQuant/word-to-md-swift.git | Word → MD 轉換 |
| `packages/ooxml-swift` | https://github.com/PsychQuant/ooxml-swift.git | OOXML 解析 |
| `packages/markdown-swift` | https://github.com/PsychQuant/markdown-swift.git | Markdown 生成 |
Expand All @@ -281,7 +291,8 @@ swift build
## Key Files

### macdoc
- `Sources/MacDocCLI/MacDoc.swift` - CLI 入口點(Word + PDF + Bib + Config 子命令群)
- `Sources/MacDocCLI/MacDoc.swift` - CLI 入口點(Convert + Word + PDF + Bib + Config 子命令群)
- `Sources/MacDocCLI/MacDoc+Convert.swift` - Convert 統一轉換入口(textutil-compatible)
- `Sources/MacDocCLI/MacDoc+PDF.swift` - PDF 子命令(Phase 1 pipeline + Phase 2 consolidation)
- `Sources/MacDocCLI/MacDoc+Bib.swift` - Bib 子命令(.bib → APA 7 HTML/Markdown)
- `Sources/MacDocCLI/MacDoc+Config.swift` - Config 子命令(AI 設定管理)
Expand Down
225 changes: 225 additions & 0 deletions Sources/MacDocCLI/MacDoc+Convert.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import ArgumentParser
import Foundation
import CommonConverterSwift
import WordToMDSwift
import HTMLToMD
import MDToHTML
import SRTToHTML
import BibAPAToHTML
import BibAPAToJSON
import BibAPAToMD

// MARK: - Convert 子命令(textutil-compatible 統一入口)
extension MacDoc {
struct Convert: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "convert",
abstract: "Convert documents between formats (textutil-compatible)"
)

@Option(name: .long, help: "Target format (md, html, json)")
var to: String

@Option(name: .long, help: "Output file path")
var output: String?

@Flag(name: .long, help: "Force output to stdout")
var stdout: Bool = false

@Option(name: .long, help: "CSS style for bib→html: minimal (academic) or web (modern)")
var css: CSSStyle = .web

@Flag(name: .long, help: "Treat soft breaks as hard line breaks")
var hardBreaks: Bool = false

@Flag(name: .long, help: "Output full HTML document instead of fragment")
var full: Bool = false

@Argument(help: "Input file")
var input: String

mutating func run() throws {
let inputURL = URL(fileURLWithPath: input)
guard FileManager.default.fileExists(atPath: inputURL.path) else {
throw ValidationError("File not found: \(input)")
}

let ext = inputURL.pathExtension.lowercased()
let target = to.lowercased()

switch (ext, target) {
case ("docx", "md"):
try convertWordToMD(inputURL: inputURL)

case ("html", "md"), ("htm", "md"):
try convertHTMLToMD(inputURL: inputURL)

case ("md", "html"), ("markdown", "html"):
try convertMDToHTML(inputURL: inputURL)

case ("srt", "html"):
try convertSRTToHTML(inputURL: inputURL)

case ("bib", "html"):
try convertBibToHTML(inputURL: inputURL)

case ("bib", "md"):
try convertBibToMD(inputURL: inputURL)

case ("bib", "json"):
try convertBibToJSON(inputURL: inputURL)

default:
throw ValidationError(
"Conversion from .\(ext) to \(target) is not supported."
)
}
}

// MARK: - Word → Markdown

private func convertWordToMD(inputURL: URL) throws {
let options = ConversionOptions(
includeFrontmatter: false,
hardLineBreaks: hardBreaks,
tableStyle: .pipe,
headingStyle: .atx
)

let converter = WordConverter()
if let outputPath = resolveOutputPath() {
let outputURL = URL(fileURLWithPath: outputPath)
try converter.convertToFile(input: inputURL, output: outputURL, options: options)
} else {
try converter.convertToStdout(input: inputURL, options: options)
}
}

// MARK: - HTML → Markdown

private func convertHTMLToMD(inputURL: URL) throws {
let options = ConversionOptions(
includeFrontmatter: false,
hardLineBreaks: hardBreaks,
tableStyle: .pipe,
headingStyle: .atx
)

let converter = HTMLConverter()
if let outputPath = resolveOutputPath() {
let outputURL = URL(fileURLWithPath: outputPath)
try converter.convertToFile(input: inputURL, output: outputURL, options: options)
} else {
try converter.convertToStdout(input: inputURL, options: options)
}
}

// MARK: - Markdown → HTML

private func convertMDToHTML(inputURL: URL) throws {
let htmlOptions = HTMLOptions(fullDocument: full)
let converter = MarkdownConverter()
let result = try converter.convert(input: inputURL, options: htmlOptions)

if let outputPath = resolveOutputPath() {
let outputURL = URL(fileURLWithPath: outputPath)
try result.write(to: outputURL, atomically: true, encoding: .utf8)
FileHandle.standardError.write(
Data("Written to: \(outputURL.path)\n".utf8)
)
} else {
print(result)
}
}

// MARK: - SRT → HTML

private func convertSRTToHTML(inputURL: URL) throws {
let options = ConversionOptions.default
let converter = SRTConverter()

if let outputPath = resolveOutputPath() {
let outputURL = URL(fileURLWithPath: outputPath)
try converter.convertToFile(input: inputURL, output: outputURL, options: options)
} else {
try converter.convertToStdout(input: inputURL, options: options)
}
}

// MARK: - Bib → HTML

private func convertBibToHTML(inputURL: URL) throws {
let entries = try loadBibEntries(from: inputURL)
let cssString = css == .minimal ? APACSS.minimal : APACSS.web

let html: String
if full {
let body = BibToAPAHTMLFormatter.formatReferenceList(entries)
html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>APA 7 References</title>
<style>
\(cssString)
</style>
</head>
<body>
<div class="apa-reference-list">
\(body)
</div>
</body>
</html>
"""
} else {
html = BibToAPAHTMLFormatter.formatReferenceListWithCSS(entries, css: cssString)
}

try writeBibOutput(html)
}

// MARK: - Bib → Markdown

private func convertBibToMD(inputURL: URL) throws {
let entries = try loadBibEntries(from: inputURL)
let md = BibToAPAFormatter.formatReferenceList(entries)
try writeBibOutput(md)
}

// MARK: - Bib → JSON

private func convertBibToJSON(inputURL: URL) throws {
let entries = try loadBibEntries(from: inputURL)
let json = try BibToAPAJSONFormatter.formatJSON(entries, prettyPrint: true)
try writeBibOutput(json)
}

// MARK: - Helpers

/// Resolve the output path: --stdout forces nil (stdout), overriding --output.
/// If neither is specified, defaults to stdout.
private func resolveOutputPath() -> String? {
if stdout { return nil }
return output
}

private func loadBibEntries(from inputURL: URL) throws -> [BibEntry] {
let bibFile = try BibParser.parse(filePath: inputURL.path)
return bibFile.entries
}

private func writeBibOutput(_ content: String) throws {
if let outputPath = resolveOutputPath() {
let outputURL = URL(fileURLWithPath: outputPath)
try content.write(to: outputURL, atomically: true, encoding: .utf8)
FileHandle.standardError.write(
Data("Written to: \(outputURL.path)\n".utf8)
)
} else {
print(content)
}
}
}
}
2 changes: 1 addition & 1 deletion Sources/MacDocCLI/MacDoc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ struct MacDoc: AsyncParsableCommand {
commandName: "macdoc",
abstract: "原生 macOS 文件處理工具",
version: "0.4.0",
subcommands: [Word.self, HTML.self, SRT.self, PDF.self, Bib.self, Config.self]
subcommands: [Convert.self, Word.self, HTML.self, SRT.self, PDF.self, Bib.self, Config.self]
)
}

Expand Down
1 change: 0 additions & 1 deletion references/swift-argument-parser
Submodule swift-argument-parser deleted from 1e7742
Loading