Peachy Bulk File Converter

Save hours with the free bulk file converter that processes multiple files at once. Upload a ZIP archive or select individual files, pick your target format, and download everything as a single ZIP. Convert images, documents, audio files, and more in batch — everything runs privately in your browser with no uploads to external servers. Ideal for designers, content creators, and anyone who needs fast, secure batch file conversion.

Bulk Options

Upload a ZIP file or select multiple files to convert them all to one format.

  • Accepts ZIP archives & multiple files
  • Convert all to one target format
  • Download results as a single ZIP
  • Images, documents, data & more
  • 100% browser-based & private
📦
Drop a ZIP, or multiple files here
Upload a ZIP archive, drop a folder, or select multiple files.
📄

Convert to:

Converting...

Conversion Complete

Supported Conversions

🎨 Images

PNG, JPG/JPEG, JFIF, WEBP, GIF, BMP, ICO, TIFF, SVG, DNG, PDF — convert between any of these formats including image-to-PDF and image-to-SVG. Extract text from images via OCR to TXT, DOCX, or RTF. Resize and control file size.

📄 Documents & Text

TXT, CSV, TSV, Markdown (.md), HTML, DAT, EPS, MHTML/MHT — convert between text-based document formats.

📊 Data

JSON, XML, YAML, CSV — transform structured data between formats.

🎵 Audio & Video

WAV, MP3, OGG, WEBM (audio), MP4 — convert audio formats or extract frames/audio from video files.

✉ Email

EML, MSG — convert email messages to TXT, HTML, JSON, or Markdown.

🔐 Security Certificates

PEM, CER, CRT — view and convert certificate files to TXT, JSON, or HTML.

⚡ Flash (SWF)

SWF — extract metadata and embedded images. Export to TXT, JSON, HTML, PNG, JPG, or GIF.

📷 RAW Photos & Special Images

HEIC/HEIF, PSD, CR2 (Canon), NEF (Nikon), ORF (Olympus), RAW, CUR — decode and convert to standard image formats.

📑 Documents & Office

DOCX, PDF, XLSX, XLS, PPTX, PPT, POTX, PPSX, PPSM, PPTM, POTM, POT, PPS — extract text and data. Spreadsheets convert to CSV/JSON/XML. Presentations export slide text.

🔡 Fonts & Design

OTF/TTF — render font previews, export metadata. SKETCH, INDD/INDT/IDML — metadata extraction only.

🎶 Additional Audio & Video

FLAC, AAC, M4R, MPEG, AVI, FLV — convert audio formats or extract frames/audio from video files.

🔒 Your files never leave your device. All conversions happen right in your browser — nothing is uploaded to any server.

Should we build a Peachy Dietary Planner app?

Your feedback helps us decide what to build next

'; return new Blob([h], { type: 'text/html' }); } if (targetExt === 'json') return new Blob([JSON.stringify(email, null, 2)], { type: 'application/json' }); if (targetExt === 'md') { let md = ''; if (email.subject) md += '# ' + email.subject + '\n\n'; if (email.from) md += '**From:** ' + email.from + ' \n'; if (email.to) md += '**To:** ' + email.to + ' \n'; if (email.date) md += '**Date:** ' + email.date + ' \n'; md += '\n---\n\n' + (email.bodyText || email.bodyHtml?.replace(/<[^>]+>/g, '') || '(no body)'); return new Blob([md], { type: 'text/markdown' }); } return new Blob([text], { type: 'text/plain' }); } function parseEml(text) { const r = { from:'', to:'', subject:'', date:'', bodyText:'', bodyHtml:'' }; const he = text.indexOf('\r\n\r\n'), ha = text.indexOf('\n\n'); let sp; if (he!==-1 && (ha===-1||heString.fromCharCode(parseInt(x,16))); if(h.includes('base64'))try{b=atob(b.replace(/\s/g,''));}catch(e){} return b; } // ================== // CERTIFICATE CONVERSION // ================== async function convertCertificate(file, targetExt) { const text = await file.text(); if (targetExt === 'txt') return new Blob([text], { type: 'text/plain' }); const cert = parsePemInfo(text); if (targetExt === 'json') return new Blob([JSON.stringify(cert, null, 2)], { type: 'application/json' }); if (targetExt === 'html') { let h = 'Certificate

Certificate Details

'; for (const [k,v] of Object.entries(cert)) { if(k!=='raw') h+=''; } h+='
'+escapeHtml(k)+''+escapeHtml(String(v))+'

Raw Content

'+escapeHtml(cert.raw||text)+'
'; return new Blob([h], { type: 'text/html' }); } return new Blob([text], { type: 'text/plain' }); } function parsePemInfo(text) { const r = { type:'Unknown', raw:text }; const tm=text.match(/-----BEGIN\s+(.+?)-----/); if(tm) r.type=tm[1]; const bm=text.match(/-----BEGIN.+?-----\s*([\s\S]+?)\s*-----END/); if(bm){const b=bm[1].replace(/\s/g,'');r.encodedLength=b.length+' chars';try{r.decodedBytes=atob(b).length+' bytes';}catch(e){}} return r; } // ================== // SWF CONVERSION // ================== async function convertSwf(file, targetExt) { const buf = await file.arrayBuffer(); const swf = await parseSwf(buf); // Text/data output if (targetExt === 'txt') { let out = 'SWF File Report\n' + '='.repeat(40) + '\n\n'; out += 'Format: ' + swf.signature + '\n'; out += 'Version: ' + swf.version + '\n'; out += 'File Size: ' + swf.fileLength + ' bytes\n'; out += 'Dimensions: ' + swf.width + ' x ' + swf.height + ' px\n'; out += 'Frame Rate: ' + swf.frameRate + ' fps\n'; out += 'Frame Count: ' + swf.frameCount + '\n'; out += 'Compressed: ' + swf.compressed + '\n'; out += 'Embedded Images: ' + swf.images.length + '\n'; if (swf.images.length > 0) { out += '\nEmbedded Image Details:\n'; swf.images.forEach((img, i) => { out += ' Image ' + (i + 1) + ': ' + img.type + ', ' + img.data.length + ' bytes\n'; }); } return new Blob([out], { type: 'text/plain' }); } if (targetExt === 'json') { const obj = { signature: swf.signature, version: swf.version, fileLength: swf.fileLength, width: swf.width, height: swf.height, frameRate: swf.frameRate, frameCount: swf.frameCount, compressed: swf.compressed, embeddedImages: swf.images.length }; return new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }); } if (targetExt === 'html') { let h = 'SWF Report'; h += ''; h += '

SWF File Report

'; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += '
PropertyValue
Format' + swf.signature + '
Version' + swf.version + '
File Size' + swf.fileLength + ' bytes
Dimensions' + swf.width + ' x ' + swf.height + ' px
Frame Rate' + swf.frameRate + ' fps
Frame Count' + swf.frameCount + '
Compressed' + swf.compressed + '
Embedded Images' + swf.images.length + '
'; if (swf.images.length > 0) { h += '

Embedded Images

'; swf.images.forEach((img, i) => { const b64 = arrayBufferToBase64(img.data); const mime = img.type === 'jpeg' ? 'image/jpeg' : 'image/png'; h += '

Image ' + (i + 1) + ' (' + img.type + ', ' + img.data.length + ' bytes)

'; h += 'Embedded image ' + (i + 1) + ''; }); } h += ''; return new Blob([h], { type: 'text/html' }); } // Image output — extract first embedded image if (['png', 'jpg', 'gif'].includes(targetExt)) { if (swf.images.length === 0) { throw new Error('No embedded images found in this SWF file. Try TXT, JSON, or HTML to export metadata instead.'); } const img = swf.images[0]; const imgBlob = new Blob([img.data], { type: img.type === 'jpeg' ? 'image/jpeg' : 'image/png' }); // If target matches source type, return directly if ((targetExt === 'jpg' && img.type === 'jpeg') || (targetExt === 'png' && img.type === 'png')) { return imgBlob; } // Otherwise re-encode via canvas return new Promise((resolve, reject) => { const url = URL.createObjectURL(imgBlob); const image = new Image(); image.onload = () => { const c = document.createElement('canvas'); c.width = image.naturalWidth; c.height = image.naturalHeight; const cx = c.getContext('2d'); if (targetExt === 'jpg') { cx.fillStyle = '#FFF'; cx.fillRect(0, 0, c.width, c.height); } cx.drawImage(image, 0, 0); URL.revokeObjectURL(url); let om = 'image/png'; if (targetExt === 'jpg') om = 'image/jpeg'; else if (targetExt === 'gif') om = 'image/gif'; c.toBlob(b => { if (!b) return reject(new Error('Image conversion failed')); resolve(b); }, om, 0.92); }; image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Failed to decode embedded image')); }; image.src = url; }); } throw new Error('Unsupported conversion target for SWF files.'); } async function parseSwf(buf) { const u8 = new Uint8Array(buf); const result = { signature: '', version: 0, fileLength: 0, width: 0, height: 0, frameRate: 0, frameCount: 0, compressed: false, images: [] }; if (u8.length < 8) throw new Error('File is too small to be a valid SWF.'); // Read signature const sig = String.fromCharCode(u8[0], u8[1], u8[2]); if (!['FWS', 'CWS', 'ZWS'].includes(sig)) { throw new Error('Not a valid SWF file (invalid signature: ' + sig + ').'); } result.signature = sig; result.version = u8[3]; result.fileLength = u8[4] | (u8[5] << 8) | (u8[6] << 16) | (u8[7] << 24); result.compressed = sig !== 'FWS'; // Decompress if needed let data; if (sig === 'FWS') { data = u8; } else if (sig === 'CWS') { // zlib compressed — skip 8 byte header, decompress rest try { const compressed = u8.slice(8); data = await inflateSync(compressed); // Prepend the 8-byte header const full = new Uint8Array(8 + data.length); full.set(u8.slice(0, 8), 0); full.set(data, 8); data = full; } catch (e) { // If decompression fails, work with what we have data = u8; } } else { // ZWS (LZMA) — too complex, work with header only data = u8; } // Parse RECT (frame size) starting at byte 8 if (data.length > 12) { try { const nBits = (data[8] >> 3) & 0x1F; const totalRectBits = 5 + nBits * 4; const totalRectBytes = Math.ceil(totalRectBits / 8); const rectEnd = 8 + totalRectBytes; // Read bits for xmin, xmax, ymin, ymax let bitPos = 5; // skip nBits field function readBits(n) { let val = 0; for (let i = 0; i < n; i++) { const byteIdx = 8 + Math.floor(bitPos / 8); const bitIdx = 7 - (bitPos % 8); if (byteIdx < data.length) { val = (val << 1) | ((data[byteIdx] >> bitIdx) & 1); } bitPos++; } return val; } const xmin = readBits(nBits); const xmax = readBits(nBits); const ymin = readBits(nBits); const ymax = readBits(nBits); // Values are in twips (1/20 pixel) result.width = Math.round((xmax - xmin) / 20); result.height = Math.round((ymax - ymin) / 20); // Frame rate and count after RECT if (rectEnd + 4 <= data.length) { result.frameRate = data[rectEnd + 1]; // 8.8 fixed, integer part result.frameCount = data[rectEnd + 2] | (data[rectEnd + 3] << 8); } // Parse tags to find embedded images let pos = rectEnd + 4; while (pos + 2 <= data.length) { const tagCodeAndLen = data[pos] | (data[pos + 1] << 8); const tagCode = tagCodeAndLen >> 6; let tagLen = tagCodeAndLen & 0x3F; pos += 2; if (tagLen === 0x3F && pos + 4 <= data.length) { // Long tag tagLen = data[pos] | (data[pos + 1] << 8) | (data[pos + 2] << 16) | (data[pos + 3] << 24); pos += 4; } if (tagCode === 0) break; // End tag // DefineBitsJPEG2 (21) or DefineBitsJPEG3 (35) if ((tagCode === 21 || tagCode === 35) && tagLen > 6) { // Skip character ID (2 bytes) let imgStart = pos + 2; let imgLen = tagLen - 2; if (tagCode === 35 && tagLen > 6) { // DefineBitsJPEG3 has alpha data offset const alphaOffset = data[pos + 2] | (data[pos + 3] << 8) | (data[pos + 4] << 16) | (data[pos + 5] << 24); imgStart = pos + 6; imgLen = alphaOffset; } if (imgStart + imgLen <= data.length && imgLen > 4) { const imgData = data.slice(imgStart, imgStart + imgLen); // Skip SWF JPEG error table markers (FF D9 FF D8) let cleanStart = 0; if (imgData[0] === 0xFF && imgData[1] === 0xD9 && imgData[2] === 0xFF && imgData[3] === 0xD8) { cleanStart = 4; } const cleanData = imgData.slice(cleanStart); if (cleanData.length > 2) { // Check if PNG or JPEG const isPng = cleanData[0] === 0x89 && cleanData[1] === 0x50; result.images.push({ type: isPng ? 'png' : 'jpeg', data: cleanData }); } } } // DefineBits (6) — JPEG data if (tagCode === 6 && tagLen > 2) { const imgData = data.slice(pos + 2, pos + tagLen); if (imgData.length > 2) { result.images.push({ type: 'jpeg', data: imgData }); } } // DefineBitsLossless (20) / DefineBitsLossless2 (36) — raw pixel data // These are zlib-compressed pixel data, complex to decode — skip for now pos += tagLen; } } catch (e) { // Parsing errors are non-fatal — return what we have } } return result; } // Simple zlib inflate (for CWS SWF decompression) function inflateSync(data) { // Use DecompressionStream if available (modern browsers) if (typeof DecompressionStream !== 'undefined') { // Synchronous workaround using response return new Promise((resolve, reject) => { const ds = new DecompressionStream('deflate'); const reader = new Response( new Blob([data]).stream().pipeThrough(ds) ).arrayBuffer(); reader.then(buf => resolve(new Uint8Array(buf))).catch(reject); }); } throw new Error('Browser does not support decompression.'); } function arrayBufferToBase64(data) { let binary = ''; for (let i = 0; i < data.length; i++) { binary += String.fromCharCode(data[i]); } return btoa(binary); } // ================== // AVI FALLBACK (extract MJPEG frames from AVI binary) // ================== async function convertAviFallback(file, targetExt) { const buf = await file.arrayBuffer(); const u8 = new Uint8Array(buf); setProgress(10, 'Analyzing AVI structure...'); // Parse AVI header to get video dimensions and codec const aviInfo = parseAviHeader(u8); // WAV extraction if (targetExt === 'wav') { const aviInfo2 = parseAviHeader(u8); // Try PCM first const pcmData = extractAviAudio(u8, aviInfo2); if (pcmData) return pcmData; // Try compressed audio (MP3/AAC) — concatenate raw chunks and decode const compressedAudio = extractAviCompressedAudio(u8); if (compressedAudio) { setProgress(40, 'Decoding compressed audio...'); const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); try { const decoded = await audioCtx.decodeAudioData(await compressedAudio.arrayBuffer()); return audioBufferToWav(decoded); } catch (e) { throw new Error('The audio track uses a codec your browser cannot decode. Try extracting audio with VLC (Media \u2192 Convert/Save).'); } } throw new Error('No audio track found in this AVI file.'); } // Strategy 1: Look for JPEG frames (MJPEG codec) setProgress(20, 'Scanning for video frames...'); const jpegFrames = []; for (let i = 0; i < u8.length - 3 && jpegFrames.length < 300; i++) { if (u8[i] === 0xFF && u8[i + 1] === 0xD8 && u8[i + 2] === 0xFF) { for (let j = i + 3; j < u8.length - 1; j++) { if (u8[j] === 0xFF && u8[j + 1] === 0xD9) { if (j + 2 - i > 500) jpegFrames.push(u8.slice(i, j + 2)); i = j + 1; break; } } } } if (jpegFrames.length > 0) { return aviFramesToOutput(jpegFrames, 'image/jpeg', targetExt, aviInfo); } // Strategy 2: Extract raw uncompressed frames from '00dc'/'00db' chunks if (aviInfo.width > 0 && aviInfo.height > 0 && (aviInfo.compression === 0 || aviInfo.codec === 'DIB ')) { setProgress(30, 'Extracting raw frames...'); const rawFrames = extractAviRawFrames(u8, aviInfo); if (rawFrames.length > 0) { // Convert raw BGR pixel data to canvas ImageData const canvasFrames = rawFrames.map(raw => rawBgrToCanvas(raw, aviInfo.width, aviInfo.height, aviInfo.bitsPerPixel)); return aviCanvasFramesToOutput(canvasFrames, targetExt, aviInfo); } } // Strategy 3: Try to find any BMP-like data in video chunks if (aviInfo.width > 0 && aviInfo.height > 0) { setProgress(30, 'Attempting frame extraction...'); const rawFrames = extractAviRawFrames(u8, aviInfo); if (rawFrames.length > 0) { const canvasFrames = rawFrames.map(raw => rawBgrToCanvas(raw, aviInfo.width, aviInfo.height, aviInfo.bitsPerPixel || 24)); return aviCanvasFramesToOutput(canvasFrames, targetExt, aviInfo); } } // Strategy 4: Use WebCodecs API to decode H.264/MPEG4 from AVI let webCodecsError = ''; if (typeof VideoDecoder !== 'undefined') { setProgress(40, 'Decoding video with WebCodecs...'); try { const result = await decodeAviWithWebCodecs(u8, aviInfo, targetExt); return result; } catch (e) { webCodecsError = e.message || 'unknown'; } } // Nothing worked const codecName = aviInfo.codec ? aviInfo.codec.trim() : 'unknown'; throw new Error( 'This AVI uses the "' + codecName + '" codec which could not be decoded. ' + 'Convert to MP4 first using VLC (Media \u2192 Convert/Save) or HandBrake (handbrake.fr).' + (webCodecsError ? ' [' + webCodecsError + ']' : '') ); } function parseAviHeader(u8) { const info = { width: 0, height: 0, codec: '', compression: -1, bitsPerPixel: 24, fps: 0, extradata: null }; const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength); try { // Find video 'strh' and the 'strf' immediately after it let videoStrhEnd = -1; for (let i = 0; i < u8.length - 60; i++) { if (u8[i]===0x73 && u8[i+1]===0x74 && u8[i+2]===0x72 && u8[i+3]===0x68) { // 'strh' const chunkLen = dv.getUint32(i + 4, true); const hdrOff = i + 8; if (u8[hdrOff]===0x76 && u8[hdrOff+1]===0x69 && u8[hdrOff+2]===0x64 && u8[hdrOff+3]===0x73) { // 'vids' info.codec = String.fromCharCode(u8[hdrOff+4], u8[hdrOff+5], u8[hdrOff+6], u8[hdrOff+7]); const scale = dv.getUint32(hdrOff + 20, true); const rate = dv.getUint32(hdrOff + 24, true); if (scale > 0) info.fps = Math.round(rate / scale); videoStrhEnd = i + 8 + chunkLen; break; } } } // Find 'strf' after the video strh const searchStart = videoStrhEnd > 0 ? videoStrhEnd : 0; for (let i = searchStart; i < Math.min(searchStart + 200, u8.length - 48); i++) { if (u8[i]===0x73 && u8[i+1]===0x74 && u8[i+2]===0x72 && u8[i+3]===0x66) { // 'strf' const strfLen = dv.getUint32(i + 4, true); const fmtOff = i + 8; if (fmtOff + 40 <= u8.length) { const biSize = dv.getUint32(fmtOff, true); info.width = dv.getInt32(fmtOff + 4, true); info.height = Math.abs(dv.getInt32(fmtOff + 8, true)); info.bitsPerPixel = dv.getUint16(fmtOff + 14, true); info.compression = dv.getUint32(fmtOff + 16, true); // Extract extradata after BITMAPINFOHEADER const extraStart = fmtOff + biSize; const extraLen = strfLen - biSize; if (extraLen > 0 && extraStart + extraLen <= u8.length) { info.extradata = u8.slice(extraStart, extraStart + extraLen); } } break; } } } catch (e) {} if (info.compression > 0 && !info.codec) { try { info.codec = String.fromCharCode(info.compression & 0xFF, (info.compression >> 8) & 0xFF, (info.compression >> 16) & 0xFF, (info.compression >> 24) & 0xFF); } catch (e) {} } return info; } function extractAviRawFrames(u8, aviInfo) { const frames = []; const expectedSize = aviInfo.width * aviInfo.height * (aviInfo.bitsPerPixel / 8); const tolerance = expectedSize * 0.5; // Allow some variance // Look for '00dc' or '00db' chunks for (let i = 0; i < u8.length - 8 && frames.length < 200; i++) { const is00dc = u8[i]===0x30 && u8[i+1]===0x30 && u8[i+2]===0x64 && u8[i+3]===0x63; const is00db = u8[i]===0x30 && u8[i+1]===0x30 && u8[i+2]===0x64 && u8[i+3]===0x62; if (is00dc || is00db) { const len = u8[i+4] | (u8[i+5] << 8) | (u8[i+6] << 16) | (u8[i+7] << 24); if (len > tolerance && len <= expectedSize * 1.5 && i + 8 + len <= u8.length) { frames.push(u8.slice(i + 8, i + 8 + len)); i += 7 + len; } } } return frames; } function rawBgrToCanvas(raw, w, h, bpp) { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); const imgData = ctx.createImageData(w, h); const px = imgData.data; const bytesPerPixel = bpp / 8; const rowBytes = Math.ceil((w * bytesPerPixel) / 4) * 4; // BMP rows are 4-byte aligned for (let y = 0; y < h; y++) { // BMP/AVI stores rows bottom-up const srcRow = (h - 1 - y) * rowBytes; const dstRow = y * w * 4; for (let x = 0; x < w; x++) { const srcIdx = srcRow + x * bytesPerPixel; const dstIdx = dstRow + x * 4; if (srcIdx + 2 < raw.length) { px[dstIdx] = raw[srcIdx + 2]; // R (BGR → RGB) px[dstIdx + 1] = raw[srcIdx + 1]; // G px[dstIdx + 2] = raw[srcIdx]; // B px[dstIdx + 3] = 255; // A } } } ctx.putImageData(imgData, 0, 0); return canvas; } async function aviFramesToOutput(frames, mimeType, targetExt, aviInfo) { // Animated GIF from JPEG frames if (targetExt === 'gif' && frames.length > 1) { if (typeof GIF === 'undefined') throw new Error('GIF library not loaded.'); if (!window._gifWorkerBlob) { const resp = await fetch('https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js'); const wt = await resp.text(); window._gifWorkerBlob = URL.createObjectURL(new Blob([wt], { type: 'application/javascript' })); } const step = Math.max(1, Math.floor(frames.length / 100)); const sel = []; for (let i = 0; i < frames.length && sel.length < 100; i += step) sel.push(frames[i]); const firstImg = await loadImageFromBlob(new Blob([sel[0]], { type: mimeType })); const scale = Math.min(1, 480 / firstImg.naturalWidth); const gw = Math.round(firstImg.naturalWidth * scale), gh = Math.round(firstImg.naturalHeight * scale); const gif = new GIF({ workers: 2, quality: 10, width: gw, height: gh, workerScript: window._gifWorkerBlob }); const c = document.createElement('canvas'); c.width = gw; c.height = gh; const cx = c.getContext('2d'); const delay = aviInfo.fps > 0 ? Math.round(1000 / aviInfo.fps) : 100; for (let i = 0; i < sel.length; i++) { setProgress(10 + Math.round((i / sel.length) * 60), 'Frame ' + (i+1) + '/' + sel.length); const img = await loadImageFromBlob(new Blob([sel[i]], { type: mimeType })); cx.drawImage(img, 0, 0, gw, gh); gif.addFrame(cx, { copy: true, delay: delay * step }); } setProgress(75, 'Encoding GIF...'); return new Promise(resolve => { gif.on('finished', resolve); gif.render(); }); } // Static image const idx = Math.min(Math.floor(frames.length / 2), frames.length - 1); const blob = new Blob([frames[idx]], { type: mimeType }); if (targetExt === 'jpg' && mimeType === 'image/jpeg') return blob; const img = await loadImageFromBlob(blob); const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight; const cx = c.getContext('2d'); cx.drawImage(img, 0, 0); return canvasToTarget(c, cx, c.width, c.height, targetExt); } async function aviCanvasFramesToOutput(canvasFrames, targetExt, aviInfo) { if (targetExt === 'gif' && canvasFrames.length > 1) { if (typeof GIF === 'undefined') throw new Error('GIF library not loaded.'); if (!window._gifWorkerBlob) { const resp = await fetch('https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js'); const wt = await resp.text(); window._gifWorkerBlob = URL.createObjectURL(new Blob([wt], { type: 'application/javascript' })); } const step = Math.max(1, Math.floor(canvasFrames.length / 100)); const sel = []; for (let i = 0; i < canvasFrames.length && sel.length < 100; i += step) sel.push(canvasFrames[i]); const scale = Math.min(1, 480 / sel[0].width); const gw = Math.round(sel[0].width * scale), gh = Math.round(sel[0].height * scale); const gif = new GIF({ workers: 2, quality: 10, width: gw, height: gh, workerScript: window._gifWorkerBlob }); const tc = document.createElement('canvas'); tc.width = gw; tc.height = gh; const tcx = tc.getContext('2d'); const delay = aviInfo.fps > 0 ? Math.round(1000 / aviInfo.fps) : 100; for (let i = 0; i < sel.length; i++) { setProgress(10 + Math.round((i / sel.length) * 60), 'Frame ' + (i+1) + '/' + sel.length); tcx.drawImage(sel[i], 0, 0, gw, gh); gif.addFrame(tcx, { copy: true, delay: delay * step }); } setProgress(75, 'Encoding GIF...'); return new Promise(resolve => { gif.on('finished', resolve); gif.render(); }); } // Static image const idx = Math.min(Math.floor(canvasFrames.length / 2), canvasFrames.length - 1); const c = canvasFrames[idx]; const cx = c.getContext('2d'); return canvasToTarget(c, cx, c.width, c.height, targetExt); } // Decode AVI video using WebCodecs API (Chrome/Edge 94+) // Extract SPS and PPS NAL units from H.264 AVI function extractH264Nals(videoChunks, u8) { let sps = null, pps = null; // Strategy 1: Check strf extradata (after 40-byte BITMAPINFOHEADER) if (u8) { for (let i = 0; i < u8.length - 50; i++) { if (u8[i]===0x73 && u8[i+1]===0x74 && u8[i+2]===0x72 && u8[i+3]===0x66) { // 'strf' const chunkLen = u8[i+4] | (u8[i+5]<<8) | (u8[i+6]<<16) | (u8[i+7]<<24); const fmtOff = i + 8; const biSize = u8[fmtOff] | (u8[fmtOff+1]<<8) | (u8[fmtOff+2]<<16) | (u8[fmtOff+3]<<24); if (biSize >= 40 && chunkLen > biSize) { const extraOff = fmtOff + biSize; const extraLen = chunkLen - biSize; if (extraLen > 8 && extraOff + extraLen <= u8.length) { const extra = u8.slice(extraOff, extraOff + extraLen); // Check if it's avcC format (starts with version=1) if (extra[0] === 1 && extra.length > 11) { const r = parseAvcC(extra); if (r.sps && r.pps) return r; } // Try as Annex B const nals = parseAnnexBNals(extra); for (const nal of nals) { const type = nal[0] & 0x1F; if (type === 7 && !sps) sps = nal; if (type === 8 && !pps) pps = nal; } if (sps && pps) return { sps, pps }; } } break; } } } // Strategy 2: Check video chunks for Annex B NALs for (const chunk of videoChunks.slice(0, 10)) { const nals = parseAnnexBNals(chunk); for (const nal of nals) { const type = nal[0] & 0x1F; if (type === 7 && !sps) sps = nal; if (type === 8 && !pps) pps = nal; if (sps && pps) return { sps, pps }; } } // Strategy 3: Check video chunks for AVCC (length-prefixed) format if (!sps || !pps) { for (const chunk of videoChunks.slice(0, 10)) { if (chunk.length < 5) continue; const r = parseAvccNals(chunk); for (const nal of r) { const type = nal[0] & 0x1F; if (type === 7 && !sps) sps = nal; if (type === 8 && !pps) pps = nal; if (sps && pps) return { sps, pps }; } } } return { sps, pps }; } // Parse avcC configuration record function parseAvcC(data) { let sps = null, pps = null; try { const numSps = data[5] & 0x1F; let off = 6; for (let i = 0; i < numSps && off + 2 <= data.length; i++) { const spsLen = (data[off] << 8) | data[off+1]; off += 2; if (off + spsLen <= data.length) { sps = data.slice(off, off + spsLen); off += spsLen; } } if (off < data.length) { const numPps = data[off]; off++; for (let i = 0; i < numPps && off + 2 <= data.length; i++) { const ppsLen = (data[off] << 8) | data[off+1]; off += 2; if (off + ppsLen <= data.length) { pps = data.slice(off, off + ppsLen); off += ppsLen; } } } } catch (e) {} return { sps, pps }; } // Parse AVCC (length-prefixed) NAL units function parseAvccNals(data) { const nals = []; let off = 0; while (off + 4 < data.length) { const len = (data[off]<<24) | (data[off+1]<<16) | (data[off+2]<<8) | data[off+3]; off += 4; if (len > 0 && len < data.length && off + len <= data.length) { nals.push(data.slice(off, off + len)); off += len; } else { break; } } return nals; } // Parse Annex B byte stream into individual NAL units function parseAnnexBNals(data) { const nals = []; let i = 0; while (i < data.length - 3) { // Find start code (0x00 0x00 0x01 or 0x00 0x00 0x00 0x01) let scLen = 0; if (data[i]===0 && data[i+1]===0 && data[i+2]===1) scLen = 3; else if (i < data.length - 4 && data[i]===0 && data[i+1]===0 && data[i+2]===0 && data[i+3]===1) scLen = 4; if (scLen > 0) { const nalStart = i + scLen; // Find next start code let nalEnd = data.length; for (let j = nalStart + 1; j < data.length - 3; j++) { if (data[j]===0 && data[j+1]===0 && (data[j+2]===1 || (data[j+2]===0 && j+3 < data.length && data[j+3]===1))) { nalEnd = j; break; } } // Remove trailing zeros while (nalEnd > nalStart && data[nalEnd-1] === 0) nalEnd--; if (nalEnd > nalStart) nals.push(data.slice(nalStart, nalEnd)); i = nalEnd; } else { i++; } } return nals; } // Build avcC configuration record from SPS and PPS function buildAvcC(sps, pps) { const len = 11 + sps.length + pps.length; const buf = new Uint8Array(len); buf[0] = 1; // configurationVersion buf[1] = sps[1]; // AVCProfileIndication buf[2] = sps[2]; // profile_compatibility buf[3] = sps[3]; // AVCLevelIndication buf[4] = 0xFF; // lengthSizeMinusOne = 3 (4 bytes) buf[5] = 0xE1; // numOfSPS = 1 buf[6] = (sps.length >> 8) & 0xFF; buf[7] = sps.length & 0xFF; buf.set(sps, 8); buf[8 + sps.length] = 1; // numOfPPS = 1 buf[9 + sps.length] = (pps.length >> 8) & 0xFF; buf[10 + sps.length] = pps.length & 0xFF; buf.set(pps, 11 + sps.length); return buf; } // Convert Annex B (start-code) format to length-prefixed format function annexBToLengthPrefixed(data) { if (data.length === 0) return data; // Check if already length-prefixed (no start codes found) const hasStartCode = (data.length > 3 && data[0]===0 && data[1]===0 && (data[2]===1 || (data[2]===0 && data[3]===1))); const nals = hasStartCode ? parseAnnexBNals(data) : []; if (nals.length === 0) return data; // Already AVCC or unknown format // Filter out SPS/PPS (type 7, 8) — they're in the description const videoNals = nals.filter(n => { const t = n[0] & 0x1F; return t !== 7 && t !== 8; }); if (videoNals.length === 0) return new Uint8Array(0); // Calculate total size let totalSize = 0; for (const nal of videoNals) totalSize += 4 + nal.length; const result = new Uint8Array(totalSize); let off = 0; for (const nal of videoNals) { // 4-byte length prefix (big-endian) result[off] = (nal.length >> 24) & 0xFF; result[off+1] = (nal.length >> 16) & 0xFF; result[off+2] = (nal.length >> 8) & 0xFF; result[off+3] = nal.length & 0xFF; result.set(nal, off + 4); off += 4 + nal.length; } return result; } async function decodeAviWithWebCodecs(u8, aviInfo, targetExt) { if (aviInfo.width <= 0 || aviInfo.height <= 0) throw new Error('No video dimensions'); // Extract video chunks ('00dc') const videoChunks = []; for (let i = 0; i < u8.length - 8 && videoChunks.length < 500; i++) { const is00dc = u8[i]===0x30 && u8[i+1]===0x30 && u8[i+2]===0x64 && u8[i+3]===0x63; if (is00dc) { const len = u8[i+4] | (u8[i+5] << 8) | (u8[i+6] << 16) | (u8[i+7] << 24); if (len > 0 && len < 50000000 && i + 8 + len <= u8.length) { videoChunks.push(u8.slice(i + 8, i + 8 + len)); i += 7 + len; } } } if (videoChunks.length === 0) throw new Error('No video chunks found'); // Determine codec and extract description const codec = aviInfo.codec ? aviInfo.codec.trim().toUpperCase() : ''; let codecString, description; if (codec === 'H264' || codec === 'X264' || codec === 'AVC1') { // Try extradata from strf first (most reliable) let sps = null, pps = null; if (aviInfo.extradata && aviInfo.extradata.length > 7) { if (aviInfo.extradata[0] === 1) { // Already avcC format — use directly as description description = aviInfo.extradata; const parsed = parseAvcC(aviInfo.extradata); sps = parsed.sps; } else { // Annex B format in extradata const nals = parseAnnexBNals(aviInfo.extradata); for (const nal of nals) { const t = nal[0] & 0x1F; if (t === 7) sps = nal; if (t === 8) pps = nal; } if (sps && pps) description = buildAvcC(sps, pps); } } // Fallback: scan video chunks if (!description) { const nalInfo = extractH264Nals(videoChunks, u8); if (nalInfo.sps && nalInfo.pps) { sps = nalInfo.sps; pps = nalInfo.pps; description = buildAvcC(sps, pps); } } if (!description) throw new Error('Cannot find H.264 SPS/PPS parameters in this AVI file'); // Build codec string from SPS if (sps) { codecString = 'avc1.' + [sps[1], sps[2], sps[3]].map(b => b.toString(16).padStart(2, '0')).join(''); } else { codecString = 'avc1.640029'; // Fallback High Profile L4.1 } // Convert Annex B chunks to length-prefixed format for WebCodecs for (let i = 0; i < videoChunks.length; i++) { videoChunks[i] = annexBToLengthPrefixed(videoChunks[i]); } } else if (codec === 'FMP4' || codec === 'DIVX' || codec === 'XVID' || codec === 'DX50' || codec === 'MP4V') { codecString = 'mp4v.20.9'; // Extract MPEG-4 VOL header as description for (const chunk of videoChunks) { for (let j = 0; j < chunk.length - 4; j++) { if (chunk[j]===0 && chunk[j+1]===0 && chunk[j+2]===1 && (chunk[j+3] >= 0x20 && chunk[j+3] <= 0x2F)) { // Found VOL start code, extract until next start code or end let end = chunk.length; for (let k = j + 4; k < chunk.length - 3; k++) { if (chunk[k]===0 && chunk[k+1]===0 && chunk[k+2]===1) { end = k; break; } } description = chunk.slice(j, end); break; } } if (description) break; } } else { throw new Error('WebCodecs does not support codec: ' + codec); } // Check if codec is supported const decoderConfig = { codec: codecString, codedWidth: aviInfo.width, codedHeight: aviInfo.height, }; if (description) decoderConfig.description = description; const support = await VideoDecoder.isConfigSupported(decoderConfig); if (!support.supported) throw new Error('Codec "' + codec + '" not supported by WebCodecs'); // Decode frames const decodedFrames = []; const decoder = new VideoDecoder({ output: (frame) => { const c = document.createElement('canvas'); c.width = frame.displayWidth; c.height = frame.displayHeight; const cx = c.getContext('2d'); cx.drawImage(frame, 0, 0); frame.close(); decodedFrames.push(c); }, error: (e) => { /* ignore individual frame errors */ } }); decoder.configure(decoderConfig); // Feed chunks to decoder const frameDuration = aviInfo.fps > 0 ? (1000000 / aviInfo.fps) : 33333; // microseconds const maxFrames = (targetExt === 'gif' || targetExt === 'webm-video') ? Math.min(videoChunks.length, 500) : Math.min(videoChunks.length, 30); for (let i = 0; i < maxFrames; i++) { try { const chunkData = videoChunks[i]; // Detect keyframe: H.264 IDR NAL type 5, or MPEG-4 VOP I-frame let isKey = (i === 0); if (chunkData.length > 4) { // Check for Annex B start code (0x00 0x00 0x00 0x01 or 0x00 0x00 0x01) let nalOff = 0; if (chunkData[0]===0 && chunkData[1]===0 && chunkData[2]===0 && chunkData[3]===1) nalOff = 4; else if (chunkData[0]===0 && chunkData[1]===0 && chunkData[2]===1) nalOff = 3; if (nalOff > 0 && nalOff < chunkData.length) { const nalType = chunkData[nalOff] & 0x1F; if (nalType === 5 || nalType === 7) isKey = true; // IDR or SPS } // Also check for MPEG-4 I-VOP (0x00 0x00 0x01 0xB6, bits indicate I-frame) for (let j = 0; j < Math.min(chunkData.length - 4, 64); j++) { if (chunkData[j]===0 && chunkData[j+1]===0 && chunkData[j+2]===1 && chunkData[j+3]===0xB6) { if (((chunkData[j+4] >> 6) & 3) === 0) isKey = true; // I-VOP break; } } } const chunk = new EncodedVideoChunk({ type: isKey ? 'key' : 'delta', timestamp: i * frameDuration, data: chunkData, }); decoder.decode(chunk); } catch (e) { /* skip bad chunks */ } if (i % 5 === 0) { setProgress(40 + Math.round((i / maxFrames) * 40), 'Decoding frame ' + (i + 1) + '/' + maxFrames + '...'); await decoder.flush(); } } await decoder.flush(); decoder.close(); if (decodedFrames.length === 0) throw new Error('No frames could be decoded. The "' + (aviInfo.codec || 'unknown').trim() + '" codec may not be supported by your browser\'s WebCodecs. Try converting to MP4 with VLC first.'); setProgress(85, 'Building output...'); // WEBM Video from decoded frames using VideoEncoder + built-in WebM muxer if (targetExt === 'webm-video' && decodedFrames.length > 0) { setProgress(87, 'Encoding WEBM video (' + decodedFrames.length + ' frames)...'); const fps = aviInfo.fps > 0 ? aviInfo.fps : 30; const w = decodedFrames[0].width, h = decodedFrames[0].height; const frameDurUs = 1000000 / fps; // Encode frames to VP8 with VideoEncoder const encodedChunks = []; const encoder = new VideoEncoder({ output: (chunk, meta) => { const buf = new Uint8Array(chunk.byteLength); chunk.copyTo(buf); encodedChunks.push({ data: buf, timestamp: chunk.timestamp, type: chunk.type, duration: chunk.duration }); }, error: (e) => {}, }); encoder.configure({ codec: 'vp8', width: w, height: h, bitrate: 2000000, framerate: fps }); for (let i = 0; i < decodedFrames.length; i++) { const frame = new VideoFrame(decodedFrames[i], { timestamp: Math.round(i * frameDurUs) }); encoder.encode(frame, { keyFrame: i % 60 === 0 }); frame.close(); if (i % 10 === 0) { setProgress(87 + Math.round((i / decodedFrames.length) * 8), 'Encoding ' + (i + 1) + '/' + decodedFrames.length); await encoder.flush(); } } await encoder.flush(); encoder.close(); if (encodedChunks.length === 0) throw new Error('VP8 encoding produced no frames'); setProgress(97, 'Building WEBM file...'); const webmBlob = buildWebmFile(encodedChunks, w, h); return webmBlob; } // GIF from decoded frames if (targetExt === 'gif' && decodedFrames.length > 1) { if (typeof GIF === 'undefined') throw new Error('GIF library not loaded'); if (!window._gifWorkerBlob) { const resp = await fetch('https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js'); window._gifWorkerBlob = URL.createObjectURL(new Blob([await resp.text()], { type: 'application/javascript' })); } const scale = Math.min(1, 480 / decodedFrames[0].width); const gw = Math.round(decodedFrames[0].width * scale), gh = Math.round(decodedFrames[0].height * scale); const gif = new GIF({ workers: 2, quality: 10, width: gw, height: gh, workerScript: window._gifWorkerBlob }); const tc = document.createElement('canvas'); tc.width = gw; tc.height = gh; const tcx = tc.getContext('2d'); const delay = aviInfo.fps > 0 ? Math.round(1000 / aviInfo.fps) : 100; for (const f of decodedFrames) { tcx.drawImage(f, 0, 0, gw, gh); gif.addFrame(tcx, { copy: true, delay }); } return new Promise(resolve => { gif.on('finished', resolve); gif.render(); }); } // Static image — use a frame from the middle const idx = Math.min(Math.floor(decodedFrames.length / 2), decodedFrames.length - 1); const c = decodedFrames[idx]; const cx = c.getContext('2d'); return canvasToTarget(c, cx, c.width, c.height, targetExt); } // ================== // FLV DEMUXER — extract H.264 frames and decode with WebCodecs // ================== async function convertFlvFallback(file, targetExt) { const buf = await file.arrayBuffer(); const u8 = new Uint8Array(buf); if (u8[0]!==0x46 || u8[1]!==0x4C || u8[2]!==0x56) throw new Error('Not a valid FLV file'); setProgress(20, 'Demuxing FLV...'); const hasVideo = (u8[4] & 0x01) !== 0; if (!hasVideo) throw new Error('FLV file contains no video stream'); // Parse FLV tags to extract H.264 video data const dataOffset = (u8[5]<<24) | (u8[6]<<16) | (u8[7]<<8) | u8[8]; let pos = dataOffset; const videoChunks = []; let spsData = null, ppsData = null; let width = 0, height = 0, fps = 30; while (pos + 15 < u8.length) { pos += 4; // skip previous tag size if (pos + 11 >= u8.length) break; const tagType = u8[pos]; const tagDataSize = (u8[pos+1]<<16) | (u8[pos+2]<<8) | u8[pos+3]; const tagDataOff = pos + 11; pos = tagDataOff + tagDataSize; if (tagType !== 9 || tagDataSize < 5) continue; // not video tag const frameType = (u8[tagDataOff] >> 4) & 0x0F; const codecId = u8[tagDataOff] & 0x0F; if (codecId !== 7) continue; // not AVC/H.264 const avcPacketType = u8[tagDataOff + 1]; if (avcPacketType === 0) { // AVC sequence header — contains SPS/PPS in avcC format const avcCOff = tagDataOff + 5; const avcCLen = tagDataSize - 5; if (avcCLen > 8) { const avcC = u8.slice(avcCOff, avcCOff + avcCLen); const parsed = parseAvcC(avcC); if (parsed.sps) { spsData = parsed.sps; width = 0; height = 0; } if (parsed.pps) ppsData = parsed.pps; } } else if (avcPacketType === 1) { // AVC NALU — video frame data (already length-prefixed) const naluData = u8.slice(tagDataOff + 5, tagDataOff + tagDataSize); if (naluData.length > 4) { videoChunks.push({ data: naluData, isKey: frameType === 1 }); } } } if (!spsData || !ppsData) throw new Error('FLV does not contain H.264 sequence parameters'); if (videoChunks.length === 0) throw new Error('No video frames found in FLV'); // Build avcC description const description = buildAvcC(spsData, ppsData); const codecString = 'avc1.' + [spsData[1], spsData[2], spsData[3]].map(b => b.toString(16).padStart(2, '0')).join(''); setProgress(40, 'Decoding H.264...'); if (typeof VideoDecoder === 'undefined') throw new Error('WebCodecs not available'); const decodedFrames = []; const decoder = new VideoDecoder({ output: (frame) => { if (width === 0) { width = frame.displayWidth; height = frame.displayHeight; } const c = document.createElement('canvas'); c.width = frame.displayWidth; c.height = frame.displayHeight; c.getContext('2d').drawImage(frame, 0, 0); frame.close(); decodedFrames.push(c); }, error: () => {} }); const config = { codec: codecString, codedWidth: width || 640, codedHeight: height || 480, description }; const support = await VideoDecoder.isConfigSupported(config); if (!support.supported) throw new Error('H.264 codec not supported by WebCodecs'); decoder.configure(config); const maxFrames = (targetExt === 'gif' || targetExt === 'webm-video') ? Math.min(videoChunks.length, 500) : Math.min(videoChunks.length, 30); for (let i = 0; i < maxFrames; i++) { try { decoder.decode(new EncodedVideoChunk({ type: videoChunks[i].isKey ? 'key' : 'delta', timestamp: i * (1000000 / fps), data: videoChunks[i].data, })); } catch (e) {} if (i % 5 === 0) { await decoder.flush(); setProgress(40 + Math.round((i/maxFrames)*40), 'Decoding ' + (i+1) + '/' + maxFrames); } } await decoder.flush(); decoder.close(); if (decodedFrames.length === 0) throw new Error('Could not decode any frames from FLV'); // Use the same output logic as AVI WebCodecs const aviInfo = { fps, width: decodedFrames[0].width, height: decodedFrames[0].height }; if (targetExt === 'webm-video' && decodedFrames.length > 0 && typeof VideoEncoder !== 'undefined') { setProgress(85, 'Encoding WEBM...'); return buildWebmFromFrames(decodedFrames, aviInfo); } if (targetExt === 'gif' && decodedFrames.length > 1) { return aviCanvasFramesToOutput(decodedFrames, targetExt, aviInfo); } if (targetExt === 'wav') throw new Error('Audio extraction from FLV is not supported. Convert to MP4 first using VLC.'); const idx = Math.min(Math.floor(decodedFrames.length / 2), decodedFrames.length - 1); return canvasToTarget(decodedFrames[idx], decodedFrames[idx].getContext('2d'), decodedFrames[idx].width, decodedFrames[idx].height, targetExt); } // Build WEBM from canvas frames using VideoEncoder async function buildWebmFromFrames(frames, info) { const fps = info.fps || 30; const w = frames[0].width, h = frames[0].height; const frameDurUs = 1000000 / fps; const encodedChunks = []; const encoder = new VideoEncoder({ output: (chunk) => { const b = new Uint8Array(chunk.byteLength); chunk.copyTo(b); encodedChunks.push({ data: b, timestamp: chunk.timestamp, type: chunk.type, duration: chunk.duration }); }, error: () => {} }); encoder.configure({ codec: 'vp8', width: w, height: h, bitrate: 2000000, framerate: fps }); for (let i = 0; i < frames.length; i++) { const f = new VideoFrame(frames[i], { timestamp: Math.round(i * frameDurUs) }); encoder.encode(f, { keyFrame: i % 60 === 0 }); f.close(); if (i % 10 === 0) await encoder.flush(); } await encoder.flush(); encoder.close(); if (encodedChunks.length === 0) throw new Error('VP8 encoding failed'); return buildWebmFile(encodedChunks, w, h); } function loadImageFromBlob(blob) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(blob); const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); }; img.src = url; }); } // ================== // MINIMAL WEBM MUXER (EBML/Matroska) // ================== function buildWebmFile(encodedChunks, width, height) { // EBML variable-size integer encoding function vintSize(val) { if (val < 0x7F) return 1; if (val < 0x3FFF) return 2; if (val < 0x1FFFFF) return 3; if (val < 0x0FFFFFFF) return 4; return 8; } function writeVint(arr, off, val, len) { if (len === 1) { arr[off] = 0x80 | val; } else if (len === 2) { arr[off] = 0x40 | (val >> 8); arr[off+1] = val & 0xFF; } else if (len === 3) { arr[off] = 0x20 | (val >> 16); arr[off+1] = (val >> 8) & 0xFF; arr[off+2] = val & 0xFF; } else if (len === 4) { arr[off] = 0x10 | (val >> 24); arr[off+1] = (val >> 16) & 0xFF; arr[off+2] = (val >> 8) & 0xFF; arr[off+3] = val & 0xFF; } else { arr[off]=1; arr[off+1]=arr[off+2]=arr[off+3]=0; arr[off+4]=(val>>24)&0xFF; arr[off+5]=(val>>16)&0xFF; arr[off+6]=(val>>8)&0xFF; arr[off+7]=val&0xFF; } } // Write EBML element: id + size + data function elemSize(idBytes, dataLen) { return idBytes.length + vintSize(dataLen) + dataLen; } function writeElem(arr, off, idBytes, data) { for (let i = 0; i < idBytes.length; i++) arr[off++] = idBytes[i]; const sl = vintSize(data.length); writeVint(arr, off, data.length, sl); off += sl; arr.set(data, off); return off + data.length; } // Unsigned int to bytes (big-endian) function uint8(v) { return new Uint8Array([v]); } function uint16(v) { return new Uint8Array([(v>>8)&0xFF, v&0xFF]); } function uint32(v) { return new Uint8Array([(v>>24)&0xFF,(v>>16)&0xFF,(v>>8)&0xFF,v&0xFF]); } function strBytes(s) { return new TextEncoder().encode(s); } function floatBytes(v) { const b = new ArrayBuffer(8); new DataView(b).setFloat64(0, v); return new Uint8Array(b); } // Build element helper function mkElem(id, data) { const idB = Array.isArray(id) ? id : [id]; const sz = vintSize(data.length); const buf = new Uint8Array(idB.length + sz + data.length); let o = 0; for (const b of idB) buf[o++] = b; writeVint(buf, o, data.length, sz); o += sz; buf.set(data, o); return buf; } function concat(...arrs) { const len = arrs.reduce((s,a) => s + a.length, 0); const r = new Uint8Array(len); let o = 0; for (const a of arrs) { r.set(a, o); o += a.length; } return r; } // Duration in ms const lastChunk = encodedChunks[encodedChunks.length - 1]; const durationMs = (lastChunk.timestamp + (lastChunk.duration || 33333)) / 1000; // EBML Header const ebmlHeader = mkElem([0x1A,0x45,0xDF,0xA3], concat( mkElem([0x42,0x86], uint8(1)), // EBMLVersion mkElem([0x42,0xF7], uint8(1)), // EBMLReadVersion mkElem([0x42,0xF2], uint8(4)), // EBMLMaxIDLength mkElem([0x42,0xF3], uint8(8)), // EBMLMaxSizeLength mkElem([0x42,0x82], strBytes('webm')), // DocType mkElem([0x42,0x87], uint8(4)), // DocTypeVersion mkElem([0x42,0x85], uint8(2)), // DocTypeReadVersion )); // Segment > Info const info = mkElem([0x15,0x49,0xA9,0x66], concat( mkElem([0x2A,0xD7,0xB1], uint32(1000000)), // TimestampScale (1ms) mkElem([0x4D,0x80], strBytes('PeachyList')), // MuxingApp mkElem([0x57,0x41], strBytes('PeachyList')), // WritingApp mkElem([0x44,0x89], floatBytes(durationMs)), // Duration )); // Segment > Tracks > TrackEntry (Video) const trackEntry = mkElem([0xAE], concat( mkElem([0xD7], uint8(1)), // TrackNumber mkElem([0x73,0xC5], uint32(1)), // TrackUID mkElem([0x83], uint8(1)), // TrackType (video) mkElem([0x86], strBytes('V_VP8')), // CodecID mkElem([0xE0], concat( // Video mkElem([0xB0], uint16(width)), // PixelWidth mkElem([0xBA], uint16(height)), // PixelHeight )), )); const tracks = mkElem([0x16,0x54,0xAE,0x6B], trackEntry); // Segment > Clusters with SimpleBlocks const clusterSize = 30; // frames per cluster const clusters = []; for (let ci = 0; ci < encodedChunks.length; ci += clusterSize) { const clusterChunks = encodedChunks.slice(ci, ci + clusterSize); const clusterTimestamp = Math.round(clusterChunks[0].timestamp / 1000); // ms const blocks = []; for (const ch of clusterChunks) { const relTime = Math.round(ch.timestamp / 1000) - clusterTimestamp; // ms relative // SimpleBlock: track=1 (0x81), timecode (int16), flags const flags = ch.type === 'key' ? 0x80 : 0x00; // keyframe flag const blockHeader = new Uint8Array(4); blockHeader[0] = 0x81; // track number (VINT: 1) blockHeader[1] = (relTime >> 8) & 0xFF; blockHeader[2] = relTime & 0xFF; blockHeader[3] = flags; const blockData = concat(blockHeader, ch.data); blocks.push(mkElem([0xA3], blockData)); // SimpleBlock } const clusterBody = concat( mkElem([0xE7], uint32(clusterTimestamp)), // Timestamp ...blocks ); clusters.push(mkElem([0x1F,0x43,0xB6,0x75], clusterBody)); } // Segment (unknown size: 0x01 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF) const segmentBody = concat(info, tracks, ...clusters); const segmentHeader = new Uint8Array([0x18,0x53,0x80,0x67, 0x01,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF]); return new Blob([ebmlHeader, segmentHeader, segmentBody], { type: 'video/webm' }); } function extractAviAudio(u8, aviInfo) { // Find audio format from 'strf' header for audio stream let sampleRate = 0, channels = 0, bitsPerSample = 16, fmtTag = 0; let foundAudioStrh = false; for (let i = 0; i < u8.length - 20; i++) { // Look for 'strh' with 'auds' type if (u8[i]===0x73 && u8[i+1]===0x74 && u8[i+2]===0x72 && u8[i+3]===0x68) { const off = i + 8; if (u8[off]===0x61 && u8[off+1]===0x75 && u8[off+2]===0x64 && u8[off+3]===0x73) { foundAudioStrh = true; } } // After finding audio strh, find the next strf if (foundAudioStrh && u8[i]===0x73 && u8[i+1]===0x74 && u8[i+2]===0x72 && u8[i+3]===0x66) { const fmtOff = i + 8; if (fmtOff + 16 <= u8.length) { fmtTag = u8[fmtOff] | (u8[fmtOff+1] << 8); channels = u8[fmtOff+2] | (u8[fmtOff+3] << 8); sampleRate = u8[fmtOff+4] | (u8[fmtOff+5] << 8) | (u8[fmtOff+6] << 16) | (u8[fmtOff+7] << 24); bitsPerSample = u8[fmtOff+14] | (u8[fmtOff+15] << 8); } break; } } // Only extract if audio is uncompressed PCM (fmtTag === 1) if (fmtTag !== 1) return null; if (sampleRate === 0 || channels === 0) return null; // Extract '01wb' audio chunks const chunks = []; for (let i = 0; i < u8.length - 8; i++) { if (u8[i] === 0x30 && u8[i+1] === 0x31 && u8[i+2] === 0x77 && u8[i+3] === 0x62) { const len = u8[i+4] | (u8[i+5] << 8) | (u8[i+6] << 16) | (u8[i+7] << 24); if (len > 0 && len < 10000000 && i + 8 + len <= u8.length) { chunks.push(u8.slice(i + 8, i + 8 + len)); i += 7 + len; } } } if (chunks.length === 0) return null; // Build WAV from audio chunks const totalAudio = chunks.reduce((s, c) => s + c.length, 0); const wavSize = 44 + totalAudio; const wav = new ArrayBuffer(wavSize); const wv = new DataView(wav); const wu = new Uint8Array(wav); writeString(wv, 0, 'RIFF'); wv.setUint32(4, wavSize - 8, true); writeString(wv, 8, 'WAVE'); writeString(wv, 12, 'fmt '); wv.setUint32(16, 16, true); wv.setUint16(20, 1, true); wv.setUint16(22, channels, true); wv.setUint32(24, sampleRate, true); wv.setUint32(28, sampleRate * channels * (bitsPerSample / 8), true); wv.setUint16(32, channels * (bitsPerSample / 8), true); wv.setUint16(34, bitsPerSample, true); writeString(wv, 36, 'data'); wv.setUint32(40, totalAudio, true); let off = 44; for (const chunk of chunks) { wu.set(chunk, off); off += chunk.length; } return new Blob([wav], { type: 'audio/wav' }); } // Extract compressed audio (MP3/AAC) from AVI — concatenate raw chunks function extractAviCompressedAudio(u8) { // Find audio format tag let fmtTag = 0; let foundAudioStrh = false; for (let i = 0; i < u8.length - 20; i++) { if (u8[i]===0x73 && u8[i+1]===0x74 && u8[i+2]===0x72 && u8[i+3]===0x68) { const off = i + 8; if (off + 4 <= u8.length && u8[off]===0x61 && u8[off+1]===0x75 && u8[off+2]===0x64 && u8[off+3]===0x73) { foundAudioStrh = true; } } if (foundAudioStrh && u8[i]===0x73 && u8[i+1]===0x74 && u8[i+2]===0x72 && u8[i+3]===0x66) { fmtTag = u8[i+8] | (u8[i+9] << 8); break; } } // Extract '01wb' audio chunks const chunks = []; for (let i = 0; i < u8.length - 8; i++) { if (u8[i]===0x30 && u8[i+1]===0x31 && u8[i+2]===0x77 && u8[i+3]===0x62) { const len = u8[i+4] | (u8[i+5] << 8) | (u8[i+6] << 16) | (u8[i+7] << 24); if (len > 0 && len < 10000000 && i + 8 + len <= u8.length) { chunks.push(u8.slice(i + 8, i + 8 + len)); i += 7 + len; } } } if (chunks.length === 0) return null; // Concatenate chunks into a single audio blob const total = chunks.reduce((s, c) => s + c.length, 0); const combined = new Uint8Array(total); let off = 0; for (const c of chunks) { combined.set(c, off); off += c.length; } // Determine MIME type from format tag let mime = 'audio/mpeg'; // default to MP3 if (fmtTag === 0x55) mime = 'audio/mpeg'; // MP3 else if (fmtTag === 0xFF || fmtTag === 0x00FF) mime = 'audio/aac'; // AAC else if (fmtTag === 0x2000) mime = 'audio/ac3'; // AC3 else if (fmtTag === 0x0161 || fmtTag === 0x0162) mime = 'audio/x-ms-wma'; // WMA return new Blob([combined], { type: mime }); } // ================== // ASE (Adobe Swatch Exchange) CONVERSION // ================== async function convertAse(file, targetExt) { const buf = await file.arrayBuffer(); const swatches = parseAse(buf); if (swatches.length === 0) throw new Error('No color swatches found in this ASE file.'); if (targetExt === 'json') { return new Blob([JSON.stringify({ swatches }, null, 2)], { type: 'application/json' }); } if (targetExt === 'txt') { let out = 'Adobe Swatch Exchange - ' + swatches.length + ' colors\n' + '='.repeat(40) + '\n\n'; swatches.forEach((s, i) => { out += (i + 1) + '. ' + s.name + ' ' + s.hex + ' RGB(' + s.r + ', ' + s.g + ', ' + s.b + ')\n'; }); return new Blob([out], { type: 'text/plain' }); } if (targetExt === 'css') { let css = '/* Adobe Swatch Exchange Colors */\n:root {\n'; swatches.forEach((s, i) => { const varName = s.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || ('color-' + (i + 1)); css += ' --' + varName + ': ' + s.hex + ';\n'; }); css += '}\n'; return new Blob([css], { type: 'text/css' }); } if (targetExt === 'html') { let h = 'Color Swatches'; h += ''; h += '

Color Swatches (' + swatches.length + ' colors)

'; swatches.forEach(s => { h += '
'; h += '
' + escapeHtml(s.name) + '
'; h += '
' + s.hex + '
'; }); h += '
'; return new Blob([h], { type: 'text/html' }); } if (targetExt === 'svg') { const cols = Math.min(swatches.length, 10); const rows = Math.ceil(swatches.length / cols); const sw = 60, sh = 60, pad = 4; const w = cols * (sw + pad) + pad, ht = rows * (sh + pad + 16) + pad; let svg = '\n\n'; svg += '\n'; swatches.forEach((s, i) => { const col = i % cols, row = Math.floor(i / cols); const x = pad + col * (sw + pad), y = pad + row * (sh + pad + 16); svg += '\n'; svg += '' + escapeHtml(s.hex) + '\n'; }); svg += ''; return new Blob([svg], { type: 'image/svg+xml' }); } if (targetExt === 'png') { const cols = Math.min(swatches.length, 10); const rows = Math.ceil(swatches.length / cols); const sw = 80, sh = 60, pad = 6; const cw = cols * (sw + pad) + pad, ch = rows * (sh + pad + 18) + pad; const c = document.createElement('canvas'); c.width = cw; c.height = ch; const cx = c.getContext('2d'); cx.fillStyle = '#FFF'; cx.fillRect(0, 0, cw, ch); swatches.forEach((s, i) => { const col = i % cols, row = Math.floor(i / cols); const x = pad + col * (sw + pad), y = pad + row * (sh + pad + 18); cx.fillStyle = s.hex; cx.beginPath(); cx.roundRect(x, y, sw, sh, 4); cx.fill(); cx.fillStyle = '#333'; cx.font = '10px sans-serif'; cx.textAlign = 'center'; cx.fillText(s.hex, x + sw / 2, y + sh + 13); }); return new Promise((resolve, reject) => { c.toBlob(b => { if (!b) reject(new Error('Render failed')); else resolve(b); }, 'image/png'); }); } return new Blob([JSON.stringify({ swatches }, null, 2)], { type: 'application/json' }); } function parseAse(buf) { const dv = new DataView(buf); const swatches = []; try { // Header: 'ASEF' (4 bytes) + version (4 bytes) + block count (4 bytes) const sig = String.fromCharCode(dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3)); if (sig !== 'ASEF') throw new Error('Not a valid ASE file'); const blockCount = dv.getUint32(8, false); let pos = 12; for (let b = 0; b < blockCount && pos < buf.byteLength - 4; b++) { const blockType = dv.getUint16(pos, false); const blockLen = dv.getUint32(pos + 2, false); pos += 6; if (blockType === 0x0001 && blockLen > 6) { // Color entry const nameLen = dv.getUint16(pos, false); let name = ''; for (let i = 0; i < nameLen - 1; i++) { const ch = dv.getUint16(pos + 2 + i * 2, false); if (ch > 0) name += String.fromCharCode(ch); } const colorModelOff = pos + 2 + nameLen * 2; const model = String.fromCharCode( dv.getUint8(colorModelOff), dv.getUint8(colorModelOff + 1), dv.getUint8(colorModelOff + 2), dv.getUint8(colorModelOff + 3) ).trim(); let r = 0, g = 0, b2 = 0; if (model === 'RGB') { r = Math.round(dv.getFloat32(colorModelOff + 4, false) * 255); g = Math.round(dv.getFloat32(colorModelOff + 8, false) * 255); b2 = Math.round(dv.getFloat32(colorModelOff + 12, false) * 255); } else if (model === 'CMYK') { const c = dv.getFloat32(colorModelOff + 4, false); const m = dv.getFloat32(colorModelOff + 8, false); const y = dv.getFloat32(colorModelOff + 12, false); const k = dv.getFloat32(colorModelOff + 16, false); r = Math.round(255 * (1 - c) * (1 - k)); g = Math.round(255 * (1 - m) * (1 - k)); b2 = Math.round(255 * (1 - y) * (1 - k)); } else if (model === 'Gray') { const gray = Math.round(dv.getFloat32(colorModelOff + 4, false) * 255); r = g = b2 = gray; } else if (model === 'LAB') { // Approximate LAB→RGB const L = dv.getFloat32(colorModelOff + 4, false) * 100; const a = dv.getFloat32(colorModelOff + 8, false); const bVal = dv.getFloat32(colorModelOff + 12, false); // Simplified conversion r = Math.round(Math.min(255, Math.max(0, L * 2.55 + a * 1.5))); g = Math.round(Math.min(255, Math.max(0, L * 2.55 - a * 0.5 - bVal * 0.5))); b2 = Math.round(Math.min(255, Math.max(0, L * 2.55 + bVal * 1.5))); } r = Math.min(255, Math.max(0, r)); g = Math.min(255, Math.max(0, g)); b2 = Math.min(255, Math.max(0, b2)); const hex = '#' + [r, g, b2].map(v => v.toString(16).padStart(2, '0')).join(''); swatches.push({ name: name || ('Color ' + (swatches.length + 1)), r, g, b: b2, hex, model }); } pos += blockLen; } } catch (e) { // Return whatever we parsed so far } return swatches; } // ================== // LAZY LIBRARY LOADER // ================== const _loadedLibs = {}; async function loadLib(name, url) { if (_loadedLibs[name]) return; setProgress(5, 'Loading ' + name + ' library...'); return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = url; s.onload = () => { _loadedLibs[name] = true; resolve(); }; s.onerror = () => reject(new Error('Failed to load ' + name + ' library. Check your internet connection.')); document.head.appendChild(s); }); } // JSZip library for ZIP handling // (loaded via CDN script tag) // ================== // HEIC CONVERSION // ================== async function convertHeic(file, targetExt) { await loadLib('heic2any', 'https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js'); setProgress(20, 'Decoding HEIC...'); const pngBlob = await heic2any({ blob: file, toType: 'image/png' }); const resultBlob = Array.isArray(pngBlob) ? pngBlob[0] : pngBlob; if (targetExt === 'png') return resultBlob; setProgress(60, 'Converting...'); const img = await loadImageFromBlob(resultBlob); const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight; const cx = c.getContext('2d'); cx.drawImage(img, 0, 0); return canvasToTarget(c, cx, c.width, c.height, targetExt); } // ================== // PSD CONVERSION // ================== async function convertPsd(file, targetExt) { setProgress(20, 'Parsing PSD...'); const buf = await file.arrayBuffer(); const dv = new DataView(buf); const u8 = new Uint8Array(buf); // Validate PSD signature const sig = String.fromCharCode(u8[0], u8[1], u8[2], u8[3]); if (sig !== '8BPS') throw new Error('Not a valid PSD file'); // Read header const channels = dv.getUint16(12, false); const height = dv.getUint32(14, false); const width = dv.getUint32(18, false); const depth = dv.getUint16(22, false); const colorMode = dv.getUint16(24, false); if (width === 0 || height === 0) throw new Error('Invalid PSD dimensions'); setProgress(40, 'Reading image data...'); let pos = 26; // Skip Color Mode Data section const colorModeLen = dv.getUint32(pos, false); pos += 4 + colorModeLen; // Skip Image Resources section const imgResLen = dv.getUint32(pos, false); pos += 4 + imgResLen; // Skip Layer and Mask Information section const layerLen = dv.getUint32(pos, false); pos += 4 + layerLen; // Image Data section — the composite image if (pos + 2 > buf.byteLength) throw new Error('PSD file appears truncated'); const compression = dv.getUint16(pos, false); pos += 2; setProgress(60, 'Decoding pixels...'); const pixelCount = width * height; let channelData; if (compression === 0) { // Raw — uncompressed pixel data const totalBytes = pixelCount * channels * (depth / 8); channelData = u8.slice(pos, pos + totalBytes); } else if (compression === 1) { // RLE — PackBits compression // Skip byte counts (2 bytes per row per channel) const byteCountsLen = height * channels * 2; pos += byteCountsLen; // Decode RLE const decoded = []; const totalPixels = pixelCount * channels; let di = 0; while (di < totalPixels && pos < buf.byteLength) { const n = dv.getInt8(pos); pos++; if (n >= 0) { const count = n + 1; for (let i = 0; i < count && pos < buf.byteLength; i++) { decoded.push(u8[pos++]); } di += count; } else if (n > -128) { const count = -n + 1; const val = u8[pos++]; for (let i = 0; i < count; i++) decoded.push(val); di += count; } } channelData = new Uint8Array(decoded); } else { throw new Error('PSD uses ZIP compression which is not supported'); } // Convert channel-planar data to RGBA canvas setProgress(80, 'Rendering...'); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); const imgData = ctx.createImageData(width, height); const px = imgData.data; const bpc = depth / 8; // bytes per channel value for (let i = 0; i < pixelCount; i++) { if (colorMode === 3 || colorMode === 0) { // RGB or Bitmap px[i*4] = channels > 0 ? channelData[i * bpc] : 0; // R px[i*4+1] = channels > 1 ? channelData[pixelCount * bpc + i * bpc] : px[i*4]; // G px[i*4+2] = channels > 2 ? channelData[pixelCount * 2 * bpc + i * bpc] : px[i*4]; // B px[i*4+3] = channels > 3 ? channelData[pixelCount * 3 * bpc + i * bpc] : 255; // A } else if (colorMode === 1) { // Grayscale const gray = channelData[i * bpc]; px[i*4] = px[i*4+1] = px[i*4+2] = gray; px[i*4+3] = channels > 1 ? channelData[pixelCount * bpc + i * bpc] : 255; } else if (colorMode === 4) { // CMYK const c = channelData[i * bpc] / 255; const m = channelData[pixelCount * bpc + i * bpc] / 255; const y = channelData[pixelCount * 2 * bpc + i * bpc] / 255; const k = channelData[pixelCount * 3 * bpc + i * bpc] / 255; px[i*4] = Math.round(255 * (1-c) * (1-k)); px[i*4+1] = Math.round(255 * (1-m) * (1-k)); px[i*4+2] = Math.round(255 * (1-y) * (1-k)); px[i*4+3] = 255; } } ctx.putImageData(imgData, 0, 0); return canvasToTarget(canvas, ctx, width, height, targetExt); } // ================== // RAW PHOTO CONVERSION (CR2, NEF, ORF, RAW) // ================== async function convertRaw(file, targetExt) { const ext = getExtension(file.name); const buf = await file.arrayBuffer(); // DNG files are TIFF-based — try UTIF.js first if (ext === 'dng') { setProgress(20, 'Decoding DNG with TIFF decoder...'); if (typeof UTIF !== 'undefined') { try { const ifds = UTIF.decode(buf); if (ifds && ifds.length > 0) { // Find the largest IFD (the main image, not thumbnail) let bestIfd = ifds[0], bestSize = 0; for (const ifd of ifds) { const s = (ifd.width || 0) * (ifd.height || 0); if (s > bestSize) { bestSize = s; bestIfd = ifd; } } UTIF.decodeImage(buf, bestIfd); const rgba = UTIF.toRGBA8(bestIfd); const w = bestIfd.width, h = bestIfd.height; if (w > 0 && h > 0 && rgba.length >= w * h * 4) { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); const imgData = ctx.createImageData(w, h); imgData.data.set(new Uint8Array(rgba)); ctx.putImageData(imgData, 0, 0); return canvasToTarget(canvas, ctx, w, h, targetExt); } } } catch (e) { /* UTIF failed, try JPEG preview */ } } } // Try JPEG preview extraction setProgress(20, 'Extracting preview from RAW...'); const u8 = new Uint8Array(buf); const jpegBlob = extractRawPreview(u8); // Last resort: try UTIF.js for any TIFF-based RAW if (!jpegBlob && typeof UTIF !== 'undefined') { try { const ifds = UTIF.decode(buf); if (ifds && ifds.length > 0) { let bestIfd = ifds[0], bestSize = 0; for (const ifd of ifds) { const s = (ifd.width||0)*(ifd.height||0); if (s > bestSize) { bestSize = s; bestIfd = ifd; } } UTIF.decodeImage(buf, bestIfd); const rgba = UTIF.toRGBA8(bestIfd); if (bestIfd.width > 0 && bestIfd.height > 0) { const canvas = document.createElement('canvas'); canvas.width = bestIfd.width; canvas.height = bestIfd.height; const ctx = canvas.getContext('2d'); const imgData = ctx.createImageData(bestIfd.width, bestIfd.height); imgData.data.set(new Uint8Array(rgba)); ctx.putImageData(imgData, 0, 0); return canvasToTarget(canvas, ctx, bestIfd.width, bestIfd.height, targetExt); } } } catch (e) {} } if (!jpegBlob) throw new Error('Could not extract preview from this RAW file. Try converting with Adobe DNG Converter or RawTherapee (free).'); setProgress(60, 'Converting preview...'); return new Promise((resolve, reject) => { const url = URL.createObjectURL(jpegBlob); const img = new Image(); img.onload = () => { const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight; const cx = c.getContext('2d'); cx.drawImage(img, 0, 0); URL.revokeObjectURL(url); canvasToTarget(c, cx, c.width, c.height, targetExt).then(resolve).catch(reject); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Failed to decode extracted preview.')); }; img.src = url; }); } function extractRawPreview(u8) { // Most RAW formats (CR2, NEF, ORF) are TIFF-based with embedded JPEG previews if (u8.length < 12) return null; const isLE = u8[0] === 0x49 && u8[1] === 0x49; // 'II' const isBE = u8[0] === 0x4D && u8[1] === 0x4D; // 'MM' if (!isLE && !isBE) { // Try to find JPEG marker directly return findJpegInBuffer(u8); } const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength); const le = isLE; const r16 = (o) => le ? dv.getUint16(o, true) : dv.getUint16(o, false); const r32 = (o) => le ? dv.getUint32(o, true) : dv.getUint32(o, false); // Find largest embedded JPEG by scanning IFDs let bestJpeg = null, bestSize = 0; try { let ifdOffset = r32(4); for (let ifdLoop = 0; ifdLoop < 10 && ifdOffset > 0 && ifdOffset < u8.length - 2; ifdLoop++) { const entries = r16(ifdOffset); let jpegOffset = 0, jpegLen = 0, subIfd = 0; for (let i = 0; i < entries && ifdOffset + 2 + i * 12 + 12 <= u8.length; i++) { const eOff = ifdOffset + 2 + i * 12; const tag = r16(eOff); const val = r32(eOff + 8); if (tag === 0x0201) jpegOffset = val; // JpegInterchangeFormat if (tag === 0x0202) jpegLen = val; // JpegInterchangeFormatLength if (tag === 0x014A || tag === 0x8769) subIfd = val; // SubIFDs or ExifIFD } if (jpegOffset > 0 && jpegLen > 0 && jpegLen > bestSize && jpegOffset + jpegLen <= u8.length) { bestJpeg = u8.slice(jpegOffset, jpegOffset + jpegLen); bestSize = jpegLen; } // Try SubIFD if (subIfd > 0 && subIfd < u8.length - 2) { const subEntries = r16(subIfd); let sJpegOff = 0, sJpegLen = 0; for (let i = 0; i < subEntries && subIfd + 2 + i * 12 + 12 <= u8.length; i++) { const eOff = subIfd + 2 + i * 12; const tag = r16(eOff); const val = r32(eOff + 8); if (tag === 0x0201) sJpegOff = val; if (tag === 0x0202) sJpegLen = val; } if (sJpegOff > 0 && sJpegLen > bestSize && sJpegOff + sJpegLen <= u8.length) { bestJpeg = u8.slice(sJpegOff, sJpegOff + sJpegLen); bestSize = sJpegLen; } } // Next IFD const nextOff = ifdOffset + 2 + entries * 12; if (nextOff + 4 <= u8.length) { ifdOffset = r32(nextOff); } else break; } } catch (e) {} if (bestJpeg && bestSize > 1000) return new Blob([bestJpeg], { type: 'image/jpeg' }); return findJpegInBuffer(u8); } function findJpegInBuffer(u8) { // Brute-force find the largest JPEG in the buffer let best = null, bestLen = 0; for (let i = 0; i < u8.length - 3; i++) { if (u8[i] === 0xFF && u8[i + 1] === 0xD8 && u8[i + 2] === 0xFF) { for (let j = i + 3; j < u8.length - 1; j++) { if (u8[j] === 0xFF && u8[j + 1] === 0xD9) { const len = j + 2 - i; if (len > bestLen && len > 100) { best = u8.slice(i, j + 2); bestLen = len; } break; } } } } return best ? new Blob([best], { type: 'image/jpeg' }) : null; } // ================== // CUR (CURSOR) CONVERSION — handled in image pipeline // ================== // CUR format is identical to ICO. The existing image pipeline handles it since // browsers can load .cur files in elements. // ================== // DOCX INPUT CONVERSION // ================== async function convertDocxInput(file, targetExt) { const srcExt = getExtension(file.name); // RTF input: strip RTF markup to get plain text if (srcExt === 'rtf') { setProgress(20, 'Parsing RTF...'); const rtfText = await file.text(); // Strip RTF control words and groups let text = rtfText .replace(/\{\\[^{}]*\}/g, '') // remove nested groups like {\fonttbl...} .replace(/\\par\b/g, '\n') // paragraph breaks .replace(/\\line\b/g, '\n') // line breaks .replace(/\\tab\b/g, '\t') // tabs .replace(/\\u(\d+)\??/g, (_, code) => String.fromCharCode(parseInt(code))) // unicode .replace(/\\['`]([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))) // hex chars .replace(/\\[a-z]+\d*\s?/gi, '') // remove remaining control words .replace(/[{}]/g, '') // remove braces .trim(); if (targetExt === 'txt') return new Blob([text], { type: 'text/plain' }); if (targetExt === 'md') return new Blob([text], { type: 'text/markdown' }); if (targetExt === 'html') { const html = 'Document
' + escapeHtml(text) + '
'; return new Blob([html], { type: 'text/html' }); } return new Blob([text], { type: 'text/plain' }); } await loadLib('mammoth', 'https://cdn.jsdelivr.net/npm/mammoth@1.8.0/mammoth.browser.min.js'); setProgress(20, 'Parsing DOCX...'); const buf = await file.arrayBuffer(); if (targetExt === 'html') { const result = await mammoth.convertToHtml({ arrayBuffer: buf }); const html = 'Document' + result.value + ''; return new Blob([html], { type: 'text/html' }); } const result = await mammoth.extractRawText({ arrayBuffer: buf }); const text = result.value; if (targetExt === 'txt') return new Blob([text], { type: 'text/plain' }); if (targetExt === 'md') return new Blob([text], { type: 'text/markdown' }); if (targetExt === 'pdf') { // Convert text to image then to PDF const c = document.createElement('canvas'); c.width = 595; c.height = 842; const cx = c.getContext('2d'); cx.fillStyle = '#FFF'; cx.fillRect(0, 0, 595, 842); cx.fillStyle = '#000'; cx.font = '12px sans-serif'; const lines = text.split('\n'); let y = 30; for (const line of lines) { if (y > 810) break; cx.fillText(line.substring(0, 80), 20, y); y += 16; } return new Promise((resolve, reject) => { c.toBlob(async (blob) => { if (!blob) return reject(new Error('PDF generation failed')); const jpgBuf = new Uint8Array(await blob.arrayBuffer()); resolve(buildPdf(jpgBuf, 595, 842)); }, 'image/jpeg', 0.92); }); } return new Blob([text], { type: 'text/plain' }); } // ================== // PDF INPUT CONVERSION // ================== async function convertPdfInput(file, targetExt) { await loadLib('pdfjs', 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js'); setProgress(10, 'Loading PDF...'); const buf = await file.arrayBuffer(); pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js'; const pdf = await pdfjsLib.getDocument({ data: buf }).promise; if (['png', 'jpg', 'webp'].includes(targetExt)) { setProgress(30, 'Rendering page 1...'); const page = await pdf.getPage(1); const viewport = page.getViewport({ scale: 2 }); const c = document.createElement('canvas'); c.width = viewport.width; c.height = viewport.height; const cx = c.getContext('2d'); if (targetExt === 'jpg') { cx.fillStyle = '#FFF'; cx.fillRect(0, 0, c.width, c.height); } await page.render({ canvasContext: cx, viewport }).promise; let mime = 'image/png'; if (targetExt === 'jpg') mime = 'image/jpeg'; else if (targetExt === 'webp') mime = 'image/webp'; return new Promise((resolve, reject) => { c.toBlob(b => { if (!b) reject(new Error('Render failed')); else resolve(b); }, mime, 0.92); }); } // Text extraction setProgress(20, 'Extracting text...'); let allText = ''; for (let i = 1; i <= pdf.numPages; i++) { setProgress(20 + Math.round((i / pdf.numPages) * 70), 'Extracting page ' + i + '...'); const page = await pdf.getPage(i); const content = await page.getTextContent(); const pageText = content.items.map(item => item.str).join(' '); allText += pageText + '\n\n'; } allText = allText.trim(); if (targetExt === 'txt') return new Blob([allText], { type: 'text/plain' }); if (targetExt === 'html') { const html = 'PDF Text
' + escapeHtml(allText) + '
'; return new Blob([html], { type: 'text/html' }); } return new Blob([allText], { type: 'text/plain' }); } // ================== // SPREADSHEET CONVERSION (XLSX, XLS) // ================== async function convertSpreadsheet(file, targetExt) { await loadLib('xlsx', 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js'); setProgress(20, 'Parsing spreadsheet...'); const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const ws = wb.Sheets[wb.SheetNames[0]]; const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }); setProgress(60, 'Converting...'); // Feed into existing data pipeline const data = { type: 'table', value: rows.map(r => r.map(c => String(c))) }; const output = convertToTarget(data, 'csv', targetExt, ''); const mimeMap = { json:'application/json',xml:'application/xml',yaml:'text/yaml',csv:'text/csv',tsv:'text/tab-separated-values',txt:'text/plain',html:'text/html' }; return new Blob([output], { type: mimeMap[targetExt] || 'text/plain' }); } // ================== // PRESENTATION CONVERSION (PPTX, POTX, PPSX, PPT, etc.) // ================== async function convertPresentation(file, targetExt) { const ext = getExtension(file.name); // OOXML formats (pptx, potx, ppsx, ppsm, pptm, potm) if (['pptx','potx','ppsx','ppsm','pptm','potm'].includes(ext)) { await loadLib('jszip', 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js'); setProgress(20, 'Parsing presentation...'); const buf = await file.arrayBuffer(); const zip = await JSZip.loadAsync(buf); const slides = []; let slideNum = 1; while (true) { const slideFile = zip.file('ppt/slides/slide' + slideNum + '.xml'); if (!slideFile) break; const xml = await slideFile.async('text'); // Extract text from elements const texts = []; const regex = /([\s\S]*?)<\/a:t>/g; let m; while ((m = regex.exec(xml)) !== null) texts.push(m[1]); slides.push({ slide: slideNum, text: texts.join(' ') }); slideNum++; } if (slides.length === 0) throw new Error('No slides found in this presentation.'); return formatSlides(slides, targetExt, file.name); } // Legacy PPT/PPS/POT — extract what text we can setProgress(20, 'Parsing legacy presentation...'); const buf = await file.arrayBuffer(); const u8 = new Uint8Array(buf); // Try to extract readable ASCII text let text = ''; let run = ''; for (let i = 0; i < u8.length; i++) { const c = u8[i]; if (c >= 32 && c < 127) { run += String.fromCharCode(c); } else { if (run.length > 5) text += run + '\n'; run = ''; } } if (run.length > 5) text += run; if (!text.trim()) throw new Error('Could not extract text from this legacy PowerPoint file.'); const slides = [{ slide: 1, text: text.trim() }]; return formatSlides(slides, targetExt, file.name); } function formatSlides(slides, targetExt, fileName) { if (targetExt === 'txt') { const out = slides.map(s => 'Slide ' + s.slide + ':\n' + s.text).join('\n\n---\n\n'); return new Blob([out], { type: 'text/plain' }); } if (targetExt === 'json') { return new Blob([JSON.stringify({ file: fileName, slides }, null, 2)], { type: 'application/json' }); } if (targetExt === 'md') { const out = slides.map(s => '## Slide ' + s.slide + '\n\n' + s.text).join('\n\n---\n\n'); return new Blob([out], { type: 'text/markdown' }); } if (targetExt === 'html') { let h = 'Presentation'; slides.forEach(s => { h += '

Slide ' + s.slide + '

' + escapeHtml(s.text) + '

'; }); h += ''; return new Blob([h], { type: 'text/html' }); } return new Blob([slides.map(s => s.text).join('\n')], { type: 'text/plain' }); } // ================== // FONT CONVERSION (OTF, TTF) // ================== async function convertFont(file, targetExt) { await loadLib('opentype', 'https://cdn.jsdelivr.net/npm/opentype.js@1.3.4/dist/opentype.min.js'); setProgress(20, 'Parsing font...'); const buf = await file.arrayBuffer(); let font; const u8f = new Uint8Array(buf); const isTTC = u8f[0]===0x74 && u8f[1]===0x74 && u8f[2]===0x63 && u8f[3]===0x66; // 'ttcf' if (isTTC) { // TTC: use browser @font-face rendering (opentype.js can't handle TTC) const dv = new DataView(buf); const numFonts = dv.getUint32(8, false); const fontName = file.name.replace(/\.[^.]+$/, ''); if (targetExt === 'json') { return new Blob([JSON.stringify({ familyName: fontName, type: 'TrueType Collection', fontsInCollection: numFonts, fileSize: file.size }, null, 2)], { type: 'application/json' }); } if (targetExt === 'txt') { return new Blob(['Font: ' + fontName + '\nType: TrueType Collection\nFonts: ' + numFonts + '\nSize: ' + file.size + ' bytes'], { type: 'text/plain' }); } // For PNG/SVG: render sample text using @font-face const blobUrl = URL.createObjectURL(new Blob([buf])); const familyId = 'ttc_' + Date.now(); const style = document.createElement('style'); style.textContent = '@font-face { font-family: "' + familyId + '"; src: url("' + blobUrl + '"); }'; document.head.appendChild(style); // Wait for font to load await document.fonts.load('48px "' + familyId + '"').catch(() => {}); await new Promise(r => setTimeout(r, 200)); const c = document.createElement('canvas'); c.width = 800; c.height = 200; const cx = c.getContext('2d'); cx.fillStyle = '#FFF'; cx.fillRect(0, 0, 800, 200); cx.fillStyle = '#333'; cx.font = '48px "' + familyId + '"'; cx.fillText('AaBbCcDd 0123456789', 20, 80); cx.fillStyle = '#999'; cx.font = '24px "' + familyId + '"'; cx.fillText(fontName + ' (' + numFonts + ' fonts)', 20, 140); document.head.removeChild(style); URL.revokeObjectURL(blobUrl); if (targetExt === 'svg') { const du = c.toDataURL('image/png'); return new Blob(['\n'], { type: 'image/svg+xml' }); } return new Promise((res, rej) => { c.toBlob(b => b ? res(b) : rej(new Error('Render failed')), 'image/png'); }); } else { try { font = opentype.parse(buf); } catch (e) { throw new Error('Could not parse this font file: ' + e.message); } } if (!font || !font.supported) throw new Error('Could not parse this font file.'); if (targetExt === 'json') { const meta = { familyName: font.names.fontFamily?.en || '', subfamilyName: font.names.fontSubfamily?.en || '', version: font.names.version?.en || '', designer: font.names.designer?.en || '', license: font.names.license?.en || '', glyphCount: font.glyphs.length, unitsPerEm: font.unitsPerEm, ascender: font.ascender, descender: font.descender, }; return new Blob([JSON.stringify(meta, null, 2)], { type: 'application/json' }); } if (targetExt === 'txt') { let out = 'Font: ' + (font.names.fontFamily?.en || 'Unknown') + '\n'; out += 'Subfamily: ' + (font.names.fontSubfamily?.en || '') + '\n'; out += 'Version: ' + (font.names.version?.en || '') + '\n'; out += 'Designer: ' + (font.names.designer?.en || '') + '\n'; out += 'Glyphs: ' + font.glyphs.length + '\n'; out += 'Units/Em: ' + font.unitsPerEm + '\n'; return new Blob([out], { type: 'text/plain' }); } // PNG — render sample text if (targetExt === 'png') { const c = document.createElement('canvas'); c.width = 800; c.height = 200; const cx = c.getContext('2d'); cx.fillStyle = '#FFF'; cx.fillRect(0, 0, 800, 200); const path = font.getPath('AaBbCcDdEeFf 0123456789', 20, 100, 48); path.fill = '#333'; path.draw(cx); const path2 = font.getPath((font.names.fontFamily?.en || 'Font Preview'), 20, 160, 24); path2.fill = '#999'; path2.draw(cx); return new Promise((resolve, reject) => { c.toBlob(b => { if (!b) reject(new Error('Render failed')); else resolve(b); }, 'image/png'); }); } // SVG — render alphabet as SVG paths if (targetExt === 'svg') { const path = font.getPath('AaBbCcDdEeFfGgHhIiJjKkLlMm', 10, 60, 48); const svgPath = path.toSVG(); const svg = '\n\n' + svgPath + '\n'; return new Blob([svg], { type: 'image/svg+xml' }); } throw new Error('Unsupported target for font files.'); } // ================== // METADATA-ONLY (InDesign, Sketch) // ================== async function convertMetadataOnly(file, targetExt, category) { const meta = { fileName: file.name, fileSize: file.size, fileType: category === 'indesign' ? 'Adobe InDesign' : 'Sketch', lastModified: new Date(file.lastModified).toISOString(), note: 'Full conversion of ' + (category === 'indesign' ? 'InDesign' : 'Sketch') + ' files is not possible in the browser. Only basic file metadata is available.' }; if (targetExt === 'json') return new Blob([JSON.stringify(meta, null, 2)], { type: 'application/json' }); let txt = Object.entries(meta).map(([k, v]) => k + ': ' + v).join('\n'); return new Blob([txt], { type: 'text/plain' }); } // ================== // CANVAS → TARGET HELPER (shared by HEIC, PSD, etc.) // ================== async function canvasToTarget(canvas, ctx, w, h, targetExt) { if (targetExt === 'tiff') return buildTiff(ctx.getImageData(0, 0, w, h), w, h); if (targetExt === 'bmp') return buildBmp(ctx.getImageData(0, 0, w, h), w, h); if (targetExt === 'pdf') { return new Promise((resolve, reject) => { canvas.toBlob(async b => { if (!b) return reject(new Error('Export failed')); resolve(buildPdf(new Uint8Array(await b.arrayBuffer()), w, h)); }, 'image/jpeg', 0.92); }); } if (targetExt === 'svg') { const du = canvas.toDataURL('image/png'); return new Blob(['\n'], { type: 'image/svg+xml' }); } if (targetExt === 'ico') { const iw = Math.min(w,256), ih = Math.min(h,256); const ic = document.createElement('canvas'); ic.width=iw; ic.height=ih; ic.getContext('2d').drawImage(canvas,0,0,iw,ih); return new Promise((res,rej) => { ic.toBlob(b => { if(!b) return rej(new Error('ICO failed')); b.arrayBuffer().then(a => res(createIco(new Uint8Array(a),iw,ih))); }, 'image/png'); }); } let mime = 'image/png'; if (targetExt === 'jpg') { mime = 'image/jpeg'; ctx.globalCompositeOperation='destination-over'; ctx.fillStyle='#FFF'; ctx.fillRect(0,0,w,h); } else if (targetExt === 'webp') mime = 'image/webp'; else if (targetExt === 'gif') mime = 'image/gif'; return new Promise((resolve, reject) => { canvas.toBlob(b => { if (!b) reject(new Error('Export failed')); else resolve(b); }, mime, 0.92); }); } // ================== // TEXT / DATA CONVERSION // ================== async function convertTextData(file, sourceMime, targetExt) { const text = await file.text(); const sourceExt = FORMAT_MAP[sourceMime]?.ext || getExtension(file.name); let data = null; try { data = parseSource(text, sourceExt); } catch (e) { throw new Error('Failed to parse source file: ' + e.message); } let output; try { output = convertToTarget(data, sourceExt, targetExt, text); } catch (e) { throw new Error('Failed to convert to ' + targetExt.toUpperCase() + ': ' + e.message); } const mm = { json:'application/json',xml:'application/xml',yaml:'text/yaml',csv:'text/csv',tsv:'text/tab-separated-values',txt:'text/plain',md:'text/markdown',html:'text/html' }; return new Blob([output], { type: mm[targetExt] || 'text/plain' }); } function parseSource(text, ext) { switch (ext) { case 'json': case 'har': return { type: 'structured', value: JSON.parse(text) }; case 'csv': return { type: 'table', value: parseCSV(text, ',') }; case 'tsv': return { type: 'table', value: parseCSV(text, '\t') }; case 'xml': return { type: 'xml', value: text, parsed: parseXML(text) }; case 'yaml': case 'yml': return { type: 'structured', value: parseYAML(text) }; case 'md': return { type: 'text', value: text, format: 'md' }; case 'html': return { type: 'text', value: text, format: 'html' }; case 'txt': return { type: 'text', value: text, format: 'txt' }; default: return { type: 'text', value: text, format: 'txt' }; } } function convertToTarget(data, sourceExt, targetExt, rawText) { if (sourceExt === targetExt) return rawText; if (data.type === 'text') { if (targetExt==='txt') return stripHtmlToText(data.value,data.format); if (targetExt==='html') return textToHtml(data.value,data.format); if (targetExt==='md') return textToMarkdown(data.value,data.format); if (targetExt==='json') return JSON.stringify({content:data.value},null,2); if (targetExt==='csv') return '"content"\n"'+data.value.replace(/"/g,'""')+'"'; if (targetExt==='tsv') return 'content\n'+data.value.replace(/\t/g,' '); if (targetExt==='xml') return '\n\n \n'; if (targetExt==='yaml') return 'content: |\n'+data.value.split('\n').map(l=>' '+l).join('\n'); } if (data.type === 'structured') { if (targetExt==='json') return JSON.stringify(data.value,null,2); if (targetExt==='yaml') return jsonToYAML(data.value); if (targetExt==='xml') return jsonToXML(data.value); if (targetExt==='csv') return jsonToCSV(data.value,','); if (targetExt==='tsv') return jsonToCSV(data.value,'\t'); if (targetExt==='txt') return JSON.stringify(data.value,null,2); if (targetExt==='html') return '
'+escapeHtml(JSON.stringify(data.value,null,2))+'
'; if (targetExt==='md') return '```json\n'+JSON.stringify(data.value,null,2)+'\n```'; } if (data.type === 'table') { if (targetExt==='csv') return tableToCSV(data.value,','); if (targetExt==='tsv') return tableToCSV(data.value,'\t'); if (targetExt==='json') return JSON.stringify(tableToObjects(data.value),null,2); if (targetExt==='yaml') return jsonToYAML(tableToObjects(data.value)); if (targetExt==='xml') return jsonToXML(tableToObjects(data.value)); if (targetExt==='html') return tableToHTML(data.value); if (targetExt==='md') return tableToMarkdown(data.value); if (targetExt==='txt') return data.value.map(r=>r.join('\t')).join('\n'); } if (data.type === 'xml') { if (targetExt==='xml') return rawText; if (targetExt==='json') return JSON.stringify(xmlToJson(data.parsed),null,2); if (targetExt==='yaml') return jsonToYAML(xmlToJson(data.parsed)); if (targetExt==='csv') return jsonToCSV(xmlToJson(data.parsed),','); if (targetExt==='tsv') return jsonToCSV(xmlToJson(data.parsed),'\t'); if (targetExt==='txt') return rawText; if (targetExt==='html') return '
'+escapeHtml(rawText)+'
'; if (targetExt==='md') return '```xml\n'+rawText+'\n```'; } return rawText; } // ================== // PARSERS / CONVERTERS // ================== function parseCSV(text, d) { const rows=[]; let cur=[],f='',q=false; for(let i=0;i0){cur.push(f);rows.push(cur);} return rows; } function tableToCSV(t,d){return t.map(r=>r.map(c=>{const s=String(c);return(s.includes(d)||s.includes('"')||s.includes('\n'))?'"'+s.replace(/"/g,'""')+'"':s;}).join(d)).join('\n');} function tableToObjects(t){if(t.length<2)return t;const h=t[0];return t.slice(1).map(r=>{const o={};h.forEach((k,i)=>{o[k||'col'+i]=r[i]||'';});return o;});} function tableToHTML(t){let h='\nTable\n\n';t.forEach((r,i)=>{const g=i===0?'th':'td';h+=' '+r.map(c=>'<'+g+'>'+escapeHtml(c)+'').join('')+'\n';});h+='
\n';return h;} function tableToMarkdown(t){if(!t.length)return'';let m='| '+t[0].join(' | ')+' |\n| '+t[0].map(()=>'---').join(' | ')+' |\n';for(let i=1;ic.nodeType===1||(c.nodeType===3&&c.textContent.trim()));if(!ch.length)return n.textContent||'';if(ch.length===1&&ch[0].nodeType===3){if(!Object.keys(o).length)return ch[0].textContent.trim();o['#text']=ch[0].textContent.trim();return o;}for(const c of ch){if(c.nodeType===3)continue;const k=c.tagName,v=n2o(c);if(o[k]!==undefined){if(!Array.isArray(o[k]))o[k]=[o[k]];o[k].push(v);}else o[k]=v;}return o;}return n2o(doc.documentElement);} function parseYAML(t){const ls=t.split('\n'),r={};let ck=null,am=false,ai=[];for(const l of ls){const tr=l.trim();if(!tr||tr.startsWith('#'))continue;const am2=tr.match(/^-\s+(.*)/);if(am2&&ck){if(!am){am=true;ai=[];}ai.push(parseYAMLValue(am2[1]));r[ck]=ai;continue;}am=false;const kv=tr.match(/^([^:]+):\s*(.*)/);if(kv){ck=kv[1].trim();const v=kv[2].trim();if(v)r[ck]=parseYAMLValue(v);}}return r;} function parseYAMLValue(v){if(v==='true')return true;if(v==='false')return false;if(v==='null'||v==='~')return null;if(/^-?\d+$/.test(v))return parseInt(v);if(/^-?\d+\.\d+$/.test(v))return parseFloat(v);if((v.startsWith('"')&&v.endsWith('"'))||(v.startsWith("'")&&v.endsWith("'")))return v.slice(1,-1);return v;} function jsonToYAML(o,ind){ind=ind||0;const p=' '.repeat(ind);if(o===null||o===undefined)return p+'null';if(typeof o==='boolean')return p+(o?'true':'false');if(typeof o==='number')return p+o;if(typeof o==='string'){if(o.includes('\n')||o.includes(':')||o.includes('#'))return p+'"'+o.replace(/\\/g,'\\\\').replace(/"/g,'\\"')+'"';return p+o;}if(Array.isArray(o)){if(!o.length)return p+'[]';return o.map(i=>{if(typeof i==='object'&&i!==null)return p+'- '+jsonToYAML(i,ind+1).trimStart();return p+'- '+(typeof i==='string'&&(i.includes(':')||i.includes('#'))?'"'+i.replace(/"/g,'\\"')+'"':i);}).join('\n');}const e=Object.entries(o);if(!e.length)return p+'{}';return e.map(([k,v])=>{if(typeof v==='object'&&v!==null)return p+k+':\n'+jsonToYAML(v,ind+1);const s=v===null?'null':v;const vs=typeof s==='string'&&(s.includes(':')||s.includes('#'))?'"'+s.replace(/"/g,'\\"')+'"':s;return p+k+': '+vs;}).join('\n');} function jsonToXML(o,rn){rn=rn||'root';function tx(k,v,i){const p=' '.repeat(i);if(v===null||v===undefined)return p+'<'+k+'/>';if(typeof v!=='object')return p+'<'+k+'>'+escapeHtml(String(v))+'';if(Array.isArray(v))return v.map(x=>tx(k,x,i)).join('\n');let s='';for(const[a,b]of Object.entries(v))s+='\n'+tx(a,b,i+1);return p+'<'+k+'>'+s+'\n'+p+'';}let x='';if(Array.isArray(o)){x+='\n<'+rn+'>';o.forEach(i=>{x+='\n'+tx('item',i,1);});x+='\n';}else if(typeof o==='object'){x+='\n'+tx(rn,o,0);}else{x+='\n<'+rn+'>'+escapeHtml(String(o))+'';}return x;} function jsonToCSV(o,d){let a=Array.isArray(o)?o:[o];if(!a.length)return'';a=a.map(i=>(typeof i!=='object'||i===null)?{value:i}:flattenObject(i));const k=[...new Set(a.flatMap(Object.keys))];const rows=[k];a.forEach(i=>{rows.push(k.map(c=>i[c]!==undefined?String(i[c]):''));});return tableToCSV(rows,d);} function flattenObject(o,px){px=px||'';const r={};for(const[k,v]of Object.entries(o)){const fk=px?px+'.'+k:k;if(typeof v==='object'&&v!==null&&!Array.isArray(v))Object.assign(r,flattenObject(v,fk));else if(Array.isArray(v))r[fk]=JSON.stringify(v);else r[fk]=v;}return r;} function escapeHtml(s){return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} function stripHtmlToText(t,f){if(f==='html'){const d=document.createElement('div');d.innerHTML=t;return d.textContent||d.innerText||'';}return t;} function textToHtml(t,f){if(f==='html')return t;if(f==='md')return markdownToHtml(t);return'\nDocument\n
'+escapeHtml(t)+'
\n';} function textToMarkdown(t,f){if(f==='md')return t;if(f==='html')return t.replace(/]*>(.*?)<\/h1>/gi,'# $1\n').replace(/]*>(.*?)<\/h2>/gi,'## $1\n').replace(/]*>(.*?)<\/h3>/gi,'### $1\n').replace(/]*>(.*?)<\/p>/gi,'$1\n\n').replace(//gi,'\n').replace(/]*>(.*?)<\/strong>/gi,'**$1**').replace(/]*>(.*?)<\/b>/gi,'**$1**').replace(/]*>(.*?)<\/em>/gi,'*$1*').replace(/]*>(.*?)<\/i>/gi,'*$1*').replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi,'[$2]($1)').replace(/<[^>]+>/g,'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').trim();return t;} function markdownToHtml(m){let h=m.replace(/^### (.*$)/gm,'

$1

').replace(/^## (.*$)/gm,'

$1

').replace(/^# (.*$)/gm,'

$1

').replace(/\*\*(.*?)\*\*/g,'$1').replace(/\*(.*?)\*/g,'$1').replace(/\[([^\]]+)\]\(([^)]+)\)/g,'$1').replace(/^- (.*$)/gm,'
  • $1
  • ').replace(/\n\n/g,'

    ').replace(/\n/g,'
    ');return'\nDocument\n

    '+h+'

    \n';} // ================== // RESULTS // ================== // ================== // Desktop dropdown expandable groups document.querySelectorAll('.nav-dropdown-parent').forEach(btn => { btn.addEventListener('click', function(e) { e.stopPropagation(); this.classList.toggle('open'); const sub = this.nextElementSibling; if (sub) sub.classList.toggle('open'); }); }); // NAV // ================== const hamburgerMenu = document.getElementById('hamburgerMenu'); const mobileNavMenu = document.getElementById('mobileNavMenu'); hamburgerMenu.addEventListener('click', function() { const isOpening = !this.classList.contains('active'); this.classList.toggle('active'); mobileNavMenu.classList.toggle('active'); this.setAttribute('aria-expanded', isOpening); }); document.querySelectorAll('.mobile-parent').forEach(function(parent) { parent.addEventListener('click', function() { this.classList.toggle('open'); const sub = this.nextElementSibling; if (sub) sub.classList.toggle('open'); }); }); document.addEventListener('click', function(e) { if (!mobileNavMenu.contains(e.target) && !hamburgerMenu.contains(e.target)) { hamburgerMenu.classList.remove('active'); mobileNavMenu.classList.remove('active'); hamburgerMenu.setAttribute('aria-expanded', 'false'); } }); // ================== // VOTE / POLL // ================== const VOTE_API = 'https://us-central1-peachytechnologies-web.cloudfunctions.net/submitVote'; const VOTE_COUNTS_API = 'https://us-central1-peachytechnologies-web.cloudfunctions.net/getVoteCounts'; let userVote = null; function showVoteResults(counts) { const total = counts.yes + counts.no; const yesPct = total > 0 ? Math.round((counts.yes / total) * 100) : 0; const noPct = total > 0 ? 100 - yesPct : 0; document.getElementById('voteYesPct').textContent = yesPct + '%'; document.getElementById('voteNoPct').textContent = noPct + '%'; document.getElementById('voteYesBar').style.width = yesPct + '%'; document.getElementById('voteNoBar').style.width = noPct + '%'; document.getElementById('voteTotalText').textContent = total >= 1000 ? total.toLocaleString() + ' votes cast' : ''; document.getElementById('voteStateNew').style.display = 'none'; document.getElementById('voteStateResults').style.display = 'block'; } document.querySelectorAll('.vote-btn').forEach(btn => { btn.addEventListener('click', function() { userVote = this.dataset.vote; document.getElementById('voteButtons').style.display = 'none'; document.getElementById('voteForm').style.display = 'block'; document.getElementById('voteConfirmed').textContent = userVote === 'yes' ? '\uD83D\uDC4D Great to hear!' : '\uD83D\uDC4E Thanks for being honest!'; }); }); document.getElementById('voteSubmit').addEventListener('click', async function() { const suggestion = document.getElementById('voteSuggestion').value.trim(); this.disabled = true; this.textContent = 'Submitting...'; try { const resp = await fetch(VOTE_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vote: userVote, suggestion: suggestion || null, page: 'dietary-planner', timestamp: new Date().toISOString() }) }); const data = await resp.json(); if (data.counts) showVoteResults(data.counts); } catch(e) { document.getElementById('voteStateNew').style.display = 'none'; document.getElementById('voteStateResults').style.display = 'block'; } localStorage.setItem('peachy_vote_dietary_planner', 'submitted'); }); if (localStorage.getItem('peachy_vote_dietary_planner') === 'submitted') { fetch(VOTE_COUNTS_API).then(r => r.json()).then(c => showVoteResults(c)) .catch(() => { document.getElementById('voteStateNew').style.display = 'none'; document.getElementById('voteStateResults').style.display = 'block'; }); } })();