docx-images.ts
78 lines · make-pdf/src/docx-images.ts at 3f79d81
1/**
2 * DOCX image guard. html-to-docx 1.8.0 sizes every data-URI <img> with its
3 * own bundled copy of image-size 1.x, which the package.json override cannot
4 * reach. That copy's detector sends a buffer to its ICNS parser exactly when
5 * the bytes start with "icns", and the parser loops forever on a zero-length
6 * entry (GHSA-w3rx-r6r6-pgpr). No other parser in the copy has a known flaw.
7 *
8 * guardDocxImages drops the <img> tags it can see that would size unsafe
9 * bytes, then re-parses the result with the same html-to-vdom html-to-docx
10 * uses and refuses the document if any <img> it would size is still unsafe,
11 * so a tag the regex misreads fails closed instead of hanging.
12 */
13import HTMLToVDOM from "html-to-vdom";
15/** html-to-docx's data-URI pattern, shared by all three of its sizeOf call sites. */
16const DATA_URI = /^data:([A-Za-z-+\/]+);base64,(.+)$/;
17const IMG_TAG = /<img\b[^>]*>/gi;
18const SRC_ATTR = /(?:^|\s)src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i;
20interface ImgNode { tagName?: string; properties?: { src?: unknown }; children?: ImgNode[] }
22const toVdom = HTMLToVDOM({
23 VNode: function (this: ImgNode, tagName: string, properties: ImgNode["properties"], children: ImgNode[]) {
24 Object.assign(this, { tagName, properties, children });
25 },
26 VText: function () {},
27});
29function tryDecode(text: string): string | null {
30 try {
31 return decodeURIComponent(text);
32 } catch {
33 return null;
34 }
35}
37function isIcns(base64: string | null | undefined): boolean {
38 return base64 != null && Buffer.from(base64, "base64").toString("ascii", 0, 4) === "icns";
39}
41/**
42 * False when html-to-docx would hand ICNS bytes to its bundled sizer. Its two
43 * paragraph paths match the raw src and URI-decode the payload; buildImage
44 * URI-decodes the whole src, matches that, and sizes the payload as is.
45 */
46function sizesSafely(src: unknown): boolean {
47 if (typeof src !== "string") return true;
48 const raw = src.match(DATA_URI);
49 if (raw && isIcns(tryDecode(raw[2]))) return false;
50 return !isIcns(tryDecode(src)?.match(DATA_URI)?.[2]);
51}
53/** html-to-docx's minifyHTMLString, so the re-parse sees the string it parses. */
54function minify(html: string): string {
55 return html.replace(/\n/g, " ").replace(/\r/g, " ").replace(/[\t]+</g, "<").replace(/>[\t ]+</g, "><").replace(/>[\t ]+$/g, ">");
56}
58function* imgSources(nodes: ImgNode | ImgNode[]): Generator<unknown> {
59 for (const node of Array.isArray(nodes) ? nodes : [nodes]) {
60 if (node.tagName === "img") yield node.properties?.src;
61 if (node.children) yield* imgSources(node.children);
62 }
63}
65export function guardDocxImages(html: string, warn: (msg: string) => void): string {
66 const guarded = html.replace(IMG_TAG, (tag) => {
67 const src = tag.match(SRC_ATTR);
68 if (sizesSafely(src?.[1] ?? src?.[2] ?? src?.[3])) return tag;
69 warn("docx: dropped an ICNS image (html-to-docx's bundled image-size hangs on it)");
70 return "";
71 });
72 for (const src of imgSources(toVdom(minify(guarded)))) {
73 if (!sizesSafely(src)) {
74 throw new Error("docx: an <img> tag hides an ICNS image, which html-to-docx's bundled image-size hangs on; convert or remove it");
75 }
76 }
77 return guarded;
78}