perf: an opened file is inflated once and its pictures encoded by the browser
The format pre-pass and the importer read the same inflated archive, where each unzipped the file for itself, and a picture is encoded through the browser's Uint8Array.toBase64, the chunked loop standing in where there is none. On a 14 MB file holding 600 pictures that takes 250 ms of inflating and 285 ms of encoding off the open: it lands 480 ms earlier and settles 240 ms earlier, and a small illustrated file lands 170 ms earlier.
This commit is contained in:
1 parent
da857fcbb8
commit
6693d04313
5 files changed
+55
-9
No files matched your search
@@ -27,7 +27,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
|
||||
- **A heading is bold by default only where the bold built-in still renders it.** `blockDefaults` suppresses a run mark the block's style supplies, and the editor's `Heading` parent supplies bold — but a file that declares its own heading style re-parents it to `Standard` (`collectStyleSheet`), so nothing supplies bold there and an explicit `w:b` / `fo:font-weight` has to stay a mark or the heading renders regular. Gated on the style's own `w:basedOn` / `style:parent-style-name` (`styleBasedOn`, `styleParent`), which is exactly what decides whether the built-in's parent survives.
|
||||
- **A run's underline is suppressed only where it draws the *same* line.** `lineSig` compares the run's shape and colour against the style's, so a red or wavy underline under a plainly underlining style survives — only a bare one repeating its style is dropped, which is what neither format can tell from a failed resolution. Both importers, `marksFor`; a strike compares its `double` the same way.
|
||||
- **Mapping:** `text:h` → heading (level clamped to `MAX_HEADING_LEVEL`), per-depth `text:list-level-style-*` defs decide bullet vs ordered and `listStyleType` (reverse lookup `orderedTypeFromFormat`) — unless the list style is a **named** one (`StyleResolver.isNamedListStyle`): then the outermost list gets `listStyleName` (display name decoded), no per-level attrs are derived, and `collectStyleSheet`/`listStyleFromOdf` put the definition into `sheet.list`, tables handle covered cells / `number-columns-repeated` / header rows / colspans / cell shading (`StyleResolver.cellBackgroundColor` → `backgroundColor`) / cell borders (`StyleResolver.cellBorders` + `borderAttrFromOdf` → the per-side border attrs) / table margins (`tableMargins` reads the table style's `fo:margin-*`, `style:width`/`style:rel-width` and `table:align` against `Ctx.contentWidthCm` — so a table narrower than the text width stays narrow instead of being stretched; DOCX does the same from `w:tblInd` + the `w:tblGrid`/`w:tblW` width), `text:s`/`text:tab`/`text:line-break` become spaces/`\t`/`hardBreak`, `draw:frame`/`draw:image` → an `image` node (`convertFrame`: bytes read from the zip's `Pictures/` entry into a data-URI, `svg:width`/`-height` cm → px, `svg:title` → `alt`, `draw:transform` rotate → `rotation`; a non-`as-char` anchor or a graphic style's `style:wrap` → floating `wrap` mode via `StyleResolver.graphicProps` — an **explicit `as-char` anchor is always inline**: LibreOffice's named Graphics style carries a `style:wrap` that would otherwise float every inherited frame), text fields keep their stored value. **Text boxes / shapes** (`convertDrawElement`): `draw:frame`+`draw:text-box` → a `textBox` node (`convertTextBoxFrame`: height = `fo:min-height` else `svg:height`; fill/stroke read straight from `graphicProps`' `draw:fill(-color)`/`draw:stroke`/`svg:stroke-*`, values equal to the editor defaults suppressed, none → explicit `null`); `draw:rect`/`draw:ellipse`/`draw:custom-shape` with a rect/round-rectangle/ellipse preset → a `textBox` with the matching `shapeKind` (`convertShape`), and `draw:polygon`/`polyline`/`path`/`connector` — or a preset no gallery covers but whose `draw:enhanced-path` is a plain outline — → a box carrying that outline as `shapePath` (`convertFreeform`, `docs/architecture/frames.md`; a geometry built from modifier formulas still reads as "Unsupported shapes were removed"). A box is an inline node, so it stays exactly where the frame sits — in the paragraph's own content, beside the runs around it, cells and list items included; only a box **inside another box** is unwrapped in place, with a warning. The DOCX importer mirrors all of this — its drawing root is the `w:drawing`'s **direct** `wp:inline`/`wp:anchor` child, since a box holding a picture nests a second drawing and a subtree search reads *its* root instead, dropping the box and the caption in it (`convertWpsShape` for DrawingML `wps:wsp` — probed before the image `a:blip` path, `convertPict` for legacy VML; Word's `mc:AlternateContent` uses only the `mc:Choice` branch so the VML fallback can't double-import).
|
||||
- **Image formats (`import/imageFormats.ts`, shared by both importers):** `<img>` only renders a fixed set of formats, so `imageDataUrl(bytes, path)` resolves the mime by extension **then** magic-number (mislabelled/extensionless entries still work) and returns `null` for anything unrenderable — the importer then skips it with a specific "format the browser can't display" warning instead of a broken `<img>`. A frame's multiple `<draw:image>` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and `<draw:a>`-wrapped frames are unwrapped (the hyperlink dropped). **Non-web formats we can decode client-side** (currently **TIFF**) are converted to PNG up front by `convertUnsupportedImages(bytes)` — an async pass `applyImport` runs before the synchronous import, passing a `path → PNG data-URI` map (`ConvertedImages`) into `importOdt`/`importDocx` (default empty, so tests stay sync). The decoder (`utif2`) is a **dynamic `import()`** → its own lazy chunk, loaded only when a convertible image is actually present, so ordinary docs pay nothing. **EMF** goes through the same lazy pre-pass into an SVG picture (`import/emf.ts`, below). **SVM/WMF** still have no decoder (SVM is StarOffice-proprietary, WMF a different 16-bit record format) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB). A **CMYK** JPEG loses its ICC profile (`stripCmykIccProfile`): Chromium colour-manages through it, LibreOffice and Word convert naively, and the file's own colours are the ones every word processor shows (measured on the contract fixture's logo: blue (0,80,131) → (0,77,183) against LibreOffice's (0,32,183), black (57,53,54) → (11,11,18) against (0,0,0)).
|
||||
- **Image formats (`import/imageFormats.ts`, shared by both importers):** `<img>` only renders a fixed set of formats, so `imageDataUrl(bytes, path)` resolves the mime by extension **then** magic-number (mislabelled/extensionless entries still work) and returns `null` for anything unrenderable — the importer then skips it with a specific "format the browser can't display" warning instead of a broken `<img>`. A frame's multiple `<draw:image>` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and `<draw:a>`-wrapped frames are unwrapped (the hyperlink dropped). **Non-web formats we can decode client-side** (currently **TIFF**) are converted to PNG up front by `convertUnsupportedImages(bytes)` — an async pass `applyImport` runs before the synchronous import, passing a `path → PNG data-URI` map (`ConvertedImages`) into `importOdt`/`importDocx` (default empty, so tests stay sync). The decoder (`utif2`) is a **dynamic `import()`** → its own lazy chunk, loaded only when a convertible image is actually present, so ordinary docs pay nothing. **EMF** goes through the same lazy pre-pass into an SVG picture (`import/emf.ts`, below). **SVM/WMF** still have no decoder (SVM is StarOffice-proprietary, WMF a different 16-bit record format) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB). Both passes read the **same inflated archive** — `unzipArchive(bytes)` keeps it per byte array, so a picture-heavy file is inflated once however many passes read it (a quarter second on a 14 MB file) — and a picture is encoded with the browser's `Uint8Array.toBase64` where there is one, the chunked `String.fromCharCode` loop standing in where there is not (300 ms against 15 ms for 600 pictures). A **CMYK** JPEG loses its ICC profile (`stripCmykIccProfile`): Chromium colour-manages through it, LibreOffice and Word convert naively, and the file's own colours are the ones every word processor shows (measured on the contract fixture's logo: blue (0,80,131) → (0,77,183) against LibreOffice's (0,32,183), black (57,53,54) → (11,11,18) against (0,0,0)).
|
||||
- **EMF (`import/emf.ts`)**: `emfToSvg(bytes)` replays the record stream into SVG — window/viewport plus world transform mapped per point, a DC stack (`SAVEDC`/`RESTOREDC`), pens/brushes/fonts (incl. the stock objects), the 16- and 32-bit poly records, path brackets, `RECTANGLE`/`ELLIPSE`, `EXTTEXTOUT` (its per-character advances become `textLength`, so labels keep their width) and `STRETCHDIBITS` (the DIB re-wrapped as a .bmp data-URI). **Text is stored as raw code units**, so a symbol font leaves lone surrogates behind — they must be stripped or `encodeURIComponent` throws and the whole picture is lost. Unknown records are skipped; a file that yields nothing returns null and keeps the placeholder.
|
||||
- **Charts (`import/chart.ts`, DOCX)**: `word/charts/chartN.xml` is parsed and **drawn as an SVG picture** of the frame's own size — series values from the `numCache` the producer left, colours from the named theme accent (`themeAccents`), title/axis titles/gridlines, and the value axis' own `c:scaling` bounds where the file fixes them. Bar (incl. stacked and `barDir="bar"`), line, area, scatter and pie; anything else keeps the placeholder. Read-only — the editor has no chart object, so a re-export carries the picture, not the chart. **ODF** charts go through the same drawing (`odfChartDataUrl`, `convertChartFrame`): `<chart:chart>` in the `draw:object`'s sub-document, series values read out of its own `local-table` by cell range, colours from the series' `draw:fill-color`, bounds from the y-axis style's `chart:maximum`/`-minimum`.
|
||||
- **Word's horizontal line** (`hrBorderAttr`, DOCX): a `w:pict` > `v:rect` flagged `o:hr` is not a shape but the paragraph's own **bottom rule** — the editor has no rule node. Height and `fillcolor` become the rule (`1.5pt`/`#A0A0A0` where the file names none), its width and `o:hralign` are dropped (a paragraph rule spans the text width) and a real `w:pBdr` bottom keeps precedence: only one line can be drawn.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { strFromU8 } from 'fflate';
|
||||
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, readTabStops, toggle as onOff, wVal, W, R, WP, A, B, WPS, MC, VML, O, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
|
||||
import { lengthToPt, WATERMARK_NAME } from './styleResolver';
|
||||
import { normalizeColor } from '../export/odt';
|
||||
@@ -19,7 +19,7 @@ import { formatOrdinal, orderedTypeFromFormat, orderedTypeAttrAt, childCycle, RO
|
||||
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
|
||||
import { DATE_FORMATS, TIME_FORMATS, docxPicture, toDateValue } from '../utils/dateTime';
|
||||
import { shapeFromPrst, isLineKind, lineKindFor, parseSvgPath, parseVmlPath, fitPath } from '../utils/shapes';
|
||||
import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats';
|
||||
import { imageDataUrl, placeholderImage, unzipArchive, type ConvertedImages } from './imageFormats';
|
||||
import { PX_PER_CM, cmToPx, fitMargins, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
|
||||
@@ -171,7 +171,7 @@ function hexColor(v: string | null | undefined): string | undefined {
|
||||
export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = new Map()): OdtImportResult {
|
||||
let files: Record<string, Uint8Array>;
|
||||
try {
|
||||
files = unzipSync(bytes);
|
||||
files = unzipArchive(bytes);
|
||||
} catch {
|
||||
throw new Error('Not a valid .docx file (could not read the archive).');
|
||||
}
|
||||
|
||||
@@ -54,7 +54,13 @@ export function displayableImageMime(bytes: Uint8Array, path: string): string |
|
||||
return sniffImageMime(bytes);
|
||||
}
|
||||
|
||||
type ToBase64 = { toBase64?: () => string };
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
// The native encoder where the browser has one — a picture-heavy file spends a
|
||||
// quarter second in the loop below, which is only the fallback.
|
||||
const native = (bytes as Uint8Array & ToBase64).toBase64;
|
||||
if (native) return native.call(bytes);
|
||||
let bin = '';
|
||||
const chunk = 0x8000; // chunk so String.fromCharCode doesn't blow the call stack
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
@@ -110,6 +116,18 @@ export function imageDataUrl(bytes: Uint8Array, path: string): string | null {
|
||||
return `data:${mime};base64,${bytesToBase64(mime === 'image/jpeg' ? stripCmykIccProfile(bytes) : bytes)}`;
|
||||
}
|
||||
|
||||
// One inflate per opened file: the format pre-pass and the importer read the same
|
||||
// archive, and a picture-heavy one costs a quarter second per pass.
|
||||
const archives = new WeakMap<Uint8Array, Record<string, Uint8Array>>();
|
||||
|
||||
export function unzipArchive(bytes: Uint8Array): Record<string, Uint8Array> {
|
||||
const known = archives.get(bytes);
|
||||
if (known) return known;
|
||||
const files = unzipSync(bytes);
|
||||
archives.set(bytes, files);
|
||||
return files;
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
@@ -182,7 +200,7 @@ function looksLikeMedia(path: string): boolean {
|
||||
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; }
|
||||
try { files = unzipArchive(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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { strFromU8 } from 'fflate';
|
||||
import { StyleResolver, NS, WATERMARK_NAME, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
|
||||
import { ODF_LOOK_ATTRS, normalizeColor } from '../export/odt';
|
||||
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL } from '../styles/headings';
|
||||
@@ -21,7 +21,7 @@ import { matchFormat, toDateValue, type Token } from '../utils/dateTime';
|
||||
import {
|
||||
shapeFromOdfType, lineKindFor, parseSvgPath, parseOdfPoints, fitPath, type ShapeKind,
|
||||
} from '../utils/shapes';
|
||||
import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats';
|
||||
import { imageDataUrl, placeholderImage, unzipArchive, type ConvertedImages } from './imageFormats';
|
||||
import { astToLatex } from '../math/latex';
|
||||
import { parseMathml } from '../math/mathml';
|
||||
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
|
||||
@@ -775,7 +775,7 @@ function odfSpacingAtPageStart(files: Record<string, Uint8Array>): boolean {
|
||||
export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = new Map()): OdtImportResult {
|
||||
let files: Record<string, Uint8Array>;
|
||||
try {
|
||||
files = unzipSync(bytes);
|
||||
files = unzipArchive(bytes);
|
||||
} catch {
|
||||
throw new Error('Not a valid .odt file (could not read the archive).');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { displayableImageMime, imageDataUrl, imageExtOf, isConvertibleImage } from '../../src/lib/import/imageFormats';
|
||||
import { zipSync } from 'fflate';
|
||||
import { displayableImageMime, imageDataUrl, imageExtOf, isConvertibleImage, unzipArchive } from '../../src/lib/import/imageFormats';
|
||||
|
||||
const bytesOf = (...b: number[]) => new Uint8Array(b);
|
||||
const PNG = bytesOf(0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0);
|
||||
@@ -60,4 +61,31 @@ describe('imageDataUrl', () => {
|
||||
expect(imageDataUrl(PNG, 'a.png')).toMatch(/^data:image\/png;base64,/);
|
||||
expect(imageDataUrl(SVM, 'a.svm')).toBeNull();
|
||||
});
|
||||
|
||||
// The encoder the browser brings and the loop that stands in for it must agree,
|
||||
// padding included — a picture is bytes, and a wrong tail is a broken picture.
|
||||
it('encodes alike with and without the native encoder', () => {
|
||||
const proto = Uint8Array.prototype as Uint8Array & { toBase64?: () => string };
|
||||
const native = proto.toBase64;
|
||||
for (const len of [70000, 70001, 70002]) {
|
||||
const bytes = new Uint8Array(len).map((_, i) => (i * 7) % 256);
|
||||
const want = `data:image/png;base64,${Buffer.from(bytes).toString('base64')}`;
|
||||
expect(imageDataUrl(bytes, 'a.png')).toBe(want);
|
||||
try {
|
||||
delete proto.toBase64;
|
||||
expect(imageDataUrl(bytes, 'a.png')).toBe(want);
|
||||
} finally {
|
||||
if (native) proto.toBase64 = native;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('unzipArchive', () => {
|
||||
it('inflates one archive once: the pre-pass and the importer share the entries', () => {
|
||||
const zip = zipSync({ 'content.xml': new Uint8Array([1, 2, 3]) });
|
||||
const once = unzipArchive(zip);
|
||||
expect(unzipArchive(zip)).toBe(once);
|
||||
expect(unzipArchive(zipSync({ 'content.xml': new Uint8Array([1, 2, 3]) }))).not.toBe(once);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user