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
6 changes: 5 additions & 1 deletion src/export/ReleaseArtifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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/"');
Expand Down
101 changes: 101 additions & 0 deletions src/formats/docx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<w:hyperlink w:anchor="([^"]+)"/g)].map(match => match[1]);
const bookmarks = [...xml.matchAll(/<w:bookmarkStart w:id="(\d+)" w:name="([^"]+)"\/>/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>(.*?)<\/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}</w:t>`));
expect(paragraph).toBeDefined();
const bookmark = paragraph?.match(/<w:bookmarkStart w:id="\d+" w:name="([^"]+)"\/>/)?.[1];
const link = xml.match(new RegExp(`<w:hyperlink w:anchor="([^"]+)"><w:r><w:t xml:space="preserve">${linkText}</w:t>`))?.[1];
expect(link).toBe(bookmark);
}
for (const bookmark of bookmarks) {
expect(xml).toContain(`<w:bookmarkEnd w:id="${bookmark[1]}"/>`);
}
});

it("keeps bookmark names unique when headings repeat", async () => {
const xml = await renderAndReadDocumentXml("## Same heading\n\n## Same heading");
const names = [...xml.matchAll(/<w:bookmarkStart w:id="\d+" w:name="([^"]+)"\/>/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(/<w:bookmarkStart w:id="\d+" w:name="([^"]+)"\/><w:hyperlink r:id=/)?.[1];
expect(bookmark).toBeDefined();
expect(xml).toContain(`<w:hyperlink w:anchor="${bookmark}">`);
});

it("writes a minimal DOCX package without external dependencies", async () => {
let writtenPath = "";
let writtenData: Uint8Array | null = null;
Expand Down Expand Up @@ -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('<w:compatSetting w:name="compatibilityMode"');
expect(settings).toContain('w:val="15"');
expect(relationships).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"');
expect(contentTypes).toContain('PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"');
});

it("supports heading levels 4-6", async () => {
let writtenData: Uint8Array | null = null;
const writer = {
Expand Down Expand Up @@ -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('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>');
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 = {
Expand Down
73 changes: 61 additions & 12 deletions src/formats/docx.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -20,6 +21,8 @@ type DocxParagraph = {
kind: "paragraph";
runs: DocxRun[];
style?: string;
anchors?: string[];
bookmarkId?: number;
};

type DocxTableCell = {
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Expand Down Expand Up @@ -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;

Expand All @@ -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));
Expand Down Expand Up @@ -244,10 +253,12 @@ function parseMarkdownToBlocks(markdown: string, imageMap: Map<string, DocxImage
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = Math.min(headingMatch[1].length, 6);
const runs = parseInline(headingMatch[2], imageMap);
blocks.push({
kind: "paragraph",
style: `Heading${level}`,
runs: parseInline(headingMatch[2], imageMap),
runs,
anchors: [slugify(runs.map(run => run.text).join(""))],
});
i++;
continue;
Expand Down Expand Up @@ -336,6 +347,33 @@ function parseMarkdownToBlocks(markdown: string, imageMap: Map<string, DocxImage
return blocks;
}

function assignBookmarks(blocks: DocxBlock[]): void {
const bookmarkNames = new Map<string, string>();
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[],
Expand Down Expand Up @@ -553,7 +591,11 @@ function tableToXml(table: DocxTable): string {
function paragraphToXml(paragraph: DocxParagraph): string {
const style = paragraph.style ? `<w:pPr><w:pStyle w:val="${paragraph.style}"/></w:pPr>` : "";
const runs = paragraph.runs.map(runToXml).join("");
return `<w:p>${style}${runs}</w:p>`;
const start = paragraph.bookmarkId
? `<w:bookmarkStart w:id="${paragraph.bookmarkId}" w:name="B${paragraph.bookmarkId}"/>`
: "";
const end = paragraph.bookmarkId ? `<w:bookmarkEnd w:id="${paragraph.bookmarkId}"/>` : "";
return `<w:p>${style}${start}${runs}${end}</w:p>`;
}

function runToXml(run: DocxRun): string {
Expand Down Expand Up @@ -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 `<Default Extension="${ext}" ContentType="${mime}"/>`;
}).join("");

Expand All @@ -605,6 +647,7 @@ function buildContentTypes(images: DocxImage[]): string {
${imageEntries}
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
</Types>`;
}

Expand All @@ -619,6 +662,7 @@ function buildRels(images: DocxImage[], hyperlinks: DocxHyperlink[]): string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
<Relationship Id="rIdSettings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>
${rels}
${hyperlinkRels}
</Relationships>`;
Expand Down Expand Up @@ -649,6 +693,11 @@ const PACKAGE_RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`;

const SETTINGS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:compat><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/></w:compat>
</w:settings>`;

const STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
Expand Down
Loading