diff --git a/CHANGELOG.md b/CHANGELOG.md index 56faadd..de92eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,15 @@ # Change Log -## 1.8.0 +## 1.9.0 -- Add more compression support +- Add a Metadata panel + +## 1.8.0 (2026-06-20) + +- Support more tiff compression formats - Improve colormap support +- Speed up loading and decoding by in average 30% by using WebGL2/GPU +- Use Rust/WASM for HDR, 16bit PNG, EXR and TIFF ## 1.7.0 (2026-06-14) diff --git a/CLAUDE.md b/CLAUDE.md index 265f230..f7501d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -443,6 +443,9 @@ The extension handles diverse image formats with minimal processor code. Each fo - Detects actual bit depth and converts to Float32Array - Uses LUT optimization for gamma/brightness in 8-bit mode +- **Raw** + - Ignore the implemented raw images support for now. It's too slow to be useful. Therefore we ignore it for now. + ### Library Integration - **geotiff.min.js**: Browser build automatically copied from node_modules during build - **parse-exr.js**: Bundled with extension for OpenEXR support diff --git a/README.md b/README.md index 2f5c20a..d2807f5 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ The viewer supports 8-bit and 16-bit integer images as well as 16-bit and 32-bit - **Session-Wide Settings**: A single VS Code window keeps visualization settings across opened images. - **Export and Copy**: Export the current visualization as PNG, copy the image, or copy image zoom level to the clipboard to paste onto other image. - **VS Code Native Controls**: Most options are available from the right-click menu, command palette, or clickable status bar entries. +- **Metadata panel** shows file info, image statistics (min/max/mean/std) and Exif/GPS sub-IFD tags. ## How to Use diff --git a/media/decode-worker.js b/media/decode-worker.js index 2fcb880..b7490b4 100644 --- a/media/decode-worker.js +++ b/media/decode-worker.js @@ -32,6 +32,7 @@ import initTiffWasm, { decode_exr_fast, decode_hdr_fast, decode_png16_fast, deco import { NpyProcessor } from './modules/npy-processor.js'; import { PfmProcessor } from './modules/pfm-processor.js'; import { PpmProcessor } from './modules/ppm-processor.js'; +import { buildTagsFromGeotiffImage } from './modules/tiff-tag-utils.js'; // Parser-only instances: the constructors just assign fields, and the // _parse* methods used here touch no DOM or vscode APIs. @@ -170,6 +171,7 @@ function decodeTiffWasm(buffer) { rasters, min: result.min_value, max: result.max_value, + allTagsJson: result.all_tags_json, decodedWith: 'wasm (worker)', decodeTimings: timings, }; @@ -242,6 +244,7 @@ async function decodeTiffGeotiff(buffer, wasmError) { planarConfiguration: fileDirectory.PlanarConfiguration || 1, data, rasters, + allTagsJson: JSON.stringify(buildTagsFromGeotiffImage(image)), decodedWith: 'geotiff.js (worker)', wasmFallbackReason: wasmError, decodeTimings: timings, @@ -303,6 +306,7 @@ function decodeExrWasm(buffer) { displayedChannels, shape: [result.width, result.height], flipY: false, + allTagsJson: result.all_tags_json, decodedWith: 'rust-exr-wasm (worker)', decodeTimings: timings, }; @@ -377,6 +381,7 @@ function decodeHdrWasm(buffer) { exposure, gamma, data, + allTagsJson: result.all_tags_json, decodedWith: 'rust-hdr-wasm (worker)', decodeTimings: timings, }; diff --git a/media/imagePreview.css b/media/imagePreview.css index be5583e..fad741e 100644 --- a/media/imagePreview.css +++ b/media/imagePreview.css @@ -522,6 +522,183 @@ body img { min-width: 0; } +/* Metadata Panel */ +.metadata-panel { + position: fixed; + top: 20px; + right: 20px; + background: var(--vscode-editor-background, #1e1e1e); + border: 1px solid var(--vscode-panel-border, #3c3c3c); + border-radius: 6px; + z-index: 1000; + font-family: var(--vscode-editor-font-family, 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif); + font-size: var(--vscode-editor-font-size, 12px); + color: var(--vscode-editor-foreground, #cccccc); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + width: 340px; + max-width: 340px; + max-height: 70vh; + box-sizing: border-box; +} + +.metadata-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--vscode-sideBarSectionHeader-background, #252526); + border-bottom: 1px solid var(--vscode-panel-border, #3c3c3c); + border-radius: 6px 6px 0 0; + gap: 8px; + flex: 0 0 auto; +} + +.metadata-panel-title { + font-weight: 600; + font-size: 13px; + flex: 1; +} + +.metadata-panel-button { + background: var(--vscode-button-secondaryBackground, #3a3a3a); + color: var(--vscode-button-secondaryForeground, #cccccc); + border: 1px solid var(--vscode-panel-border, #3c3c3c); + border-radius: 3px; + padding: 2px 8px; + font-size: 11px; + cursor: pointer; + white-space: nowrap; +} + +.metadata-panel-button:hover { + background: var(--vscode-button-secondaryHoverBackground, #454545); +} + +.metadata-panel-close { + background: transparent; + border: none; + color: var(--vscode-icon-foreground, #cccccc); + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + flex: 0 0 auto; +} + +.metadata-panel-close:hover { + background: var(--vscode-toolbar-hoverBackground, #3a3a3a); +} + +.metadata-panel-body { + overflow-y: auto; + padding: 4px 0; +} + +.metadata-panel-empty { + padding: 12px; + font-size: 11px; + color: var(--vscode-descriptionForeground, #999999); +} + +.metadata-panel-section { + border-bottom: 1px solid var(--vscode-panel-border, #3c3c3c); +} + +.metadata-panel-section:last-child { + border-bottom: none; +} + +.metadata-panel-section > summary { + padding: 6px 12px; + font-weight: 600; + font-size: 11px; + cursor: pointer; + user-select: none; + list-style: none; +} + +.metadata-panel-section > summary::-webkit-details-marker { + display: none; +} + +.metadata-panel-section > summary::before { + content: '▶'; + display: inline-block; + margin-right: 6px; + font-size: 9px; + transition: transform 0.1s ease; +} + +.metadata-panel-section[open] > summary::before { + transform: rotate(90deg); +} + +.metadata-panel-section-content { + padding: 0 12px 8px; + font-family: var(--vscode-editor-font-family, 'Consolas', 'Monaco', monospace); + font-size: 11px; +} + +.metadata-panel-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding: 2px 0; +} + +.metadata-panel-row-name { + color: var(--vscode-descriptionForeground, #999999); + white-space: nowrap; + flex: 0 0 auto; +} + +.metadata-panel-row-value { + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.vscode-light .metadata-panel { + background: var(--vscode-editor-background, #ffffff); + border: 1px solid var(--vscode-panel-border, #e5e5e5); + color: var(--vscode-editor-foreground, #333333); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.vscode-light .metadata-panel-header { + background: var(--vscode-sideBarSectionHeader-background, #f3f3f3); + border-color: var(--vscode-panel-border, #e5e5e5); +} + +.vscode-light .metadata-panel-button { + background: var(--vscode-button-secondaryBackground, #e8e8e8); + color: var(--vscode-button-secondaryForeground, #333333); + border-color: var(--vscode-panel-border, #d0d0d0); +} + +.vscode-light .metadata-panel-button:hover { + background: var(--vscode-button-secondaryHoverBackground, #d8d8d8); +} + +.vscode-light .metadata-panel-close:hover { + background: var(--vscode-toolbar-hoverBackground, #e8e8e8); +} + +.vscode-light .metadata-panel-section { + border-color: var(--vscode-panel-border, #e5e5e5); +} + /* Light theme adjustments for histogram */ .vscode-light .histogram-overlay { background: var(--vscode-editor-background, #ffffff); @@ -612,6 +789,10 @@ body img { z-index: 3000; min-width: 120px; user-select: none; + /* Never let the menu exceed the viewport: scroll if it is too tall. */ + max-height: calc(100vh - 16px); + overflow-y: auto; + overflow-x: hidden; } .context-menu-item { diff --git a/media/imagePreview.js b/media/imagePreview.js index 2644c2d..f526fc3 100644 --- a/media/imagePreview.js +++ b/media/imagePreview.js @@ -16,6 +16,7 @@ import { RawProcessor } from './modules/raw-processor.js'; import { ZoomController } from './modules/zoom-controller.js'; import { MouseHandler } from './modules/mouse-handler.js'; import { HistogramOverlay } from './modules/histogram-overlay.js'; +import { MetadataPanel } from './modules/metadata-panel.js'; import { ColormapConverter } from './modules/colormap-converter.js'; import { ImageRenderer, ImageStatsCalculator } from './modules/normalization-helper.js'; import { DecodeWorkerClient } from './modules/decode-worker-client.js'; @@ -73,6 +74,7 @@ import { LayersPanel } from './modules/layers-panel.js'; const pfmProcessor = new PfmProcessor(settingsManager, vscode); const ppmProcessor = new PpmProcessor(settingsManager, vscode); const pngProcessor = new PngProcessor(settingsManager, vscode); + pngProcessor.onMetadataTagsReady = () => updateMetadataData(); const hdrProcessor = new HdrProcessor(settingsManager, vscode); const tgaProcessor = new TgaProcessor(settingsManager, vscode); const webImageProcessor = new WebImageProcessor(settingsManager, vscode); @@ -88,6 +90,7 @@ import { LayersPanel } from './modules/layers-panel.js'; const workerProcessors = [tiffProcessor, exrProcessor, npyProcessor, pfmProcessor, ppmProcessor, pngProcessor, hdrProcessor]; for (const p of workerProcessors) { p.decodeWorker = decodeWorkerClient; } const histogramOverlay = new HistogramOverlay(settingsManager, vscode); + const metadataPanel = new MetadataPanel(settingsManager, vscode); const colormapConverter = new ColormapConverter(); mouseHandler.setNpyProcessor(npyProcessor); mouseHandler.setPfmProcessor(pfmProcessor); @@ -1853,6 +1856,11 @@ import { LayersPanel } from './modules/layers-panel.js'; }); break; + case 'toggleMetadata': + metadataPanel.toggle(); + updateMetadataData(); + break; + case 'restoreHistogramState': // Restore histogram state from extension (global state) // Skip notification since extension already knows the state @@ -1917,11 +1925,135 @@ import { LayersPanel } from './modules/layers-panel.js'; }); } + /** + * Gather the currently active image's metadata/tags/statistics for the + * Metadata panel, checking each processor's stored raw data in the same + * priority order as updateHistogramData below. + * @returns {import('./modules/metadata-panel.js').MetadataInfo|null} + */ + function gatherActiveMetadataInfo() { + /** @type {string} */ + let formatLabel; + /** @type {Record} */ + let fileFields; + /** @type {import('./modules/tiff-tag-utils.js').TagEntry[]} */ + let tags = []; + /** @type {ArrayLike|null} */ + let data = null; + let width = 0, height = 0, channels = 1; + + if (tiffProcessor.rawTiffData) { + const ifd = tiffProcessor.rawTiffData.ifd; + width = ifd.width; height = ifd.height; channels = ifd.t277 || 1; + const bitsPerSample = ifd.t258 || 8; + const sampleFormat = ifd.t339; + const sampleFormatLabel = sampleFormat === 3 ? 'IEEE float' : (sampleFormat === 2 ? 'signed int' : 'unsigned int'); + formatLabel = 'TIFF'; + fileFields = { + 'Dimensions': `${width} x ${height}`, + 'Channels': String(channels), + 'Bits/Sample': String(bitsPerSample), + 'Sample Format': sampleFormatLabel + }; + tags = tiffProcessor._lastAllTags || []; + data = tiffProcessor.rawTiffData.data; + } else if (exrProcessor.rawExrData) { + const r = exrProcessor.rawExrData; + width = r.width; height = r.height; channels = r.channels || 1; + formatLabel = 'EXR'; + fileFields = { + 'Dimensions': `${width} x ${height}`, + 'Channels': String(channels), + 'Channel Names': (r.channelNames || []).join(', ') || 'n/a', + 'Precision': r.type === 1016 ? 'half (float16)' : 'float32' + }; + tags = exrProcessor._lastAllTags || []; + data = r.data; + } else if (rawProcessor._lastRaw) { + const r = rawProcessor._lastRaw; + width = r.width; height = r.height; channels = r.channels || 3; + formatLabel = 'Camera RAW'; + fileFields = { 'Dimensions': `${width} x ${height}`, 'Channels': String(channels), 'Bit Depth': String(r.bitDepth || 8) }; + tags = rawProcessor._lastAllTags || []; + data = r.data; + } else if (npyProcessor._lastRaw) { + const r = npyProcessor._lastRaw; + width = r.width; height = r.height; channels = r.channels || 1; + formatLabel = 'NPY/NPZ'; + fileFields = { 'Dimensions': `${width} x ${height}`, 'Channels': String(channels), 'Dtype': r.dtype || 'n/a' }; + data = r.data; + } else if (pfmProcessor._lastRaw) { + const r = pfmProcessor._lastRaw; + width = r.width; height = r.height; channels = r.channels || 1; + formatLabel = 'PFM'; + fileFields = { 'Dimensions': `${width} x ${height}`, 'Channels': String(channels) }; + data = r.data; + } else if (hdrProcessor._lastRaw) { + const r = hdrProcessor._lastRaw; + width = r.width; height = r.height; channels = r.channels || 3; + formatLabel = 'HDR (Radiance)'; + fileFields = { 'Dimensions': `${width} x ${height}`, 'Channels': String(channels) }; + tags = hdrProcessor._lastAllTags || []; + data = r.data; + } else if (ppmProcessor._lastRaw) { + const r = ppmProcessor._lastRaw; + width = r.width; height = r.height; channels = r.channels || 1; + formatLabel = r.format || 'PPM/PGM/PBM'; + fileFields = { 'Dimensions': `${width} x ${height}`, 'Channels': String(channels), 'Max Value': String(r.maxval) }; + data = r.data; + } else if (pngProcessor._lastRaw || pngProcessor._lazyNativeReadback) { + const r = pngProcessor._lastRaw; + const lazy = pngProcessor._lazyNativeReadback; + const isJpeg = currentFormatInfo?.formatType === 'jpg'; + formatLabel = isJpeg ? 'JPEG' : 'PNG'; + if (r) { + width = r.width; height = r.height; channels = r.channels || 4; + fileFields = { 'Dimensions': `${width} x ${height}`, 'Channels': String(channels), 'Bit Depth': String(r.bitDepth || 8) }; + data = r.data; + } else { + // Large JPEGs skip pixel-array storage (lazy native-Image + // readback) — still show file info and any embedded Exif tags. + fileFields = { 'Dimensions': `${lazy.width} x ${lazy.height}` }; + } + tags = pngProcessor._lastAllTags || []; + } else { + return null; + } + + /** @type {import('./modules/metadata-panel.js').MetadataInfo['stats']} */ + let stats = null; + if (data && width && height) { + try { + stats = ImageStatsCalculator.calculateExtendedStats(data, width, height, channels || 1); + } catch { + stats = null; + } + } + + return { formatLabel, fileFields, tags, stats }; + } + + /** + * Refresh the Metadata panel, if visible, from the currently active image. + * Cheap no-op when the panel is closed. + */ + function updateMetadataData() { + if (!canvas || !hasLoadedImage || !metadataPanel.getVisibility()) { + return; + } + try { + metadataPanel.render(gatherActiveMetadataInfo()); + } catch (error) { + console.warn('[MetadataPanel] Failed to gather metadata:', error); + } + } + /** * Update histogram with current image data. * Uses raw image data when available for accurate value representation. */ function updateHistogramData() { + updateMetadataData(); if (!canvas || !hasLoadedImage) { return; } @@ -2910,6 +3042,11 @@ import { LayersPanel } from './modules/layers-panel.js'; })); } + // Add Toggle Metadata Panel option + menu.appendChild(createMenuItem('Toggle Metadata Panel', () => { + vscode.postMessage({ type: 'executeCommand', command: 'tiffVisualizer.toggleMetadata' }); + })); + // Open as Point Cloud — only when ply-visualizer is installed and format is supported const plyFormats = ['tiff-float', 'tiff-int', 'pfm', 'npy', 'npy-float', 'npy-uint', 'png']; if (settingsManager.settings.plyVisualizerInstalled && currentFormatInfo && plyFormats.includes(currentFormatInfo.formatType ?? '')) { @@ -2921,6 +3058,22 @@ import { LayersPanel } from './modules/layers-panel.js'; document.body.appendChild(menu); + // Keep the menu inside the viewport: if it would overflow the right or + // bottom edge, shift it back so it isn't clipped by the webview bounds. + // (An over-tall menu is capped and made scrollable via CSS max-height.) + const edgeMargin = 8; + const menuRect = menu.getBoundingClientRect(); + let menuLeft = e.clientX; + let menuTop = e.clientY; + if (menuLeft + menuRect.width > window.innerWidth - edgeMargin) { + menuLeft = Math.max(edgeMargin, window.innerWidth - menuRect.width - edgeMargin); + } + if (menuTop + menuRect.height > window.innerHeight - edgeMargin) { + menuTop = Math.max(edgeMargin, window.innerHeight - menuRect.height - edgeMargin); + } + menu.style.left = `${menuLeft}px`; + menu.style.top = `${menuTop}px`; + // Remove menu when clicking outside const removeMenu = (/** @type {MouseEvent} */ event) => { if (!menu.contains(/** @type {Node} */ (event.target))) { diff --git a/media/jsconfig.json b/media/jsconfig.json index 6a5fce7..df4daec 100644 --- a/media/jsconfig.json +++ b/media/jsconfig.json @@ -8,7 +8,8 @@ "paths": { "tga-js": ["./types/tga-js.d.ts"], "parse-hdr": ["./types/parse-hdr.d.ts"], - "libraw-wasm": ["./types/libraw-wasm.d.ts"] + "libraw-wasm": ["./types/libraw-wasm.d.ts"], + "pako": ["./types/pako.d.ts"] }, "typeRoots": ["./types", "../node_modules/@types"], "noImplicitAny": true, diff --git a/media/modules/exr-processor.js b/media/modules/exr-processor.js index d1b3cde..1328b9d 100644 --- a/media/modules/exr-processor.js +++ b/media/modules/exr-processor.js @@ -3,6 +3,7 @@ import { NormalizationHelper, ImageRenderer, ImageStatsCalculator } from './normalization-helper.js'; import { DecodeWorkerClient } from './decode-worker-client.js'; import { WebGL2FloatRenderer } from './webgl2-float-renderer.js'; +import { parseAllTagsJson } from './tiff-tag-utils.js'; /** @typedef {import('./settings-manager.js').ImageSettings} ImageSettings */ /** @typedef {import('./settings-manager.js').SettingsManager} SettingsManager */ @@ -32,6 +33,8 @@ export class ExrProcessor { this.settingsManager = settingsManager; this.vscode = vscode; this._lastRaw = null; // { width, height, data: Float32Array } + /** @type {import('./tiff-tag-utils.js').TagEntry[]} EXR header attributes, for the Metadata panel (Rust decode path only) */ + this._lastAllTags = []; this._pendingRenderData = null; // Store data waiting for format-specific settings this._isInitialLoad = true; // Track if this is the first render this._cachedStats = undefined; // Cache for min/max stats (only used in stats mode) @@ -105,6 +108,7 @@ export class ExrProcessor { const { width, height, data, format, type, channelNames, displayedChannels } = exrResult; const flipY = exrResult.flipY !== false; + this._lastAllTags = parseAllTagsJson(exrResult.allTagsJson); // Determine channels based on format // RGBAFormat = 1023, RedFormat = 1028 diff --git a/media/modules/hdr-processor.js b/media/modules/hdr-processor.js index 2659309..d9e454c 100644 --- a/media/modules/hdr-processor.js +++ b/media/modules/hdr-processor.js @@ -5,6 +5,7 @@ import { DecodeWorkerClient } from './decode-worker-client.js'; import { WebGL2FloatRenderer } from './webgl2-float-renderer.js'; import { PerfTrace } from './perf-trace.js'; import parseHdr from 'parse-hdr'; +import { parseAllTagsJson } from './tiff-tag-utils.js'; /** @typedef {import('./settings-manager.js').SettingsManager} SettingsManager */ /** @typedef {import('./settings-manager.js').ImageSettings} ImageSettings */ @@ -28,6 +29,8 @@ export class HdrProcessor { this.vscode = vscode; /** @type {{width:number, height:number, data:Float32Array, channels:number}|null} */ this._lastRaw = null; + /** @type {import('./tiff-tag-utils.js').TagEntry[]} HDR header lines, for the Metadata panel (Rust decode path only) */ + this._lastAllTags = []; this._pendingRenderData = null; this._isInitialLoad = true; /** @type {{min:number,max:number}|undefined} */ @@ -56,6 +59,7 @@ export class HdrProcessor { this.decodeWorker, 'hdr', buffer, src, loadSignal, (b) => parseHdr(b)); const width = parsed.shape[0]; const height = parsed.shape[1]; + this._lastAllTags = parseAllTagsJson(parsed.allTagsJson); /** @type {Float32Array} */ const data = parsed.data; diff --git a/media/modules/metadata-panel.js b/media/modules/metadata-panel.js new file mode 100644 index 0000000..dac6fc6 --- /dev/null +++ b/media/modules/metadata-panel.js @@ -0,0 +1,247 @@ +// @ts-check +"use strict"; + +/** @typedef {import('./settings-manager.js').SettingsManager} SettingsManager */ +/** @typedef {{postMessage: (msg: any) => any}} VsCodeApi */ +/** @typedef {import('./tiff-tag-utils.js').TagEntry} TagEntry */ +/** + * @typedef {Object} MetadataInfo + * @property {string} formatLabel + * @property {Record} fileFields + * @property {TagEntry[]} tags + * @property {{min:number,max:number,mean:number,std:number,validCount:number,nonFiniteCount:number,totalCount:number}|null} stats + */ + +/** + * Metadata Panel Module + * + * Shows file/format info, image statistics, and — for TIFF/GeoTIFF — every + * tag found in the file (main IFD plus Exif/GPS sub-IFDs), generically: no + * curated subset, whatever the decoder found is listed here. + */ +export class MetadataPanel { + /** + * @param {SettingsManager} settingsManager + * @param {VsCodeApi} vscode + */ + constructor(settingsManager, vscode) { + this.settingsManager = settingsManager; + this.vscode = vscode; + + /** @type {HTMLDivElement|null} */ + this.overlay = null; + /** @type {HTMLDivElement|null} */ + this.body = null; + /** @type {HTMLButtonElement|null} */ + this.copyButton = null; + this.isVisible = false; + /** @type {MetadataInfo|null} */ + this.lastInfo = null; + + this.createOverlay(); + } + + createOverlay() { + this.overlay = document.createElement('div'); + this.overlay.className = 'metadata-panel'; + this.overlay.style.display = 'none'; + + const header = document.createElement('div'); + header.className = 'metadata-panel-header'; + + const title = document.createElement('div'); + title.className = 'metadata-panel-title'; + title.textContent = 'Metadata'; + + this.copyButton = document.createElement('button'); + this.copyButton.className = 'metadata-panel-button'; + this.copyButton.textContent = 'Copy as JSON'; + this.copyButton.title = 'Copy all metadata and statistics as JSON'; + this.copyButton.onclick = () => this.copyAsJson(); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'metadata-panel-close'; + closeBtn.textContent = '×'; + closeBtn.title = 'Close metadata panel'; + closeBtn.onclick = () => this.hide(); + + header.appendChild(title); + header.appendChild(this.copyButton); + header.appendChild(closeBtn); + + this.body = document.createElement('div'); + this.body.className = 'metadata-panel-body'; + + this.overlay.appendChild(header); + this.overlay.appendChild(this.body); + + document.body.appendChild(this.overlay); + } + + /** + * @param {boolean} [skipNotification=false] + */ + show(skipNotification = false) { + this.isVisible = true; + if (this.overlay) this.overlay.style.display = 'flex'; + if (!skipNotification) { + this.vscode.postMessage({ type: 'metadataVisibilityChanged', isVisible: true }); + } + } + + /** + * @param {boolean} [skipNotification=false] + */ + hide(skipNotification = false) { + this.isVisible = false; + if (this.overlay) this.overlay.style.display = 'none'; + if (!skipNotification) { + this.vscode.postMessage({ type: 'metadataVisibilityChanged', isVisible: false }); + } + } + + toggle() { + if (this.isVisible) { + this.hide(); + } else { + this.show(); + } + } + + getVisibility() { + return this.isVisible; + } + + /** + * @param {number} value + */ + formatNumber(value) { + if (!Number.isFinite(value)) { return String(value); } + if (Math.abs(value) !== 0 && (Math.abs(value) < 0.001 || Math.abs(value) >= 100000)) { + return value.toExponential(3); + } + return value.toPrecision(6).replace(/\.?0+$/, '') || '0'; + } + + /** + * @param {string} title + * @param {boolean} open + * @returns {{details: HTMLDetailsElement, content: HTMLDivElement}} + */ + createSection(title, open) { + const details = document.createElement('details'); + details.className = 'metadata-panel-section'; + details.open = open; + + const summary = document.createElement('summary'); + summary.textContent = title; + details.appendChild(summary); + + const content = document.createElement('div'); + content.className = 'metadata-panel-section-content'; + details.appendChild(content); + + return { details, content }; + } + + /** + * @param {HTMLElement} parent + * @param {string} name + * @param {string} value + */ + appendRow(parent, name, value) { + const row = document.createElement('div'); + row.className = 'metadata-panel-row'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'metadata-panel-row-name'; + nameSpan.textContent = name; + + const valueSpan = document.createElement('span'); + valueSpan.className = 'metadata-panel-row-value'; + valueSpan.textContent = value; + valueSpan.title = value; + + row.appendChild(nameSpan); + row.appendChild(valueSpan); + parent.appendChild(row); + } + + /** + * @param {MetadataInfo|null} info + */ + render(info) { + this.lastInfo = info; + if (!this.body) { return; } + this.body.textContent = ''; + + if (!info) { + const empty = document.createElement('div'); + empty.className = 'metadata-panel-empty'; + empty.textContent = 'No metadata available for this image.'; + this.body.appendChild(empty); + return; + } + + // File section + const fileSection = this.createSection(`File (${info.formatLabel})`, true); + for (const [name, value] of Object.entries(info.fileFields)) { + this.appendRow(fileSection.content, name, value); + } + this.body.appendChild(fileSection.details); + + // Statistics section + if (info.stats) { + const statsSection = this.createSection('Statistics', true); + const s = info.stats; + this.appendRow(statsSection.content, 'Min', this.formatNumber(s.min)); + this.appendRow(statsSection.content, 'Max', this.formatNumber(s.max)); + this.appendRow(statsSection.content, 'Mean', this.formatNumber(s.mean)); + this.appendRow(statsSection.content, 'Std Dev', this.formatNumber(s.std)); + this.appendRow(statsSection.content, 'Valid Samples', `${s.validCount.toLocaleString()} / ${s.totalCount.toLocaleString()}`); + if (s.nonFiniteCount > 0) { + this.appendRow(statsSection.content, 'NaN/Infinite', s.nonFiniteCount.toLocaleString()); + } + this.body.appendChild(statsSection.details); + } + + // Tag sections, grouped, in a stable order with the main tag table first. + if (info.tags && info.tags.length > 0) { + /** @type {Map} */ + const groups = new Map(); + for (const entry of info.tags) { + const group = entry.group || 'Tags'; + if (!groups.has(group)) { groups.set(group, []); } + /** @type {TagEntry[]} */ (groups.get(group)).push(entry); + } + const groupOrder = ['TIFF', 'GeoKeys', 'Exif', 'GPS']; + const orderedGroups = [ + ...groupOrder.filter(g => groups.has(g)), + ...Array.from(groups.keys()).filter(g => !groupOrder.includes(g)) + ]; + for (const group of orderedGroups) { + const entries = groups.get(group) || []; + const section = this.createSection(`${group} Tags (${entries.length})`, group === 'TIFF'); + for (const entry of entries) { + this.appendRow(section.content, entry.name, entry.value); + } + this.body.appendChild(section.details); + } + } + } + + async copyAsJson() { + if (!this.lastInfo || !this.copyButton) { return; } + const text = JSON.stringify(this.lastInfo, null, 2); + const originalLabel = this.copyButton.textContent; + try { + await navigator.clipboard.writeText(text); + this.copyButton.textContent = 'Copied!'; + } catch { + this.copyButton.textContent = 'Copy failed'; + } + setTimeout(() => { + if (this.copyButton) { this.copyButton.textContent = originalLabel; } + }, 1500); + } +} diff --git a/media/modules/normalization-helper.js b/media/modules/normalization-helper.js index 4a7f7f3..64f5d54 100644 --- a/media/modules/normalization-helper.js +++ b/media/modules/normalization-helper.js @@ -289,6 +289,56 @@ export class ImageStatsCalculator { PerfTrace.mark('stats'); return { min: minVal, max: maxVal }; } + + /** + * On-demand extended statistics (mean/std/valid & non-finite counts) for + * the Metadata panel. Not part of the hot render path — computed once + * when the panel is opened, using the same first-3-channels scan + * convention as calculateFloatStats/calculateIntegerStats above. + * @param {ArrayLike} data + * @param {number} width + * @param {number} height + * @param {number} channels + * @returns {{min:number,max:number,mean:number,std:number,validCount:number,nonFiniteCount:number,totalCount:number}} + */ + static calculateExtendedStats(data, width, height, channels) { + let minVal = Infinity; + let maxVal = -Infinity; + let sum = 0; + let sumSq = 0; + let validCount = 0; + let nonFiniteCount = 0; + + const len = width * height; + const scanChannels = channels === 1 ? 1 : Math.min(channels, 3); + for (let i = 0; i < len; i++) { + const base = i * channels; + for (let c = 0; c < scanChannels; c++) { + const val = data[base + c]; + if (Number.isFinite(val)) { + if (val < minVal) minVal = val; + if (val > maxVal) maxVal = val; + sum += val; + sumSq += val * val; + validCount++; + } else { + nonFiniteCount++; + } + } + } + + const mean = validCount > 0 ? sum / validCount : NaN; + const variance = validCount > 0 ? Math.max(0, sumSq / validCount - mean * mean) : NaN; + return { + min: validCount > 0 ? minVal : NaN, + max: validCount > 0 ? maxVal : NaN, + mean, + std: Math.sqrt(variance), + validCount, + nonFiniteCount, + totalCount: len * scanChannels + }; + } } /** diff --git a/media/modules/png-processor.js b/media/modules/png-processor.js index 4234ed4..0ea8d11 100644 --- a/media/modules/png-processor.js +++ b/media/modules/png-processor.js @@ -4,6 +4,8 @@ import { NormalizationHelper, ImageRenderer, ImageStatsCalculator } from './norm import { DecodeWorkerClient } from './decode-worker-client.js'; import { WebGL2FloatRenderer } from './webgl2-float-renderer.js'; import { PerfTrace } from './perf-trace.js'; +import { findJpegExifBlob, parsePngChunks } from './tiff-tag-utils.js'; +import { extractExifTagsFromBlob } from './tiff-wasm-wrapper.js'; /** * @typedef {Object} RawImageData @@ -47,6 +49,10 @@ export class PngProcessor { this.loadSignal = undefined; // Set before each load; aborts the fetch when a newer image switch supersedes it /** @type {DecodeWorkerClient|null} */ this.decodeWorker = null; // Off-thread decoder, set by imagePreview.js; null falls back to local decoding + /** @type {import('./tiff-tag-utils.js').TagEntry[]} Embedded Exif/text tags, for the Metadata panel */ + this._lastAllTags = []; + /** @type {(() => void)|null} Set by imagePreview.js; called when an async embedded-metadata fetch (JPEG Exif) resolves after the image is already displayed */ + this.onMetadataTagsReady = null; } /** @@ -60,7 +66,12 @@ export class PngProcessor { // JPEG files always use native browser Image API (they don't support 16-bit) const isJpeg = src.toLowerCase().includes('.jpg') || src.toLowerCase().includes('.jpeg'); + this._lastAllTags = []; if (isJpeg) { + // Don't block the visible decode/display on this: fetch bytes and + // scan for an embedded Exif APP1 segment separately, refreshing the + // Metadata panel (if open) once it resolves. + this._loadJpegExifTags(src, loadSignal); return this._processWithNativeAPI(src); } @@ -71,6 +82,12 @@ export class PngProcessor { this._cachedStatsRgb24Mode = false; const arrayBuffer = await DecodeWorkerClient.fetchArrayBuffer(src, loadSignal, 'png'); + const { exifBlob, textEntries } = parsePngChunks(new Uint8Array(arrayBuffer)); + /** @type {import('./tiff-tag-utils.js').TagEntry[]} */ + this._lastAllTags = textEntries.map(({ name, value }) => ({ tag: null, name, group: 'PNG', value })); + if (exifBlob) { + this._lastAllTags.push(...await extractExifTagsFromBlob(exifBlob)); + } if (loadSignal?.aborted) { throw new DOMException('Load superseded', 'AbortError'); } // Quick bit depth detection from PNG IHDR chunk (just reads byte 24) @@ -192,6 +209,28 @@ export class PngProcessor { } } + /** + * Fetch a JPEG's bytes and, if it carries an Exif APP1 segment, decode it + * into tags for the Metadata panel. Runs independently of (and doesn't + * delay) the visible native-Image decode/display path above. + * @param {string} src + * @param {AbortSignal|undefined} loadSignal + */ + async _loadJpegExifTags(src, loadSignal) { + try { + const arrayBuffer = await DecodeWorkerClient.fetchArrayBuffer(src, loadSignal, 'jpeg-exif'); + if (loadSignal?.aborted) { return; } + const exifBlob = findJpegExifBlob(new Uint8Array(arrayBuffer)); + if (!exifBlob) { return; } + const tags = await extractExifTagsFromBlob(exifBlob); + if (loadSignal?.aborted) { return; } + this._lastAllTags = tags; + this.onMetadataTagsReady?.(); + } catch (error) { + console.warn('[PngProcessor] Failed to read JPEG Exif tags:', error); + } + } + /** * Process image using native browser Image API (for 8-bit PNGs and JPEGs) * @param {string} src - Source URI diff --git a/media/modules/raw-processor.js b/media/modules/raw-processor.js index 05b887d..f0b6d4f 100644 --- a/media/modules/raw-processor.js +++ b/media/modules/raw-processor.js @@ -3,6 +3,7 @@ import LibRaw from 'libraw-wasm'; import { NormalizationHelper, ImageRenderer, ImageStatsCalculator } from './normalization-helper.js'; +import { flattenObjectToTags } from './tiff-tag-utils.js'; /** @typedef {import('./settings-manager.js').SettingsManager} SettingsManager */ /** @typedef {import('./settings-manager.js').ImageSettings} ImageSettings */ @@ -28,6 +29,8 @@ export class RawProcessor { this._workerBootstrapUrl = null; /** @type {ArrayBuffer|null} Cached file bytes to avoid re-fetching for full decode */ this._arrayBuffer = null; + /** @type {import('./tiff-tag-utils.js').TagEntry[]} Every field from libraw's metadata(), for the Metadata panel */ + this._lastAllTags = []; /** @type {AbortSignal|undefined} */ this.loadSignal = undefined; // Set before each load; aborts the fetch when a newer image switch supersedes it } @@ -447,7 +450,7 @@ export class RawProcessor { ); const meta = await this._withTimeout( - this._libRaw.metadata(), + this._libRaw.metadata(true), // fullOutput: include vendor-specific blocks (canon/nikon/fuji/sony/...) 20000, 'RAW decode (metadata)' ); @@ -456,6 +459,7 @@ export class RawProcessor { if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { throw new Error(`Invalid RAW dimensions from decoder: ${width}x${height}`); } + this._lastAllTags = flattenObjectToTags(meta, 'RAW'); // fetch RGB or RGBA array from libraw const rawOutput = await this._withTimeout( this._libRaw.imageData(), diff --git a/media/modules/tiff-processor.js b/media/modules/tiff-processor.js index 9109fa8..868ae0c 100644 --- a/media/modules/tiff-processor.js +++ b/media/modules/tiff-processor.js @@ -4,6 +4,7 @@ import { NormalizationHelper, ImageRenderer, ImageStatsCalculator } from './norm import { TiffWasmProcessor } from './tiff-wasm-wrapper.js'; import { PerfTrace } from './perf-trace.js'; import { WebGL2FloatRenderer } from './webgl2-float-renderer.js'; +import { parseAllTagsJson, buildTagsFromGeotiffImage } from './tiff-tag-utils.js'; /** * @typedef {Object} GeoTIFFGlobal @@ -40,6 +41,8 @@ export class TiffProcessor { this._lastStatistics = null; // Cache min/max statistics this._lastStatisticsRgb24Mode = false; // Track whether cached stats were computed in rgb24 mode this._lastRenderHistogram = null; // Histogram computed during render when requested + /** @type {import('./tiff-tag-utils.js').TagEntry[]} */ + this._lastAllTags = []; // Every TIFF/Exif/GPS tag found in the current file, for the Metadata panel this._lastRenderUsedWebGL = false; // True when the latest render drew directly to the canvas /** @type {{ floatData: Float32Array, width?: number, height?: number, min?: number, max?: number } | null} */ this._convertedFloatData = null; // Cache converted float data for analysis @@ -330,6 +333,7 @@ export class TiffProcessor { this._lastStatistics = { min: wasmResult.min, max: wasmResult.max }; this._lastStatisticsRgb24Mode = false; } + this._lastAllTags = parseAllTagsJson(wasmResult.allTagsJson); // Send format information to VS Code if (this.vscode && this._isInitialLoad) { @@ -456,6 +460,7 @@ export class TiffProcessor { }, data: data }; + this._lastAllTags = buildTagsFromGeotiffImage(image); // Send format information to VS Code BEFORE rendering // This allows the extension to apply format-specific settings first diff --git a/media/modules/tiff-tag-utils.js b/media/modules/tiff-tag-utils.js new file mode 100644 index 0000000..90cc10b --- /dev/null +++ b/media/modules/tiff-tag-utils.js @@ -0,0 +1,246 @@ +// @ts-check +"use strict"; + +import pako from 'pako'; + +/** + * Shared helpers for the TIFF tag/metadata dump surfaced in the Metadata panel. + * + * The Rust/WASM decoder (default path) walks the raw IFD generically and + * returns every tag it finds — including Exif/GPS sub-IFD tags — as a JSON + * string via `TiffResult.all_tags_json`. `parseAllTagsJson` just parses that. + * + * The geotiff.js fallback path (used when the Rust decoder rejects a TIFF + * variant) doesn't expose an equivalent raw-tag walk, so `buildTagsFromGeotiffImage` + * enumerates whatever geotiff.js already parsed into `image.fileDirectory` / + * `image.geoKeys` generically — no curated tag list on this path either. + */ + +/** @typedef {{tag: number|null, name: string, group: string, value: string}} TagEntry */ + +/** + * @param {string|undefined|null} json + * @returns {TagEntry[]} + */ +export function parseAllTagsJson(json) { + if (!json) { return []; } + try { + const parsed = JSON.parse(json); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * @param {any} value + * @returns {string} + */ +function stringifyTagValue(value) { + if (value === null || value === undefined) { return ''; } + if (Array.isArray(value) || ArrayBuffer.isView(value)) { + return Array.from(/** @type {ArrayLike} */ (value)).join(', '); + } + if (typeof value === 'object') { + try { return JSON.stringify(value); } catch { return String(value); } + } + return String(value); +} + +/** + * Enumerate every tag geotiff.js has already parsed for an image, generically + * (no curated subset) — used only on the geotiff.js compatibility fallback + * path, since the Rust/WASM decoder path has its own raw-IFD walk. + * @param {any} image - geotiff.js GeoTIFFImage instance + * @returns {TagEntry[]} + */ +export function buildTagsFromGeotiffImage(image) { + /** @type {TagEntry[]} */ + const out = []; + const fileDirectory = image?.fileDirectory || {}; + for (const [name, rawValue] of Object.entries(fileDirectory)) { + out.push({ tag: null, name, group: 'TIFF', value: stringifyTagValue(rawValue) }); + } + // geotiff.js resolves the GeoKeyDirectory into a plain object when present. + let geoKeys = null; + try { geoKeys = typeof image?.getGeoKeys === 'function' ? image.getGeoKeys() : image?.geoKeys; } catch { /* not a GeoTIFF */ } + if (geoKeys && typeof geoKeys === 'object') { + for (const [name, rawValue] of Object.entries(geoKeys)) { + out.push({ tag: null, name, group: 'GeoKeys', value: stringifyTagValue(rawValue) }); + } + } + return out; +} + +/** + * Flatten an arbitrary nested metadata object (e.g. libraw-wasm's `Metadata`) + * into {tag,name,group,value} rows, generically — nested plain objects + * become dotted name prefixes (e.g. "lens.MinFocal"), so every field the + * decoder returns is included, however deeply nested, without a curated list + * of known fields. Binary sample blobs (e.g. an embedded ICC profile) are + * skipped since they aren't meaningful as text. + * @param {any} obj + * @param {string} group + * @returns {TagEntry[]} + */ +export function flattenObjectToTags(obj, group) { + /** @type {TagEntry[]} */ + const out = []; + + /** @param {string} prefix @param {any} value */ + function walk(prefix, value) { + if (value === null || value === undefined) { return; } + if (value instanceof Date) { + out.push({ tag: null, name: prefix, group, value: value.toISOString() }); + return; + } + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + const typedArray = /** @type {Uint8Array} */ (/** @type {unknown} */ (value)); + if (typedArray.length > 64) { return; } // binary blob, not useful as text + out.push({ tag: null, name: prefix, group, value: stringifyTagValue(typedArray) }); + return; + } + if (Array.isArray(value)) { + out.push({ tag: null, name: prefix, group, value: stringifyTagValue(value) }); + return; + } + if (typeof value === 'object') { + for (const [key, v] of Object.entries(value)) { + walk(prefix ? `${prefix}.${key}` : key, v); + } + return; + } + out.push({ tag: null, name: prefix, group, value: String(value) }); + } + + walk('', obj); + return out; +} + +/** + * Locate a JPEG's embedded Exif blob (the APP1 segment's payload, minus its + * "Exif\0\0" prefix) by scanning markers linearly. Returns the raw TIFF- + * structured bytes ready for the WASM decoder's generic tag walker, or null + * if the file isn't a JPEG or carries no Exif APP1 segment. + * @param {Uint8Array} bytes + * @returns {Uint8Array|null} + */ +export function findJpegExifBlob(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.length < 4) { return null; } + if (bytes[0] !== 0xFF || bytes[1] !== 0xD8) { return null; } // not a JPEG (no SOI) + + let offset = 2; + while (offset + 4 <= bytes.length) { + if (bytes[offset] !== 0xFF) { break; } // not a marker — malformed, stop scanning + const marker = bytes[offset + 1]; + offset += 2; + // Markers with no payload: TEM (0x01) and RST0-7 (0xD0-0xD7). + if (marker === 0x01 || (marker >= 0xD0 && marker <= 0xD7)) { continue; } + if (marker === 0xD9) { break; } // EOI + if (marker === 0xDA) { break; } // SOS — entropy-coded data follows, no more markers to scan + if (offset + 2 > bytes.length) { break; } + const segmentLength = (bytes[offset] << 8) | bytes[offset + 1]; // includes these 2 length bytes + const payloadStart = offset + 2; + const payloadEnd = offset + segmentLength; + if (segmentLength < 2 || payloadEnd > bytes.length) { break; } + + if (marker === 0xE1 && payloadEnd - payloadStart >= 6) { // APP1 + const sig = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0" + let matches = true; + for (let i = 0; i < 6; i++) { + if (bytes[payloadStart + i] !== sig[i]) { matches = false; break; } + } + if (matches) { + return bytes.subarray(payloadStart + 6, payloadEnd); + } + } + offset = payloadEnd; + } + return null; +} + +/** @param {Uint8Array} bytes */ +function latin1Decode(bytes) { + let s = ''; + for (let i = 0; i < bytes.length; i++) { s += String.fromCharCode(bytes[i]); } + return s; +} + +/** + * @typedef {Object} PngChunkMetadata + * @property {Uint8Array|null} exifBlob - raw TIFF-structured bytes from the eXIf chunk, if present + * @property {{name:string,value:string}[]} textEntries - tEXt/zTXt/iTXt keyword+text pairs + */ + +/** + * Scan a PNG byte stream's chunks for the eXIf chunk (a raw Exif/TIFF blob, + * no "Exif\0\0" prefix — unlike JPEG's APP1) and any tEXt/zTXt/iTXt text + * chunks (PNG's own native key/value metadata), decompressing zTXt/iTXt + * payloads with pako where needed. Malformed/unsupported individual chunks + * are skipped rather than aborting the whole scan. + * @param {Uint8Array} bytes + * @returns {PngChunkMetadata} + */ +export function parsePngChunks(bytes) { + /** @type {PngChunkMetadata} */ + const result = { exifBlob: null, textEntries: [] }; + const sig = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + if (!(bytes instanceof Uint8Array) || bytes.length < 8) { return result; } + for (let i = 0; i < 8; i++) { + if (bytes[i] !== sig[i]) { return result; } + } + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let offset = 8; + while (offset + 12 <= bytes.length) { + const length = view.getUint32(offset, false); + const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]); + const dataStart = offset + 8; + const dataEnd = dataStart + length; + if (dataEnd + 4 > bytes.length) { break; } + const chunk = bytes.subarray(dataStart, dataEnd); + + try { + if (type === 'eXIf') { + result.exifBlob = chunk; + } else if (type === 'tEXt') { + const nullIdx = chunk.indexOf(0); + if (nullIdx >= 0) { + result.textEntries.push({ + name: latin1Decode(chunk.subarray(0, nullIdx)), + value: latin1Decode(chunk.subarray(nullIdx + 1)) + }); + } + } else if (type === 'zTXt') { + const nullIdx = chunk.indexOf(0); + if (nullIdx >= 0) { + const keyword = latin1Decode(chunk.subarray(0, nullIdx)); + const compressed = chunk.subarray(nullIdx + 2); // skip null + 1-byte compression method + const text = new TextDecoder('utf-8').decode(pako.inflate(compressed)); + result.textEntries.push({ name: keyword, value: text }); + } + } else if (type === 'iTXt') { + let p = chunk.indexOf(0); + const keyword = latin1Decode(chunk.subarray(0, p)); + const compressionFlag = chunk[p + 1]; + p += 3; // null + compression flag + compression method + const langEnd = chunk.indexOf(0, p); + p = langEnd + 1; + const translatedEnd = chunk.indexOf(0, p); + p = translatedEnd + 1; + const textBytes = chunk.subarray(p); + const text = compressionFlag === 1 + ? new TextDecoder('utf-8').decode(pako.inflate(textBytes)) + : new TextDecoder('utf-8').decode(textBytes); + result.textEntries.push({ name: keyword, value: text }); + } else if (type === 'IEND') { + break; + } + } catch { + // Skip malformed/unsupported individual chunk, keep scanning. + } + + offset = dataEnd + 4; // skip CRC + } + return result; +} diff --git a/media/modules/tiff-wasm-wrapper.js b/media/modules/tiff-wasm-wrapper.js index 34a5b2c..8fcabe8 100644 --- a/media/modules/tiff-wasm-wrapper.js +++ b/media/modules/tiff-wasm-wrapper.js @@ -1,9 +1,11 @@ // @ts-check "use strict"; +import { parseAllTagsJson } from './tiff-tag-utils.js'; + /** * Fast TIFF Processor using WebAssembly - * + * * This module provides a high-performance TIFF decoder using Rust/WebAssembly. * It can be used as a drop-in replacement for geotiff.js for faster loading. */ @@ -31,12 +33,12 @@ async function initWasm() { // Import the JS bindings generated by wasm-pack // Use relative path for VS Code webview compatibility const wasmBindingsPath = /** @type {string} */ ('../wasm/tiff-wasm.js'); - const { default: init, decode_tiff } = await import(wasmBindingsPath); + const { default: init, decode_tiff, extract_exif_tags } = await import(wasmBindingsPath); // Initialize the WASM module await init(); - wasmModule = { decode_tiff }; + wasmModule = { decode_tiff, extract_exif_tags }; return wasmModule; } catch (error) { console.warn('Failed to load WASM module, will use geotiff.js fallback:', error); @@ -69,6 +71,7 @@ async function initWasm() { * @property {Float32Array} data - Pixel data as floats * @property {number} min - Minimum value * @property {number} max - Maximum value + * @property {string} allTagsJson - JSON array of every tag found in the file (TIFF/Exif/GPS), see tiff-tag-utils.js */ /** @@ -130,12 +133,32 @@ export class TiffWasmProcessor { directDecode: result.direct_decode, data: new Float32Array(result.get_data_as_f32()), min: result.min_value, - max: result.max_value + max: result.max_value, + allTagsJson: result.all_tags_json }; return decodeResult; } } +/** + * Walk a raw TIFF-structured Exif blob (e.g. a JPEG APP1 payload with its + * "Exif\0\0" prefix already stripped, or a PNG eXIf chunk's raw bytes) and + * return every tag found, in the same shape as TIFF's own tag dump. Lazily + * loads/reuses the same WASM module as TiffWasmProcessor. + * @param {Uint8Array} blob + * @returns {Promise} + */ +export async function extractExifTagsFromBlob(blob) { + const wasm = await initWasm(); + if (!wasm || typeof wasm.extract_exif_tags !== 'function') { return []; } + try { + return parseAllTagsJson(wasm.extract_exif_tags(blob)); + } catch (error) { + console.warn('[extractExifTagsFromBlob] Failed to parse embedded Exif blob:', error); + return []; + } +} + // Export for use export { initWasm }; diff --git a/media/types/pako.d.ts b/media/types/pako.d.ts new file mode 100644 index 0000000..9438294 --- /dev/null +++ b/media/types/pako.d.ts @@ -0,0 +1,8 @@ +declare module 'pako' { + function inflate(data: Uint8Array | ArrayBuffer, options?: { to?: 'string' }): Uint8Array; + function deflate(data: Uint8Array | ArrayBuffer, options?: unknown): Uint8Array; + + const pako: { inflate: typeof inflate; deflate: typeof deflate }; + export default pako; + export { inflate, deflate }; +} diff --git a/media/wasm/tiff-wasm.js b/media/wasm/tiff-wasm.js index 163878d..b37f7a7 100644 --- a/media/wasm/tiff-wasm.js +++ b/media/wasm/tiff-wasm.js @@ -126,63 +126,63 @@ function takeFromExternrefTable0(idx) { } /** * @param {Uint8Array} data - * @returns {PngResult} + * @returns {ExrResult} */ -export function decode_png16_fast(data) { +export function decode_exr_fast(data) { const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc); const len0 = WASM_VECTOR_LEN; - const ret = wasm.decode_png16_fast(ptr0, len0); + const ret = wasm.decode_exr_fast(ptr0, len0); if (ret[2]) { throw takeFromExternrefTable0(ret[1]); } - return PngResult.__wrap(ret[0]); + return ExrResult.__wrap(ret[0]); } /** + * Decode a TIFF file without eagerly computing min/max statistics. + * + * The webview render path computes stats lazily when a non-gamma mode needs + * them. Skipping eager stats saves a full pass over large float TIFFs during + * the common gamma-mode initial load. * @param {Uint8Array} data - * @returns {HdrResult} + * @returns {TiffResult} */ -export function decode_hdr_fast(data) { +export function decode_tiff_fast(data) { const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc); const len0 = WASM_VECTOR_LEN; - const ret = wasm.decode_hdr_fast(ptr0, len0); + const ret = wasm.decode_tiff_fast(ptr0, len0); if (ret[2]) { throw takeFromExternrefTable0(ret[1]); } - return HdrResult.__wrap(ret[0]); + return TiffResult.__wrap(ret[0]); } /** - * Decode a TIFF file without eagerly computing min/max statistics. - * - * The webview render path computes stats lazily when a non-gamma mode needs - * them. Skipping eager stats saves a full pass over large float TIFFs during - * the common gamma-mode initial load. * @param {Uint8Array} data - * @returns {TiffResult} + * @returns {HdrResult} */ -export function decode_tiff_fast(data) { +export function decode_hdr_fast(data) { const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc); const len0 = WASM_VECTOR_LEN; - const ret = wasm.decode_tiff_fast(ptr0, len0); + const ret = wasm.decode_hdr_fast(ptr0, len0); if (ret[2]) { throw takeFromExternrefTable0(ret[1]); } - return TiffResult.__wrap(ret[0]); + return HdrResult.__wrap(ret[0]); } /** * @param {Uint8Array} data - * @returns {ExrResult} + * @returns {PngResult} */ -export function decode_exr_fast(data) { +export function decode_png16_fast(data) { const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc); const len0 = WASM_VECTOR_LEN; - const ret = wasm.decode_exr_fast(ptr0, len0); + const ret = wasm.decode_png16_fast(ptr0, len0); if (ret[2]) { throw takeFromExternrefTable0(ret[1]); } - return ExrResult.__wrap(ret[0]); + return PngResult.__wrap(ret[0]); } /** @@ -201,6 +201,36 @@ export function decode_tiff(data) { return TiffResult.__wrap(ret[0]); } +/** + * Walk a raw Exif-only IFD blob (a JPEG APP1 payload with its "Exif\0\0" + * prefix already stripped, or a PNG eXIf chunk's raw bytes) and return + * every tag as JSON, in the same shape as `TiffResult.all_tags_json`. + * + * These blobs are TIFF-*structured* (byte order + magic 42 + IFD entries) + * but are not full TIFF files — they carry no ImageWidth/PhotometricInterpretation/ + * etc., so the `tiff` crate's `Decoder::new()` (which always validates a + * full image directory) rejects them. `extract_bare_ifd_tags_json` reads + * the IFD structure directly instead, bypassing `Decoder` entirely; real + * `.tif`/`.tiff` files keep using the `Decoder`-based `extract_all_tags_json` + * via `decode_tiff`/`decode_tiff_fast` above. + * @param {Uint8Array} data + * @returns {string} + */ +export function extract_exif_tags(data) { + let deferred2_0; + let deferred2_1; + try { + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.extract_exif_tags(ptr0, len0); + deferred2_0 = ret[0]; + deferred2_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); + } +} + let cachedFloat64ArrayMemory0 = null; function getFloat64ArrayMemory0() { @@ -254,6 +284,21 @@ export class ExrResult { const ptr = this.__destroy_into_raw(); wasm.__wbg_exrresult_free(ptr, 0); } + /** + * @returns {string} + */ + get all_tags_json() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.exrresult_all_tags_json(this.__wbg_ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } /** * @returns {number} */ @@ -377,6 +422,21 @@ export class HdrResult { const ptr = this.__destroy_into_raw(); wasm.__wbg_hdrresult_free(ptr, 0); } + /** + * @returns {string} + */ + get all_tags_json() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.hdrresult_all_tags_json(this.__wbg_ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } /** * @returns {Float32Array} */ @@ -560,6 +620,21 @@ export class TiffResult { const ret = wasm.tiffresult_tile_length(this.__wbg_ptr); return ret >>> 0; } + /** + * @returns {string} + */ + get all_tags_json() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.tiffresult_all_tags_json(this.__wbg_ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } /** * @returns {boolean} */ diff --git a/media/wasm/tiff-wasm.wasm b/media/wasm/tiff-wasm.wasm index 4bfbaed..11fa27b 100644 Binary files a/media/wasm/tiff-wasm.wasm and b/media/wasm/tiff-wasm.wasm differ diff --git a/package.json b/package.json index b4a001a..6496219 100644 --- a/package.json +++ b/package.json @@ -175,6 +175,11 @@ "title": "Toggle Histogram", "category": "TIFF Visualizer" }, + { + "command": "tiffVisualizer.toggleMetadata", + "title": "Toggle Metadata Panel", + "category": "TIFF Visualizer" + }, { "command": "tiffVisualizer.convertColormapToFloat", "title": "Decode Colormap Image to Float", @@ -218,6 +223,12 @@ "mac": "cmd+h", "when": "activeCustomEditorId == 'tiffVisualizer.previewEditor'" }, + { + "command": "tiffVisualizer.toggleMetadata", + "key": "ctrl+m", + "mac": "cmd+m", + "when": "activeCustomEditorId == 'tiffVisualizer.previewEditor'" + }, { "command": "tiffVisualizer.copyImageInfo", "key": "ctrl+shift+i", diff --git a/src/imagePreview/commands.ts b/src/imagePreview/commands.ts index 8b82abd..47ebee2 100644 --- a/src/imagePreview/commands.ts +++ b/src/imagePreview/commands.ts @@ -1328,6 +1328,16 @@ export function registerImagePreviewCommands( } })); + disposables.push(vscode.commands.registerCommand('tiffVisualizer.toggleMetadata', () => { + logCommand('toggleMetadata', 'start'); + try { + previewManager.activePreview?.toggleMetadata(); + logCommand('toggleMetadata', 'success'); + } catch (error) { + logCommand('toggleMetadata', 'error', String(error)); + } + })); + disposables.push(vscode.commands.registerCommand('tiffVisualizer.convertColormapToFloat', async () => { logCommand('convertColormapToFloat', 'start'); const activePreview = previewManager.activePreview; diff --git a/src/imagePreview/imagePreview.ts b/src/imagePreview/imagePreview.ts index 5708d76..75bc92a 100644 --- a/src/imagePreview/imagePreview.ts +++ b/src/imagePreview/imagePreview.ts @@ -392,6 +392,12 @@ export class ImagePreview extends MediaPreview { } } + public toggleMetadata(): void { + if (this.previewState === PreviewState.Active) { + this._webviewEditor.webview.postMessage({ type: 'toggleMetadata' }); + } + } + /** * Send the global histogram state to the webview. * Called when the webview becomes active to restore histogram visibility. diff --git a/src/imagePreview/types.ts b/src/imagePreview/types.ts index ee97e11..15276f8 100644 --- a/src/imagePreview/types.ts +++ b/src/imagePreview/types.ts @@ -48,4 +48,5 @@ export interface IImagePreview { removeCurrentFromCollection(): void; getManager(): IImagePreviewManager; toggleHistogram(): void; + toggleMetadata(): void; } \ No newline at end of file diff --git a/wasm/tiff-decoder/src/lib.rs b/wasm/tiff-decoder/src/lib.rs index 3d97ecb..a10e4bd 100644 --- a/wasm/tiff-decoder/src/lib.rs +++ b/wasm/tiff-decoder/src/lib.rs @@ -47,6 +47,9 @@ pub struct TiffResult { timing_convert_ms: f64, timing_stats_ms: f64, timing_pack_ms: f64, + // JSON array of every tag found in the main IFD, plus any Exif/GPS sub-IFD, + // as `{"tag":,"name":"","group":"TIFF"|"Exif"|"GPS","value":""}`. + all_tags_json: String, } #[wasm_bindgen] @@ -62,6 +65,10 @@ pub struct ExrResult { timing_read_ms: f64, timing_pack_ms: f64, timing_total_ms: f64, + // JSON array of every EXR header attribute (image + layer, named fields + // plus the crate's generic "other"/custom-attribute bags), in the same + // {"tag","name","group","value"} shape as TiffResult.all_tags_json. + all_tags_json: String, } #[wasm_bindgen] @@ -82,10 +89,16 @@ pub struct PngResult { pub struct HdrResult { data_f32: Vec, metadata_f64: Vec, + all_tags_json: String, } #[wasm_bindgen] impl HdrResult { + #[wasm_bindgen(getter)] + pub fn all_tags_json(&self) -> String { + self.all_tags_json.clone() + } + #[wasm_bindgen] pub fn take_data_as_f32(&mut self) -> Vec { mem::take(&mut self.data_f32) @@ -184,6 +197,11 @@ impl ExrResult { self.timing_total_ms } + #[wasm_bindgen(getter)] + pub fn all_tags_json(&self) -> String { + self.all_tags_json.clone() + } + #[wasm_bindgen] pub fn take_data_as_f32(&mut self) -> Vec { mem::take(&mut self.data_f32) @@ -312,6 +330,11 @@ impl TiffResult { self.direct_decode } + #[wasm_bindgen(getter)] + pub fn all_tags_json(&self) -> String { + self.all_tags_json.clone() + } + /// Get raw data as bytes (for transferring to JS) #[wasm_bindgen] pub fn get_data_bytes(&self) -> Vec { @@ -377,6 +400,22 @@ pub fn decode_tiff(data: &[u8]) -> Result { decode_tiff_impl(data, true) } +/// Walk a raw Exif-only IFD blob (a JPEG APP1 payload with its "Exif\0\0" +/// prefix already stripped, or a PNG eXIf chunk's raw bytes) and return +/// every tag as JSON, in the same shape as `TiffResult.all_tags_json`. +/// +/// These blobs are TIFF-*structured* (byte order + magic 42 + IFD entries) +/// but are not full TIFF files — they carry no ImageWidth/PhotometricInterpretation/ +/// etc., so the `tiff` crate's `Decoder::new()` (which always validates a +/// full image directory) rejects them. `extract_bare_ifd_tags_json` reads +/// the IFD structure directly instead, bypassing `Decoder` entirely; real +/// `.tif`/`.tiff` files keep using the `Decoder`-based `extract_all_tags_json` +/// via `decode_tiff`/`decode_tiff_fast` above. +#[wasm_bindgen] +pub fn extract_exif_tags(data: &[u8]) -> String { + extract_bare_ifd_tags_json(data) +} + /// Decode a TIFF file without eagerly computing min/max statistics. /// /// The webview render path computes stats lazily when a non-gamma mode needs @@ -489,6 +528,24 @@ pub fn decode_hdr_fast(data: &[u8]) -> Result { decode_hdr_impl(data) } +/// Turn Radiance HDR header lines into generic {name, value} tags: `KEY=VALUE` +/// lines split on the first `=`, `#`-prefixed lines become "Comment" rows, +/// anything else (e.g. the resolution line) is kept verbatim under "Header". +fn hdr_header_lines_to_json(lines: &[String]) -> String { + let mut out = Vec::new(); + for line in lines { + let (name, value) = if let Some(rest) = line.strip_prefix('#') { + ("Comment", rest.trim()) + } else if let Some(eq_pos) = line.find('=') { + (&line[..eq_pos], &line[eq_pos + 1..]) + } else { + ("Header", line.as_str()) + }; + push_generic_attr_row(&mut out, "HDR", name, value.to_string()); + } + format!("[{}]", out.join(",")) +} + fn decode_hdr_impl(data: &[u8]) -> Result { let start_time = js_sys::Date::now(); let mut offset = 0usize; @@ -497,6 +554,9 @@ fn decode_hdr_impl(data: &[u8]) -> Result { let mut exposure = 1.0f32; let mut gamma = 1.0f32; let mut rle = false; + // Every non-empty header line (comments, SOFTWARE=, VIEW=, custom fields, + // and the recognized ones below), kept generically for the Metadata panel. + let mut header_lines: Vec = Vec::new(); for _ in 0..128 { let line_start = offset; @@ -511,6 +571,9 @@ fn decode_hdr_impl(data: &[u8]) -> Result { let line = std::str::from_utf8(line_bytes) .map_err(|_| JsValue::from_str("HDR header is not UTF-8"))? .trim(); + if !line.is_empty() { + header_lines.push(line.to_string()); + } if line == "FORMAT=32-bit_rle_rgbe" { rle = true; } else if let Some(value) = line.strip_prefix("EXPOSURE=") { @@ -645,9 +708,87 @@ fn decode_hdr_impl(data: &[u8]) -> Result { convert_time, js_sys::Date::now() - start_time, ], + all_tags_json: hdr_header_lines_to_json(&header_lines), }) } +/// Push one `{"tag":null,"name":...,"group":...,"value":...}` JSON fragment. +fn push_generic_attr_row(out: &mut Vec, group: &str, name: &str, value_debug: String) { + out.push(format!( + "{{\"tag\":null,\"name\":\"{}\",\"group\":\"{}\",\"value\":\"{}\"}}", + json_escape(name), json_escape(group), json_escape(&value_debug) + )); +} + +/// Dump every EXR header attribute (image + layer) as JSON, generically: +/// each named `Option` field on `ImageAttributes`/`LayerAttributes` plus +/// the crate's own catch-all `other` maps for custom/vendor attributes, so +/// nothing an EXR file carries is left out. +fn extract_exr_tags_json( + image_attrs: &exr::meta::header::ImageAttributes, + layer_attrs: &exr::meta::header::LayerAttributes, +) -> String { + let mut out = Vec::new(); + const GROUP: &str = "EXR"; + + macro_rules! opt_field { + ($field:expr, $name:expr) => { + if let Some(v) = &$field { + push_generic_attr_row(&mut out, GROUP, $name, format!("{:?}", v)); + } + }; + } + + push_generic_attr_row(&mut out, GROUP, "displayWindow", format!("{:?}", image_attrs.display_window)); + push_generic_attr_row(&mut out, GROUP, "pixelAspect", format!("{}", image_attrs.pixel_aspect)); + opt_field!(image_attrs.chromaticities, "chromaticities"); + opt_field!(image_attrs.time_code, "timeCode"); + for (key, value) in image_attrs.other.iter() { + push_generic_attr_row(&mut out, GROUP, &key.to_string(), format!("{:?}", value)); + } + + opt_field!(layer_attrs.layer_name, "layerName"); + push_generic_attr_row(&mut out, GROUP, "layerPosition", format!("{:?}", layer_attrs.layer_position)); + push_generic_attr_row(&mut out, GROUP, "screenWindowCenter", format!("{:?}", layer_attrs.screen_window_center)); + push_generic_attr_row(&mut out, GROUP, "screenWindowWidth", format!("{}", layer_attrs.screen_window_width)); + opt_field!(layer_attrs.white_luminance, "whiteLuminance"); + opt_field!(layer_attrs.adopted_neutral, "adoptedNeutral"); + opt_field!(layer_attrs.rendering_transform_name, "renderingTransformName"); + opt_field!(layer_attrs.look_modification_transform_name, "lookModificationTransformName"); + opt_field!(layer_attrs.horizontal_density, "horizontalDensity"); + opt_field!(layer_attrs.owner, "owner"); + opt_field!(layer_attrs.comments, "comments"); + opt_field!(layer_attrs.capture_date, "captureDate"); + opt_field!(layer_attrs.utc_offset, "utcOffset"); + opt_field!(layer_attrs.longitude, "longitude"); + opt_field!(layer_attrs.latitude, "latitude"); + opt_field!(layer_attrs.altitude, "altitude"); + opt_field!(layer_attrs.focus, "focus"); + opt_field!(layer_attrs.exposure, "exposure"); + opt_field!(layer_attrs.aperture, "aperture"); + opt_field!(layer_attrs.iso_speed, "isoSpeed"); + opt_field!(layer_attrs.environment_map, "environmentMap"); + opt_field!(layer_attrs.film_key_code, "filmKeyCode"); + opt_field!(layer_attrs.wrap_mode_name, "wrapModeName"); + opt_field!(layer_attrs.frames_per_second, "framesPerSecond"); + opt_field!(layer_attrs.multi_view_names, "multiViewNames"); + opt_field!(layer_attrs.world_to_camera, "worldToCamera"); + opt_field!(layer_attrs.world_to_normalized_device, "worldToNormalizedDevice"); + opt_field!(layer_attrs.deep_image_state, "deepImageState"); + opt_field!(layer_attrs.original_data_window, "originalDataWindow"); + opt_field!(layer_attrs.view_name, "viewName"); + opt_field!(layer_attrs.software_name, "softwareName"); + opt_field!(layer_attrs.near_clip_plane, "nearClipPlane"); + opt_field!(layer_attrs.far_clip_plane, "farClipPlane"); + opt_field!(layer_attrs.horizontal_field_of_view, "horizontalFieldOfView"); + opt_field!(layer_attrs.vertical_field_of_view, "verticalFieldOfView"); + for (key, value) in layer_attrs.other.iter() { + push_generic_attr_row(&mut out, GROUP, &key.to_string(), format!("{:?}", value)); + } + + format!("[{}]", out.join(",")) +} + fn decode_exr_impl(data: &[u8]) -> Result { use exr::prelude::*; @@ -722,6 +863,7 @@ fn decode_exr_impl(data: &[u8]) -> Result { let format = if output_channels == 1 { 1028 } else { 1023 }; let pack_time = js_sys::Date::now() - pack_start; let total_time = js_sys::Date::now() - start_time; + let all_tags_json = extract_exr_tags_json(&image.attributes, &layer.attributes); Ok(ExrResult { width: width as u32, @@ -735,6 +877,7 @@ fn decode_exr_impl(data: &[u8]) -> Result { timing_read_ms: read_time, timing_pack_ms: pack_time, timing_total_ms: total_time, + all_tags_json, }) } @@ -865,6 +1008,255 @@ fn fill_exr_interleaved_channel( } } +/// Escape a string for embedding as a JSON string literal. +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} + +/// Render any TIFF tag value as a human-readable string, regardless of its +/// underlying type. Falls back to `Debug` for the rarer/deprecated variants +/// (e.g. `RationalBig`) so this stays correct as the `tiff` crate's +/// `#[non_exhaustive]` `Value` enum grows. +fn value_to_display_string(value: &tiff::decoder::ifd::Value) -> String { + use tiff::decoder::ifd::Value; + match value { + Value::Byte(v) => v.to_string(), + Value::Short(v) => v.to_string(), + Value::SignedByte(v) => v.to_string(), + Value::SignedShort(v) => v.to_string(), + Value::Signed(v) => v.to_string(), + Value::SignedBig(v) => v.to_string(), + Value::Unsigned(v) => v.to_string(), + Value::UnsignedBig(v) => v.to_string(), + Value::Float(v) => v.to_string(), + Value::Double(v) => v.to_string(), + Value::Rational(n, d) if *d != 0 => format!("{}/{} ({:.6})", n, d, *n as f64 / *d as f64), + Value::Rational(n, d) => format!("{}/{}", n, d), + Value::SRational(n, d) if *d != 0 => format!("{}/{} ({:.6})", n, d, *n as f64 / *d as f64), + Value::SRational(n, d) => format!("{}/{}", n, d), + Value::Ascii(s) => s.trim_end_matches('\0').to_string(), + Value::Ifd(v) => format!("IFD@{}", v), + Value::IfdBig(v) => format!("IFD@{}", v), + Value::List(items) => items + .iter() + .map(value_to_display_string) + .collect::>() + .join(", "), + other => format!("{:?}", other), + } +} + +/// Recursively serialize every tag in `entries` (and, for `ExifDirectory` / +/// `GpsDirectory` pointer tags, the sub-IFD they point to) into `out` as JSON +/// object fragments. This walks the raw tag map generically, so it surfaces +/// every tag present in the file rather than a curated subset. +fn append_ifd_tags( + decoder: &mut Decoder>, + entries: Vec<(tiff::tags::Tag, tiff::decoder::ifd::Value)>, + group: &str, + out: &mut Vec, +) { + use tiff::tags::Tag; + + for (tag, value) in entries { + if matches!(tag, Tag::ExifDirectory | Tag::GpsDirectory) { + if let Ok(ptr) = value.clone().into_ifd_pointer() { + if let Ok(subdir) = decoder.read_directory(ptr) { + let sub_entries: Vec<_> = decoder + .read_directory_tags(&subdir) + .tag_iter() + .filter_map(|r| r.ok()) + .collect(); + let sub_group = if matches!(tag, Tag::ExifDirectory) { "Exif" } else { "GPS" }; + append_ifd_tags(decoder, sub_entries, sub_group, out); + continue; + } + } + } + + out.push(format!( + "{{\"tag\":{},\"name\":\"{}\",\"group\":\"{}\",\"value\":\"{}\"}}", + tag.to_u16(), + json_escape(&format!("{:?}", tag)), + json_escape(group), + json_escape(&value_to_display_string(&value)) + )); + } +} + +/// Dump every tag in the file's main IFD (plus any Exif/GPS sub-IFD) as a JSON +/// array. Independent of whichever specialized pixel-decode path is used, so +/// it's recomputed cheaply (a handful of IFD entries, not the pixel data) +/// wherever a `TiffResult` is built. +fn extract_all_tags_json(data: &[u8]) -> String { + let mut decoder = match Decoder::new(Cursor::new(data)) { + Ok(d) => d, + Err(_) => return "[]".to_string(), + }; + let main_entries: Vec<_> = decoder + .image_ifd() + .tag_iter() + .filter_map(|r| r.ok()) + .collect(); + let mut out = Vec::new(); + append_ifd_tags(&mut decoder, main_entries, "TIFF", &mut out); + format!("[{}]", out.join(",")) +} + +/// TIFF/Exif field type sizes in bytes, per the TIFF6/Exif spec (type IDs 1-12). +fn ifd_type_size(type_id: u16) -> usize { + match type_id { + 1 | 2 | 6 | 7 => 1, // BYTE, ASCII, SBYTE, UNDEFINED + 3 | 8 => 2, // SHORT, SSHORT + 4 | 9 | 11 => 4, // LONG, SLONG, FLOAT + 5 | 10 | 12 => 8, // RATIONAL, SRATIONAL, DOUBLE + _ => 0, + } +} + +/// Render one IFD entry's value bytes as a human-readable string, generically +/// across all twelve standard TIFF/Exif field types. Caps very long arrays at +/// 16 shown elements, mirroring `value_to_display_string`'s `List` handling. +fn format_bare_ifd_value(data: &[u8], type_id: u16, count: u32, inline_bytes: &[u8], big_endian: bool) -> String { + let elem_size = ifd_type_size(type_id); + if elem_size == 0 { + return format!("", type_id); + } + let total_size = elem_size.saturating_mul(count as usize); + let bytes: &[u8] = if total_size <= 4 { + &inline_bytes[..total_size.min(inline_bytes.len())] + } else { + let offset = if big_endian { + u32::from_be_bytes([inline_bytes[0], inline_bytes[1], inline_bytes[2], inline_bytes[3]]) + } else { + u32::from_le_bytes([inline_bytes[0], inline_bytes[1], inline_bytes[2], inline_bytes[3]]) + } as usize; + match data.get(offset..offset.saturating_add(total_size)) { + Some(b) => b, + None => return "".to_string(), + } + }; + + let u16_at = |i: usize| -> u16 { let b = &bytes[i * 2..i * 2 + 2]; if big_endian { u16::from_be_bytes([b[0], b[1]]) } else { u16::from_le_bytes([b[0], b[1]]) } }; + let i16_at = |i: usize| -> i16 { let b = &bytes[i * 2..i * 2 + 2]; if big_endian { i16::from_be_bytes([b[0], b[1]]) } else { i16::from_le_bytes([b[0], b[1]]) } }; + let u32_at = |i: usize| -> u32 { let b = &bytes[i * 4..i * 4 + 4]; if big_endian { u32::from_be_bytes([b[0], b[1], b[2], b[3]]) } else { u32::from_le_bytes([b[0], b[1], b[2], b[3]]) } }; + let i32_at = |i: usize| -> i32 { let b = &bytes[i * 4..i * 4 + 4]; if big_endian { i32::from_be_bytes([b[0], b[1], b[2], b[3]]) } else { i32::from_le_bytes([b[0], b[1], b[2], b[3]]) } }; + let f32_at = |i: usize| -> f32 { let b = &bytes[i * 4..i * 4 + 4]; if big_endian { f32::from_be_bytes([b[0], b[1], b[2], b[3]]) } else { f32::from_le_bytes([b[0], b[1], b[2], b[3]]) } }; + let f64_at = |i: usize| -> f64 { let b = &bytes[i * 8..i * 8 + 8]; let a = [b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]; if big_endian { f64::from_be_bytes(a) } else { f64::from_le_bytes(a) } }; + + let join_all = |n: usize, render: &dyn Fn(usize) -> String| -> String { + (0..n).map(render).collect::>().join(", ") + }; + + let n = count as usize; + match type_id { + 2 => { // ASCII: NUL-terminated string + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + String::from_utf8_lossy(&bytes[..end]).to_string() + } + 1 | 7 => join_all(bytes.len(), &|i| bytes[i].to_string()), // BYTE, UNDEFINED + 6 => join_all(bytes.len(), &|i| (bytes[i] as i8).to_string()), // SBYTE + 3 => join_all(n, &|i| u16_at(i).to_string()), + 8 => join_all(n, &|i| i16_at(i).to_string()), + 4 => join_all(n, &|i| u32_at(i).to_string()), + 9 => join_all(n, &|i| i32_at(i).to_string()), + 11 => join_all(n, &|i| f32_at(i).to_string()), + 12 => join_all(n, &|i| f64_at(i).to_string()), + 5 => join_all(n, &|i| { // RATIONAL: pairs of u32 + let (num, den) = (u32_at(i * 2), u32_at(i * 2 + 1)); + if den != 0 { format!("{}/{} ({:.6})", num, den, num as f64 / den as f64) } else { format!("{}/{}", num, den) } + }), + 10 => join_all(n, &|i| { // SRATIONAL: pairs of i32 + let (num, den) = (i32_at(i * 2), i32_at(i * 2 + 1)); + if den != 0 { format!("{}/{} ({:.6})", num, den, num as f64 / den as f64) } else { format!("{}/{}", num, den) } + }), + _ => "".to_string(), + } +} + +/// Recursively walk a raw IFD's entries (byte-level, no `tiff` crate +/// `Decoder`) starting at `ifd_offset`, pushing each as a JSON tag row and +/// following the Exif (0x8769) / GPS (0x8825) sub-IFD pointer tags. +fn walk_bare_ifd(data: &[u8], ifd_offset: usize, big_endian: bool, group: &str, out: &mut Vec, depth: u32) { + if depth > 4 { return; } // guard against absurd/cyclic offsets in malformed input + let read_u16 = |offset: usize| -> Option { + let b = data.get(offset..offset + 2)?; + Some(if big_endian { u16::from_be_bytes([b[0], b[1]]) } else { u16::from_le_bytes([b[0], b[1]]) }) + }; + let read_u32 = |offset: usize| -> Option { + let b = data.get(offset..offset + 4)?; + Some(if big_endian { u32::from_be_bytes([b[0], b[1], b[2], b[3]]) } else { u32::from_le_bytes([b[0], b[1], b[2], b[3]]) }) + }; + + let entry_count = match read_u16(ifd_offset) { Some(c) => c as usize, None => return }; + for i in 0..entry_count { + let entry_offset = ifd_offset + 2 + i * 12; + let (Some(tag_id), Some(type_id), Some(count)) = ( + read_u16(entry_offset), + read_u16(entry_offset + 2), + read_u32(entry_offset + 4), + ) else { continue }; + let value_bytes = match data.get(entry_offset + 8..entry_offset + 12) { + Some(b) => b, + None => continue, + }; + + // Exif (0x8769) / GPS (0x8825) sub-IFD pointer tags: a single LONG offset. + if (tag_id == 0x8769 || tag_id == 0x8825) && type_id == 4 && count == 1 { + if let Some(sub_offset) = read_u32(entry_offset + 8) { + let sub_group = if tag_id == 0x8769 { "Exif" } else { "GPS" }; + walk_bare_ifd(data, sub_offset as usize, big_endian, sub_group, out, depth + 1); + continue; + } + } + + let tag_name = format!("{:?}", tiff::tags::Tag::from_u16_exhaustive(tag_id)); + let value_str = format_bare_ifd_value(data, type_id, count, value_bytes, big_endian); + push_generic_attr_row(out, group, &tag_name, value_str); + } +} + +/// Entry point for `extract_exif_tags`: parse a bare Exif-structured blob +/// (JPEG APP1 payload sans "Exif\0\0", or a PNG eXIf chunk) into JSON tags. +fn extract_bare_ifd_tags_json(data: &[u8]) -> String { + if data.len() < 8 { + return "[]".to_string(); + } + let big_endian = match &data[0..2] { + b"II" => false, + b"MM" => true, + _ => return "[]".to_string(), + }; + let read_u16 = |offset: usize| -> u16 { + let b = &data[offset..offset + 2]; + if big_endian { u16::from_be_bytes([b[0], b[1]]) } else { u16::from_le_bytes([b[0], b[1]]) } + }; + let read_u32 = |offset: usize| -> u32 { + let b = &data[offset..offset + 4]; + if big_endian { u32::from_be_bytes([b[0], b[1], b[2], b[3]]) } else { u32::from_le_bytes([b[0], b[1], b[2], b[3]]) } + }; + if read_u16(2) != 42 { + return "[]".to_string(); + } + let ifd0_offset = read_u32(4) as usize; + + let mut out = Vec::new(); + walk_bare_ifd(data, ifd0_offset, big_endian, "Exif", &mut out, 0); + format!("[{}]", out.join(",")) +} + fn decode_tiff_impl(data: &[u8], compute_stats: bool) -> Result { #[cfg(feature = "console_error_panic_hook")] console_error_panic_hook::set_once(); @@ -1229,6 +1621,7 @@ fn decode_tiff_impl(data: &[u8], compute_stats: bool) -> Result Result