執筆・検証: MD Converter編集部
JavaScriptでMarkdownをWord(docx)に変換すると崩れる原因と直し方|表・リスト・コード・画像
ブラウザやNode.jsでMarkdownからdocxを生成すると、表が潰れる・コードが1行になる・見出しが大きくならない・番号付きリストが平坦になる。docx(npm)で実装して実際に起きた崩れの原因を、Wordの仕組み(OOXML)から整理し、修正コードとWordが無くてもできる確認方法を解説します。
MarkdownからWord(docx)を生成して崩れる原因の多くは、HTMLやCSSの感覚でWordの文書構造を組み立ててしまうことです。docxの中身はOffice Open XML(OOXML)で、表の列幅、改行、見出しの書式、段落番号には、それぞれWord側の決まった仕組みがあります。そこに合わせて書き出せば、Wordで開いたときの崩れはほぼ解消できます。
この記事は、ブラウザ上でMarkdownをdocxに変換する機能をnpmの docx パッケージ(v9)で実装し、最初の版がWordで崩れたときに調べた原因と修正の記録です。Markdownの解析には remark(mdast)を使っていますが、考え方はほかのパーサーでも同じです。
最初の版で起きた崩れの一覧
最初の版は「Markdownの要素をそれっぽいWordの要素に置き換える」だけの実装でした。開いてみると、次の症状がまとめて出ました。
| 症状 | 原因 | 直し方 |
|---|---|---|
| 表の列が1文字幅に潰れ、数字が縦に並ぶ | 列幅(w:tblGrid)を指定せず、表の幅を%だけで指定した | 列幅をtwips単位で指定し、固定レイアウトにする |
| コードブロックが1行に潰れる | テキスト中の改行文字は、Wordでは改行にならない | 行ごとに段落を分ける |
| 見出しが本文と同じ大きさになる | 文字(ラン)に直接付けたサイズが、見出しスタイルを上書きした | 書式は見出しスタイル側にまとめる |
| 番号付きリストに字下げも入れ子もない | 「1. 」を文字として書いていた | 番号定義(numbering)を使う |
| 2つ目の番号付きリストが「4.」から始まる | リストごとの番号の振り直しが、一部のアプリで無視された | リストごとに別の番号定義を作る |
| 和文が想定と違うフォントになる | CSSの総称フォント名を渡し、和文用のフォント指定がなかった | Wordに実在するフォント名と和文用の指定を両方入れる |
| 画像・Mermaid図が消える | docxは画像のURLを参照できない | 画像データを埋め込み、SVGにはPNGを添える |
| 引用の罫線がずれる、箱になる | 段落罫線の位置がアプリごとに違う | 罫線を1つのセルだけの表で描く |
以下、原因と直し方を順に説明します。コード例はすべて docx v9 のAPIです。
表:列幅を指定しないと潰れる
最初の版では、表の幅を width: { size: 100, type: WidthType.PERCENTAGE } とだけ指定していました。これだと各列の幅が決まらず、表示するアプリによっては列が最小幅まで縮みます。macOSのクイックルックで確認すると、「100,000」が1文字ずつ縦に並ぶほど潰れていました。
Wordの表は、列幅の一覧(w:tblGrid)と各セルの幅を持つのが基本です。docx では columnWidths が w:tblGrid になります。幅の単位はtwips(1インチ = 1440、1mm ≒ 56.7)です。
import { Paragraph, Table, TableCell, TableLayoutType, TableRow, WidthType } from "docx";
// A4(幅 11906 twips)から左右余白 22mm ずつを引いた本文幅
const contentWidth = 11906 - 1247 * 2; // 9412
const widths = [2353, 2353, 4706]; // 合計を本文幅に揃える
const table = new Table({
width: { size: contentWidth, type: WidthType.DXA },
columnWidths: widths,
layout: TableLayoutType.FIXED,
rows: rows.map((cells, r) =>
new TableRow({
tableHeader: r === 0, // ページをまたいだら見出し行を繰り返す
cantSplit: true,
children: cells.map((text, c) =>
new TableCell({
width: { size: widths[c], type: WidthType.DXA },
children: [new Paragraph(text)],
})
),
})
),
});
列幅は均等割りより、列ごとの文字数に比例させるほうが読みやすくなります。全角文字を半角2文字分として数え、極端に細い列ができないよう最小幅を決めておくと安定します。Markdownの :---: などの列揃えは、セル内の段落の alignment に反映します。
改行:テキスト中の「\n」は改行にならない
OOXMLでは、文字列(w:t)の中の改行文字は空白として扱われます。Wordで改行するには、改行要素(w:br)を入れるか、段落を分ける必要があります。最初の版はコードブロック全体を1つの TextRun に入れていたため、コードが1行に潰れました。
コードブロックは、1行を1段落にするのが扱いやすい方法です。行頭の空白は docx が xml:space="preserve" を付けて保持してくれますが、タブは環境で幅が変わるので空白に置き換えておきます。
import { Paragraph, TextRun } from "docx";
const mono = { ascii: "Consolas", hAnsi: "Consolas", eastAsia: "Yu Gothic" };
const codeParagraphs = code
.replace(/\t/g, " ")
.split("\n")
.map((line) => new Paragraph({ style: "CodeBlock", children: [new TextRun({ text: line, font: mono })] }));
段落の途中で改行したい場合(Markdownの行末スペース2つや <br>)は new TextRun({ break: 1 }) を使います。
コードの背景色と枠線は、段落ごとに付けると行間に隙間が出たり、アプリによって罫線のつながり方が変わったりします。1つのセルだけの表にコードの段落をまとめて入れ、セルに背景色を付けると、どのアプリでも同じ見た目になりやすくなります。シンタックスハイライトが必要なら、highlight.js などの出力からトークンごとの色を取り出し、TextRun の color に入れます。
見出し:文字に付けたサイズがスタイルを上書きする
Wordの書式には優先順位があり、文字(ラン)に直接付けた書式は段落スタイルより優先されます。最初の版は、本文と同じ処理で見出しの文字にも size と font を付けていたため、見出しスタイルの大きさがすべて打ち消され、本文と同じ大きさになっていました。
見出しの書式は見出しスタイルにまとめ、見出しの文字には太字や斜体など、Markdownで指定された装飾だけを付けます。
import { Document, HeadingLevel, Paragraph, TextRun } from "docx";
const doc = new Document({
styles: {
default: {
document: { run: { size: 21 } }, // 10.5pt(単位は半ポイント)
heading1: { run: { size: 40, bold: true }, paragraph: { spacing: { before: 360, after: 200 }, keepNext: true } },
heading2: { run: { size: 30, bold: true }, paragraph: { spacing: { before: 400, after: 160 }, keepNext: true } },
},
},
sections: [
{
children: [
// ラン側に size を付けない
new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("見出し")] }),
],
},
],
});
Word標準の「見出し1〜6」スタイルを使っておくと、ナビゲーションウィンドウやWordの目次機能もそのまま使えます。
番号付きリスト:文字の「1.」ではなく番号定義を使う
最初の版は、番号付きリストを「1. 」という文字を先頭に足した普通の段落で表現していました。これでは字下げがなく、入れ子のリストも親と同じ位置に並び、Wordで項目を追加しても番号が振り直されません。
Wordの段落番号は、番号の書式を定義した番号定義(numbering)を段落から参照する仕組みです。レベルごとに書式と字下げを決めておきます。
import { AlignmentType, Document, LevelFormat, Paragraph } from "docx";
const formats = [LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN];
const orderedList = (reference: string) => ({
reference,
levels: Array.from({ length: 9 }, (_, level) => ({
level,
format: formats[level % 3],
text: `%${level + 1}.`,
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 420 * (level + 1), hanging: 360 } } },
})),
});
const doc = new Document({
numbering: { config: [orderedList("list-1"), orderedList("list-2")] },
sections: [
{
children: [
new Paragraph({ text: "手順A", numbering: { reference: "list-1", level: 0 } }),
new Paragraph({ text: "手順A-1", numbering: { reference: "list-1", level: 1 } }),
// 別のリストは別の定義を参照し、1 から始め直す
new Paragraph({ text: "新しいリスト", numbering: { reference: "list-2", level: 0 } }),
],
},
],
});
注意したいのは、番号の振り直しです。docx には同じ定義を instance で使い分けて番号を振り直す機能があり、OOXMLの仕様上はこの指定で番号が1から始まります。ただ、macOSのクイックルックでは2つ目のリストが前のリストの続き番号になり、ブラウザ用ビューアのdocx-previewでは番号自体が表示されませんでした。Word以外のアプリでも開かれる可能性があるなら、上の例のようにリストごとに別の番号定義を作るのが確実です。
タスクリスト(- [ ] / - [x])はWordに対応する仕組みがないため、番号定義を使わず「☐」「☑」を文字として先頭に置き、字下げだけを揃えるのが現実的です。
フォント:CSSのフォント名はWordでは通じない
Webの文書設定にある ui-sans-serif や system-ui は、CSSの総称フォント名でありフォントの実名ではありません。そのままWordに渡すと、Wordは存在しないフォントとして別のフォントに置き換えます。
また、Wordのフォント指定は文字の種類ごとに分かれていて、和文には eastAsia の指定が必要です。欧文用のフォントだけを指定すると、和文はWordの既定のフォントになります。
const doc = new Document({
styles: {
default: {
document: {
run: { font: { ascii: "Arial", hAnsi: "Arial", cs: "Arial", eastAsia: "Yu Gothic" }, size: 21 },
},
},
},
sections: [/* … */],
});
和文フォントは、相手の環境にあるものを選びます。たとえばメイリオはWindows向けのフォントで、Macには標準では入っていません。WindowsとMacの両方で開かれる文書なら、游ゴシック(Yu Gothic)や游明朝(Yu Mincho)のように両方の環境で使われていることが多いフォントを選ぶと、置き換わりが起きにくくなります。
画像・図:URLではなくデータを埋め込む
HTMLの <img> と違い、docxは画像のURLを参照できません。画像のデータをファイル内に埋め込む必要があります。ブラウザで生成する場合は fetch で画像を取得しますが、配信元がほかのサイトからの取得を許可していない(CORS)と取得できないため、失敗したときは代替テキストを表示するなどの逃げ道を用意します。
docx の ImageRun が受け付ける形式はPNG・JPEG・GIF・BMP・SVGです。WebPなどはcanvasに描いてPNGに変換します。SVGを埋め込むときはPNGのフォールバックが必須で、SVGに対応していないアプリではPNGが表示されます。
import { ImageRun } from "docx";
new ImageRun({
type: "svg",
data: svgBytes, // Uint8Array
fallback: { type: "png", data: pngBytes },
transformation: { width: 480, height: 270 }, // 表示サイズ(px)
altText: { name: "diagram", description: "処理の流れ", title: "処理の流れ" },
});
表示サイズは、本文幅を超えないように縮小します。twipsで計算した本文幅を15で割るとpx(96dpi換算)になります。
Mermaid図は、SVGに描画してから同じ方法で埋め込めます。ただしMermaidは既定でラベルをHTMLとしてSVG内に埋め込む(foreignObject)ため、Wordなどで文字が表示されないことがあります。%%{init: {"flowchart": {"htmlLabels": false}}}%% を図の先頭に付けてSVGのテキストで描かせると安定します。数式も、MathJaxのSVG出力を fontCache: "none" で使うと外部フォントに頼らない自己完結したSVGになり、同じ方法で画像として埋め込めます。
引用:段落罫線より1つのセルだけの表で描く
引用の左側の縦線は、段落の左罫線で描くのが素直な方法です。ただ、引用の中に箇条書きがあると、行頭の記号を置くための字下げ(ぶら下げインデント)と罫線の位置の関係がアプリごとに違い、ブラウザ用ビューアでは縦線が文字に重なりました。
左罫線だけを付けた1つのセルだけの表で引用全体を囲むと、中に箇条書きやコード、入れ子の引用があっても罫線の位置がずれません。
import { BorderStyle, Table, TableCell, TableRow, WidthType } from "docx";
// 罫線なし。none を無視して細線を描くビューアがあるため、色は白にしておく
const none = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" };
const bar = { style: BorderStyle.SINGLE, size: 18, color: "D6D3D1" };
new Table({
width: { size: contentWidth, type: WidthType.DXA },
columnWidths: [contentWidth],
borders: { top: none, bottom: none, right: none, left: bar, insideHorizontal: none, insideVertical: none },
rows: [
new TableRow({
children: [
new TableCell({
borders: { top: none, bottom: none, right: none, left: bar },
margins: { left: 220, right: 120 },
children: quoteParagraphs,
}),
],
}),
],
});
コード中のコメントのとおり、罫線を none にしてもそれを無視して黒い細線を描くビューアがありました。Wordは none を正しく扱いますが、色を白にしておけばどちらでも見えなくなります。
Wordがなくても崩れを確認する方法
docxの崩れは、Wordで開くまで気づきにくいのが厄介な点です。Wordが手元にない環境でも、次の方法を組み合わせるとかなりの問題を見つけられます。
- 中身のXMLを直接読む:docxはzipなので、展開して
word/document.xmlを確認できます。列幅が入っているか、番号定義を参照しているかを確かめるのに向いています。 - 別のアプリで表示する:macOSならクイックルック(
qlmanage)、LibreOfficeがあればPDFへの変換、ブラウザならdocx-previewなどで表示します。 - 複数の結果を突き合わせる:あるアプリだけで崩れる場合は、そのアプリの再現度の問題か、ファイル側の問題かを、XMLを見て切り分けます。
mkdir -p out && unzip -o output.docx -d out
# 表の列幅(w:gridCol)が入っているか
grep -o '<w:gridCol w:w="[0-9]*"/>' out/word/document.xml
# macOS:1ページ目のサムネイル画像を作る
qlmanage -t -s 1400 -o . output.docx
# LibreOffice:PDFに変換して確認する
soffice --headless --convert-to pdf output.docx
ただし、どのビューアもWordとまったく同じには表示しません。今回も、ビューアごとに番号付きリストや罫線の扱いが違いました。最終的な見た目は、配布先が使うWordやGoogleドキュメントで確認してください。
出力前のチェックリスト
- 表に列幅(
columnWidths)とセル幅を指定し、合計を本文幅に揃えた - コードブロックを行ごとの段落にし、タブを空白に置き換えた
- 見出しの文字に
sizeやfontを直接付けていない - 番号付きリストに番号定義を使い、リストごとに別の定義を参照している
- フォントにWordで実在する名前を使い、和文用の
eastAsiaを指定した - 画像はデータを埋め込み、SVGにはPNGのフォールバックを付けた
- 取得できなかった画像に代替テキストを表示している
- Word以外のビューアでも表示し、XMLで原因を切り分けた
MarkdownからWord(docx)を手早く作りたい場合は、Markdown Document ConverterのMD→DOCX変換が使えます。この記事で扱った対策を入れた変換で、Wordの見出しスタイル、番号付きリスト、列幅を調整した表、色分けしたコード、Mermaid図や数式を含むdocxをブラウザ内で作れます(https の画像を埋め込む場合は、画像の配信元に接続します)。
関連記事: Markdownの表が崩れる・PDFではみ出す原因と対処法 / MarkdownをPowerPointに変換すると編集できない? / Mermaid図入りMarkdownをPDFにする方法