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 00000000..7c5af8dc Binary files /dev/null and b/examples/vite/public/tiff-test.docx differ 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..c7a18380 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 @@ -307,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 */ @@ -318,19 +329,27 @@ function buildMediaMap(raw: RawDocxContent, _rels: RelationshipMap): Map). + // 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; + effectiveMimeType = 'image/png'; + } else { + dataUrl = arrayBufferToDataUrl(data, mimeType); + } + } else { + dataUrl = arrayBufferToDataUrl(data, mimeType); } - const base64 = btoa(binary); - const dataUrl = `data:${mimeType};base64,${base64}`; const mediaFile: MediaFile = { path, filename, - mimeType, + mimeType: effectiveMimeType, data, dataUrl, }; 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 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; + } +}