feat: add lazy tiff to png decoding
This commit is contained in:
1 parent
edf51d9a68
commit
8e6c4817d8
7 files changed
+135
-17
No files matched your search
Generated
+17
-1
@@ -37,7 +37,8 @@
|
||||
"html2canvas": "^1.4.1",
|
||||
"hunspell-asm": "^4.0.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"odf-kit": "^0.13.5"
|
||||
"odf-kit": "^0.13.5",
|
||||
"utif2": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
@@ -2910,6 +2911,21 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utif2": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
|
||||
"integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/utif2/node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
+2
-1
@@ -60,6 +60,7 @@
|
||||
"html2canvas": "^1.4.1",
|
||||
"hunspell-asm": "^4.0.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"odf-kit": "^0.13.5"
|
||||
"odf-kit": "^0.13.5",
|
||||
"utif2": "^4.1.0"
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -11,6 +11,7 @@
|
||||
import { supportsFsAccess, saveOdt, saveAsOdt, saveAsDocx, openOdt } from './lib/export/saveFile';
|
||||
import { importOdt } from './lib/import/odt';
|
||||
import { importDocx } from './lib/import/docx';
|
||||
import { convertUnsupportedImages } from './lib/import/imageFormats';
|
||||
import { getPageBreakDebug } from './lib/editor/extensions/pageBreaks';
|
||||
import { getColumnsFlowDebug } from './lib/editor/extensions/columnsFlow';
|
||||
import { getColorDebug } from './lib/utils/colorDebug';
|
||||
@@ -325,11 +326,14 @@
|
||||
|
||||
// Replace the document with a parsed .odt; adopt its geometry/header/footer and
|
||||
// track the source handle (null for the fallback file input) so Save overwrites it.
|
||||
function applyImport(bytes: Uint8Array, handle: FileSystemFileHandle | null, sourceName?: string) {
|
||||
async function applyImport(bytes: Uint8Array, handle: FileSystemFileHandle | null, sourceName?: string) {
|
||||
if (!editor) return;
|
||||
try {
|
||||
const isDocx = sourceName?.toLowerCase().endsWith('.docx');
|
||||
const result = isDocx ? importDocx(bytes) : importOdt(bytes);
|
||||
// Pre-decode any images in a format the browser can't render (TIFF, …) to PNG.
|
||||
// Lazy: the decoder loads only when such an image is present, else this is a no-op.
|
||||
const converted = await convertUnsupportedImages(bytes);
|
||||
const result = isDocx ? importDocx(bytes, converted) : importOdt(bytes, converted);
|
||||
|
||||
const hasContent = editor.state.doc.textContent.length > 0 || editor.state.doc.childCount > 1;
|
||||
if (hasContent && !confirm(t().dialogs.confirmReplace)) {
|
||||
@@ -372,7 +376,7 @@
|
||||
if (fsSupported) {
|
||||
try {
|
||||
const r = await openOdt();
|
||||
if (r) applyImport(r.bytes, r.handle, r.name);
|
||||
if (r) await applyImport(r.bytes, r.handle, r.name);
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name !== 'AbortError') {
|
||||
console.error('[open] Failed to open file:', err);
|
||||
@@ -389,7 +393,7 @@
|
||||
const file = input.files?.[0];
|
||||
input.value = ''; // allow re-selecting the same file
|
||||
if (!file) return;
|
||||
applyImport(new Uint8Array(await file.arrayBuffer()), null, file.name);
|
||||
await applyImport(new Uint8Array(await file.arrayBuffer()), null, file.name);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
|
||||
import { DATE_FORMATS, TIME_FORMATS, docxPicture, toDateValue } from '../utils/dateTime';
|
||||
import { imageDataUrl } from './imageFormats';
|
||||
import { imageDataUrl, type ConvertedImages } from './imageFormats';
|
||||
import { PX_PER_CM, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
|
||||
@@ -31,6 +31,7 @@ type Ctx = {
|
||||
files: Record<string, Uint8Array>;
|
||||
rels: Map<string, RelInfo>;
|
||||
imageCache: Map<string, string>;
|
||||
convertedImages: ConvertedImages;
|
||||
pendingBlocks: Node[];
|
||||
};
|
||||
|
||||
@@ -81,7 +82,7 @@ function hexColor(v: string | null | undefined): string | undefined {
|
||||
}
|
||||
|
||||
// ---- entry ------------------------------------------------------------------
|
||||
export function importDocx(bytes: Uint8Array): OdtImportResult {
|
||||
export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = new Map()): OdtImportResult {
|
||||
let files: Record<string, Uint8Array>;
|
||||
try {
|
||||
files = unzipSync(bytes);
|
||||
@@ -98,7 +99,7 @@ export function importDocx(bytes: Uint8Array): OdtImportResult {
|
||||
const styles = new DocxStyles(stylesDoc, numberingDoc, themeDoc);
|
||||
const warnings = new Set<string>();
|
||||
|
||||
const ctx: Ctx = { styles, warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), pendingBlocks: [] };
|
||||
const ctx: Ctx = { styles, warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [] };
|
||||
|
||||
const body = docDoc.getElementsByTagNameNS(W, 'body')[0];
|
||||
if (!body) throw new Error('Not a Word document (no w:body).');
|
||||
@@ -704,6 +705,9 @@ function marksFor(props: RunProps, headingLevel: number | null, boldByDefault: b
|
||||
function loadImageDataUrl(path: string, ctx: Ctx): string | null {
|
||||
const cached = ctx.imageCache.get(path);
|
||||
if (cached) return cached;
|
||||
// A format the browser can't render may have been pre-decoded to PNG by the async pass.
|
||||
const converted = ctx.convertedImages.get(path);
|
||||
if (converted) { ctx.imageCache.set(path, converted); return converted; }
|
||||
const bytes = ctx.files[path];
|
||||
if (!bytes) return null;
|
||||
const url = imageDataUrl(bytes, path);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Image-format handling shared by the ODF and DOCX importers. An <img> can only
|
||||
// render a fixed set of formats; anything else (WMF/EMF/SVM/TIFF/…) has to be skipped
|
||||
// with a warning rather than emitted as a broken image. The format is resolved from
|
||||
// the file extension first, then confirmed/recovered from the bytes' magic number so a
|
||||
// mislabelled or extensionless entry still works.
|
||||
// render a fixed set of formats; anything else (WMF/EMF/SVM/TIFF/…) is either decoded
|
||||
// client-side (see convertUnsupportedImages) or skipped with a warning rather than
|
||||
// emitted as a broken image. The format is resolved from the file extension first, then
|
||||
// confirmed/recovered from the bytes' magic number so a mislabelled or extensionless
|
||||
// entry still works.
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
@@ -70,3 +72,74 @@ export function imageDataUrl(bytes: Uint8Array, path: string): string | null {
|
||||
if (!mime) return null;
|
||||
return `data:${mime};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
|
||||
// ---- client-side decoding of formats the browser can't render ----------------
|
||||
|
||||
// A map from archive path to a decoded PNG data-URI, produced by the async pre-pass and
|
||||
// consulted by the synchronous importers before they read the raw bytes.
|
||||
export type ConvertedImages = Map<string, string>;
|
||||
|
||||
// TIFF: 'II*\0' (little-endian) or 'MM\0*' (big-endian), or a .tif/.tiff extension.
|
||||
function isTiff(bytes: Uint8Array, path: string): boolean {
|
||||
const ext = imageExtOf(path);
|
||||
if (ext === 'tif' || ext === 'tiff') return true;
|
||||
return bytes.length >= 4
|
||||
&& ((bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00)
|
||||
|| (bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a));
|
||||
}
|
||||
|
||||
// True when the browser can't display the bytes directly but a lazy decoder can turn
|
||||
// them into something it can (see convertImageToDataUrl).
|
||||
export function isConvertibleImage(bytes: Uint8Array, path: string): boolean {
|
||||
return displayableImageMime(bytes, path) === null && isTiff(bytes, path);
|
||||
}
|
||||
|
||||
// RGBA pixels → a PNG data-URI via an offscreen canvas (browser only, which is where
|
||||
// import runs). null when there's no 2D context or the dimensions are empty.
|
||||
function rgbaToPngDataUrl(rgba: Uint8Array, w: number, h: number): string | null {
|
||||
if (!w || !h) return null;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const cctx = canvas.getContext('2d');
|
||||
if (!cctx) return null;
|
||||
cctx.putImageData(new ImageData(new Uint8ClampedArray(rgba), w, h), 0, 0);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
// Decode a convertible format to a PNG data-URI, lazy-loading the decoder so it never
|
||||
// enters the baseline bundle. null when it isn't convertible or decoding fails.
|
||||
export async function convertImageToDataUrl(bytes: Uint8Array, path: string): Promise<string | null> {
|
||||
try {
|
||||
if (isTiff(bytes, path)) {
|
||||
const UTIF = await import('utif2');
|
||||
// A fresh ArrayBuffer (not the possibly-shared source buffer) for UTIF's typing.
|
||||
const buf = new Uint8Array(bytes).buffer;
|
||||
const ifds = UTIF.decode(buf);
|
||||
if (!ifds.length) return null;
|
||||
UTIF.decodeImage(buf, ifds[0]);
|
||||
return rgbaToPngDataUrl(UTIF.toRGBA8(ifds[0]), ifds[0].width, ifds[0].height);
|
||||
}
|
||||
} catch { /* corrupt/unsupported → the importer skips it with a warning */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only archive entries in an image folder (ODF Pictures/, DOCX word/media/) are probed.
|
||||
function looksLikeMedia(path: string): boolean {
|
||||
return /(^|\/)(Pictures|media)\//i.test(path);
|
||||
}
|
||||
|
||||
// Pre-decode every convertible image in an .odt/.docx archive to a PNG data-URI, so the
|
||||
// synchronous importer can resolve them. Lazy: the decoder is only imported when such an
|
||||
// image is actually present, so ordinary documents load with zero extra cost.
|
||||
export async function convertUnsupportedImages(bytes: Uint8Array): Promise<ConvertedImages> {
|
||||
const out: ConvertedImages = new Map();
|
||||
let files: Record<string, Uint8Array>;
|
||||
try { files = unzipSync(bytes); } catch { return out; }
|
||||
for (const [path, data] of Object.entries(files)) {
|
||||
if (!looksLikeMedia(path) || !isConvertibleImage(data, path)) continue;
|
||||
const url = await convertImageToDataUrl(data, path);
|
||||
if (url) out.set(path, url);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
|
||||
import { matchFormat, toDateValue, type Token } from '../utils/dateTime';
|
||||
import { imageDataUrl } from './imageFormats';
|
||||
import { imageDataUrl, type ConvertedImages } from './imageFormats';
|
||||
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import type { PageFormat } from '../storage/pageFormat';
|
||||
@@ -55,6 +55,7 @@ type Ctx = {
|
||||
warnings: Set<string>;
|
||||
files: Record<string, Uint8Array>;
|
||||
imageCache: Map<string, string>;
|
||||
convertedImages: ConvertedImages;
|
||||
pendingBlocks: Node[];
|
||||
};
|
||||
|
||||
@@ -63,6 +64,9 @@ type Ctx = {
|
||||
function loadImageDataUrl(href: string, ctx: Ctx): string | null {
|
||||
const cached = ctx.imageCache.get(href);
|
||||
if (cached) return cached;
|
||||
// A format the browser can't render may have been pre-decoded to PNG by the async pass.
|
||||
const converted = ctx.convertedImages.get(href);
|
||||
if (converted) { ctx.imageCache.set(href, converted); return converted; }
|
||||
const bytes = ctx.files[href];
|
||||
if (!bytes) return null;
|
||||
const url = imageDataUrl(bytes, href);
|
||||
@@ -268,7 +272,7 @@ const LIST_INDENT_EPS_CM = 0.05;
|
||||
|
||||
// ---- entry --------------------------------------------------------------------
|
||||
|
||||
export function importOdt(bytes: Uint8Array): OdtImportResult {
|
||||
export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = new Map()): OdtImportResult {
|
||||
let files: Record<string, Uint8Array>;
|
||||
try {
|
||||
files = unzipSync(bytes);
|
||||
@@ -286,7 +290,7 @@ export function importOdt(bytes: Uint8Array): OdtImportResult {
|
||||
const body = contentDoc.getElementsByTagNameNS(NS.office, 'text')[0];
|
||||
if (!body) throw new Error('Not a text document (no office:text body).');
|
||||
|
||||
const ctx: Ctx = { resolver, warnings, files, imageCache: new Map(), pendingBlocks: [] };
|
||||
const ctx: Ctx = { resolver, warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [] };
|
||||
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
|
||||
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { displayableImageMime, imageDataUrl, imageExtOf } from '../../src/lib/import/imageFormats';
|
||||
import { displayableImageMime, imageDataUrl, imageExtOf, isConvertibleImage } from '../../src/lib/import/imageFormats';
|
||||
|
||||
const bytesOf = (...b: number[]) => new Uint8Array(b);
|
||||
const PNG = bytesOf(0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0);
|
||||
@@ -39,6 +39,22 @@ describe('displayableImageMime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const TIFF_LE = bytesOf(0x49, 0x49, 0x2a, 0x00, 8, 0, 0, 0);
|
||||
const TIFF_BE = bytesOf(0x4d, 0x4d, 0x00, 0x2a, 0, 0, 0, 8);
|
||||
|
||||
describe('isConvertibleImage', () => {
|
||||
it('flags TIFF (by extension or magic) as client-side convertible', () => {
|
||||
expect(isConvertibleImage(TIFF_LE, 'scan.tiff')).toBe(true);
|
||||
expect(isConvertibleImage(TIFF_BE, 'scan.tif')).toBe(true);
|
||||
expect(isConvertibleImage(TIFF_LE, 'noext')).toBe(true); // magic-byte fallback
|
||||
});
|
||||
it('does not flag renderable or truly-unsupported formats', () => {
|
||||
expect(isConvertibleImage(PNG, 'a.png')).toBe(false); // already displayable
|
||||
expect(isConvertibleImage(SVM, 'a.svm')).toBe(false); // no client-side decoder
|
||||
expect(isConvertibleImage(bytesOf(0, 0, 0, 0), 'a.wmf')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('imageDataUrl', () => {
|
||||
it('emits a data-URI for renderable bytes, null otherwise', () => {
|
||||
expect(imageDataUrl(PNG, 'a.png')).toMatch(/^data:image\/png;base64,/);
|
||||
|
||||
Reference in new issue
Block a user