Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added examples/vite/public/tiff-test.docx
Binary file not shown.
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
35 changes: 27 additions & 8 deletions packages/core/src/docx/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand All @@ -318,19 +329,27 @@ function buildMediaMap(raw: RawDocxContent, _rels: RelationshipMap): Map<string,
const filename = path.split('/').pop() || path;
const mimeType = getMediaMimeType(path);

// Create a data URL for the image
const bytes = new Uint8Array(data);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
// Convert TIFF to PNG for browser display (browsers don't support TIFF in <img>).
// 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,
};
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/layout-painter/renderParagraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions packages/core/src/utils/tiffConverter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading