From ad03b818dc8f083286dd0a3ee70c26438f0ef503 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:28:15 +0800 Subject: [PATCH] fix: improve DOCX Word compatibility --- src/export/ReleaseArtifacts.test.ts | 6 +- src/formats/docx.test.ts | 101 ++++++++++++++++++++++++++++ src/formats/docx.ts | 73 ++++++++++++++++---- 3 files changed, 167 insertions(+), 13 deletions(-) diff --git a/src/export/ReleaseArtifacts.test.ts b/src/export/ReleaseArtifacts.test.ts index e7b3fa7..9250cfd 100644 --- a/src/export/ReleaseArtifacts.test.ts +++ b/src/export/ReleaseArtifacts.test.ts @@ -267,7 +267,10 @@ describe("release artifacts (headless)", () => { const documentXml = readStoredZipEntry(zip, "word/document.xml"); parseXml(documentXml); - parseXml(readStoredZipEntry(zip, "[Content_Types].xml")); + const contentTypes = readStoredZipEntry(zip, "[Content_Types].xml"); + parseXml(contentTypes); + parseXml(readStoredZipEntry(zip, "word/settings.xml")); + expect(contentTypes).toContain('PartName="/word/settings.xml"'); for (const marker of [ "BEGIN-CONTENT", "END-CONTENT", "CODE-CONTENT", "Alpha", "123", "中文", "456", @@ -277,6 +280,7 @@ describe("release artifacts (headless)", () => { const rels = readStoredZipEntry(zip, "word/_rels/document.xml.rels"); parseXml(rels); + expect(rels).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"'); expect(rels).toContain('Target="media/image1.png"'); expect(rels).toContain('Target="media/image2.png"'); expect(rels).toContain('Target="https://example.com/"'); diff --git a/src/formats/docx.test.ts b/src/formats/docx.test.ts index 23976d8..e6aa939 100644 --- a/src/formats/docx.test.ts +++ b/src/formats/docx.test.ts @@ -133,6 +133,52 @@ describe("DOCX rendering", () => { expect(relationshipsXml).not.toContain("caption"); }); + it("links to Word-compatible bookmarks on headings and the document title", async () => { + const xml = await renderAndReadDocumentXml( + "[Top](#fixture) [English](#section-one) [Chinese](#中文标题)\n\n## Section One\n\n## 中文标题", + ); + const anchors = [...xml.matchAll(/ match[1]); + const bookmarks = [...xml.matchAll(//g)]; + + expect(anchors).toHaveLength(3); + for (const anchor of anchors) { + expect(anchor).toMatch(/^[A-Za-z][A-Za-z0-9_]{0,39}$/); + expect(bookmarks.some(bookmark => bookmark[2] === anchor)).toBe(true); + } + const paragraphs = [...xml.matchAll(/(.*?)<\/w:p>/g)].map(match => match[1]); + for (const [label, linkText] of [ + ["Fixture", "Top"], + ["Section One", "English"], + ["中文标题", "Chinese"], + ]) { + const paragraph = paragraphs.find(value => value.includes(`>${label}`)); + expect(paragraph).toBeDefined(); + const bookmark = paragraph?.match(//)?.[1]; + const link = xml.match(new RegExp(`${linkText}`))?.[1]; + expect(link).toBe(bookmark); + } + for (const bookmark of bookmarks) { + expect(xml).toContain(``); + } + }); + + it("keeps bookmark names unique when headings repeat", async () => { + const xml = await renderAndReadDocumentXml("## Same heading\n\n## Same heading"); + const names = [...xml.matchAll(//g)] + .map(match => match[1]); + expect(names).toHaveLength(3); + expect(new Set(names).size).toBe(names.length); + }); + + it("targets the visible text of a heading with an inline link", async () => { + const xml = await renderAndReadDocumentXml( + "[Jump](#linked-heading)\n\n## [Linked heading](https://example.com)", + ); + const bookmark = xml.match(/`); + }); + it("writes a minimal DOCX package without external dependencies", async () => { let writtenPath = ""; let writtenData: Uint8Array | null = null; @@ -173,6 +219,38 @@ describe("DOCX rendering", () => { expect(packageText).toContain("bold"); }); + it("includes document settings in a DOCX without images or links", async () => { + let writtenData: Uint8Array | null = null; + const writer = { + ensureFolder: vi.fn(), + writeBinary: vi.fn((_path: string, data: Uint8Array) => { + writtenData = data; + }), + }; + const doc: AssembledDocument = { + title: "Editable document", + sections: [{ + title: "Editable document", + sourcePath: "Note.md", + markdown: "Plain text", + frontmatter: {}, + }], + attachments: [], + }; + const plan = { outputRoot: "output", outputFilename: "document.docx" } as ExportPlan; + + await renderDocx(doc, plan, writer as never); + if (!writtenData) throw new Error("DOCX was not written"); + + const settings = readStoredZipEntry(writtenData, "word/settings.xml"); + const relationships = readStoredZipEntry(writtenData, "word/_rels/document.xml.rels"); + const contentTypes = readStoredZipEntry(writtenData, "[Content_Types].xml"); + expect(settings).toContain(' { let writtenData: Uint8Array | null = null; const writer = { @@ -280,6 +358,29 @@ describe("DOCX rendering", () => { expect(packageText).toContain("image/png"); }); + it("registers embedded SVG images with the SVG media type", async () => { + let writtenData: Uint8Array | null = null; + const writer = { + ensureFolder: vi.fn(), + writeBinary: vi.fn((_path: string, data: Uint8Array) => { writtenData = data; }), + }; + const svgData = new TextEncoder().encode(''); + const app = { vault: { + getAbstractFileByPath: () => makeTFile("assets/diagram.svg", "svg"), + readBinary: async () => svgData.buffer, + } }; + const doc: AssembledDocument = { + title: "SVG", + sections: [{ title: "SVG", sourcePath: "note.md", markdown: "![Diagram](assets/diagram.svg)", frontmatter: {} }], + attachments: [{ sourcePath: "assets/diagram.svg", outputRelativePath: "assets/diagram.svg" }], + }; + + await renderDocx(doc, { outputRoot: "output", outputFilename: "test.docx" } as ExportPlan, writer as never, app as never); + if (!writtenData) throw new Error("DOCX was not written"); + const contentTypes = readStoredZipEntry(writtenData, "[Content_Types].xml"); + expect(contentTypes).toContain('Extension="svg" ContentType="image/svg+xml"'); + }); + it("limits embedded image width for readable document layout", async () => { let writtenData: Uint8Array | null = null; const writer = { diff --git a/src/formats/docx.ts b/src/formats/docx.ts index a993537..94cfcb9 100644 --- a/src/formats/docx.ts +++ b/src/formats/docx.ts @@ -1,6 +1,7 @@ import { App, TFile } from "obsidian"; import { AssembledDocument, AttachmentCopy, ExportPlan } from "@/types"; import { OutputWriter } from "@/export/OutputWriter"; +import { slugify } from "@/export/LinkRewriter"; import { createZip } from "@/formats/zip"; import { MARKDOWN_ESCAPE_RE } from "@/formats/html-document"; @@ -20,6 +21,8 @@ type DocxParagraph = { kind: "paragraph"; runs: DocxRun[]; style?: string; + anchors?: string[]; + bookmarkId?: number; }; type DocxTableCell = { @@ -65,6 +68,7 @@ export async function renderDocx( const images = await collectImages(doc.attachments, app, warnings); const blocks = buildDocxBlocks(doc, images); + assignBookmarks(blocks); const hyperlinks = assignHyperlinkRelationships(blocks, images.length + 2); const documentXml = buildDocumentXml(blocks); @@ -73,16 +77,15 @@ export async function renderDocx( { name: "_rels/.rels", data: encodeXml(PACKAGE_RELS_XML) }, { name: "word/document.xml", data: encodeXml(documentXml) }, { name: "word/styles.xml", data: encodeXml(STYLES_XML) }, - ]; - - if (images.length > 0 || hyperlinks.length > 0) { - files.push({ + { name: "word/settings.xml", data: encodeXml(SETTINGS_XML) }, + { name: "word/_rels/document.xml.rels", data: encodeXml(buildRels(images, hyperlinks)), - }); - for (const img of images) { - files.push({ name: img.mediaPath, data: img.data }); - } + }, + ]; + + for (const img of images) { + files.push({ name: img.mediaPath, data: img.data }); } const buffer = createZip(files); @@ -176,7 +179,12 @@ function buildDocxBlocks(doc: AssembledDocument, images: DocxImage[]): DocxBlock } const blocks: DocxBlock[] = [ - { kind: "paragraph", style: "Title", runs: [{ text: doc.title }] }, + { + kind: "paragraph", + style: "Title", + runs: [{ text: doc.title }], + anchors: [slugify(doc.title), ...(doc.sections.length === 1 ? [slugify(doc.sections[0].title)] : [])], + }, ]; const isSingleSection = doc.sections.length === 1; @@ -186,6 +194,7 @@ function buildDocxBlocks(doc: AssembledDocument, images: DocxImage[]): DocxBlock kind: "paragraph", style: "Heading1", runs: [{ text: section.title }], + anchors: [slugify(section.title)], }); } blocks.push(...parseMarkdownToBlocks(section.markdown, imageMap)); @@ -244,10 +253,12 @@ function parseMarkdownToBlocks(markdown: string, imageMap: Map run.text).join(""))], }); i++; continue; @@ -336,6 +347,33 @@ function parseMarkdownToBlocks(markdown: string, imageMap: Map(); + let nextId = 1; + for (const block of blocks) { + if (block.kind !== "paragraph" || !block.anchors?.some(Boolean)) continue; + block.bookmarkId = nextId++; + for (const anchor of block.anchors) { + if (anchor && !bookmarkNames.has(anchor)) bookmarkNames.set(anchor, `B${block.bookmarkId}`); + } + } + + const rewriteRun = (run: DocxRun): void => { + if (!run.hyperlink?.startsWith("#")) return; + const bookmark = bookmarkNames.get(slugify(run.hyperlink.slice(1))); + if (bookmark) run.hyperlink = `#${bookmark}`; + }; + for (const block of blocks) { + if (block.kind === "paragraph") { + block.runs.forEach(rewriteRun); + } else { + for (const row of block.rows) { + for (const cell of row) cell.runs.forEach(rewriteRun); + } + } + } +} + function buildTable( headerCells: string[], bodyLines: string[], @@ -553,7 +591,11 @@ function tableToXml(table: DocxTable): string { function paragraphToXml(paragraph: DocxParagraph): string { const style = paragraph.style ? `` : ""; const runs = paragraph.runs.map(runToXml).join(""); - return `${style}${runs}`; + const start = paragraph.bookmarkId + ? `` + : ""; + const end = paragraph.bookmarkId ? `` : ""; + return `${style}${start}${runs}${end}`; } function runToXml(run: DocxRun): string { @@ -594,7 +636,7 @@ function buildContentTypes(images: DocxImage[]): string { imageTypes.add(img.ext); } const imageEntries = Array.from(imageTypes).map(ext => { - const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : `image/${ext}`; + const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "svg" ? "image/svg+xml" : `image/${ext}`; return ``; }).join(""); @@ -605,6 +647,7 @@ function buildContentTypes(images: DocxImage[]): string { ${imageEntries} + `; } @@ -619,6 +662,7 @@ function buildRels(images: DocxImage[], hyperlinks: DocxHyperlink[]): string { return ` + ${rels} ${hyperlinkRels} `; @@ -649,6 +693,11 @@ const PACKAGE_RELS_XML = ` `; +const SETTINGS_XML = ` + + +`; + const STYLES_XML = `