diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d896bb..10f2fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ - Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders, merge cells and split cells (N×M, Word/LibreOffice-style), cell background shading, header row / first column toggles, named table styles (see Styles); a table splits cleanly across page boundaries - Table border control (Word/LibreOffice-style): per-side cell borders with presets (all / outside / inside / single edges / none), line width and color; buttons show active states matching the current pen and toggle borders off; round-trips to ODF `fo:border-*` and DOCX `w:tcBorders` - Images: inline or floating with text wrap (left / right / top-bottom), resize handles, rotation, live size badge; insert via toolbar, drag-and-drop, or paste. A floating frame is placed by the file's own offsets, and two top-and-bottom frames set against opposite ends of the text share one band, side by side +- A CMYK JPEG's embedded ICC profile is dropped on import: Chromium colour-manages through it where LibreOffice and Word convert naively, which turned a contract's logo from vivid blue to dull teal and its black to grey. It also removes what is often most of the file (a 757 KB logo → 102 KB, easing the localStorage ceiling below) - EMF metafiles are drawn (`import/emf.ts`): the picture is rebuilt as SVG from the metafile's own records — paths, filled shapes, text with its font and colour, embedded bitmaps — so a plot pasted out of MATLAB or Excel arrives as a picture instead of a placeholder - Anything wider than the sheet — an oversized formula, a frame reaching past the margin — is cut at the page edge, as it is in LibreOffice - Charts are drawn from the file — DrawingML `chartN.xml` and ODF `chart:chart`, bar / line / area / scatter / pie, with their titles, axis titles, gridlines, axis bounds and series colours (read-only; see the limitations below) diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index f828afc..e083f5b 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -19,7 +19,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( - **The yardstick is the style the block can name.** Only a **body** block carries a `styleName`, so a paragraph in a cell or a text box is measured against the *default* paragraph style instead of its own — otherwise everything its style provides is suppressed as "the style will supply it" and nothing ever does (a Word caption in a text box arrived without its italic or its colour). - **Default suppression** keeps round trips (editor → LibreOffice → editor) from accreting explicit attrs: values matching the editor's defaults (12pt body, `HEADING_STYLE_OVERRIDES` sizes/margins, **0 paragraph spacing**, list margin 0, Times New Roman/Liberation Serif, `#000000` text) are imported as `null`/no mark, with small tolerances for producer unit rounding (pt↔cm↔twips). This is why the editor's defaults must equal Word's/LibreOffice's (below) — an editor-only default is indistinguishable from a failed style resolution and silently lands in every imported document. - **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`), 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`; other presets → "Unsupported shapes were removed"). A box is a block node but its frame sits inside a `text:p`, so converted boxes ride `Ctx.pendingBlocks` and `convertBlocks` flushes them after the anchor paragraph (dropping our own export's empty anchor); in cells/other boxes their blocks are unwrapped in place with a warning. The DOCX importer mirrors all of this (`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):** `` 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 ``. A frame's multiple `` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and ``-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). +- **Image formats (`import/imageFormats.ts`, shared by both importers):** `` 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 ``. A frame's multiple `` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and ``-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)). - **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`): `` 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`. - **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (the thesis fixture lost every `[7]`-style reference and broke its lines one page short). diff --git a/src/lib/import/imageFormats.ts b/src/lib/import/imageFormats.ts index fbd38d7..f9108f8 100644 --- a/src/lib/import/imageFormats.ts +++ b/src/lib/import/imageFormats.ts @@ -63,11 +63,51 @@ function bytesToBase64(bytes: Uint8Array): string { return btoa(bin); } +// A JPEG's colour component count (4 = CMYK) and where its ICC profile segments sit. +// Marker walk up to the scan; a segment is 0xFF, marker, 2-byte length, payload. +function jpegHeader(b: Uint8Array): { components: number; icc: Array<[number, number]> } { + const icc: Array<[number, number]> = []; + let components = 0; + let i = 2; + while (i + 3 < b.length && b[i] === 0xff) { + const marker = b[i + 1]; + if (marker === 0xd9 || marker === 0xda) break; + const len = (b[i + 2] << 8) | b[i + 3]; + // SOF0…SOF15 (not the DHT/JPG/DAC markers sharing the range) carry the component count. + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + components = b[i + 9]; + } else if (marker === 0xe2 && String.fromCharCode(...b.subarray(i + 4, i + 15)) === 'ICC_PROFILE') { + icc.push([i, i + 2 + len]); + } + i += 2 + len; + } + return { components, icc }; +} + +// Chromium colour-manages a CMYK JPEG through its embedded profile where LibreOffice and +// Word convert it naively — measured on a CMYK logo: its blue arrived (0,80,131) against +// LibreOffice's (0,32,183), its black as grey. Dropping the profile makes the two agree. +export function stripCmykIccProfile(bytes: Uint8Array): Uint8Array { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return bytes; + const { components, icc } = jpegHeader(bytes); + if (components !== 4 || !icc.length) return bytes; + const out = new Uint8Array(bytes.length - icc.reduce((n, [from, to]) => n + (to - from), 0)); + let at = 0; + let kept = 0; + for (const [from, to] of icc) { + out.set(bytes.subarray(kept, from), at); + at += from - kept; + kept = to; + } + out.set(bytes.subarray(kept), at); + return out; +} + // A base64 data-URI for a displayable image, or null when the format can't be shown. export function imageDataUrl(bytes: Uint8Array, path: string): string | null { const mime = displayableImageMime(bytes, path); if (!mime) return null; - return `data:${mime};base64,${bytesToBase64(bytes)}`; + return `data:${mime};base64,${bytesToBase64(mime === 'image/jpeg' ? stripCmykIccProfile(bytes) : bytes)}`; } // ---- client-side decoding of formats the browser can't render ---------------- diff --git a/tests/unit/cmyk-jpeg-icc.test.ts b/tests/unit/cmyk-jpeg-icc.test.ts new file mode 100644 index 0000000..307cbc6 --- /dev/null +++ b/tests/unit/cmyk-jpeg-icc.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { stripCmykIccProfile } from '../../src/lib/import/imageFormats'; + +// SOF0 with `components` colour channels, an ICC APP2 of `iccBytes`, then a scan. +function jpeg(components: number, iccBytes: number): Uint8Array { + const seg = (marker: number, payload: number[]) => + [0xff, marker, ((payload.length + 2) >> 8) & 0xff, (payload.length + 2) & 0xff, ...payload]; + const icc = [...[...'ICC_PROFILE'].map(c => c.charCodeAt(0)), 0, 1, 1, ...new Array(iccBytes).fill(0x42)]; + const sof = [8, 0, 16, 0, 16, components, ...new Array(components * 3).fill(1)]; + return new Uint8Array([ + 0xff, 0xd8, + ...seg(0xe2, icc), + ...seg(0xc0, sof), + ...seg(0xda, [components, 0, 0]), + 0xff, 0xd9, + ]); +} + +describe('stripCmykIccProfile', () => { + it('drops the profile of a CMYK JPEG', () => { + const src = jpeg(4, 1000); + const out = stripCmykIccProfile(src); + expect(out.length).toBe(src.length - (1000 + 14 + 4)); // payload + ICC header + marker + expect(out[0]).toBe(0xff); + expect(out[1]).toBe(0xd8); + expect(out[3]).toBe(0xc0); // the SOF now follows the start marker directly + expect(Array.from(out).includes(0x42)).toBe(false); + }); + + it('leaves an RGB JPEG, a profile-less CMYK one and a PNG alone', () => { + const rgb = jpeg(3, 1000); + expect(stripCmykIccProfile(rgb)).toBe(rgb); + const bare = jpeg(4, 0).slice(0, 2); + expect(stripCmykIccProfile(bare)).toBe(bare); + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); + expect(stripCmykIccProfile(png)).toBe(png); + }); +});