DOCX 建立、編輯與分析
概覽
.docx 檔案是包含 XML 檔案的 ZIP 壓縮檔。
快速參考
| 任務 | 處理方式 |
|---|---|
| 讀取/分析內容 | pandoc 或解壓縮以取得原始 XML |
| 建立新文件 | 使用 docx-js - 請參閱下方「建立新文件」段落 |
| 編輯現有文件 | 解壓縮 → 編輯 XML → 重新打包 - 請參閱下方「編輯現有文件」段落 |
將 .doc 轉換為 .docx
傳統的 .doc 檔案在編輯前必須先進行轉換:
python scripts/office/soffice.py --headless --convert-to docx document.doc
讀取內容
# 保留追蹤修訂的文字萃取
pandoc --track-changes=all document.docx -o output.md
# 原始 XML 存取
python scripts/office/unpack.py document.docx unpacked/
轉換為圖片
python scripts/office/soffice.py --headless --convert-to pdf document.docx
pdftoppm -jpeg -r 150 document.pdf page
接受追蹤修訂
若要產生已接受所有追蹤修訂的乾淨文件(需要 LibreOffice):
python scripts/accept_changes.py input.docx output.docx
建立新文件
使用 JavaScript 產生 .docx 檔案,然後進行驗證。安裝:npm install -g docx
設定
const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, ImageRun,
Header, Footer, AlignmentType, PageOrientation, LevelFormat, ExternalHyperlink,
InternalHyperlink, Bookmark, FootnoteReferenceRun, PositionalTab,
PositionalTabAlignment, PositionalTabRelativeTo, PositionalTabLeader,
TabStopType, TabStopPosition, Column, SectionType,
TableOfContents, HeadingLevel, BorderStyle, WidthType, ShadingType,
VerticalAlign, PageNumber, PageBreak } = require('docx');
const doc = new Document({ sections: [{ children: [/* 內容 */] }] });
Packer.toBuffer(doc).then(buffer => fs.writeFileSync("doc.docx", buffer));
驗證
建立檔案後,對其進行驗證。如果驗證失敗,請解壓縮、修正 XML,然後重新打包。
python scripts/office/validate.py doc.docx
頁面大小
// 注意 (CRITICAL): docx-js 預設為 A4,而非 US Letter
// 始終明確設定頁面大小以獲取一致的結果
sections: [{
properties: {
page: {
size: {
width: 12240, // 8.5 英吋的 DXA 單位
height: 15840 // 11 英吋的 DXA 單位
},
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } // 1 英吋邊距
}
},
children: [/* 內容 */]
}]
常見的頁面大小 (DXA 單位,1440 DXA = 1 英吋):
| 紙張 | 寬度 | 高度 | 內容寬度 (1 英吋邊距) |
|---|---|---|---|
| US Letter | 12,240 | 15,840 | 9,360 |
| A4 (預設) | 11,906 | 16,838 | 9,026 |
橫向方向 (Landscape orientation): docx-js 在內部會對調寬度/高度,因此請傳入直向的尺寸,讓它處理對調:
size: {
width: 12240, // 將短邊作為寬度傳入
height: 15840, // 將長邊作為高度傳入
orientation: PageOrientation.LANDSCAPE // docx-js 會在 XML 中將它們對調
},
// 內容寬度 = 15840 - 左邊距 - 右邊距 (使用長邊)
樣式 (覆寫內建標題)
使用 Arial 作為預設字型(普遍支援)。保持標題為黑色以便於閱讀。
const doc = new Document({
styles: {
default: { document: { run: { font: "Arial", size: 24 } } }, // 12pt 預設值
paragraphStyles: [
// 重要 (IMPORTANT): 使用完全相同的 ID 來覆寫內建樣式
{ id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { size: 32, bold: true, font: "Arial" },
paragraph: { spacing: { before: 240, after: 240 }, outlineLevel: 0 } }, // 目錄 (TOC) 需要 outlineLevel
{ id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { size: 28, bold: true, font: "Arial" },
paragraph: { spacing: { before: 180, after: 180 }, outlineLevel: 1 } },
]
},
sections: [{
children: [
new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("Title")] }),
]
}]
});
清單 (絕對不要使用 Unicode 項目符號)
// ❌ 錯誤做法 - 絕對不要手動插入項目符號字元
new Paragraph({ children: [new TextRun("• Item")] }) // 錯誤
new Paragraph({ children: [new TextRun("\u2022 Item")] }) // 錯誤
// ✅ 正確做法 - 使用帶有 LevelFormat.BULLET 的編號設定
const doc = new Document({
numbering: {
config: [
{ reference: "bullets",
levels: [{ level: 0, format: LevelFormat.BULLET, text: "•", alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] },
{ reference: "numbers",
levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] },
]
},
sections: [{
children: [
new Paragraph({ numbering: { reference: "bullets", level: 0 },
children: [new TextRun("Bullet item")] }),
new Paragraph({ numbering: { reference: "numbers", level: 0 },
children: [new TextRun("Numbered item")] }),
]
}]
});
// ⚠️ 每個 reference 會建立獨立的編號系統
// 相同的 reference = 延續 (1,2,3 然後 4,5,6)
// 不同的 reference = 重新開始 (1,2,3 然後 1,2,3)
表格
注意 (CRITICAL): 表格需要雙重寬度設定 - 在表格上設定 columnWidths,並且在每個儲存格設定 width。如果沒有兩者都設定,表格在某些平台上會呈現錯誤。
// 注意 (CRITICAL): 始終設定表格寬度以獲得一致的渲染結果
// 注意 (CRITICAL): 使用 ShadingType.CLEAR (非 SOLID) 來防止黑色背景
const border = { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" };
const borders = { top: border, bottom: border, left: border, right: border };
new Table({
width: { size: 9360, type: WidthType.DXA }, // 始終使用 DXA (百分比設定在 Google Docs 中會損壞)
columnWidths: [4680, 4680], // 必須加總為表格寬度 (DXA: 1440 = 1 英吋)
rows: [
new TableRow({
children: [
new TableCell({
borders,
width: { size: 4680, type: WidthType.DXA }, // 同時也設定在每個儲存格上
shading: { fill: "D5E8F0", type: ShadingType.CLEAR }, // 是 CLEAR 而非 SOLID
margins: { top: 80, bottom: 80, left: 120, right: 120 }, // 儲存格內邊距 (內部的,不會增加到寬度)
children: [new Paragraph({ children: [new TextRun("Cell")] })]
})
]
})
]
})
表格寬度計算:
始終使用 WidthType.DXA — WidthType.PERCENTAGE 在 Google Docs 中會損壞。
// 表格寬度 = columnWidths 的總和 = 內容寬度
// US Letter 搭配 1 英吋邊距: 12240 - 2880 = 9360 DXA
width: { size: 9360, type: WidthType.DXA },
columnWidths: [7000, 2360] // 必須加總為表格寬度
寬度規則:
- 始終使用
WidthType.DXA— 絕對不要用WidthType.PERCENTAGE(與 Google Docs 不相容) - 表格寬度必須等於
columnWidths的總和 - 儲存格的
width必須與對應的columnWidth相符 - 儲存格的
margins是內部邊距 - 它們會減少內容區域,而不會增加到儲存格寬度 - 對於全寬表格:使用內容寬度 (頁面寬度減去左邊與右邊距)
圖片
// 注意 (CRITICAL): type 參數是必須的
new Paragraph({
children: [new ImageRun({
type: "png", // 必須是: png, jpg, jpeg, gif, bmp, svg
data: fs.readFileSync("image.png"),
transformation: { width: 200, height: 150 },
altText: { title: "Title", description: "Desc", name: "Name" } // 這三個都是必須的
})]
})
分頁符號
// 注意 (CRITICAL): PageBreak 必須在一個 Paragraph 內
new Paragraph({ children: [new PageBreak()] })
// 或使用 pageBreakBefore
new Paragraph({ pageBreakBefore: true, children: [new TextRun("New page")] })
超連結
// 外部連結
new Paragraph({
children: [new ExternalHyperlink({
children: [new TextRun({ text: "Click here", style: "Hyperlink" })],
link: "https://example.com",
})]
})
// 內部連結 (書籤 + 參考)
// 1. 在目標位置建立書籤
new Paragraph({ heading: HeadingLevel.HEADING_1, children: [
new Bookmark({ id: "chapter1", children: [new TextRun("Chapter 1")] }),
]})
// 2. 連結到它
new Paragraph({ children: [new InternalHyperlink({
children: [new TextRun({ text: "See Chapter 1", style: "Hyperlink" })],
anchor: "chapter1",
})]})
註腳
const doc = new Document({
footnotes: {
1: { children: [new Paragraph("Source: Annual Report 2024")] },
2: { children: [new Paragraph("See appendix for methodology")] },
},
sections: [{
children: [new Paragraph({
children: [
new TextRun("Revenue grew 15%"),
new FootnoteReferenceRun(1),
new TextRun(" using adjusted metrics"),
new FootnoteReferenceRun(2),
],
})]
}]
});
定位點 (Tab Stops)
// 在同一行上靠右對齊文字 (例如,標題對面的日期)
new Paragraph({
children: [
new TextRun("Company Name"),
new TextRun("\tJanuary 2025"),
],
tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }],
})
// 點狀前導字元 (例如,目錄樣式)
new Paragraph({
children: [
new TextRun("Introduction"),
new TextRun({ children: [
new PositionalTab({
alignment: PositionalTabAlignment.RIGHT,
relativeTo: PositionalTabRelativeTo.MARGIN,
leader: PositionalTabLeader.DOT,
}),
"3",
]}),
],
})
多欄版面配置
// 等寬的欄位
sections: [{
properties: {
column: {
count: 2, // 欄數
space: 720, // 欄與欄之間的間距,以 DXA 為單位 (720 = 0.5 英吋)
equalWidth: true,
separate: true, // 欄之間的分隔線
},
},
children: [/* 內容會自然地跨欄流動 */]
}]
// 自訂寬度的欄位 (equalWidth 必須是 false)
sections: [{
properties: {
column: {
equalWidth: false,
children: [
new Column({ width: 5400, space: 720 }),
new Column({ width: 3240 }),
],
},
},
children: [/* 內容 */]
}]
強制使用新節 (type: SectionType.NEXT_COLUMN) 進行分欄。
目錄
// 注意 (CRITICAL): 標題必須只使用 HeadingLevel - 標題段落上不得有自訂樣式
new TableOfContents("Table of Contents", { hyperlink: true, headingStyleRange: "1-3" })
頁首/頁尾
sections: [{
properties: {
page: { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } } // 1440 = 1 英吋
},
headers: {
default: new Header({ children: [new Paragraph({ children: [new TextRun("Header")] })] })
},
footers: {
default: new Footer({ children: [new Paragraph({
children: [new TextRun("Page "), new TextRun({ children: [PageNumber.CURRENT] })]
})] })
},
children: [/* 內容 */]
}]
docx-js 的關鍵規則
- 明確設定頁面大小 - docx-js 預設為 A4;對於 US 文件,請使用 US Letter (12240 x 15840 DXA)
- 橫向方向:傳遞直向尺寸 - docx-js 在內部會調換寬度和高度;傳遞短邊作為
width,長邊作為height,並設定orientation: PageOrientation.LANDSCAPE - 絕對不要使用
\n- 使用個別的 Paragraph 元素 - 絕對不要使用 Unicode 項目符號字元 - 使用帶有 numbering config 的
LevelFormat.BULLET - PageBreak 必須在 Paragraph 中 - 單獨使用建立的 XML 將無效
- ImageRun 需要
type屬性 - 一定要指定 png/jpg/等 - 始終以 DXA 設定表格
width- 絕對不要使用WidthType.PERCENTAGE(在 Google Docs 會毀損) - 表格需要雙重寬度 -
columnWidths陣列 AND 儲存格的width,兩者必須匹配 - 表格寬度 = columnWidths 之和 - 對於 DXA,請確保它們完全相加
- 務必加入儲存格 margins - 使用
margins: { top: 80, bottom: 80, left: 120, right: 120 },提供可讀的留白 - 使用
ShadingType.CLEAR- 不要為表格陰影使用 SOLID - 絕不要使用表格作為分隔線/規則 - 儲存格有最小高度,而且將被渲染成空盒子(即便是頁首/頁尾也是這樣);在 Paragraph 上使用
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: "2E75B6", space: 1 } }。如果兩欄頁尾,使用 tab stops(請見上述介紹),別使用表格。 - TOC 僅需 HeadingLevel - 不要自訂標題圖形段落樣式。
- 複寫內建樣式 - 使用確切的 IDs:"Heading1"、"Heading2" 等。
- 包含
outlineLevel- 為 TOC 需要 (H1 為 0, H2 為 1, 等。)
編輯現有文件
請依序執行這 3 個步驟。
步驟 1:解壓縮
python scripts/office/unpack.py document.docx unpacked/
擷取 XML,進行排版美化 (pretty-print),合併相鄰片段 (runs),並將智慧引號轉換為 XML 實體 (“ 等),這樣它們就能在編輯過程存留下來。使用 --merge-runs false 可跳過合併 fragment 過程。
步驟 2:編輯 XML
直接編輯在 unpacked/word/ 的檔案。在以下可找到 XML 參考模式。
追蹤修訂與註解的作者預設使用 "Claude",除非使用者明確要求使用不同的名稱。
請直接使用 Edit 工具進行字串置換,不要寫 Python 腳本。 腳本會帶來不必要的複雜度;Edit 工具會明確顯示替換的內容。
關鍵(CRITICAL):新增內容請使用 smart quotes。 加入帶有撇號或引號的文字時,請以 XML 實體產生 smart quotes:
<!-- 使用以下實體以呈現專業排版 -->
<w:t>Here’s a quote: “Hello”</w:t>
| 實體 | 字元 |
|---|---|
‘ |
‘(左單引號) |
’ |
’(右單引號/撇號) |
“ |
“(左雙引號) |
” |
”(右雙引號) |
加入註解(Comments): 使用 comment.py 處理多個 XML 檔案的樣板程式碼(傳入的文字必須先做 XML 跳脫):
python scripts/comment.py unpacked/ 0 "Comment text with & and ’"
python scripts/comment.py unpacked/ 1 "Reply text" --parent 0 # 回覆編號 0 的註解
python scripts/comment.py unpacked/ 0 "Text" --author "Custom Author" # 自訂作者名稱
然後在 document.xml 中加入標記(請見下方「XML 參考資料」中的「註解」一節)。
步驟 3:打包
python scripts/office/pack.py unpacked/ output.docx --original document.docx
透過自動修復進行驗證、壓縮 XML 並產生 DOCX。加上 --validate false 可略過驗證。
自動修復會處理:
durableId>= 0x7FFFFFFF(重新產生合法的 ID)- 含有空白的
<w:t>缺少xml:space="preserve"屬性
自動修復不會處理:
- 格式錯誤的 XML、不合法的元素巢狀、缺少的 relationships、違反 schema 的內容。
常見陷阱
- 整個替換
<w:r>元素:加入追蹤修訂時,請以<w:del>...<w:ins>...作為兄弟節點,整個取代原本的<w:r>...</w:r>區塊。不要把追蹤修訂標籤塞在 run 內部。 - 保留
<w:rPr>的格式設定:將原始 run 的<w:rPr>區塊複製到你的追蹤修訂 run 內,以維持粗體、字型大小等格式。
XML 參考資料
Schema 合規
<w:pPr>內元素的順序:<w:pStyle>、<w:numPr>、<w:spacing>、<w:ind>、<w:jc>,最後才是<w:rPr>。- 空白處理:含有前導/尾隨空白的
<w:t>必須加上xml:space="preserve"。 - RSIDs:必須是 8 位元的十六進位(例如
00AB1234)。
追蹤修訂
插入:
<w:ins w:id="1" w:author="Claude" w:date="2025-01-01T00:00:00Z">
<w:r><w:t>inserted text</w:t></w:r>
</w:ins>
刪除:
<w:del w:id="2" w:author="Claude" w:date="2025-01-01T00:00:00Z">
<w:r><w:delText>deleted text</w:delText></w:r>
</w:del>
<w:del> 內部:使用 <w:delText> 而非 <w:t>,使用 <w:delInstrText> 而非 <w:instrText>。
最小幅度編輯 —— 只標記真正改動的部分:
<!-- 將 "30 days" 改為 "60 days" -->
<w:r><w:t>The term is </w:t></w:r>
<w:del w:id="1" w:author="Claude" w:date="...">
<w:r><w:delText>30</w:delText></w:r>
</w:del>
<w:ins w:id="2" w:author="Claude" w:date="...">
<w:r><w:t>60</w:t></w:r>
</w:ins>
<w:r><w:t> days.</w:t></w:r>
刪除整個段落/清單項目 —— 移除一段(或一個項目)的所有內容時,連同段落標記也要標記為已刪除,這樣接受變更後該段才會與下一段合併。請在 <w:pPr><w:rPr> 中加入 <w:del/>:
<w:p>
<w:pPr>
<w:numPr>...</w:numPr> <!-- 若是清單項目,保留編號設定 -->
<w:rPr>
<w:del w:id="1" w:author="Claude" w:date="2025-01-01T00:00:00Z"/>
</w:rPr>
</w:pPr>
<w:del w:id="2" w:author="Claude" w:date="2025-01-01T00:00:00Z">
<w:r><w:delText>Entire paragraph content being deleted...</w:delText></w:r>
</w:del>
</w:p>
若 <w:pPr><w:rPr> 內未加上 <w:del/>,接受變更後會留下一個空段落/空清單項目。
拒絕其他作者的插入 —— 將刪除巢狀放在他人的插入之內:
<w:ins w:author="Jane" w:id="5">
<w:del w:author="Claude" w:id="10">
<w:r><w:delText>their inserted text</w:delText></w:r>
</w:del>
</w:ins>
還原其他作者的刪除 —— 在他人的刪除之後新增插入(不要修改他人原本的刪除):
<w:del w:author="Jane" w:id="5">
<w:r><w:delText>deleted text</w:delText></w:r>
</w:del>
<w:ins w:author="Claude" w:id="10">
<w:r><w:t>deleted text</w:t></w:r>
</w:ins>
註解
執行完 comment.py(見「步驟 2」)之後,在 document.xml 中加入標記。要回覆某則註解時,請使用 --parent 旗標,並把回覆的標記巢狀放在父註解的標記之內。
CRITICAL:<w:commentRangeStart> 與 <w:commentRangeEnd> 是 <w:r> 的兄弟節點,絕對不能放在 <w:r> 內部。
<!-- 註解標記是 <w:p> 的直接子節點,絕不放在 <w:r> 內部 -->
<w:commentRangeStart w:id="0"/>
<w:del w:id="1" w:author="Claude" w:date="2025-01-01T00:00:00Z">
<w:r><w:delText>deleted</w:delText></w:r>
</w:del>
<w:r><w:t> more text</w:t></w:r>
<w:commentRangeEnd w:id="0"/>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>
<!-- 註解 0,內部巢狀放入回覆 1 -->
<w:commentRangeStart w:id="0"/>
<w:commentRangeStart w:id="1"/>
<w:r><w:t>text</w:t></w:r>
<w:commentRangeEnd w:id="1"/>
<w:commentRangeEnd w:id="0"/>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
圖片
- 將圖片檔案放到
word/media/。 - 在
word/_rels/document.xml.rels加入 relationship:
<Relationship Id="rId5" Type=".../image" Target="media/image1.png"/>
- 在
[Content_Types].xml加入 content type:
<Default Extension="png" ContentType="image/png"/>
- 在
document.xml中引用圖片:
<w:drawing>
<wp:inline>
<wp:extent cx="914400" cy="914400"/> <!-- EMU 單位:914400 = 1 英吋 -->
<a:graphic>
<a:graphicData uri=".../picture">
<pic:pic>
<pic:blipFill><a:blip r:embed="rId5"/></pic:blipFill>
</pic:pic>
</a:graphicData>
</a:graphic>
</wp:inline>
</w:drawing>
相依套件
- pandoc:文字擷取。
- docx:
npm install -g docx(用於建立新文件)。 - LibreOffice:PDF 轉換(透過
scripts/office/soffice.py自動配置沙盒環境)。 - Poppler:以
pdftoppm將 PDF 轉為圖片。