diff --git a/.gitmodules b/.gitmodules index a5d451c8..4c4ea221 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,6 +7,3 @@ [submodule "mcp/che-pptx-mcp"] path = mcp/che-pptx-mcp url = git@github.com:PsychQuant/che-pptx-mcp.git -[submodule "cli/FastOCR"] - path = cli/FastOCR - url = https://github.com/PsychQuant/FastOCR.git diff --git a/CLAUDE.md b/CLAUDE.md index 46f758da..31f96442 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -347,7 +347,7 @@ swift build 主 repo 以兩種方式追蹤外部 repo: -- **Submodule**(`.gitmodules`):`mcp/` 下三個 MCP server + `cli/FastOCR`。Clone 主 repo 時加 `--recurse-submodules` 會自動拉齊,或事後 `git submodule update --init --recursive` +- **Submodule**(`.gitmodules`):`mcp/` 下三個 MCP server。Clone 主 repo 時加 `--recurse-submodules` 會自動拉齊,或事後 `git submodule update --init --recursive` - **Gitignore 忽略**(各自獨立管理):`packages/` 下的 Swift 套件、`reference/`。重建環境時在對應目錄 `git clone` 即可 | 目錄 | Git Remote | 說明 | @@ -367,9 +367,10 @@ swift build | `mcp/che-word-mcp` | https://github.com/PsychQuant/che-word-mcp.git | Word MCP(submodule) | | `mcp/che-pdf-mcp` | https://github.com/PsychQuant/che-pdf-mcp.git | PDF MCP(submodule) | | `mcp/che-pptx-mcp` | https://github.com/PsychQuant/che-pptx-mcp.git | PPTX MCP(submodule) | -| `cli/FastOCR` | https://github.com/PsychQuant/FastOCR.git | GLM-OCR PDF→Markdown CLI + 實驗 harness(submodule) | | `reference/*` | 見 [`reference/README.md`](reference/README.md) | 外部參考 repo(docx-js、pandoc、mlx-swift-lm、swift-argument-parser)— clone-on-demand,只有 README 進版控 | +> **MeasureOCR 已遷出(2026-07-18)**:原 `cli/FastOCR`(後改名 MeasureOCR)是研究儀器而非文件工具,已遷至 `~/Developer/bestOCR/repos/measureOCR`(GitHub repo 同步改名 `PsychQuant/measureOCR`,舊 URL 自動轉址)。OCR **能力**不受影響——macdoc 的 PDF 工具照常透過 published package `packages/ocr-swift`(`PsychQuant/ocr-swift`)取用 OCR;搬走的只是 benchmark 儀器。遷移全紀錄見 bestOCR repo 的 `docs/migration-2026-07-18.md`。 + ## Key Files ### macdoc diff --git a/cli/FastOCR b/cli/FastOCR deleted file mode 160000 index aa99d149..00000000 --- a/cli/FastOCR +++ /dev/null @@ -1 +0,0 @@ -Subproject commit aa99d149a597ba6ac2eab9e23a043ca7455f2f1e diff --git a/docs/genoffice-roundtrip-comparison.md b/docs/genoffice-roundtrip-comparison.md new file mode 100644 index 00000000..034bbee4 --- /dev/null +++ b/docs/genoffice-roundtrip-comparison.md @@ -0,0 +1,314 @@ +# genoffice DOCX 窄幅修補與 macdoc round-trip 對照 + +> 分析基準:`genspark-ai/genoffice` snapshot `4da673d4dfa994bd0b4a9bc43430e4a058a17c61` +>(2026-08-03)。本文件以 byte-level fidelity 為主;render fidelity 另節討論, +> 不把兩者混為同一種保證。 + +## 結論先行 + +genoffice 為研究 DOCX round-trip 所需的原始碼,**實質上完整公開**: +`packages/docx-engine/src/` 共 10,958 行 TypeScript,runtime 只依賴 +`jszip` 與 `fast-xml-parser`;`tests/` 有 50 個 `*.test.ts` suite,加上一個 +`helpers/build-docx.ts`,合計 51 個 TypeScript 測試檔。`ee/` 只有授權與 +說明檔,沒有藏住 DOCX engine 的另一份實作。 + +這不等於「整個 Genspark 產品都完整開源」。雲端 AI service、選配的瀏覽器 +driver 與內部完整開發歷史不在 snapshot 裡;但它們不參與本文件研究的 DOCX +格式保留路徑。因此,對「能不能研究它如何保留 Word 格式」的答案是**可以**, +對「整套產品是否一行不漏」的答案則是**不能如此宣稱**。 + +genoffice 的核心不是把整份 `document.xml` parse 成 tree 後再序列化,而是: + +1. 掃描原始 `document.xml`,記住 `` 每個直屬元素的字串範圍。 +2. 用 `docxIndex` 把 editor block 對回原始範圍。 +3. 儲存時,以 `SaveBlock` 決定逐塊複製原始 substring,或重生一個新 fragment。 +4. 只替換需要異動的 ZIP part;其他 entry 的**解壓內容**原樣搬運。 + +這個設計很有效,但保證必須說精確: + +- **零修改**:直接回傳輸入的同一份 `Uint8Array`,整個 DOCX 檔案逐位元組相等。 +- **有修改**:未動的 top-level body block 在 UTF-8 解碼/重編碼前提下保留 + 原始 XML substring;未動 ZIP entry 的解壓內容保持相等。 +- **不保證**:修改後的 ZIP 容器位元組、被重生 block 的詞彙形、所有 XML + part 的全域 byte equality,或 Word render equality。 + +這與 ooxml-swift 的 dual-track 不同。ooxml-swift 把 raw channel 當全 part-set +的 byte-equal floor;只有 typed operations 從受控基線重放後也能逐位元組重建 +該 part,才讓它通過 trial-rebuild gate 升級到 DSL channel。兩者都保守,但 +genoffice 的保守單位是「原始 substring/entry」,ooxml-swift 的保守單位是 +「raw part/通過證明的 typed part」。 + +## 分析範圍與證據 + +本次閱讀下列 pinned snapshot 檔案: + +| 檔案 | 本文件使用的證據 | +|---|---| +| `packages/docx-engine/src/scan.ts` | `` 直屬元素的原始字串範圍 | +| `packages/docx-engine/src/parse.ts` | `originalBytes`、`documentXml`、`bodyInnerStart/End`、`docxIndex` 與 Block tree | +| `packages/docx-engine/src/patch.ts` | `SaveBlock`、無修改快速路徑、substring splice、part 搬運與 JSZip 重組 | +| `packages/docx-engine/src/text-patch.ts` | comment/footnote/endnote 的 paragraph/`w:t` 窄幅文字修補與 self-check | +| `packages/docx-engine/src/generate.ts` | 被改 block 的 OOXML 重生、raw pPr/rPr 合併與 schema order | +| `packages/docx-engine/src/xml-utils.ts` | parse-tree fragment 是 semantic fidelity,不是 byte fidelity | +| `packages/docx-engine/tests/roundtrip.test.ts` | 無修改全檔相等、其他 entry 與未動 block 相等、重讀成功 | +| `packages/docx-engine/tests/text-patch.test.ts` | 未動 paragraph/run 格式保留、跨 run 修補與無安全錨點時拒絕 | +| `packages/docx-engine/tests/raw-rpr.test.ts` | 未建模 rPr token 保留與 modeled group 局部重建 | +| `packages/docx-engine/tests/schema-order.test.ts` | 重建後 CT_PPr/CT_RPr 子元素順序 | +| `packages/docx-engine/tests/mce-namespace.test.ts` | 新增 MCE 內容的 `Requires` prefix 可解析性 | + +所有 genoffice 路徑均相對於 `reference/genoffice/`。該目錄是只讀 shallow clone, +不進 macdoc 版控;要重現請先依 [reference index](../reference/README.md) clone, +並 checkout 上述 commit。python-docx 對照則固定在 v1.2.0 snapshot +`e45454602b53e8e572b179ccf1c91093ec9f4ed7`。 + +## 完整資料流 + +### 1. Parse:同時建立可編輯模型與原始範圍 + +`parseDocx(bytes)` 先讓 JSZip 讀 package,再把 `word/document.xml` 讀成字串。 +`scanBody(documentXml)` 不重建 XML tree;它以可容忍 quoted `>` 的 tag scanner, +找出 `` 每個直屬元素的 `[start, end)`。每個區段都記錄: + +- `docxIndex`:原始 body 直屬元素的序號; +- `originalXml`:該元素的原始字串 slice; +- `id: b`:editor session 內的 bookkeeping ID。 + +parser 仍會把 paragraph、table、image、field 等投影成 Block tree,供 UI 顯示與 +編輯;但這棵 tree **不是 untouched serialization 的來源**。真正的保真來源是 +`ParsedDoc.internal` 內的: + +- `originalBytes` +- `documentXml` +- `bodyInnerStart` +- `bodyInnerEnd` +- `extras.elements[]` 的原始範圍 + +這是整個策略最重要的分層:semantic model 可以不完整,只要未碰到的內容仍能 +以 `docxIndex` 回到原始 slice,就不需要懂它也能保留它。 + +SDT 是一個值得注意的特例。parser 可把 `w:sdtContent` 中的 paragraph/table +投影成可編輯 block,同時保存 shell 的 open/close XML;重生內層內容時再包回 +原 shell。這比單純丟掉未知 wrapper 更保守,但一旦進入重生路徑,內層仍受 +generator 詞彙能力限制。 + +### 2. Editor boundary:`SaveBlock` 明說每個 block 的來源 + +儲存輸入不是一棵要整體 serialize 的 tree,而是一列 `SaveBlock`: + +| kind | 行為 | +|---|---| +| `original` | 依 `docxIndex` 擷取原始 `document.xml` substring | +| `generated` | 將 editor 的 `GeneratedBlock` 重生為新 paragraph XML | +| `xml` | 插入 caller 提供的自含 OOXML fragment;可帶原 block index | +| `image` | 產生 drawing、media entry 與 relationship | +| `chart` | 產生 chart part、workbook 與 relationship | + +刪除就是不把原 block 放進 `finalBlocks`;搬動就是調整 `original` block 的順序; +插入則加入 `generated`/`xml`/media block。`docxIndex` 是對原始 snapshot 的位置 +錨點,不是跨儲存、跨 Word session 的 stable element identity。 + +### 3. Save:原始 slice 與重生 fragment 拼接 + +`saveDocx` 先做完整的 no-change 判斷。block 順序、revision 與所有 `SaveOptions` +都沒有異動時,它直接 `return originalBytes`。測試用 reference identity +`expect(saved).toBe(bytes)` 鎖住這條路徑,因此不是「解壓後內容一樣」,而是 +連同 ZIP metadata、壓縮結果在內的同一份輸入 bytes。 + +有任何修改時,資料流如下: + +1. 重新從 `originalBytes` 開啟 JSZip。 +2. `original` block 執行 `documentXml.slice(start, end)`。 +3. `generated` block 走 `generateParagraphXml`;`xml` 直接插入;media 類型配置 + 新 relationship 與 part。 +4. trailing hidden `w:sectPr` 預設仍取原始 slice;只有 page/section options + 明確要求時才做局部字串修補。 +5. 組成:`原 document.xml body 前綴 + blocks.join('') + 原 body 後綴`。 +6. 依 options 窄幅修改 rels、content types、settings、styles、comments、notes、 + header/footer、theme 等指定 parts。 +7. 對其他 entry 讀出 `uint8array` 後放入新 JSZip,保留 entry date,最後以 + DEFLATE level 6 產生新容器。 + +因此,修改後「其他 zip entry 逐 byte 複製」指的是 **entry payload 解壓後的 +bytes**,不是原壓縮 stream、central-directory 順序或整個 `.docx` 檔案 bytes。 +另外 `docProps/core.xml` 若含 `dcterms:modified`/`cp:revision`,一般儲存會更新 +時間與 revision;round-trip test 也明確把它列為例外。 + +### 4. `text-patch.ts`:比 block 更窄的文字修補 + +`patchParagraphTexts` 主要服務 comment、footnote、endnote 等 rich-text entry: + +1. depth-aware 找出每個 paragraph slice; +2. 將各 paragraph 的 `w:t` 串成 plain text; +3. paragraph 沒變就直接沿用原 slice; +4. paragraph 有變就算 common prefix/suffix,只替換碰到變更區間的 `w:t`; +5. 其他 run、hyperlink、image、field 與格式 shell 保留; +6. 重新抽出文字做 postcondition;不等於目標文字就回傳 `null`。 + +paragraph 數量改變或找不到安全的 `w:t` 錨點時,它不猜測,直接回傳 `null`, +由 caller 決定是否整段重建。這個「窄幅嘗試 + 可驗證 postcondition + 明確 +放棄」的模式,比其 regex 實作細節更值得借鏡。 + +## Byte 保證的精確邊界 + +| 情境 | 實際保證 | 不保證 | +|---|---|---| +| 無任何修改 | 回傳原 `Uint8Array`;整個 DOCX byte-identical | 無 | +| 有修改、未動 ZIP entry | 解壓後 entry payload 相等,entry date 被帶入新容器 | 原 compressed bytes、ZIP entry order、central directory、整檔 bytes | +| `original` body block | 原 `document.xml` substring;UTF-8 round-trip 下詞彙形保留 | 非 UTF-8 XML 宣告、編碼層完全不變 | +| `generated` body block | generator 支援的語意、schema order 與保留的 raw fragments | 原 `` attrs、所有原 run/text 詞彙形、block byte equality | +| `text-patch` touched paragraph | 未動 paragraph/run shell 保留,文字 postcondition 通過 | 被碰到的 `w:t` opening tag 與 entity/`xml:space` 詞彙形 | +| 指定 part option | 只改該 helper 處理的區域,部分 helper 會保留其他 substring | helper 未建模卻落在重建範圍內的內容必然保留 | + +`scan.ts` 的註解很準確:parse→serialize 會改變 attribute order、self-closing +form 與 entity form,所以未動內容不走 serializer。但相反命題不成立:**被改 +block 不會因為鄰居保留原 bytes,就自動獲得 byte equality**。 + +### 測試實際證明了什麼 + +`roundtrip.test.ts` 的三個主要 gate 是: + +- no edit 直接比較同一份 `Uint8Array`; +- 編輯一個 paragraph 後,除 `document.xml` 外的 entry payload 逐一比較,且 + `document.xml` 內每個未動 block 都仍包含原 `originalXml`; +- 輸出再交給 `parseDocx`,確認仍能解析。 + +它沒有宣稱修改後整個 ZIP byte-equal,也沒有用 Microsoft Word 當 parser 或 +renderer。`text-patch.test.ts` 主要以原 substring/token 仍存在、格式 shell +仍存在、輸出文字正確來驗收;`raw-rpr.test.ts` 與 `schema-order.test.ts` 驗證 +未建模 token 與 CT_RPr/CT_PPr 順序。這些都是有用的結構證據,但不是 render +oracle。 + +## Word-canonical 邊界:躲掉還是踩到 + +### Root namespace 雲:多數靠「不碰 root」躲掉 + +`document.xml` 的 `` 前後部分直接取自原字串,所以 root namespace +declarations、`mc:Ignorable`、XML declaration 與 body 外 whitespace 通常不經 +serializer。這是 pass-through,不是 typed model 已理解每一個 namespace。 + +生成 OMML 而原 root 缺 `xmlns:m` 時,`patch.ts` 會以字串方式補宣告;新增 +DrawingML/VML MCE 時,builder 在 `mc:AlternateContent` 局部宣告所需 prefix, +`mce-namespace.test.ts` 只驗 `Requires` 所指 prefix 在該 scope 可解析。 + +判斷:**既有 namespace 雲主要是躲掉重建;新增的已知 namespace 才有專門 +builder。** + +### `rsid`、`w14:paraId`、`w14:textId`:untouched 保留,generated paragraph 會掉 + +原 block 整段搬運時,paragraph opening tag 上的 rsid 家族與 w14 IDs 自然保留。 +但 `generateParagraphXml` 的 opening tag 固定從 `` 開始,Block model 沒有 +一般化保存這些 attrs;因此重生 paragraph 會捨棄它們。comment path 對 +`w14:paraId` 有局部特例,不等於一般 body paragraph 已完整建模。 + +判斷:**未動時躲掉;一般 touched block 仍踩到。** 這與 #131 要求 serializer +能逐字拼回 rsid/root attrs 才通過 typed gate,是兩種不同契約。 + +### `xml:space`:語意保守,但詞彙形會 canonicalize + +原 block 的 `xml:space` 原樣保留。新 run 或被 `text-patch` 碰到的 `w:t` 一律 +輸出 `xml:space="preserve"`,即使文字不一定需要它。這通常比漏掉前後空白安全, +但不是原詞彙形 byte-equal;原本沒有 attribute 或使用不同 entity form 時會改變。 + +判斷:**語意上主動保守,byte-level 上仍是重生。** + +### `pPr`:文字修改可保存原 slice;格式修改採 group replacement + +parser 用 depth-aware scanner 擷取 paragraph 自己的完整 `` 原字串, +`GeneratedBlock.rawPPr` 可直接放回。純文字修改因此能保留 paragraph property +詞彙形。若 caller 修改段落格式,`mergePPrFormat` 只重建它管理的 property +groups,其餘 child 沿用原 XML,並依 CT_PPr order 合併。 + +判斷:**比整段重生更細,但保證取決於「變更欄位分組」是否完整。** + +### `rPr`:保留長尾,但不是原始 byte slice + +run property 的 `rawRPr` 來自 `fast-xml-parser` tree 再經 `serializeXNode` 輸出; +該 helper 的註解明說是 semantic fidelity,不是 byte fidelity。它會維持 parse +order,但會正規化 entity、empty element form 等。`mergeRPrModel` 逐 group +比較 modeled value:相等的 group 沿用這份 reserialized raw XML,變更的 group +重建,未建模 child 保留。 + +判斷:**它解的是「不丟長尾格式」,不是「touched run 的 rPr 原 bytes」。** + +## 三方對照 + +| 面向 | genoffice | ooxml-swift / macdoc | python-docx | +|---|---|---|---| +| 核心表示 | Block tree + 原字串 range | XmlNode tree + stable ElementID + operation log | lxml tree + typed wrapper | +| 未動保真 floor | top-level substring + entry payload | raw channel/preserved archive,全 part-set Stage B | 沒有 byte floor;XML part 由 lxml serialize | +| touched 內容 | 窄幅 patch 或 fragment 重生 | typed op 套到 tree;只有 trial-rebuild byte-equal 才宣稱 DSL 拼寫能力 | 直接 mutate lxml element 後 serialize | +| 全檔 byte-equal | 只有 no-op 快速路徑 | 比較 XML part set;規格明確排除 ZIP container equality | 無 | +| typed 誠實 gate | 無統一 gate;依 helper 測試與保守 fallback | per-part trial-rebuild byte-equal;失敗留 raw 並記 form-gap | 無;能 serialize 不代表保留原詞彙形 | +| 身分穩定性 | `docxIndex` 只對當次原 snapshot | paraId/bookmark/library UUID 等 stable ID | wrapper 指向當次 lxml node,無持久 stable ID | +| 歷史/重放 | 無 op log | operation log 可持久化、重放、undo/redo、Word import | 無 op log | +| 未知 OOXML | 原 block/entry 未動即可 pass-through | tree/raw channel pass-through;typed 升級需 gate | 常可留在 lxml tree,但所有 XML part 仍重新序列化;typed setter 也可能重建 subtree | +| 測試主張 | substring/entry payload、token、schema order、reparse | Stage A/B byte equality、coverage、form-gap、真實 Word gated probe | 主要驗 typed API 與 XML 語意,不提供原檔 byte contract | +| 維護成本 | 每種 edit helper 都要定義安全邊界;regex/string scan 邊界多 | op taxonomy、reducer、serializer、stable ID、sync 與 gate 成本高 | API 最直接,但無歷史與 byte 證明 | + +python-docx 的差異可從其 save path 直接看出:`PackageWriter` 重新產生 +`[Content_Types].xml` 與 relationships,XML part 的 `blob` 由 +`serialize_part_xml()` 呼叫 `lxml.etree.tostring()`;它沒有原 substring channel。 +這不代表每次都會遺失未知元素,而是**沒有承諾保留原始 XML bytes**。 + +## 哪些做法值得 macdoc 借鏡 + +### 值得採用的原則 + +1. **把 source span provenance 當 first-class metadata**:tree node 除了 stable + identity,也可保留原始 byte range;窄幅 writer 能明確證明哪些 bytes 沒碰。 +2. **preservation unit 要可列舉**:genoffice 的 `SaveBlock.kind` 一眼看出原始、 + 重生或 media;macdoc 的 certificate/coverage 也應逐 part/node 列出來源。 +3. **先窄幅修補,再驗 postcondition**:`patchParagraphTexts` 重新抽文字核對, + 不成立就回傳 `null`。macdoc 的 specialized patch 可採同樣 fail-loud 介面, + 再交由較高層 policy 決定 raw carry、typed rebuild 或拒絕。 +4. **no-op 快速路徑直接回原 bytes**:這是最強、最便宜、也最不含糊的 + round-trip 證明。 +5. **測試分開鎖 preservation 與 validity**:既比對未動 entry/block,也重新 + parse;macdoc 再加 trial rebuild 與 Word oracle,形成更完整的證據梯。 + +### 不宜直接移植的部分 + +1. **`docxIndex` 不能取代 stable ID**:位置只對單次 snapshot 成立,無法支撐 + Word 雙向同步、跨 session op log 或 non-conflicting merge。 +2. **regex/string scan 不宜成為通用 OOXML tree**:它很適合範圍清楚的 splice, + 但 namespace lexical scope、MCE、nested revision/table/SDT 的組合會讓每個 + helper 都背負新的 parser 邊界。 +3. **「保留鄰居」不能取代 touched-region gate**:被改 block 若重生,仍需 + 像 ooxml-swift 一樣對可宣稱的層級做 trial-rebuild/structural diff;否則 + 只能說鄰居沒變,不能說目標格式完整。 +4. **JS 字串 offset 不適合直接搬到 Swift Data**:TypeScript 的 range 是 + UTF-16 string index;Swift 若要強化 byte 證明,應以原始 `Data` 的 byte range + 或 parser 提供的 source offsets 為準,避免非 ASCII 與編碼轉換造成誤解。 +5. **JSZip 重組不是 container preservation**:macdoc 現行 Stage B 明確比較 + part set 而不比較 ZIP 容器,這個契約比含糊說「zip 逐 byte 複製」更準確。 + +## Render 層:三者都不能只靠 byte 說自己「看懂」 + +genoffice docx-engine 的測試會重讀輸出、檢查 schema order、relationship、token +與原 substring;本次閱讀未發現以真實 Microsoft Word 渲染後做幾何或像素差異 +的 gate。因此它能證明特定結構沒有被意外改寫,不能由此推出「被改內容在 Word +中的排版效果與預測完全一致」。 + +python-docx 同樣沒有 render oracle。ooxml-swift/macdoc 則把這個問題明確放在 +第三層:byte-equal 與 typed trial-rebuild 之外,使用 gated Microsoft Word +render probes、PDF 幾何量測與 effect registry。這個成本較高,但主張也不同: +前兩層說「保留/會拼」,第三層才說「知道設定改變後人會看到什麼」。 + +所以三方比較的正確結論不是「哪一家保證 Word 看起來一樣」,而是: + +- genoffice 以 substring locality 將**未動範圍**的風險壓得很低; +- python-docx 提供便利的 tree mutation,但不建立 byte 證明; +- macdoc 額外付出 op log、trial-rebuild 與 render oracle 成本,換取可重放、 + 可量測且能限制主張範圍的證據鏈。 + +## 最終判斷 + +genoffice 證明了一件重要的事:對 AI 編輯器而言,不必先完整理解 Word 所有 +詞彙,仍可藉由「原始區段是預設,重生是例外」大幅降低格式破壞面。這與 +macdoc 的 raw-channel-first 原則方向一致,而且 `text-patch` 的 postcondition +做法值得直接吸收成設計原則。 + +但它沒有取代 op log 路線。substring splice 不提供跨 session stable identity、 +歷史、重放、Word import merge 或 typed 理解度量;touched block 也沒有統一的 +byte-equal gate。macdoc 應借鏡它的**局部性與原始範圍 provenance**,不應把 +positional anchors 或 regex serializer 當成 op log/XmlNode tree 的替代品。 diff --git a/reference/README.md b/reference/README.md index bd6cc51e..17e4b801 100644 --- a/reference/README.md +++ b/reference/README.md @@ -11,6 +11,7 @@ git clone https://github.com/python-openxml/python-docx.git git clone https://github.com/ml-explore/mlx-swift-lm.git git clone https://github.com/jgm/pandoc.git git clone https://github.com/apple/swift-argument-parser.git +git clone --depth 1 https://github.com/genspark-ai/genoffice.git # 43M,只讀 source 不需要 history # textutil-manpage.txt 已在版控中 ``` @@ -22,6 +23,7 @@ git clone https://github.com/apple/swift-argument-parser.git | `python-docx/` | git repo | https://github.com/python-openxml/python-docx | Python + lxml 的 OOXML 函式庫。**典範:tree-backed wrapper**——每個 `Document` / `Paragraph` / `Run` 包一個 lxml `_Element`,typed accessor 讀寫該 element。`word-aligned-state-sync` 的 Phase 1 (typed views as tree projections) 直接對照它。詳見 `docs/docx-libraries-comparison.md` | | `mlx-swift-lm/` | git repo | https://github.com/ml-explore/mlx-swift-lm | Apple MLX Swift LLM runtime。`pdf-to-latex-swift` Phase 1 的 local GLM-OCR backend 用它載模型 | | `pandoc/` | git repo | https://github.com/jgm/pandoc | Haskell 文件轉換工具。macdoc 不依賴 pandoc,純粹參考它怎麼處理邊界情況(複雜 table、field、footnote 跨段落) | +| `genoffice/` | git repo (shallow) | https://github.com/genspark-ai/genoffice | Genspark 的 AI-native office suite(Electron GUI)。**不是競品**——它沒有 MCP / CLI / public API,AI 綁自家雲端帳號;但 `packages/` 下的 engine 是純 TS、無 Electron 依賴,是**唯一同時涵蓋 docx + xlsx + pptx + pdf 的現代開源對照組**。看三件事:xlsx 缺口、patch-narrowly round-trip、pptx 功能廣度。Apache-2.0(`ee/` 另授權) | | `swift-argument-parser/` | git repo | https://github.com/apple/swift-argument-parser | Apple 官方 CLI 解析器。`macdoc` CLI 已是使用者,這裡留一份方便查 source-level 行為(尤其是 subcommand dispatch、ExitCode) | | `textutil-manpage.txt` | 單檔 | [textutil(1) macOS man page](https://ss64.com/mac/textutil.html) | macOS 內建 `textutil` 的 manual。`macdoc convert` 的 CLI 語法對齊 textutil(見 `.claude/rules/cli-design/textutil-compat.md`),改 CLI 前對一下 | @@ -67,6 +69,46 @@ git clone https://github.com/apple/swift-argument-parser.git - `pandoc/src/Text/Pandoc/Readers/HTML.hs` — HTML 讀取邏輯 - `pandoc/src/Text/Pandoc/Writers/Markdown.hs` — Markdown 輸出邏輯 +### genoffice → xlsx 缺口 / patch-narrowly round-trip / pptx 功能廣度 + +先講清楚定位,免得誤判:genoffice 是**桌面 GUI 套裝軟體**(Electron,五個 app 共用 engine 層),macdoc 是 CLI + MCP 的管線工具,兩者不是同一個品類,也不互相取代。它沒有 MCP server、沒有 public API、沒有自動化 CLI,AI 走 Genspark 帳號的雲端(本機不存 API key)。 + +**真正有參考價值的是 `packages/` 那層**——官方描述為「All pure TypeScript, no Electron dependency」,可以完全脫離 GUI 單獨閱讀。這是目前少見的、同時涵蓋 docx + xlsx + pptx + pdf 四種格式的現代開源實作。 + +三個具體對照點: + +**1. xlsx——macdoc 唯一缺的 OOXML 主格式** + +macdoc 有 word / pptx / keynote / pdf,沒有 Excel。genoffice 的做法是 UI 用 Univer core(Apache-2.0),import/export 走 Rust sidecar(calamine + IronCalc)。 +- `genoffice/packages/file-parse/src/xlsx.ts` — 解析入口,先看它把 xlsx 拆成什麼中介結構 +- `genoffice/apps/sheets/` — 上層怎麼消費 + +注意這是「要不要做 xlsx」的判斷材料,不是「該做」的理由。先問自己的工作流有沒有 Excel 需求,不要為了對齊功能表而追賽道。 + +**2. patch-narrowly / byte-preserving round-trip——跟 `ooxml-swift` op log 同目標、不同解法** + +genoffice 的 docx 存檔只重新產生被改動的段落,未觸碰的 block 保留原始 bytes,其餘 zip entry 逐 byte 複製。目的跟 `word-aligned-state-sync` 的 op log 一樣(存檔不破壞 Word 版面),但走的是「差異化重生」而非「op 重放」。 +- `genoffice/packages/docx-engine/src/patch.ts` — 核心 patch 邏輯 +- `genoffice/packages/docx-engine/src/text-patch.ts` — 文字層級的窄幅修改 +- `genoffice/packages/docx-engine/src/parse.ts` / `scan.ts` — 解析與掃描(patch 的前置) +- `genoffice/packages/docx-engine/src/generate.ts` — 產生端 +- `genoffice/packages/pptx-engine/src/zip.ts` — zip entry 保留策略 + +對照重點:python-docx 是「直接 mutate tree、沒有 op log」,genoffice 是「保留原 bytes + 窄幅重生」,macdoc 是「op log 重放」。三種解法擺在一起看,才知道 op log 的成本換到了什麼。 + +完整研究筆記(含 byte 保證邊界、Word-canonical 詞彙與三方比較): +[`docs/genoffice-roundtrip-comparison.md`](../docs/genoffice-roundtrip-comparison.md)。 + +**3. pptx 功能廣度——`pptx-swift` 的擴充 checklist** + +`genoffice/packages/pptx-engine/src/` 有 macdoc 目前沒有的項目,可當功能對照表:`smartart.ts` / `smartart-layout.ts`、`custgeom.ts`(自訂幾何)、`animation.ts`、`theme-apply.ts`、`format-brush.ts`、`slide-transfer.ts`、`table-style.ts`。 + +**授權注意**:Apache-2.0,但 `ee/` 目錄保留給未來的 enterprise 模組(另一套 GenOffice Enterprise License)。GenOffice / Genspark 名稱與 logo 是 Mainfunc, Inc. 商標。當**設計參考**讀沒問題;若要逐行移植實作到 Swift,先確認 Apache-2.0 的 NOTICE 保留義務與 macdoc 自身授權相容。 + +**成熟度警語**:clone 當下(2026-08-04)發布版是 v0.4.110,pre-1.0,354 stars / 3 watchers。`main` 的 commit history 極短且訊息形如 `Sync snapshot (2026-08-03) (#6)`——是**內部開發、定期推 snapshot 出來**的模式,不是長期公開開發史,所以看不到 PR 討論、design rationale、bug 修復的來龍去脈,只能讀最終碼。程式碼本身看起來紮實(有 CI、Vitest、ESLint、SECURITY.md),但**不要當作經過長期生產驗證的參考**——跟 pandoc、python-docx 那種十年老專案不是同一個信賴等級。 + +上游仍在活躍同步(上次 snapshot 距 clone 僅一天),所以這份 clone 會很快過時。要更新直接 `cd reference/genoffice && git pull`;因為是 `--depth 1` 淺 clone,必要時 `git fetch --unshallow` 才拿得到完整歷史(但如上所述,歷史本身資訊量不高)。 + ### swift-argument-parser → `macdoc` CLI `macdoc` 的 `Sources/MacDocCLI/` 全部基於 `ArgumentParser`。遇到奇怪行為(subcommand completion、async command、ValidationError 訊息控制)時查: