From 0c448671819b4e1b7f13b590a89a87e30b3be823 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Fri, 13 Mar 2026 10:34:19 -0700 Subject: [PATCH 1/3] fix: convert TIFF images to PNG for browser display (fixes #146) Browsers don't support TIFF in tags. During DOCX parsing, TIFF images are now decoded with utif2 and re-encoded as PNG via Canvas API. Falls back gracefully to the raw TIFF data URL in Node.js environments. Co-Authored-By: Claude Opus 4.6 --- bun.lock | 5 +- examples/vite/public/tiff-test.docx | Bin 0 -> 4193 bytes packages/core/package.json | 1 + packages/core/src/docx/parser.ts | 30 ++++++++---- packages/core/src/utils/tiffConverter.ts | 57 +++++++++++++++++++++++ 5 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 examples/vite/public/tiff-test.docx create mode 100644 packages/core/src/utils/tiffConverter.ts diff --git a/bun.lock b/bun.lock index 792a928a..0ece03df 100644 --- a/bun.lock +++ b/bun.lock @@ -52,12 +52,13 @@ "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.8.5", "prosemirror-view": "^1.32.7", + "utif2": "^4.1.0", "xml-js": "^1.6.11", }, }, "packages/react": { "name": "@eigenpal/docx-js-editor", - "version": "0.0.22", + "version": "0.0.26", "dependencies": { "@radix-ui/react-select": "^2.2.6", "clsx": "^2.1.0", @@ -1094,6 +1095,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, ""], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx"], "bin": "bin/vite.js" }, ""], diff --git a/examples/vite/public/tiff-test.docx b/examples/vite/public/tiff-test.docx new file mode 100644 index 0000000000000000000000000000000000000000..7c5af8dc4bd51942a638ebf49cf16581991591aa GIT binary patch literal 4193 zcmd@XO>f*p)JbS*gM^5nMJgbTjJQx`osB|it=+8zAt-7os}N3rs#$xyYifJU%kw?F>+ z%ikN8h3j>A_tnQdQ7lnUPtQ{(zv-RD(cmH6$btzO@?>cDdwVuviO2mgnGWqQPmgx* z+e9jw_%z}P8`|eg+7CzD11*98b0UZKOsUj$9O=ziOl6O!ECEcwMNAbuim8)Q?-`vk zXMb<+p5qxab`_dokG2UJ9I}AUBSk(r0|SjMf<@9Mxyh(*Xwx)}LXRr2)mbEdOVMtw zqz9auM$W=i-UUl+XPFX2F4wV=RfR#&;tgZ;Q6r4T&mpoR^cfjbp`Oqfd~h-@e8=bB zJcd+Y4q8Gkagql?=&_O?rKN&L;GSzA^QWTZjW3M`fCtYEutCuJTdwYBd;KR2NioB3-eL$mV|S*wI~*vveKN zzMz@zw?=By_(Oy2l1{H6d|3?A(7S>(;8JtJOv}vN3HQ(OaD+2Bb5jFpSd8F>8lBEU zNy>cU$qgBigp$+aqa&g>5}B}wXYRm3dc2EVAb`2V3$n|U0w#}Tq1)J!x?vK92_t?e z)#=cNxdfq)O6YM3ogh@_M*CBj;V7YqcQ&*i^bZd9K!SH(!uou!stOsv%dsFK92{_J zB>J!(n_!W?nILNP)SU{N&O)!L7*v;-L#im5i|{`tsZy#V1Nn1dwbhomWC2Ex^At3B z!8qjho-A@dAtc7nRzqyFmBNt~rMIGI=m!o;U!h-XpG0AL6h={(13G$0K!nTU2`kR< zPIa1CD#4UD8$r|q5PHHqcxt9lEYme>v|vN`mg|Q|q(Z7s8IK79Fa|+TY>W_HJeGNQ3X(1#OnkcCH#o?1 zn-Pu5cLdgb6J0~r5zs4&ZC<^`2GZ&XYxb19U$ML^(FtELB4>Oa`Pe(sh@KOQtxGVU z$nWM<0BK+v88o*1BZxpReiYZp2ax#8odNv8eSpjI z)pVax0Z7H+oWZt)lp>tYAW_BRge%44>N#RT4Lf7hXK?;~xUYEw&Q6L^!c*2vrbw+FsT#ygNMt-S8Q_|Y|;#hWmxVgm2JiVXh(z++dr literal 0 HcmV?d00001 diff --git a/packages/core/package.json b/packages/core/package.json index b93d1bed..6562d614 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -68,6 +68,7 @@ "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.8.5", "prosemirror-view": "^1.32.7", + "utif2": "^4.1.0", "xml-js": "^1.6.11" }, "keywords": [ diff --git a/packages/core/src/docx/parser.ts b/packages/core/src/docx/parser.ts index 15134db0..585f60df 100644 --- a/packages/core/src/docx/parser.ts +++ b/packages/core/src/docx/parser.ts @@ -41,6 +41,7 @@ import { parseFootnotes, parseEndnotes } from './footnoteParser'; import { parseComments } from './commentParser'; import { loadFontsWithMapping } from '../utils/fontLoader'; import { type DocxInput, toArrayBuffer } from '../utils/docxInput'; +import { isTiffMimeType, convertTiffToPngDataUrl } from '../utils/tiffConverter'; // ============================================================================ // PROGRESS CALLBACK @@ -318,19 +319,32 @@ function buildMediaMap(raw: RawDocxContent, _rels: RelationshipMap): Map) + let dataUrl: string | undefined; + let displayMimeType = mimeType; + + if (isTiffMimeType(mimeType)) { + const pngDataUrl = convertTiffToPngDataUrl(data); + if (pngDataUrl) { + dataUrl = pngDataUrl; + displayMimeType = 'image/png'; + } + } + + // Fallback: encode as base64 data URL with original MIME type + if (!dataUrl) { + const bytes = new Uint8Array(data); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + dataUrl = `data:${mimeType};base64,${btoa(binary)}`; } - const base64 = btoa(binary); - const dataUrl = `data:${mimeType};base64,${base64}`; const mediaFile: MediaFile = { path, filename, - mimeType, + mimeType: displayMimeType, data, dataUrl, }; diff --git a/packages/core/src/utils/tiffConverter.ts b/packages/core/src/utils/tiffConverter.ts new file mode 100644 index 00000000..f3801537 --- /dev/null +++ b/packages/core/src/utils/tiffConverter.ts @@ -0,0 +1,57 @@ +/** + * TIFF to PNG Converter + * + * Converts TIFF image data to PNG using utif2 decoder + Canvas API. + * Falls back gracefully in environments without Canvas (e.g., Node.js). + */ + +import * as UTIF from 'utif2'; + +/** + * Check if a MIME type is TIFF + */ +export function isTiffMimeType(mimeType: string): boolean { + return mimeType === 'image/tiff' || mimeType === 'image/tif'; +} + +/** + * Convert TIFF ArrayBuffer to a PNG data URL. + * Returns null if conversion fails or Canvas API is unavailable. + */ +export function convertTiffToPngDataUrl(tiffData: ArrayBuffer): string | null { + try { + // Decode the TIFF file + const ifds = UTIF.decode(tiffData); + if (ifds.length === 0) return null; + + // Decode the first image in the TIFF + const firstImage = ifds[0]; + UTIF.decodeImage(tiffData, firstImage); + const rgba = UTIF.toRGBA8(firstImage); + + const width = firstImage.width; + const height = firstImage.height; + if (!width || !height || rgba.length === 0) return null; + + // Use Canvas API to convert RGBA pixels to PNG + if (typeof document === 'undefined' || typeof document.createElement !== 'function') { + return null; // No DOM available (Node.js headless without canvas) + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + const clamped = new Uint8ClampedArray(rgba.length); + clamped.set(rgba); + const imageData = new ImageData(clamped, width, height); + ctx.putImageData(imageData, 0, 0); + + return canvas.toDataURL('image/png'); + } catch (error) { + console.warn('[tiffConverter] Failed to convert TIFF to PNG:', error); + return null; + } +} From 31a714a1c68a1fea84d0fba9c690eb4ab2d39ab1 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Fri, 13 Mar 2026 10:39:20 -0700 Subject: [PATCH 2/3] chore: update outdated TODO comment about floating image text wrapping The measurement-time floating image support was already implemented. Replaced the misleading TODO with an accurate description of the flow. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/layout-painter/renderParagraph.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/src/layout-painter/renderParagraph.ts b/packages/core/src/layout-painter/renderParagraph.ts index b1e0187a..08601477 100644 --- a/packages/core/src/layout-painter/renderParagraph.ts +++ b/packages/core/src/layout-painter/renderParagraph.ts @@ -57,11 +57,9 @@ export interface FloatingImageInfo { bottomY: number; } -// NOTE: Per-line floating margin calculation has been disabled. -// Text wrapping around floating images requires passing exclusion zones -// to the MEASUREMENT phase so lines can be broken at reduced widths. -// Currently, floating images render at page level and text flows under them. -// TODO: Implement measurement-time floating image support for proper text wrapping. +// Text wrapping around floating images is handled in the measurement phase: +// measureParagraph() receives FloatingImageZone[] and adjusts per-line widths. +// The rendering phase reads line.leftOffset/rightOffset and applies CSS margins. /** * Options for rendering a paragraph From 41f923de43e8282e4334c334cc809813435ec593 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Fri, 13 Mar 2026 13:01:06 -0700 Subject: [PATCH 3/3] refactor: extract arrayBufferToDataUrl helper, clarify TIFF re-save behavior DRY: extract base64 encoding into reusable arrayBufferToDataUrl(). Document that TIFF images are saved as PNG on re-export. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/docx/parser.ts | 35 ++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/core/src/docx/parser.ts b/packages/core/src/docx/parser.ts index 585f60df..c7a18380 100644 --- a/packages/core/src/docx/parser.ts +++ b/packages/core/src/docx/parser.ts @@ -308,6 +308,16 @@ export async function parseDocx(input: DocxInput, options: ParseOptions = {}): P // HELPER FUNCTIONS // ============================================================================ +/** Encode an ArrayBuffer as a base64 data URL with the given MIME type. */ +function arrayBufferToDataUrl(buffer: ArrayBuffer, mimeType: string): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return `data:${mimeType};base64,${btoa(binary)}`; +} + /** * Build media file map from raw content and relationships */ @@ -319,32 +329,27 @@ function buildMediaMap(raw: RawDocxContent, _rels: RelationshipMap): Map) - let dataUrl: string | undefined; - let displayMimeType = mimeType; + // Convert TIFF to PNG for browser display (browsers don't support TIFF in ). + // On re-save, the image will be written as PNG (not original TIFF). + let dataUrl: string; + let effectiveMimeType = mimeType; if (isTiffMimeType(mimeType)) { const pngDataUrl = convertTiffToPngDataUrl(data); if (pngDataUrl) { dataUrl = pngDataUrl; - displayMimeType = 'image/png'; - } - } - - // Fallback: encode as base64 data URL with original MIME type - if (!dataUrl) { - const bytes = new Uint8Array(data); - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); + effectiveMimeType = 'image/png'; + } else { + dataUrl = arrayBufferToDataUrl(data, mimeType); } - dataUrl = `data:${mimeType};base64,${btoa(binary)}`; + } else { + dataUrl = arrayBufferToDataUrl(data, mimeType); } const mediaFile: MediaFile = { path, filename, - mimeType: displayMimeType, + mimeType: effectiveMimeType, data, dataUrl, };