feat(ee): docx word export

This commit is contained in:
Philipinho
2026-06-19 13:10:55 +01:00
parent 2b56c09afc
commit ba3c83bc1d
19 changed files with 431 additions and 387 deletions
@@ -6,13 +6,21 @@ import {
Select,
Switch,
Divider,
Tooltip,
Badge,
} from "@mantine/core";
import { exportPage } from "@/features/page/services/page-service.ts";
import {
exportPage,
exportPageToDocx,
} from "@/features/page/services/page-service.ts";
import { useState } from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
import { exportSpace } from "@/features/space/services/space-service";
import { useTranslation } from "react-i18next";
import { Feature } from "@/ee/features";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
interface ExportModalProps {
id: string;
@@ -32,17 +40,25 @@ export default function ExportModal({
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
const [isExporting, setIsExporting] = useState<boolean>(false);
const { t } = useTranslation();
const upgradeLabel = useUpgradeLabel();
const isDocx = format === ExportFormat.Docx;
const docxEntitled = useHasFeature(Feature.DOCX_EXPORT);
const blockedByLicense = isDocx && !docxEntitled;
const handleExport = async () => {
setIsExporting(true);
try {
if (type === "page") {
await exportPage({
pageId: id,
format,
includeChildren,
includeAttachments,
});
if (format === ExportFormat.Docx) {
await exportPageToDocx({ pageId: id });
} else {
await exportPage({
pageId: id,
format,
includeChildren,
includeAttachments,
});
}
}
if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments });
@@ -88,10 +104,15 @@ export default function ExportModal({
<div>
<Text size="md">{t("Format")}</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
<ExportFormatSelection
format={format}
onChange={handleChange}
includeDocx={type === "page"}
docxEntitled={docxEntitled}
/>
</Group>
{type === "page" && (
{type === "page" && !isDocx && (
<>
<Divider my="sm" />
@@ -143,7 +164,16 @@ export default function ExportModal({
<Button onClick={onClose} variant="default">
{t("Cancel")}
</Button>
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
<Tooltip label={upgradeLabel} disabled={!blockedByLicense} withArrow>
<Button
onClick={handleExport}
loading={isExporting}
disabled={blockedByLicense}
data-disabled={blockedByLicense || undefined}
>
{t("Export")}
</Button>
</Tooltip>
</Group>
</Modal.Body>
</Modal.Content>
@@ -154,23 +184,49 @@ export default function ExportModal({
interface ExportFormatSelection {
format: ExportFormat;
onChange: (value: string) => void;
includeDocx?: boolean;
docxEntitled?: boolean;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
function ExportFormatSelection({
format,
onChange,
includeDocx,
docxEntitled,
}: ExportFormatSelection) {
const { t } = useTranslation();
const data = [
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
...(includeDocx
? [{ value: "docx", label: "Word (.docx)", disabled: !docxEntitled }]
: []),
];
return (
<Select
data={[
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
]}
data={data}
defaultValue={format}
onChange={onChange}
styles={{ wrapper: { maxWidth: 120 } }}
comboboxProps={{ width: "120" }}
styles={{ wrapper: { maxWidth: 140 }, option: { opacity: 1 } }}
comboboxProps={{ width: 200 }}
allowDeselect={false}
withCheckIcon={false}
aria-label={t("Select export format")}
renderOption={({ option }) =>
option.value === "docx" && !docxEntitled ? (
<div>
<Text size="sm" c="dimmed">
{option.label}
</Text>
<Badge size="xs" mt={4}>
{t("Enterprise")}
</Badge>
</div>
) : (
<Text size="sm">{option.label}</Text>
)
}
/>
);
}
+1
View File
@@ -19,4 +19,5 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
DOCX_EXPORT: 'export:docx',
} as const;
@@ -132,6 +132,25 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
saveAs(req.data, decodedFileName);
}
export async function exportPageToDocx(data: { pageId: string }): Promise<void> {
const req = await api.post("/docx-export", data, {
responseType: "blob",
});
const fileName = req?.headers["content-disposition"]
.split("filename=")[1]
.replace(/"/g, "");
let decodedFileName = fileName;
try {
decodedFileName = decodeURIComponent(fileName);
} catch (err) {
// fallback to raw filename
}
saveAs(req.data, decodedFileName);
}
export async function importPage(file: File, spaceId: string) {
const formData = new FormData();
formData.append("spaceId", spaceId);
@@ -98,4 +98,5 @@ export interface IExportPageParams {
export enum ExportFormat {
HTML = "html",
Markdown = "markdown",
Docx = "docx",
}
+5 -1
View File
@@ -10,7 +10,11 @@ const api: AxiosInstance = axios.create({
api.interceptors.response.use(
(response) => {
// we need the response headers for these endpoints
const exemptEndpoints = ["/api/pages/export", "/api/spaces/export"];
const exemptEndpoints = [
"/api/pages/export",
"/api/spaces/export",
"/api/docx-export",
];
if (response.request.responseURL) {
const path = new URL(response.request.responseURL)?.pathname;
if (path && exemptEndpoints.includes(path)) {
+2 -2
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
+21 -20
View File
@@ -30,14 +30,14 @@
"test:e2e": "jest --config test/jest-e2e.json"
},
"dependencies": {
"@ai-sdk/google": "^3.0.52",
"@ai-sdk/openai": "^3.0.47",
"@ai-sdk/openai-compatible": "^2.0.37",
"@ai-sdk/google": "3.0.52",
"@ai-sdk/openai": "3.0.47",
"@ai-sdk/openai-compatible": "2.0.37",
"@aws-sdk/client-s3": "3.1050.0",
"@aws-sdk/lib-storage": "3.1050.0",
"@aws-sdk/s3-request-presigner": "3.1050.0",
"@azure/storage-blob": "12.31.0",
"@clickhouse/client": "^1.18.2",
"@clickhouse/client": "1.18.2",
"@docmost/pdf-inspector": "1.9.6",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
@@ -65,19 +65,19 @@
"@nestjs/websockets": "^11.1.19",
"@node-saml/passport-saml": "^5.1.0",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.134",
"ai-sdk-ollama": "^3.8.1",
"bcrypt": "^6.0.0",
"bowser": "^2.14.1",
"bullmq": "^5.76.10",
"cache-manager": "^7.2.8",
"cheerio": "^1.2.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie": "^1.1.1",
"ai": "6.0.134",
"ai-sdk-ollama": "3.8.1",
"bcrypt": "6.0.0",
"bowser": "2.14.1",
"bullmq": "5.76.10",
"cache-manager": "7.2.8",
"cheerio": "1.2.0",
"class-transformer": "0.5.1",
"class-validator": "0.15.1",
"cookie": "1.1.1",
"fast-bm25": "0.0.5",
"fastify-ip": "^2.0.0",
"fs-extra": "^11.3.4",
"fastify-ip": "2.0.0",
"fs-extra": "11.3.4",
"happy-dom": "20.8.9",
"ioredis": "^5.10.1",
"js-tiktoken": "^1.0.21",
@@ -114,9 +114,9 @@
"scimmy": "1.3.5",
"socket.io": "^4.8.3",
"stripe": "^17.7.0",
"tlds": "^1.261.0",
"tmp-promise": "^3.0.3",
"tseep": "^1.3.1",
"tlds": "1.261.0",
"tmp-promise": "3.0.3",
"tseep": "1.3.1",
"typesense": "^3.0.5",
"undici": "7.24.0",
"ws": "^8.20.1",
@@ -192,7 +192,8 @@
"moduleNameMapper": {
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1"
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
"^src/(.*)$": "<rootDir>/$1"
}
}
}
+1
View File
@@ -20,6 +20,7 @@ export const Feature = {
VIEWER_COMMENTS: 'comment:viewer',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
DOCX_EXPORT: 'export:docx',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
+22 -21
View File
@@ -19,15 +19,15 @@
"clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite"
},
"dependencies": {
"@braintree/sanitize-url": "^7.1.2",
"@braintree/sanitize-url": "7.1.2",
"@casl/ability": "6.8.0",
"@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3",
"@floating-ui/dom": "1.7.3",
"@hocuspocus/provider": "3.4.4",
"@hocuspocus/server": "3.4.4",
"@hocuspocus/transformer": "3.4.4",
"@joplin/turndown": "^4.0.82",
"@joplin/turndown-plugin-gfm": "^1.0.64",
"@joplin/turndown": "4.0.82",
"@joplin/turndown-plugin-gfm": "1.0.64",
"@sindresorhus/slugify": "3.0.0",
"@tiptap/core": "3.20.4",
"@tiptap/extension-audio": "3.20.4",
@@ -58,31 +58,32 @@
"@tiptap/starter-kit": "3.20.4",
"@tiptap/suggestion": "3.20.4",
"@tiptap/y-tiptap": "3.0.2",
"bytes": "^3.1.2",
"cross-env": "^10.1.0",
"date-fns": "^4.1.0",
"bytes": "3.1.2",
"cross-env": "10.1.0",
"date-fns": "4.1.0",
"diff": "8.0.3",
"docx": "9.7.1",
"dompurify": "3.4.1",
"fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1",
"image-dimensions": "^2.5.0",
"jszip": "^3.10.1",
"linkifyjs": "^4.3.2",
"fractional-indexing-jittered": "1.0.0",
"highlight.js": "11.11.1",
"image-dimensions": "2.5.0",
"jszip": "3.10.1",
"linkifyjs": "4.3.2",
"marked": "17.0.5",
"ms": "3.0.0-canary.1",
"qrcode": "^1.5.4",
"qrcode": "1.5.4",
"rfc6902": "5.2.0",
"uuid": "^14.0.0",
"y-indexeddb": "^9.0.12",
"uuid": "14.0.0",
"y-indexeddb": "9.0.12",
"y-prosemirror": "1.3.7",
"yjs": "^13.6.30"
},
"devDependencies": {
"@nx/js": "22.6.1",
"@types/bytes": "^3.1.5",
"@types/qrcode": "^1.5.6",
"@types/turndown": "^5.0.6",
"concurrently": "^9.2.1",
"@types/bytes": "3.1.5",
"@types/qrcode": "1.5.6",
"@types/turndown": "5.0.6",
"concurrently": "9.2.1",
"nx": "22.6.1",
"tsx": "^4.21.0"
},
@@ -133,8 +134,8 @@
"axios": "1.16.0",
"langsmith": "0.7.0",
"follow-redirects": "1.16.0",
"protobufjs": "7.5.8",
"ip-address": "10.1.1"
"protobufjs": "7.5.8",
"ip-address": "10.1.1"
},
"neverBuiltDependencies": []
}
+1
View File
@@ -2,6 +2,7 @@
"name": "@docmost/editor-ext",
"homepage": "https://docmost.com",
"private": true,
"sideEffects": false,
"scripts": {
"build": "tsc --build",
"dev": "tsc --watch"
+4
View File
@@ -34,3 +34,7 @@ export * from "./lib/pdf";
export * from "./lib/page-break";
export * from "./lib/resizable-nodeview";
export {
pageNodeToDocxBuffer,
type DocxImageResolver,
} from "./lib/prosemirror-docx";
@@ -16,10 +16,9 @@ export {
MAX_IMAGE_WIDTH,
} from './serializer';
export {
defaultDocxSerializer,
defaultDocxSerializerAsync,
defaultAsyncNodes,
defaultNodes,
defaultMarks,
pageNodeToDocxBuffer,
type DocxImageResolver,
} from './schema';
export { writeDocx, createDocFromState, buildDoc } from './utils';
@@ -1,76 +1,67 @@
import { HeadingLevel, ShadingType } from 'docx';
import { Node } from 'prosemirror-model';
import {
DocxSerializer,
MarkSerializer,
NodeSerializer,
DocxSerializerAsync,
MarkSerializer,
NodeSerializerAsync,
OptionsAsync,
} from './serializer';
import { getLatexFromNode } from './utils';
import { writeDocx } from './utils';
export const defaultNodes: NodeSerializer = {
text(state, node) {
state.text(node.text ?? '');
},
paragraph(state, node) {
state.renderInline(node);
state.closeBlock(node);
},
heading(state, node) {
state.renderInline(node);
const heading = [
HeadingLevel.HEADING_1,
HeadingLevel.HEADING_2,
HeadingLevel.HEADING_3,
HeadingLevel.HEADING_4,
HeadingLevel.HEADING_5,
HeadingLevel.HEADING_6,
][node.attrs.level - 1];
state.closeBlock(node, { heading });
},
blockquote(state, node) {
state.renderContent(node, { style: 'IntenseQuote' });
},
code_block(state, node) {
// TODO: something for code
state.renderContent(node);
state.closeBlock(node);
},
horizontal_rule(state, node) {
// Kinda hacky, but this works to insert two paragraphs, the first with a break
state.closeBlock(node, { thematicBreak: true });
state.closeBlock(node);
},
hard_break(state) {
state.addRunOptions({ break: 1 });
},
ordered_list(state, node) {
state.renderList(node, 'numbered');
},
bullet_list(state, node) {
state.renderList(node, 'bullets');
},
list_item(state, node) {
state.renderListItem(node);
},
// Presentational
image(state, node) {
const { src } = node.attrs;
state.image(src);
state.closeBlock(node);
},
// Technical
math(state, node) {
state.math(getLatexFromNode(node), { inline: true });
},
equation(state, node) {
const { id, numbered } = node.attrs;
state.math(getLatexFromNode(node), { inline: false, numbered, id });
state.closeBlock(node);
},
table(state, node) {
state.table(node);
},
export type DocxImageResolver = OptionsAsync['getImageBuffer'];
// docx requires a 6-digit hex color (no leading #). Convert #rgb, #rrggbb,
// and rgb()/rgba() inputs to 6-digit hex; return undefined for anything else
// (named colors, hsl, etc.) so the caller omits the color rather than letting
// docx throw "Invalid hex value".
function toDocxColor(input?: string): string | undefined {
if (!input) return undefined;
const value = input.trim().toLowerCase();
const hex = value.startsWith('#') ? value.slice(1) : value;
if (/^[0-9a-f]{6}$/.test(hex)) return hex;
if (/^[0-9a-f]{3}$/.test(hex)) {
return hex
.split('')
.map((ch) => ch + ch)
.join('');
}
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgb) {
const channel = (n: string) =>
Math.max(0, Math.min(255, parseInt(n, 10)))
.toString(16)
.padStart(2, '0');
return channel(rgb[1]) + channel(rgb[2]) + channel(rgb[3]);
}
return undefined;
}
// Images and diagrams embed via the image resolver; the URL (with its file
// extension) is passed through so docx can infer the image type.
const renderImage: NodeSerializerAsync[string] = async (state, node) => {
const src = node.attrs?.src || node.attrs?.attachmentId;
if (src) {
try {
await state.image(src, 100);
} catch {
// Unrenderable/missing image: skip rather than fail the whole export.
}
}
state.closeBlock(node);
};
// Non-embeddable media render as a labelled line.
const renderFileLine: NodeSerializerAsync[string] = (state, node) => {
const label =
node.attrs?.name || node.attrs?.src || node.attrs?.url || 'attachment';
state.text(label);
state.closeBlock(node);
};
const renderEmbedLine: NodeSerializerAsync[string] = (state, node) => {
const label = node.attrs?.src || node.attrs?.url || 'embed';
state.text(label);
state.closeBlock(node);
};
export const defaultAsyncNodes: NodeSerializerAsync = {
@@ -90,111 +81,170 @@ export const defaultAsyncNodes: NodeSerializerAsync = {
HeadingLevel.HEADING_4,
HeadingLevel.HEADING_5,
HeadingLevel.HEADING_6,
][node.attrs.level - 1];
][(node.attrs.level ?? 1) - 1];
state.closeBlock(node, { heading });
},
blockquote(state, node) {
state.renderContent(node, { style: 'IntenseQuote' });
async blockquote(state, node) {
await state.renderContent(node, { style: 'IntenseQuote' });
},
code_block(state, node) {
// TODO: something for code
state.renderContent(node);
async codeBlock(state, node) {
await state.renderContent(node);
state.closeBlock(node);
},
horizontal_rule(state, node) {
// Kinda hacky, but this works to insert two paragraphs, the first with a break
horizontalRule(state, node) {
state.closeBlock(node, { thematicBreak: true });
state.closeBlock(node);
},
hard_break(state) {
hardBreak(state) {
state.addRunOptions({ break: 1 });
},
async ordered_list(state, node) {
await state.renderList(node, 'numbered');
},
async bullet_list(state, node) {
async bulletList(state, node) {
await state.renderList(node, 'bullets');
},
async list_item(state, node) {
async orderedList(state, node) {
await state.renderList(node, 'numbered');
},
async listItem(state, node) {
await state.renderListItem(node);
},
// Presentational
async image(state, node) {
const { src } = node.attrs;
await state.image(src);
state.closeBlock(node);
async taskList(state, node) {
await state.renderList(node, 'bullets');
},
// Technical
math(state, node) {
state.math(getLatexFromNode(node), { inline: true });
},
equation(state, node) {
const { id, numbered } = node.attrs;
state.math(getLatexFromNode(node), { inline: false, numbered, id });
state.closeBlock(node);
async taskItem(state, node) {
if (state.currentNumbering) {
state.addParagraphOptions({ numbering: state.currentNumbering });
}
state.text(node.attrs?.checked ? '☑ ' : '☐ ');
await state.renderContent(node);
},
async table(state, node) {
await state.table(node);
},
// Docmost stores LaTeX in attrs.text.
mathInline(state, node) {
state.math(node.attrs?.text ?? '', { inline: true });
},
mathBlock(state, node) {
state.math(node.attrs?.text ?? '', { inline: false, numbered: false });
state.closeBlock(node);
},
image: renderImage,
drawio: renderImage,
excalidraw: renderImage,
video: renderFileLine,
audio: renderFileLine,
pdf: renderFileLine,
attachment: renderFileLine,
embed: renderEmbedLine,
youtube: renderEmbedLine,
async callout(state, node) {
await state.renderContent(node, { style: 'IntenseQuote' });
},
async details(state, node) {
await state.renderContent(node);
},
async detailsSummary(state, node) {
await state.renderInline(node);
state.closeBlock(node, { heading: HeadingLevel.HEADING_4 });
},
async detailsContent(state, node) {
await state.renderContent(node);
},
async columns(state, node) {
await state.renderContent(node);
},
async column(state, node) {
await state.renderContent(node);
},
async transclusionSource(state, node) {
await state.renderContent(node);
},
mention(state, node) {
state.text(`@${node.attrs?.label ?? ''}`);
},
status(state, node) {
state.text(`[${node.attrs?.text ?? ''}]`);
},
pageBreak(state, node) {
state.closeBlock(node, { pageBreakBefore: true });
},
// No usable static export representation: skip without failing.
subpages() {},
transclusionReference() {},
};
export const defaultMarks: MarkSerializer = {
em() {
return { italics: true };
},
strong() {
bold() {
return { bold: true };
},
italic() {
return { italics: true };
},
bold() {
return { bold: true };
strike() {
return { strike: true };
},
link() {
// Note, this is handled specifically in the serializer
// Word treats links more like a Node rather than a mark
return {};
underline() {
return { underline: {} };
},
code() {
return {
font: {
name: 'Monospace',
},
font: { name: 'Monospace' },
color: '000000',
shading: {
type: ShadingType.SOLID,
color: 'D2D3D2',
fill: 'D2D3D2',
},
shading: { type: ShadingType.SOLID, color: 'D2D3D2', fill: 'D2D3D2' },
};
},
abbr() {
// TODO: abbreviation
return {};
},
subscript() {
return { subScript: true };
},
superscript() {
return { superScript: true };
},
strikethrough() {
// doubleStrike!
return { strike: true };
subscript() {
return { subScript: true };
},
underline() {
return {
underline: {},
};
link() {
// Handled specifically in the serializer; Word treats links as nodes.
return {};
},
smallcaps() {
return { smallCaps: true };
highlight(_state, _node, mark) {
const fill = toDocxColor(mark.attrs?.color);
return fill
? { shading: { type: ShadingType.CLEAR, fill } }
: { highlight: 'yellow' };
},
allcaps() {
return { allCaps: true };
// @tiptap/extension-color stores the color on the textStyle mark.
textStyle(_state, _node, mark) {
const color = toDocxColor(mark.attrs?.color);
return color ? { color } : {};
},
// Comments are editor-only; drop the annotation in the export.
comment() {
return {};
},
};
export const defaultDocxSerializer = new DocxSerializer(defaultNodes, defaultMarks);
export const defaultDocxSerializerAsync = new DocxSerializerAsync(defaultAsyncNodes, defaultMarks);
export async function pageNodeToDocxBuffer(
doc: Node,
getImageBuffer: DocxImageResolver,
): Promise<Buffer> {
const serializer = new DocxSerializerAsync(defaultAsyncNodes, defaultMarks);
const wordDoc = await serializer.serializeAsync(
doc,
{ getImageBuffer },
// docx's built-in heading styles are blue (#2E74B5 / #1F4D78). The editor
// has no heading color, so override the default heading run colors to the
// normal text color. Sizes/italics mirror docx's own defaults so only the
// color changes.
() =>
({
styles: {
default: {
heading1: { run: { color: '000000', size: 32 } },
heading2: { run: { color: '000000', size: 26 } },
heading3: { run: { color: '000000', size: 24 } },
heading4: { run: { color: '000000', italics: true } },
heading5: { run: { color: '000000' } },
heading6: { run: { color: '000000' } },
},
},
}) as any,
);
return writeDocx(wordDoc);
}
@@ -117,7 +117,7 @@ export class DocxSerializerState {
constructor(nodes: NodeSerializer, marks: MarkSerializer, options: Options) {
this.nodes = nodes;
this.marks = marks;
this.options = options ?? {};
this.options = options ?? ({} as Options);
this.children = [];
this.numbering = [];
@@ -342,7 +342,7 @@ export class DocxSerializerState {
// Check if all cells are headers in this row
let tableHeader = true;
row.content.forEach((cell) => {
if (cell.type.name !== 'table_header') {
if (cell.type.name !== 'tableHeader') {
tableHeader = false;
}
});
@@ -529,7 +529,7 @@ export class DocxSerializerStateAsync {
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer, options: OptionsAsync) {
this.nodes = nodes;
this.marks = marks;
this.options = options ?? {};
this.options = options ?? ({} as OptionsAsync);
this.children = [];
this.numbering = [];
@@ -765,7 +765,7 @@ export class DocxSerializerStateAsync {
// Check if all cells in the row are headers
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
const cell = row.content.child(cellIndex);
if (cell.type.name !== 'table_header') {
if (cell.type.name !== 'tableHeader') {
tableHeader = false;
}
}
@@ -1,28 +0,0 @@
import { Schema } from 'prosemirror-model';
import { builders } from 'prosemirror-test-builder';
import { schemas } from '@curvenote/schema';
const schema = new Schema(schemas.presets.full);
export const tnodes = builders(schema, {
p: { nodeType: 'paragraph' },
h1: { nodeType: 'heading', level: 1 },
h2: { nodeType: 'heading', level: 2 },
hr: { nodeType: 'horizontal_rule' },
li: { nodeType: 'list_item' },
ol: { nodeType: 'ordered_list' },
ol3: { nodeType: 'ordered_list', order: 3 },
ul: { nodeType: 'bullet_list' },
pre: { nodeType: 'code_block' },
br: { nodeType: 'hard_break' },
img: { nodeType: 'image', src: 'img.png', alt: 'x' },
a: { markType: 'link', href: 'https://example.com' },
math: { nodeType: 'math' },
equation: { nodeType: 'equation', numbered: true, id: 'eq1' },
equationUnnumbered: { nodeType: 'equation', numbered: false, id: 'eq2' },
abbr: { nodeType: 'abbr', title: 'Cascading Style Sheets' },
aside: { nodeType: 'aside' },
figure: { nodeType: 'figure' },
}) as any;
export const tdoc = (...args: Parameters<typeof tnodes.doc>) => tnodes.doc('', ...args);
@@ -1,109 +0,0 @@
import * as fs from 'fs';
import { describe, it, expect } from 'vitest';
import {
DocxSerializerAsync,
defaultAsyncNodes,
defaultMarks,
defaultDocxSerializer,
writeDocx,
} from '../src';
import { tnodes, tdoc } from './build';
import { writeFileSync } from 'fs';
const {
blockquote,
h1,
h2,
p,
hr,
li,
ol,
ol3,
ul,
pre,
em,
strong,
code,
a,
br,
img,
math,
equation,
equationUnnumbered,
figure,
} = tnodes;
const imageBase64Data = `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`;
/**
* Adds image type to base64 encoded images
*/
export const docxSerializer = new DocxSerializerAsync(
{
...defaultAsyncNodes,
async image(state, node) {
const { src } = node.attrs;
await state.image(src, 70, 'center', undefined, 'png');
state.closeBlock(node);
},
},
defaultMarks,
);
describe('DOCX Serialization', () => {
it('serializes document structure with async image handling', async () => {
const w = await docxSerializer.serializeAsync(
tdoc(
h1('Welcome to ', code('prosemirror-docx'), strong('!!')),
p('This is ', code('code'), br(), 'hello!'),
ul(li(p('bullet 1')), li(p('bullet 2')), ul(li(p('bullet 3.1')), li(p('bullet 3.2')))),
ul(li(p('bullet 1')), li(p('bullet 2')), ul(li(p('bullet 3.1')), li(p('bullet 3.2')))),
ol(li(p('bullet 1')), li(p('bullet 2')), ul(li(p('bullet 3.1')), li(p('bullet 3.2')))),
p(a('This is '), a(em('emphasized'))),
hr(),
p('Some math in a paragraph: ', math('Ax=b'), ' and then a standalone numbered equation:'),
equation('Ax=b'),
p('And an unnumbered equation:'),
equationUnnumbered('\\sum^{9}_{i=0}i+2 = ??'),
img({ src: 'https://avatars.githubusercontent.com/u/78044536' }),
img({ src: `data:text/plain;base64,${imageBase64Data}` }),
),
{
async getImageBuffer(src: string) {
const arrayBuffer = await fetch(src).then((res) => res.arrayBuffer());
return new Uint8Array(arrayBuffer);
},
},
);
const buffer = await writeDocx(w);
fs.writeFileSync(`hello-async.docx`, buffer);
expect(1).toBe(1);
});
it('serializes document structure with sync image handling', async () => {
const w = defaultDocxSerializer.serialize(
tdoc(
h1('Welcome to ', code('prosemirror-docx'), strong('!!')),
p('This is ', code('code'), br(), 'hello!'),
ul(li(p('bullet 1')), li(p('bullet 2')), ul(li(p('bullet 3.1')), li(p('bullet 3.2')))),
ul(li(p('bullet 1')), li(p('bullet 2')), ul(li(p('bullet 3.1')), li(p('bullet 3.2')))),
ol(li(p('bullet 1')), li(p('bullet 2')), ul(li(p('bullet 3.1')), li(p('bullet 3.2')))),
p(a('This is '), a(em('emphasized'))),
hr(),
p('Some math in a paragraph: ', math('Ax=b'), ' and then a standalone numbered equation:'),
equation('Ax=b'),
p('And an unnumbered equation:'),
equationUnnumbered('\\sum^{9}_{i=0}i+2 = ??'),
img(),
),
{
getImageBuffer(src: string) {
return Buffer.from(imageBase64Data, 'base64');
},
},
);
await writeDocx(w).then((buffer) => {
fs.writeFileSync('hello.docx', buffer);
});
expect(2).toBe(2);
});
});
@@ -14,14 +14,16 @@ export function createShortId() {
}
export function buildDoc(state: SerializationState, opts?: IPropertiesOptions): Document {
let sections = state?.sections?.map((section) => ({
properties: section.config.properties || {
type: SectionType.CONTINUOUS,
},
headers: section.config.headers,
footers: section.config.footers,
children: section.children,
}));
let sections = state?.sections?.length
? state.sections.map((section) => ({
properties: section.config.properties || {
type: SectionType.CONTINUOUS,
},
headers: section.config.headers,
footers: section.config.footers,
children: section.children,
}))
: undefined;
if (!sections) {
sections = [
{
+82 -41
View File
@@ -53,7 +53,7 @@ importers:
.:
dependencies:
'@braintree/sanitize-url':
specifier: ^7.1.2
specifier: 7.1.2
version: 7.1.2
'@casl/ability':
specifier: 6.8.0
@@ -62,7 +62,7 @@ importers:
specifier: workspace:*
version: link:packages/editor-ext
'@floating-ui/dom':
specifier: ^1.7.3
specifier: 1.7.3
version: 1.7.3
'@hocuspocus/provider':
specifier: 3.4.4
@@ -74,10 +74,10 @@ importers:
specifier: 3.4.4
version: 3.4.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)
'@joplin/turndown':
specifier: ^4.0.82
specifier: 4.0.82
version: 4.0.82
'@joplin/turndown-plugin-gfm':
specifier: ^1.0.64
specifier: 1.0.64
version: 1.0.64
'@sindresorhus/slugify':
specifier: 3.0.0
@@ -170,34 +170,37 @@ importers:
specifier: 3.0.2
version: 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)
bytes:
specifier: ^3.1.2
specifier: 3.1.2
version: 3.1.2
cross-env:
specifier: ^10.1.0
specifier: 10.1.0
version: 10.1.0
date-fns:
specifier: ^4.1.0
specifier: 4.1.0
version: 4.1.0
diff:
specifier: 8.0.3
version: 8.0.3
docx:
specifier: 9.7.1
version: 9.7.1
dompurify:
specifier: 3.4.1
version: 3.4.1
fractional-indexing-jittered:
specifier: ^1.0.0
specifier: 1.0.0
version: 1.0.0
highlight.js:
specifier: ^11.11.1
specifier: 11.11.1
version: 11.11.1
image-dimensions:
specifier: ^2.5.0
specifier: 2.5.0
version: 2.5.0
jszip:
specifier: ^3.10.1
specifier: 3.10.1
version: 3.10.1
linkifyjs:
specifier: ^4.3.2
specifier: 4.3.2
version: 4.3.2
marked:
specifier: 17.0.5
@@ -206,16 +209,16 @@ importers:
specifier: 3.0.0-canary.1
version: 3.0.0-canary.1
qrcode:
specifier: ^1.5.4
specifier: 1.5.4
version: 1.5.4
rfc6902:
specifier: 5.2.0
version: 5.2.0
uuid:
specifier: ^14.0.0
specifier: 14.0.0
version: 14.0.0
y-indexeddb:
specifier: ^9.0.12
specifier: 9.0.12
version: 9.0.12(yjs@13.6.30)
y-prosemirror:
specifier: 1.3.7
@@ -228,16 +231,16 @@ importers:
specifier: 22.6.1
version: 22.6.1(@babel/traverse@7.28.5)(nx@22.6.1)
'@types/bytes':
specifier: ^3.1.5
specifier: 3.1.5
version: 3.1.5
'@types/qrcode':
specifier: ^1.5.6
specifier: 1.5.6
version: 1.5.6
'@types/turndown':
specifier: ^5.0.6
specifier: 5.0.6
version: 5.0.6
concurrently:
specifier: ^9.2.1
specifier: 9.2.1
version: 9.2.1
nx:
specifier: 22.6.1
@@ -484,13 +487,13 @@ importers:
apps/server:
dependencies:
'@ai-sdk/google':
specifier: ^3.0.52
specifier: 3.0.52
version: 3.0.52(zod@4.3.6)
'@ai-sdk/openai':
specifier: ^3.0.47
specifier: 3.0.47
version: 3.0.47(zod@4.3.6)
'@ai-sdk/openai-compatible':
specifier: ^2.0.37
specifier: 2.0.37
version: 2.0.37(zod@4.3.6)
'@aws-sdk/client-s3':
specifier: 3.1050.0
@@ -505,7 +508,7 @@ importers:
specifier: 12.31.0
version: 12.31.0
'@clickhouse/client':
specifier: ^1.18.2
specifier: 1.18.2
version: 1.18.2
'@docmost/pdf-inspector':
specifier: 1.9.6
@@ -589,43 +592,43 @@ importers:
specifier: ^8.3.0
version: 8.3.0(socket.io-adapter@2.5.4)
ai:
specifier: ^6.0.134
specifier: 6.0.134
version: 6.0.134(zod@4.3.6)
ai-sdk-ollama:
specifier: ^3.8.1
specifier: 3.8.1
version: 3.8.1(ai@6.0.134(zod@4.3.6))(zod@4.3.6)
bcrypt:
specifier: ^6.0.0
specifier: 6.0.0
version: 6.0.0
bowser:
specifier: ^2.14.1
specifier: 2.14.1
version: 2.14.1
bullmq:
specifier: ^5.76.10
specifier: 5.76.10
version: 5.76.10
cache-manager:
specifier: ^7.2.8
specifier: 7.2.8
version: 7.2.8
cheerio:
specifier: ^1.2.0
specifier: 1.2.0
version: 1.2.0
class-transformer:
specifier: ^0.5.1
specifier: 0.5.1
version: 0.5.1
class-validator:
specifier: ^0.15.1
specifier: 0.15.1
version: 0.15.1
cookie:
specifier: ^1.1.1
specifier: 1.1.1
version: 1.1.1
fast-bm25:
specifier: 0.0.5
version: 0.0.5(typescript@5.9.3)
fastify-ip:
specifier: ^2.0.0
specifier: 2.0.0
version: 2.0.0
fs-extra:
specifier: ^11.3.4
specifier: 11.3.4
version: 11.3.4
happy-dom:
specifier: 20.8.9
@@ -736,13 +739,13 @@ importers:
specifier: ^17.7.0
version: 17.7.0
tlds:
specifier: ^1.261.0
specifier: 1.261.0
version: 1.261.0
tmp-promise:
specifier: ^3.0.3
specifier: 3.0.3
version: 3.0.3
tseep:
specifier: ^1.3.1
specifier: 1.3.1
version: 1.3.1
typesense:
specifier: ^3.0.5
@@ -6306,6 +6309,10 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
docx@9.7.1:
resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==}
engines: {node: '>=10'}
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
@@ -6977,6 +6984,9 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hash.js@1.1.7:
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
hashery@1.4.0:
resolution: {integrity: sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==}
engines: {node: '>=20'}
@@ -8111,6 +8121,9 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
@@ -10264,6 +10277,10 @@ packages:
xml-encryption@3.1.0:
resolution: {integrity: sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==}
xml-js@1.6.11:
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
hasBin: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -10276,6 +10293,9 @@ packages:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xml@1.0.1:
resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
xmlbuilder@10.1.1:
resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==}
engines: {node: '>=4.0'}
@@ -14449,7 +14469,7 @@ snapshots:
'@tiptap/extension-bubble-menu@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies:
'@floating-ui/dom': 1.7.4
'@floating-ui/dom': 1.7.3
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4
optional: true
@@ -16620,6 +16640,15 @@ snapshots:
dependencies:
esutils: 2.0.3
docx@9.7.1:
dependencies:
'@types/node': 25.5.0
hash.js: 1.1.7
jszip: 3.10.1
nanoid: 5.1.7
xml: 1.0.1
xml-js: 1.6.11
dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
@@ -17541,6 +17570,11 @@ snapshots:
dependencies:
has-symbols: 1.1.0
hash.js@1.1.7:
dependencies:
inherits: 2.0.4
minimalistic-assert: 1.0.1
hashery@1.4.0:
dependencies:
hookified: 1.15.1
@@ -18821,6 +18855,8 @@ snapshots:
min-indent@1.0.1: {}
minimalistic-assert@1.0.1: {}
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.6
@@ -20163,8 +20199,7 @@ snapshots:
sax@1.4.1: {}
sax@1.6.0:
optional: true
sax@1.6.0: {}
saxes@6.0.0:
dependencies:
@@ -21223,6 +21258,10 @@ snapshots:
escape-html: 1.0.3
xpath: 0.0.32
xml-js@1.6.11:
dependencies:
sax: 1.6.0
xml-name-validator@5.0.0: {}
xml-naming@0.1.0: {}
@@ -21232,6 +21271,8 @@ snapshots:
sax: 1.4.1
xmlbuilder: 11.0.1
xml@1.0.1: {}
xmlbuilder@10.1.1: {}
xmlbuilder@11.0.1: {}