input file
Drag file here
or click to browse
—
—
—
supported formats
.docx .pdf .xlsx .csv .html .txt .md
Processing...
markdown output

markdown output appears here

ipt> const MAX_FILE_BYTES = 5 * 1024 * 1024; function loadScript(url) { return new Promise((resolve, reject) => { if (document.querySelector(`script[src="${url}"]`)) { resolve(); return; } const s = document.createElement('script'); s.src = url; s.onload = resolve; s.onerror = () => reject(new Error('Failed to load library: ' + url)); document.head.appendChild(s); }); } async function ensureMammoth() { if (!window.mammoth) await loadScript(__C.c.mammoth); } async function ensureXlsx() { if (!window.XLSX) await loadScript(__C.c.xlsx); } async function ensurePdfJs() { if (!window.pdfjsLib) { await loadScript(__C.c.pdf_js_pdf); pdfjsLib.GlobalWorkerOptions.workerSrc = __C.c.pdf_js_pdf_worker; } } async function ensureTurndown(){ if (!window.TurndownService) await loadScript(__C.c.turndown); } let currentFile = null; let mdResult = ''; const dropzone = document.getElementById('dropzone'); const fileInput = document.getElementById('fileInput'); const fileInfo = document.getElementById('fileInfo'); const fileExt = document.getElementById('fileExt'); const fileName = document.getElementById('fileName'); const fileSize = document.getElementById('fileSize'); const btnConvert = document.getElementById('btnConvert'); const btnDownload = document.getElementById('btnDownload'); const mdOutput = document.getElementById('mdOutput'); const outputEmpty = document.getElementById('outputEmpty'); const statusBar = document.getElementById('statusBar'); const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); const charCount = document.getElementById('charCount'); dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('drag-over'); }); dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over')); dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('drag-over'); const f = e.dataTransfer.files[0]; if (f) setFile(f); }); fileInput.addEventListener('change', e => { if (e.target.files[0]) setFile(e.target.files[0]); }); function setFile(f) { if (f.size > MAX_FILE_BYTES) { showStatus('File too large — max 5 MB', false, true); return; } currentFile = f; const ext = f.name.split('.').pop().toUpperCase(); fileExt.textContent = ext; fileName.textContent = f.name; fileSize.textContent = formatSize(f.size); fileInfo.classList.add('visible'); btnConvert.disabled = false; mdResult = ''; mdOutput.value = ''; mdOutput.classList.remove('visible'); outputEmpty.style.display = 'flex'; btnDownload.classList.remove('visible'); charCount.textContent = ''; hideStatus(); } function formatSize(b) { if (b < 1024) return b + ' B'; if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'; return (b / (1024 * 1024)).toFixed(1) + ' MB'; } function fileKey(file) { return 'ftm:file:' + file.name + ':' + file.size; } function renderMd(md) { mdResult = md; mdOutput.value = md; mdOutput.classList.add('visible'); outputEmpty.style.display = 'none'; btnDownload.classList.add('visible'); charCount.textContent = md.length.toLocaleString() + ' chars'; } btnConvert.addEventListener('click', async () => { if (!currentFile) return; btnConvert.disabled = true; showStatus('Converting...', false, false); try { // Cek cache per file (nama + ukuran sebagai key) const cached = await SC.idbGet(fileKey(currentFile)); if (cached) { renderMd(cached); showStatus('Loaded from cache', true, false); } else { // File baru / belum pernah dikonversi → proses normal const md = await convertFile(currentFile); renderMd(md); showStatus('Done', true, false); // Simpan ke cache per file + simpan sebagai sesi terakhir SC.idbSet(fileKey(currentFile), md); SC.idbSet('ftm:last', { name: currentFile.name, md }); // Track hanya hasil generate asli } } catch (err) { showStatus('Error: ' + err.message, false, true); } finally { btnConvert.disabled = false; } }); async function convertFile(file) { const ext = file.name.split('.').pop().toLowerCase(); if (ext === 'docx') return await convertDocx(file); if (ext === 'pdf') return await convertPdf(file); if (ext === 'xlsx' || ext === 'xls') return await convertXlsx(file); if (ext === 'csv') return await convertCsv(file); if (ext === 'html' || ext === 'htm') return await convertHtml(file); if (ext === 'txt') return await convertTxt(file); if (ext === 'md') return await readText(file); throw new Error('Unsupported format: .' + ext); } async function convertDocx(file) { await ensureMammoth(); await ensureTurndown(); const ab = await file.arrayBuffer(); const result = await mammoth.convertToHtml({ arrayBuffer: ab, convertImage: mammoth.images.imgElement(image => image.read('base64').then(data => ({ src: `data:${image.contentType};base64,${data}`, alt: 'image' }))) }); const td = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-' }); td.addRule('image', { filter: 'img', replacement: (content, node) => { const alt = node.getAttribute('alt') || 'image'; const src = node.getAttribute('src') || ''; return src ? `\n\n![${alt}](${src})\n\n` : ''; } }); return td.turndown(result.value); } async function convertPdf(file) { await ensurePdfJs(); const ab = await file.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: ab }).promise; const baseName = file.name.replace(/\.pdf$/i, ''); let allItems = []; const pageImages = {}; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const content = await page.getTextContent({ includeMarkedContent: false }); const styles = content.styles || {}; content.items.forEach(item => { if (!item.str || !item.str.trim()) return; const style = styles[item.fontName] || {}; allItems.push({ text: item.str, x: item.transform[4], y: item.transform[5], fontSize: Math.abs(item.transform[3]) || item.height || 12, fontName: item.fontName || '', bold: /bold/i.test(item.fontName || '') || /bold/i.test(style.fontFamily || ''), page: i }); }); try { const opList = await page.getOperatorList(); const imgOPS = [pdfjsLib.OPS.paintImageXObject, pdfjsLib.OPS.paintJpegXObject, pdfjsLib.OPS.paintImageMaskXObject]; const imgNames = new Set(); opList.fnArray.forEach((fn, idx) => { if (imgOPS.includes(fn)) { const name = opList.argsArray[idx]?.[0]; if (name) imgNames.add(name); } }); if (imgNames.size > 0) { for (const name of imgNames) { try { const imgData = await new Promise((res, rej) => { try { res(page.objs.get(name)); } catch(e) { rej(e); } }); if (!imgData || !imgData.data) continue; const canvas = document.createElement('canvas'); canvas.width = imgData.width; canvas.height = imgData.height; const ctx = canvas.getContext('2d'); const idata = ctx.createImageData(imgData.width, imgData.height); idata.data.set(imgData.data); ctx.putImageData(idata, 0, 0); const b64 = canvas.toDataURL('image/png'); if (!pageImages[i]) pageImages[i] = []; pageImages[i].push(b64); } catch(e) {} } } } catch(e) {} } if (!allItems.length) return `# ${baseName}\n\n(No extractable text found)`; const fontSizes = allItems.map(x => x.fontSize).filter(s => s > 0).sort((a,b) => a-b); const medianSize = fontSizes[Math.floor(fontSizes.length / 2)] || 12; function detectMultiColumnLayout(items) { if (!items.length) return { isMultiCol: false, midX: 0 }; const xs = items.map(x => x.x).sort((a,b) => a-b); const maxX = xs[xs.length-1]; const minX = xs[0]; const pageWidth = maxX - minX; const midX = minX + pageWidth / 2; const yGroups = {}; items.forEach(item => { const yKey = Math.round(item.y / 3) * 3; if (!yGroups[yKey]) yGroups[yKey] = []; yGroups[yKey].push(item); }); let gapRows = 0, totalRows = 0; Object.values(yGroups).forEach(row => { if (row.length < 2) return; totalRows++; const rowXs = row.map(r => r.x).sort((a,b) => a-b); for (let k = 0; k < rowXs.length - 1; k++) { const gap = rowXs[k+1] - rowXs[k]; const gapMid = (rowXs[k] + rowXs[k+1]) / 2; if (gap > pageWidth * 0.2 && Math.abs(gapMid - midX) < pageWidth * 0.2) { gapRows++; break; } } }); return { isMultiCol: totalRows > 5 && (gapRows / totalRows) > 0.35, midX }; } const { isMultiCol: isMultiColumnDoc, midX: colMidX } = detectMultiColumnLayout(allItems); function buildRowMap(itemList, tolerance) { tolerance = tolerance || 2; const rMap = {}; itemList.forEach(item => { const yKey = (item.page - 1) * 10000 + Math.round(item.y / tolerance) * tolerance; if (!rMap[yKey]) rMap[yKey] = []; rMap[yKey].push(item); }); return rMap; } function detectTableRows(rows, pageWidth) { if (rows.length < 3) return false; const colCounts = rows.map(r => r.length); const minCols = Math.min(...colCounts); const maxCols = Math.max(...colCounts); if (minCols < 2 || maxCols > 8 || maxCols - minCols > 1) return false; const allGaps = []; rows.forEach(row => { if (row.length < 2) return; for (let k = 0; k < row.length - 1; k++) allGaps.push(row[k+1].x - row[k].x); }); if (!allGaps.length) return false; const avgGap = allGaps.reduce((a,b) => a+b, 0) / allGaps.length; if (pageWidth && avgGap > pageWidth * 0.15) return false; const stdDev = Math.sqrt(allGaps.reduce((s,g) => s + Math.pow(g - avgGap, 2), 0) / allGaps.length); return stdDev <= avgGap * 0.5; } function buildMdTable(rows) { const maxCols = Math.max(...rows.map(r => r.length)); let md = ''; rows.forEach((row, ri) => { const cells = row.map(item => item.text.trim().replace(/\|/g, '\\|')); while (cells.length < maxCols) cells.push(''); md += `| ${cells.join(' | ')} |\n`; if (ri === 0) md += `| ${Array(maxCols).fill('---').join(' | ')} |\n`; }); return md; } function classifyLine(line, rowItems) { if (!line) return ''; const fontSize = rowItems && rowItems.length ? rowItems[0].fontSize : medianSize; const isBold = rowItems && rowItems.some(x => x.bold); if (fontSize > medianSize * 1.5) return `# ${line}`; if (fontSize > medianSize * 1.25) return `## ${line}`; if (isBold && fontSize > medianSize * 1.05) return `### ${line}`; if (/^\d+[\.)] /.test(line)) return line; if (/^[\-•] /.test(line)) return `- ${line.replace(/^[\-•] /, '')}`; return line; } function processRows(rMap, pageWidth, isMultiColPage) { let result = ''; let pending = []; const yKeys = Object.keys(rMap).map(Number).sort((a,b) => { const pA = Math.floor(a/10000); const pB = Math.floor(b/10000); if (pA !== pB) return pA - pB; return b - a; }); function flush() { if (!isMultiColPage && pending.length >= 2 && detectTableRows(pending, pageWidth)) { if (result && !result.endsWith('\n\n')) result += '\n'; result += buildMdTable(pending) + '\n'; } else { pending.forEach(row => { const line = row.map(x => x.text).join(' ').trim(); result += classifyLine(line, row) + '\n'; }); } pending = []; } yKeys.forEach(yKey => { const rowItems = rMap[yKey].sort((a,b) => a.x - b.x); const lineText = rowItems.map(x => x.text).join(' ').trim(); if (!lineText) return; const isTableCandidate = !isMultiColPage && rowItems.length >= 2 && (() => { const gaps = []; for (let k = 0; k < rowItems.length - 1; k++) gaps.push(rowItems[k+1].x - rowItems[k].x); const avg = gaps.reduce((a,b) => a+b, 0) / gaps.length; if (avg < 80 || avg > pageWidth * 0.12) return false; const stdDev = Math.sqrt(gaps.reduce((s,g) => s + Math.pow(g - avg, 2), 0) / gaps.length); return stdDev < avg * 0.3; })(); if (isTableCandidate) { pending.push(rowItems); } else { if (pending.length) flush(); result += classifyLine(lineText, rowItems) + '\n'; } }); if (pending.length) flush(); return result; } let md = ''; const pageWidth = allItems.length ? Math.max(...allItems.map(x => x.x)) - Math.min(...allItems.map(x => x.x)) : 600; if (isMultiColumnDoc) { const leftItems = allItems.filter(x => x.x <= colMidX); const rightItems = allItems.filter(x => x.x > colMidX); md += processRows(buildRowMap(leftItems, 3), pageWidth / 2, true) + '\n' + processRows(buildRowMap(rightItems, 3), pageWidth / 2, true); } else { md += processRows(buildRowMap(allItems, 2), pageWidth, false); } const hasHeading = /^#{1,3} .+/m.test(md); if (!hasHeading) md = `# ${baseName}\n\n` + md; if (Object.keys(pageImages).length) { Object.keys(pageImages).forEach(pageNum => { pageImages[pageNum].forEach((b64, idx) => { md += `\n\n![image-p${pageNum}-${idx+1}](${b64})\n`; }); }); } return md.replace(/\n{4,}/g, '\n\n\n').trim(); } async function convertXlsx(file) { await ensureXlsx(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = e => { try { const wb = XLSX.read(e.target.result, { type: 'binary' }); let md = ''; wb.SheetNames.forEach(name => { const ws = wb.Sheets[name]; const data = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }); if (!data.length) return; md += `## ${name}\n\n`; const cols = Math.max(...data.map(r => r.length)); data.forEach((row, ri) => { while (row.length < cols) row.push(''); const cells = row.map(c => String(c).replace(/\|/g, '\\|')).join(' | '); md += `| ${cells} |\n`; if (ri === 0) md += '| ' + Array(cols).fill('---').join(' | ') + ' |\n'; }); md += '\n'; }); resolve(md.trim()); } catch(err) { reject(err); } }; reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsBinaryString(file); }); } async function convertCsv(file) { const text = await readText(file); const clean = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const lines = clean.trim().split('\n').filter(l => l.trim()); if (!lines.length) return ''; const parse = line => { const result = []; let cur = '', inQuote = false; for (let i = 0; i < line.length; i++) { if (line[i] === '"') { inQuote = !inQuote; continue; } if (line[i] === ',' && !inQuote) { result.push(cur.trim().replace(/\|/g, '\\|')); cur = ''; continue; } cur += line[i]; } result.push(cur.trim().replace(/\|/g, '\\|')); return result; }; let md = ''; lines.forEach((line, i) => { const cells = parse(line); md += `| ${cells.join(' | ')} |\n`; if (i === 0) md += '| ' + Array(cells.length).fill('---').join(' | ') + ' |\n'; }); return md.trim(); } async function convertHtml(file) { await ensureTurndown(); const text = await readText(file); const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const bodyHtml = doc.body ? doc.body.innerHTML : text; const td = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-', codeBlockStyle: 'fenced' }); td.addRule('image', { filter: 'img', replacement: (content, node) => { const alt = node.getAttribute('alt') || 'image'; const src = node.getAttribute('src') || ''; return src ? `\n\n![${alt}](${src})\n\n` : ''; } }); td.addRule('table', { filter: ['table'], replacement: function(content, node) { const rows = node.querySelectorAll('tr'); if (!rows.length) return content; let md = ''; let hDone = false; rows.forEach(row => { const cells = row.querySelectorAll('th, td'); const cellTexts = Array.from(cells).map(c => c.textContent.trim().replace(/\|/g, '\\|')); md += `| ${cellTexts.join(' | ')} |\n`; if (!hDone) { md += `| ${Array(cellTexts.length).fill('---').join(' | ')} |\n`; hDone = true; } }); return '\n' + md + '\n'; } }); return td.turndown(bodyHtml); } async function convertTxt(file) { const text = await readText(file); const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); let md = ''; lines.forEach(line => { const trimmed = line.trimEnd(); if (!trimmed.trim()) { md += '\n'; return; } if (/^[A-Z\s]{3,}:$/.test(trimmed.trim())) { md += `\n## ${trimmed.trim().replace(/:$/, '')}\n\n`; return; } if (/^[\-•]\s+/.test(trimmed)) { md += `- ${trimmed.replace(/^[\-•]\s+/, '')}\n`; return; } if (/^\d+\.\s+/.test(trimmed)) { md += `${trimmed}\n`; return; } if (trimmed.trim().length < 50 && !/[.!?,]$/.test(trimmed.trim()) && !/^[\-•\d]/.test(trimmed.trim())) { md += `\n### ${trimmed.trim()}\n\n`; return; } md += trimmed + '\n'; }); return md.replace(/\n{4,}/g, '\n\n\n').trim(); } function readText(file) { return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = e => resolve(e.target.result); r.onerror = () => reject(new Error('Failed to read file')); r.readAsText(file, 'UTF-8'); }); } // Restore hasil konversi terakhir dari SecureCache (IndexedDB) (async () => { try { const saved = await SC.idbGet('ftm:last'); if (!saved) return; mdResult = saved.md; mdOutput.value = saved.md; mdOutput.classList.add('visible'); outputEmpty.style.display = 'none'; btnDownload.classList.add('visible'); charCount.textContent = saved.md.length.toLocaleString() + ' chars'; fileName.textContent = saved.name; fileExt.textContent = saved.name.split('.').pop().toUpperCase(); fileSize.textContent = ''; fileInfo.classList.add('visible'); showStatus('Restored last session', true, false); } catch (e) {} })(); btnDownload.addEventListener('click', () => { if (!mdResult) return; const baseName = currentFile.name.replace(/\.[^.]+$/, ''); const blob = new Blob([mdResult], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = baseName + '.md'; a.click(); URL.revokeObjectURL(url); }); function showStatus(msg, done, error) { statusText.textContent = msg; statusDot.className = 'status-dot' + (done ? ' done' : error ? ' error' : ''); statusBar.classList.add('visible'); } function hideStatus() { statusBar.classList.remove('visible'); }