Skip to content

Commit eb8a9f5

Browse files
authored
Merge pull request #5 from md2docx/optimize
Optimize
2 parents 534975a + 08d3250 commit eb8a9f5

File tree

15 files changed

+306
-102
lines changed

15 files changed

+306
-102
lines changed

.changeset/plenty-kiwis-float.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@m2d/image": major
3+
---
4+
5+
Changing the plugin signature in accordace with this discussion - https://github.com/md2docx/mdast2docx/discussions/15

.changeset/real-bobcats-accept.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@m2d/image": minor
3+
---
4+
5+
Use caching

examples/nextjs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"start": "next start"
1313
},
1414
"dependencies": {
15-
"@m2d/core": "^0.0.5",
15+
"@m2d/core": "^1.0.1",
1616
"@m2d/emoji": "^0.1.1",
1717
"@m2d/image": "workspace:*",
1818
"@repo/shared": "workspace:*",

examples/vite/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"lint:fix": "eslint src/ --fix"
1313
},
1414
"dependencies": {
15-
"@m2d/core": "^0.0.5",
15+
"@m2d/core": "^1.0.1",
1616
"@m2d/emoji": "^0.1.1",
1717
"@m2d/image": "workspace:*",
1818
"@repo/shared": "workspace:*",

lib/__tests__/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ describe.concurrent("toDocx", () => {
1717
const docxBlob = await toDocx(
1818
mdast,
1919
{},
20+
// @ts-expect-error -- plugin types mismatch for time being
2021
{ plugins: [htmlPlugin(), mermaidPlugin(), imagePlugin()] },
2122
);
2223
expect(docxBlob).toBeInstanceOf(Blob);

lib/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
},
3434
"devDependencies": {
3535
"@m2d/html": "^1.0.1",
36-
"@m2d/mermaid": "^0.0.3",
36+
"@m2d/mermaid": "^1.0.1",
3737
"@repo/eslint-config": "workspace:*",
3838
"@repo/typescript-config": "workspace:*",
3939
"@types/jsdom": "^21.1.7",
@@ -51,7 +51,7 @@
5151
"vitest": "^3.1.3"
5252
},
5353
"dependencies": {
54-
"@m2d/core": "^0.0.5"
54+
"@m2d/core": "^1.0.1"
5555
},
5656
"peerDependencies": {
5757
"docx": "^9.3.0"

lib/src/index.ts

Lines changed: 94 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
import type { IImageOptions } from "docx";
2-
import type { Image, ImageReference, IPlugin, Optional, SVG } from "@m2d/core";
2+
import type {
3+
Image,
4+
ImageData,
5+
ImageReference,
6+
IPlugin,
7+
Optional,
8+
Parent,
9+
PhrasingContent,
10+
Root,
11+
RootContent,
12+
SVG,
13+
} from "@m2d/core";
314
import { handleSvg } from "./svg-utils";
15+
import { Definitions } from "@m2d/core/utils";
416

517
/**
618
* List of image types directly supported by `docx`.
@@ -179,7 +191,7 @@ const handleNonDataUrls = async (
179191

180192
if (/(svg|xml)/.test(response.headers.get("content-type") ?? "") || url.endsWith(".svg")) {
181193
const svgText = await response.text();
182-
return handleSvg({ type: "svg", value: svgText, id: `s${crypto.randomUUID()}` }, options);
194+
return handleSvg({ type: "svg", value: svgText }, options);
183195
}
184196

185197
const arrayBuffer = await response.arrayBuffer();
@@ -256,6 +268,46 @@ const defaultOptions: IDefaultImagePluginOptions = {
256268
dpi: 96,
257269
};
258270

271+
const cache: Record<string, Promise<IImageOptions>> = {};
272+
273+
/**
274+
* Generate image data
275+
*
276+
* Extracting this logic in an async function for the purpose of caching the promises
277+
*/
278+
const createImageData = async (
279+
node: Image | SVG,
280+
url: string,
281+
options: IDefaultImagePluginOptions,
282+
) => {
283+
const imgOptions =
284+
node.type === "svg"
285+
? await handleSvg(node, options)
286+
: await options.imageResolver(url, options);
287+
288+
// apply data props
289+
const { data } = node as Image;
290+
const { width: origW, height: origH } = imgOptions.transformation;
291+
let { width, height } = data ?? {};
292+
if (width && !height) {
293+
height = (origH * width) / origW;
294+
} else if (!width && height) {
295+
width = (origW * height) / origH;
296+
} else if (!width && !height) {
297+
height = origH;
298+
width = origW;
299+
}
300+
301+
const scale = Math.min(
302+
(options.maxW * options.dpi) / width!,
303+
(options.maxH * options.dpi) / height!,
304+
1,
305+
);
306+
// @ts-expect-error -- we are mutating the immutable options.
307+
imgOptions.transformation = { width: width * scale, height: height * scale };
308+
return imgOptions;
309+
};
310+
259311
/**
260312
* Image plugin for processing inline image nodes in the Markdown AST.
261313
* Resolves both base64 and URL-based images for inclusion in DOCX.
@@ -264,48 +316,48 @@ const defaultOptions: IDefaultImagePluginOptions = {
264316
* @returns Plugin implementation for use in the `@m2d/core` pipeline.
265317
*/
266318
export const imagePlugin: (options?: IImagePluginOptions) => IPlugin = options_ => {
267-
const options: IDefaultImagePluginOptions = { ...defaultOptions, ...options_ };
319+
const options = { ...defaultOptions, ...options_ };
320+
321+
/** preprocess images */
322+
const preprocess = async (root: Root, definitions: Definitions) => {
323+
const promises: Promise<void>[] = [];
324+
325+
/** process images and create promises - use max parallel processing */
326+
const preprocessInternal = (node: Root | RootContent | PhrasingContent) => {
327+
(node as Parent).children?.forEach(preprocessInternal);
328+
329+
if (/^(image|svg)/.test(node.type))
330+
promises.push(
331+
(async () => {
332+
// Only process image nodes
333+
const url =
334+
(node as Image).url ??
335+
definitions[(node as ImageReference).identifier?.toUpperCase()];
336+
337+
// for SVG if the value is promise, it must have mermaid. We will in future provide better type safety after considering if there are any other mermaid like tools that we might want to support
338+
const cacheKey = node.type === "svg" ? (node.data?.mermaid ?? String(node.value)) : url;
339+
340+
cache[cacheKey] ??= createImageData(node as Image | SVG, url, options);
341+
const alt = (node as Image).alt ?? url?.split("/")?.pop() ?? "";
342+
343+
node.data = {
344+
...(await cache[cacheKey]),
345+
altText: { description: alt, name: alt, title: alt },
346+
...(node as Image | SVG).data,
347+
};
348+
})(),
349+
);
350+
};
351+
preprocessInternal(root);
352+
await Promise.all(promises);
353+
};
268354
return {
269-
inline: async (docx, node, runProps, definitions) => {
355+
preprocess,
356+
inline: (docx, node, runProps) => {
270357
if (/^(image|svg)/.test(node.type)) {
271-
const alt = (node as Image).alt ?? (node as Image).url?.split("/")?.pop() ?? "";
272-
const url =
273-
(node as Image).url ?? definitions[(node as ImageReference).identifier?.toUpperCase()];
274-
275-
const imgOptions =
276-
node.type === "svg"
277-
? await handleSvg(node, options)
278-
: await options.imageResolver(url, options);
279-
280-
// apply data props
281-
const { data } = node as Image;
282-
const { width: origW, height: origH } = imgOptions.transformation;
283-
let { width, height } = data ?? {};
284-
if (width && !height) {
285-
height = (origH * width) / origW;
286-
} else if (!width && height) {
287-
width = (origW * height) / origH;
288-
} else if (!width && !height) {
289-
height = origH;
290-
width = origW;
291-
}
292-
293-
const scale = Math.min(
294-
(options.maxW * options.dpi) / width!,
295-
(options.maxH * options.dpi) / height!,
296-
1,
297-
);
298-
// @ts-expect-error -- we are mutating the immutable options.
299-
imgOptions.transformation = { width: width * scale, height: height * scale };
300-
node.type = "";
301-
return [
302-
new docx.ImageRun({
303-
...imgOptions,
304-
altText: { description: alt, name: alt, title: alt },
305-
...runProps,
306-
...(node as Image | SVG).data,
307-
}),
308-
];
358+
const { imageOptions, ...data } = (node as Image).data as ImageData;
359+
// @ts-expect-error -- merging a lot of data types here
360+
return [new docx.ImageRun({ ...imageOptions, ...data, ...runProps })];
309361
}
310362
return [];
311363
},

lib/src/svg-utils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,14 @@ export const handleSvg = async (
8787
svgNode: SVG,
8888
options: IDefaultImagePluginOptions,
8989
): Promise<IImageOptions> => {
90-
const svg = svgNode.value;
90+
const value = svgNode.value;
91+
const svg = typeof value === "string" ? value : (await value).svg;
9192
try {
9293
const img = new Image();
9394
const container = getContainer(options);
9495
container.appendChild(img);
9596
// @ts-expect-error -- extra data
96-
const isGantt = /(?:^|\n)\s*gantt\s*/.test(svgNode.data?.mermaid);
97+
const isGantt = /^\s*gantt\s*/.test(svgNode.data?.mermaid);
9798
const croppedSvg = isGantt ? { svg, scale: 1 } : await tightlyCropSvg(svg, container);
9899

99100
const svgDataURL = await svgToBase64(croppedSvg.svg);

lib/src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Makes all properties in T optional recursively, with special handling for functions.
3+
* This utility type preserves function signatures while making them optional.
4+
*/
5+
export type Optional<T> = {
6+
[K in keyof T]?: T[K] extends (...args: any[]) => any
7+
? T[K] // Preserve function types as-is
8+
: T[K] extends object
9+
? Optional<T[K]>
10+
: T[K];
11+
};

lib/vitest.setup.ts

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Root, RootContent, PhrasingContent, Parent, SVG } from "@m2d/core";
12
import { vi } from "vitest";
23

34
// Mock createImageBitmap
@@ -76,30 +77,86 @@ globalThis.fetch = mockFetch;
7677

7778
globalThis.Image = class {
7879
_src = "";
79-
width?: number;
80-
height?: number;
80+
width = 100;
81+
height = 100;
8182
onload: (() => void) | null = null;
8283
onerror: ((err?: unknown) => void) | null = null;
8384

84-
constructor(width?: number, height?: number) {
85-
this.width = width;
86-
this.height = height;
87-
88-
// Simulate async image load
89-
setTimeout(() => {
90-
if (this.onload) this.onload();
91-
}, 10);
92-
}
93-
9485
set src(value: string) {
9586
this._src = value;
96-
// Simulate async loading
87+
88+
// simulate successful image load after a small delay
9789
setTimeout(() => {
9890
if (this.onload) this.onload();
99-
}, 10);
91+
}, 1);
10092
}
10193

10294
get src() {
10395
return this._src;
10496
}
10597
} as unknown as typeof Image;
98+
99+
const originalCreateElement = document.createElement;
100+
101+
document.createElement = vi.fn((tagName: string) => {
102+
if (tagName.toLowerCase() === "img") {
103+
const fakeImg: any = {
104+
_src: "",
105+
width: 100,
106+
height: 100,
107+
set src(value: string) {
108+
this._src = value;
109+
// Simulate async load
110+
setTimeout(() => this.onload?.(), 1);
111+
},
112+
get src() {
113+
return this._src;
114+
},
115+
onload: null,
116+
onerror: null,
117+
};
118+
return fakeImg;
119+
}
120+
121+
return originalCreateElement.call(document, tagName);
122+
});
123+
124+
vi.mock("@m2d/mermaid", () => {
125+
const preprocess = (node: Root | RootContent | PhrasingContent) => {
126+
// Preprocess the AST to detect and cache Mermaid or Mindmap blocks
127+
(node as Parent).children?.forEach(preprocess);
128+
129+
// Only process code blocks with a supported language tag
130+
if (node.type === "code" && /(mindmap|mermaid|mmd)/.test(node.lang ?? "")) {
131+
// Create an extended MDAST-compatible SVG node
132+
const svgNode: SVG = {
133+
type: "svg",
134+
value: '<svg xmlns="http://www.w3.org/2000/svg"><text>Mock</text></svg>',
135+
// Store original Mermaid source in data for traceability/debug
136+
data: { mermaid: node.value },
137+
};
138+
139+
// Replace the code block with a paragraph that contains the SVG
140+
Object.assign(node, {
141+
type: "paragraph",
142+
children: [svgNode],
143+
data: { alignment: "center" }, // center-align diagram
144+
});
145+
}
146+
};
147+
return {
148+
__esModule: true,
149+
mermaidPlugin: () => ({
150+
preprocess,
151+
}),
152+
};
153+
});
154+
155+
(SVGElement.prototype as any).getComputedTextLength = () => 100; // or any fixed number
156+
157+
(HTMLCanvasElement.prototype as any).getContext = vi.fn(() => {
158+
return {
159+
drawImage: vi.fn(),
160+
toDataURL: vi.fn(() => "data:image/png;base64,fakepng"),
161+
};
162+
});

0 commit comments

Comments
 (0)