1// GROBID Tools - browser-side PDF metadata and reference extractor. 2// Everything below runs locally: the PDF is read with PDF.js and never uploaded. 3// Note: this is a lightweight heuristic extractor, not the GROBID machine-learning library. 4 5import * as pdfjsLib from 'https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.min.mjs'; 6 7pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.worker.min.mjs'; 8 9const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB 10const CURRENT_YEAR = new Date().getFullYear(); 11 12// DOM Elements 13const dropZone = document.getElementById('dropZone'); 14const dropZoneContent = document.getElementById('dropZoneContent'); 15const fileInput = document.getElementById('fileInput'); 16const uploadSection = document.getElementById('upload-section'); 17const resultsSection = document.getElementById('results-section'); 18const metadataContainer = document.getElementById('metadata-container'); 19const citationsContainer = document.getElementById('citations-container'); 20const resetBtn = document.getElementById('resetBtn'); 21 22const DROP_ZONE_DEFAULT = dropZoneContent ? dropZoneContent.innerHTML : ''; 23 24let currentPdfData = null; 25 26// ---------- helpers ---------- 27 28function escapeHtml(value) { 29 return String(value ?? '') 30 .replace(/&/g, '&') 31 .replace(/</g, '<') 32 .replace(/>/g, '>') 33 .replace(/"/g, '"') 34 .replace(/'/g, '''); 35} 36 37function baseFilename() { 38 return (currentPdfData?.filename || 'document').replace(/\.pdf$/i, ''); 39} 40 41function downloadBlob(content, mime, filename) { 42 const blob = content instanceof Blob ? content : new Blob([content], { type: mime }); 43 const url = URL.createObjectURL(blob); 44 const a = document.createElement('a'); 45 a.href = url; 46 a.download = filename; 47 document.body.appendChild(a); 48 a.click(); 49 a.remove(); 50 setTimeout(() => URL.revokeObjectURL(url), 1000); 51} 52 53function cleanDoi(doi) { 54 if (!doi) return null; 55 // Strip trailing punctuation that is almost never part of a DOI in running text. 56 return doi.replace(/[.,;:)\]}>'"]+$/, ''); 57} 58 59function isPlausibleYear(y) { 60 const n = parseInt(y, 10); 61 return n >= 1900 && n <= CURRENT_YEAR + 1; 62} 63 64// Split "Jane Q. Smith" into { family: 'Smith', given: 'Jane Q.' } 65function splitName(name) { 66 const parts = name.trim().split(/\s+/); 67 if (parts.length === 1) return { family: parts[0], given: '' }; 68 return { family: parts[parts.length - 1], given: parts.slice(0, -1).join(' ') }; 69} 70 71function isPdfFile(file) { 72 return file && (file.type === 'application/pdf' || /\.pdf$/i.test(file.name)); 73} 74 75// ---------- init ---------- 76 77function init() { 78 dropZone.addEventListener('click', () => fileInput.click()); 79 dropZone.addEventListener('keydown', (e) => { 80 if (e.key === 'Enter' || e.key === ' ') { 81 e.preventDefault(); 82 fileInput.click(); 83 } 84 }); 85 86 fileInput.addEventListener('change', (e) => { 87 const file = e.target.files[0]; 88 if (file) handleFile(file); 89 }); 90 91 dropZone.addEventListener('dragover', (e) => { 92 e.preventDefault(); 93 dropZone.classList.add('dragover'); 94 }); 95 dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); 96 dropZone.addEventListener('drop', (e) => { 97 e.preventDefault(); 98 dropZone.classList.remove('dragover'); 99 const file = e.dataTransfer.files[0]; 100 if (file) handleFile(file); 101 }); 102 103 const handlers = { 104 downloadJson: downloadAsJSON, 105 downloadText: downloadAsText, 106 downloadDocx: downloadAsDocx, 107 downloadMarkdown: downloadAsMarkdown, 108 downloadBibtex: downloadAsBibtex, 109 downloadRis: downloadAsRis, 110 downloadCslJson: downloadAsCslJson 111 }; 112 for (const [id, fn] of Object.entries(handlers)) { 113 const el = document.getElementById(id); 114 if (el) el.addEventListener('click', fn); 115 } 116 117 resetBtn.addEventListener('click', reset); 118} 119 120function handleFile(file) { 121 if (!isPdfFile(file)) { 122 alert('Please choose a PDF file.'); 123 return; 124 } 125 if (file.size > MAX_FILE_SIZE) { 126 alert('This PDF is larger than 50 MB. Please try a smaller file.'); 127 return; 128 } 129 processPDF(file); 130} 131 132// ---------- processing ---------- 133 134async function processPDF(file) { 135 try { 136 dropZoneContent.innerHTML = '<div class="loading" style="border-color:rgba(37,99,235,.3);border-top-color:#2563eb"></div><p style="margin-top: 1rem;">Processing PDF locally...</p>'; 137 138 const arrayBuffer = await file.arrayBuffer(); 139 const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; 140 141 const textData = await extractAllText(pdf); 142 const metadata = await extractMetadata(pdf, textData); 143 const citations = extractCitations(textData); 144 145 currentPdfData = { 146 filename: file.name, 147 metadata, 148 citations, 149 fullText: textData.fullText 150 }; 151 152 displayResults(metadata, citations, textData.fullText.trim().length); 153 } catch (error) { 154 console.error('Error processing PDF:', error); 155 alert('Error processing PDF. The file may be damaged, encrypted, or not a valid PDF.'); 156 reset(); 157 } 158} 159 160// Rebuild text lines from PDF.js text items using their positions. 161async function extractAllText(pdf) { 162 const textByPage = []; 163 let firstPageItems = []; 164 165 for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { 166 const page = await pdf.getPage(pageNum); 167 const textContent = await page.getTextContent(); 168 169 const lines = []; 170 let line = ''; 171 let lastY = null; 172 let lastEndX = null; 173 174 for (const item of textContent.items) { 175 if (typeof item.str !== 'string') continue; 176 const [a, b, , , x, y] = item.transform; 177 const fontSize = Math.hypot(a, b) || item.height || 10; 178 179 if (lastY !== null && Math.abs(y - lastY) > fontSize * 0.5) { 180 if (line.trim()) lines.push(line); 181 line = ''; 182 lastEndX = null; 183 } 184 185 if (item.str) { 186 const gap = lastEndX === null ? 0 : x - lastEndX; 187 const needsSpace = line && !/\s$/.test(line) && !/^\s/.test(item.str) && gap > fontSize * 0.15; 188 line += (needsSpace ? ' ' : '') + item.str; 189 lastEndX = x + (item.width || 0); 190 lastY = y; 191 } 192 193 if (item.hasEOL) { 194 if (line.trim()) lines.push(line); 195 line = ''; 196 lastEndX = null; 197 } 198 } 199 if (line.trim()) lines.push(line); 200 201 if (pageNum === 1) { 202 firstPageItems = textContent.items 203 .filter(i => typeof i.str === 'string' && i.str.trim()) 204 .map(i => ({ 205 str: i.str, 206 size: Math.hypot(i.transform[0], i.transform[1]) || i.height || 0, 207 // Rotated text (e.g. the arXiv stamp in the margin) and identifier lines are never the title. 208 skip: Math.abs(i.transform[1]) > 0.01 || /^\s*arXiv:/i.test(i.str) 209 })); 210 } 211 212 textByPage.push(lines.map(l =>
212 l.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n')); 213 } 214 215 return { fullText: textByPage.join('\n\n'), textByPage, firstPageItems }; 216} 217 218async function extractMetadata(pdf, textData) { 219 const metadata = {}; 220 const { fullText, textByPage, firstPageItems } = textData; 221 222 let info = {}; 223 try { 224 info = (await pdf.getMetadata()).info || {}; 225 } catch (e) { 226 info = {}; 227 } 228 metadata.pdfInfo = info; 229 230 const firstPages = textByPage.slice(0, 2).join('\n'); 231 232 // DOI 233 const doiMatch = firstPages.match(/\b10\.\d{4,9}\/[-._;()\/:A-Z0-9]+/i); 234 metadata.doi = doiMatch ? cleanDoi(doiMatch[0]) : null; 235 236 // arXiv ID: new style (YYMM.NNNN[N]) or old style (archive/YYMMNNN), with optional version 237 const arxivNew = firstPages.match(/arXiv[:\s]*(\d{4}\.\d{4,5})(v\d+)?/i) || firstPages.match(/arxiv\.org\/(?:abs|pdf)\/(\d{4}\.\d{4,5})(v\d+)?/i); 238 const arxivOld = firstPages.match(/arXiv[:\s]*([a-z\-]+(?:\.[A-Z]{2})?\/\d{7})(v\d+)?/i); 239 const arxiv = arxivNew || arxivOld; 240 metadata.arxiv = arxiv ? arxiv[1] + (arxiv[2] || '') : null; 241 242 // PubMed ID 243 const pmidMatch = firstPages.match(/PMID[:\s]*(\d{1,9})/i); 244 metadata.pmid = pmidMatch ? pmidMatch[1] : null; 245 246 // ISBN 247 const isbnMatch = firstPages.match(/ISBN(?:-1[03])?[:\s]*((?:97[89][-\s]?)?\d{1,5}[-\s]?\d{1,7}[-\s]?\d{1,7}[-\s]?[\dX])/i); 248 metadata.isbn = isbnMatch ? isbnMatch[1].trim() : null; 249 250 // Title: trust the PDF Info title only when it looks like a real title 251 const infoTitle = (info.Title || '').trim(); 252 const infoTitleUsable = infoTitle.length > 8 && 253 !/^(untitled|microsoft word|document\d*|title)\b/i.test(infoTitle) && 254 !/\.(docx?|pdf|tex|dvi|indd)$/i.test(infoTitle); 255 metadata.title = infoTitleUsable ? infoTitle : (titleFromFontSize(firstPageItems) || extractTitle(textByPage[0] || '')); 256 257 metadata.authors = extractAuthors(firstPages, metadata.title); 258 if (metadata.authors.length === 0 && info.Author) { 259 metadata.authors = info.Author.split(/\s*(?:;|,| and )\s*/).filter(n => n.length > 2).slice(0, 10); 260 } 261 262 metadata.abstract = extractAbstract(fullText); 263 metadata.year = extractYear(firstPages, metadata.arxiv); 264 metadata.keywords = extractKeywords(fullText); 265 metadata.pageCount = textByPage.length; 266 267 return metadata; 268} 269 270// The title is usually the run of text set in the largest font on page 1. 271function titleFromFontSize(items) { 272 if (!items || items.length === 0) return null; 273 items = items.filter(i => !i.skip); 274 const sizes = items.filter(i => i.str.trim().length > 1).map(i => i.size); 275 if (sizes.length === 0) return null; 276 const max = Math.max(...sizes); 277 let started = false; 278 const parts = []; 279 for (const item of items) { 280 const isMax = Math.abs(item.size - max) < 0.6; 281 if (isMax) { 282 started = true; 283 parts.push(item.str); 284 } else if (started && item.str.trim()) { 285 break; 286 } 287 } 288 const title = parts.join(' ').replace(/\s+/g, ' ').trim(); 289 return title.length >= 8 && title.length <= 300 ? title : null; 290} 291 292function extractTitle(firstPageText) { 293 const lines = firstPageText.split('\n').map(l => l.trim()).filter(Boolean); 294 const abstractIdx = lines.findIndex(l => /^abstract\b/i.test(l)); 295 const candidates = (abstractIdx > 0 ? lines.slice(0, abstractIdx) : lines.slice(0, 15)) 296 .filter(l => l.length > 15 && !/@|https?:|doi|arxiv|journal|vol\.|volume|©|copyright|university|department/i.test(l)); 297 return candidates.length ? candidates.reduce((a, b) => (a.length >= b.length ? a : b)) : 'Unknown Title'; 298} 299 300const NOT_A_NAME = /\b(University|Universit|Department|Institute|Laboratory|School|College|Center|Centre|Faculty|Abstract|Introduction|Journal|Proceedings|Conference|Received|Accepted|Published|Copyright|Research|Science|Sciences|Engineering|Technology|National|International|Society|Press|Volume|Article|Keywords|Correspondence|Email|Open Access|Creative
300Commons|Google|Microsoft|Facebook|Meta AI|Inc|Labs?|Brain|Corporation)\b/; 301 302function extractAuthors(text, title) { 303 const abstractMatch = text.match(/\babstract\b/i); 304 if (!abstractMatch) return []; 305 let block = text.substring(0, abstractMatch.index); 306 const normTitle = (title || '').replace(/\s+/g, ' ').toLowerCase(); 307 308 const authors = []; 309 for (const line of block.split('\n')) { 310 const l = line.replace(/\s+/g, ' ').trim().toLowerCase(); 311 if (l.length > 3 && normTitle.includes(l)) continue; // part of the title 312 if (/^arxiv:/i.test(l)) continue; 313 if (NOT_A_NAME.test(line) || /@|https?:|\d{3,}/.test(line)) continue; 314 const names = line.match(/\b[A-Z][a-zà -ÿ'â\-]+(?:\s+[A-Z]\.)*(?:\s+[A-Z][a-zà -ÿ'â\-]+){1,2}\b/g); 315 if (names) authors.push(...names); 316 } 317 return [...new Set(authors.map(n => n.trim()))].slice(0, 10); 318} 319 320function extractAbstract(text) { 321 const m = text.match(/\babstract\b[\s.:ââ-]*([\s\S]{50,4000}?)(?=\n\s*(?:keywords|key words|index terms|ccs concepts|(?:1\.?|I\.?)?\s*introduction)\b)/i); 322 let abstract = m ? m[1] : null; 323 if (!abstract) { 324 const start = text.search(/\babstract\b/i); 325 if (start === -1) return null; 326 abstract = text.substring(start + 8, start + 1500); 327 } 328 abstract = abstract.replace(/-\n(?=[a-z])/g, '').replace(/\s+/g, ' ').trim(); 329 return abstract.length > 2000 ? abstract.substring(0, 2000) + 'â¦' : abstract; 330} 331 332function extractKeywords(text) { 333 const m = text.match(/\b(?:keywords|key words|index terms)\b\s*[:ââ.-]?\s*([^\n]+(?:\n(?!\s*(?:\d\.?\s*)?introduction)[^\n]+)?)/i); 334 if (!m) return []; 335 return m[1] 336 .split(/[;,·â¢]|\s{2,}/) 337 .map(kw => kw.replace(/\.$/, '').trim()) 338 .filter(kw => kw.length > 2 && kw.length < 60) 339 .slice(0, 12); 340} 341 342function extractYear(firstPages, arxiv) { 343 // Prefer a year next to a publication cue, then the year encoded in a new-style arXiv ID. 344 const cue = firstPages.match(/(?:©|\(c\)|copyright|published(?: online)?|accepted|received|proceedings of[^\n]{0,60}?)\D{0,40}?\b((?:19|20)\d{2})\b/i); 345 if (cue && isPlausibleYear(cue[1])) return cue[1]; 346 if (arxiv && /^\d{4}\./.test(arxiv)) return '20' + arxiv.substring(0, 2); 347 const years = (firstPages.match(/\b(?:19|20)\d{2}\b/g) || []).filter(isPlausibleYear); 348 return years.length ? years[0] : null; 349} 350 351// ---------- references ---------- 352 353function extractCitations(textData) { 354 const { fullText } = textData; 355 356 // Use the LAST heading that looks like a references section (avoids matching "references" in body text). 357 const headingRe = /^\s*(?:\d+\.?\s*)?(references|bibliography|literature cited|works cited|reference list)\s*:?\s*$/gim; 358 let lastHeading = null; 359 let m; 360 while ((m = headingRe.exec(fullText)) !== null) lastHeading = m; 361 if (!lastHeading) return []; 362 363 let refText = fullText.substring(lastHeading.index + lastHeading[0].length); 364 const appendix = refText.search(/^\s*(?:appendix|appendices|supplementary material)\b/im); 365 if (appendix > 200) refText = refText.substring(0, appendix); 366 367 const lines = refText.split('\n').map(l => l.trim()).filter(Boolean); 368 const numbered = /^(?:\[\d{1,3}\]|\(\d{1,3}\)|\d{1,3}\.\s)/; 369 const useNumbers = lines.filter(l => numbered.test(l)).length >= 3; 370 const authorStart = /^(?:[A-Z][A-Za-zÃ-ÿ'â\-]+(?:\s[A-Z][A-Za-zÃ-ÿ'â\-]+)?,\s*(?:[A-Z]\.|[A-Z][a-z]+)|[A-Z]\.\s?(?:[A-Z]\.\s?)?[A-Z][a-z]+,)/; 371 372 const raw = []; 373 let current = ''; 374 for (const line of lines) { 375 const startsNew = useNumbers ? numbered.test(line) : (authorStart.test(line) && /[.)]\s*$/.test(current)); 376 if (startsNew && current) { 377 raw.push(current); 378 current = line; 379 } else { 380 current = current ? (current.endsWith('-') ? current.slice(0, -1) + line : current + ' ' + line) : line; 381 } 382 } 383 if (current) raw.push(current); 384 385 return raw 386 .filter(r => r.length > 25) 387 .slice(0, 300) 388 .map(processCitation); 389} 390 391function processCitation(text) { 392 const doiMatch = text.match(/\b10\.\d{4,9}\/[-._;()\/:A-Z0-9]+/i); 393 // Prefer a year in parentheses (author-year styles), else the first plausible year. 394 const paren = text.match(/\(((?:19|20)\d{2})[a-z]?\)/); 395 const years = (text.match(/\b(?:19|20)\d{2}\b/g) || []).filter(isPlausibleYear); 396 const urlMatch = text.match(/https?:\/\/[^\s<>"]+/i); 397 const arxivMatch = text.match(/arXiv[:\s]*(\d{4}\.\d{4,5}
397(?:v\d+)?)/i); 398 399 return { 400 raw: text.length > 600 ? text.substring(0, 600) + 'â¦' : text, 401 doi: doiMatch ? cleanDoi(doiMatch[0]) : null, 402 year: paren && isPlausibleYear(paren[1]) ? paren[1] : (years[0] || null), 403 url: urlMatch ? urlMatch[0].replace(/[.,;)]+$/, '') : null, 404 arxiv: arxivMatch ? arxivMatch[1] : null 405 }; 406} 407 408// ---------- display ---------- 409 410function displayResults(metadata, citations, textLength) { 411 uploadSection.classList.add('hidden'); 412 resultsSection.classList.remove('hidden'); 413 414 metadataContainer.innerHTML = ''; 415 416 if (textLength < 50) { 417 const warn = document.createElement('p'); 418 warn.style.cssText = 'padding:.75rem;background:#fef3c7;border-radius:.375rem;color:#92400e'; 419 warn.innerHTML = 'Almost no text was found. This looks like a scanned or image-only PDF. Run OCR first, see <a href="/guides/scanned-pdf-and-ocr">Working with scanned PDFs and OCR</a>.'; 420 metadataContainer.appendChild(warn); 421 } 422 423 const metadataItems = [ 424 { label: 'Title', value: metadata.title }, 425 { label: 'DOI', value: metadata.doi || 'Not found' }, 426 { label: 'Authors', value: metadata.authors.length > 0 ? metadata.authors.join(', ') : 'Not detected' }, 427 { label: 'Year', value: metadata.year || 'Unknown' }, 428 { label: 'Pages', value: metadata.pageCount }, 429 { label: 'arXiv ID', value: metadata.arxiv || 'Not found' }, 430 { label: 'PubMed ID', value: metadata.pmid || 'Not found' }, 431 { label: 'ISBN', value: metadata.isbn || 'Not found' }, 432 { label: 'Abstract', value: metadata.abstract ? (metadata.abstract.length > 300 ? metadata.abstract.substring(0, 300) + 'â¦' : metadata.abstract) : 'Not found' }, 433 { label: 'Keywords', value: metadata.keywords.length > 0 ? metadata.keywords.join(', ') : 'Not found' } 434 ]; 435 436 for (const item of metadataItems) { 437 const div = document.createElement('div'); 438 div.className = 'metadata-item'; 439 div.innerHTML = `<div class="metadata-label">${escapeHtml(item.label)}:</div><div class="metadata-value">${escapeHtml(item.value ?? 'N/A')}</div>`; 440 metadataContainer.appendChild(div); 441 } 442 443 citationsContainer.innerHTML = ''; 444 if (citations.length === 0) { 445 citationsContainer.innerHTML = '<p style="color: var(--text-light);">No reference list detected.</p>'; 446 } else { 447 const intro = document.createElement('p'); 448 intro.style.cssText = 'margin-bottom: 1rem; color: var(--text-light);'; 449 intro.textContent = `Found ${citations.length} references (heuristic split, check before relying on it).`; 450 citationsContainer.appendChild(intro); 451 452 citations.slice(0, 10).forEach((citation, index) => { 453 const div = document.createElement('div'); 454 div.className = 'metadata-item'; 455 div.style.gridTemplateColumns = '50px 1fr'; 456 div.innerHTML = ` 457 <div class="metadata-label">[${index + 1}]</div> 458 <div class="metadata-value"> 459 ${escapeHtml(citation.raw)} 460 ${citation.doi ? `<br><strong>DOI:</strong> ${escapeHtml(citation.doi)}` : ''} 461 ${citation.year ? `<br><strong>Year:</strong> ${escapeHtml(citation.year)}` : ''} 462 </div>`; 463 citationsContainer.appendChild(div); 464 }); 465 466 if (citations.length > 10) { 467 const more = document.createElement('p'); 468 more.style.cssText = 'margin-top: 1rem; color: var(--text-light);'; 469 more.textContent = `... and ${citations.length - 10} more references (download JSON, RIS or BibTeX for the full list)`; 470 citationsContainer.appendChild(more); 471 } 472 } 473 474 resultsSection.scrollIntoView({ behavior: 'smooth' }); 475} 476 477// ---------- exports ---------- 478 479function downloadAsJSON() { 480 if (!currentPdfData) return; 481 downloadBlob(JSON.stringify(currentPdfData, null, 2), 'application/json', baseFilename() + '_metadata.json'); 482} 483 484function downloadAsText() { 485 if (!currentPdfData) return; 486 const { metadata, fullText } = currentPdfData; 487 488 let text = 'PDF TEXT EXTRACTION\n' + '='.repeat(80) + '\n\n'; 489 text += `Title: ${metadata.title}\n`; 490 text += `Authors: ${metadata.authors.join(', ') || 'N/A'}\n`;
491 text += `Year: ${metadata.year || 'N/A'}\n`; 492 text += `DOI: ${metadata.doi || 'N/A'}\n`; 493 text += `Pages: ${metadata.pageCount}\n\n`; 494 text += '='.repeat(80) + '\n\nFULL TEXT:\n\n' + fullText; 495 496 downloadBlob(text, 'text/plain;charset=utf-8', baseFilename() + '.txt'); 497} 498 499async function downloadAsDocx() { 500 if (!currentPdfData) return; 501 if (typeof window.docx === 'undefined') { 502 alert('The Word export library could not be loaded. Check your connection and try again.'); 503 return; 504 } 505 const { metadata, fullText, citations } = currentPdfData; 506 507 try { 508 const { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } = window.docx; 509 const field = (label, value) => new Paragraph({ 510 children: [new TextRun({ text: label + ': ', bold: true }), new TextRun(String(value))], 511 spacing: { after: 100 } 512 }); 513 const heading = (text) => new Paragraph({ text, heading: HeadingLevel.HEADING_2, spacing: { before: 400, after: 200 } }); 514 515 const doc = new Document({ 516 sections: [{ 517 properties: {}, 518 children: [ 519 new Paragraph({ text: metadata.title, heading: HeadingLevel.HEADING_1, alignment: AlignmentType.CENTER, spacing: { after: 400 } }), 520 new Paragraph({ 521 children: [new TextRun({ text: metadata.authors.join(', ') || 'No authors detected', italics: true })], 522 alignment: AlignmentType.CENTER, 523 spacing: { after: 200 } 524 }), 525 heading('Document Metadata'), 526 field('DOI', metadata.doi || 'Not found'), 527 field('Year', metadata.year || 'Unknown'), 528 field('Pages', metadata.pageCount), 529 ...(metadata.arxiv ? [field('arXiv ID', metadata.arxiv)] : []), 530 ...(metadata.abstract ? [heading('Abstract'), new Paragraph({ text: metadata.abstract, spacing: { after: 400 } })] : []), 531 heading('Full Text'), 532 ...fullText.split('\n\n').filter(p => p.trim()).map(page => 533 new Paragraph({ text: page.replace(/\n/g, ' ').trim(), spacing: { after: 200 } }) 534 ), 535 ...(citations.length > 0 ? [ 536 heading('References'), 537 ...citations.map((c, i) => new Paragraph({ 538 children: [new TextRun({ text: `[${i + 1}] `, bold: true }), new TextRun(c.raw)], 539 spacing: { after: 100 } 540 })) 541 ] : []) 542 ] 543 }] 544 }); 545 546 const blob = await Packer.toBlob(doc); 547 downloadBlob(blob, null, baseFilename() + '.docx'); 548 } catch (error) { 549 console.error('Error generating DOCX:', error); 550 alert('Error generating Word document. Please try another format.'); 551 } 552} 553 554function downloadAsMarkdown() { 555 if (!currentPdfData) return; 556 const { metadata, fullText, citations } = currentPdfData; 557 558 let md = `# ${metadata.title}\n\n`; 559 if (metadata.authors.length > 0) md += `*${metadata.authors.join(', ')}*\n\n`; 560 md += '---\n\n## Metadata\n\n'; 561 md += `- **DOI**: ${metadata.doi || 'Not found'}\n`; 562 md += `- **Year**: ${metadata.year || 'Unknown'}\n`; 563 md += `- **Pages**: ${metadata.pageCount}\n`; 564 if (metadata.arxiv) md += `- **arXiv**: ${metadata.arxiv}\n`; 565 if (metadata.pmid) md += `- **PubMed ID**: ${metadata.pmid}\n`; 566 if (metadata.isbn) md += `- **ISBN**: ${metadata.isbn}\n`; 567 if (metadata.keywords.length > 0) md += `- **Keywords**: ${metadata.keywords.join(', ')}\n`; 568 if (metadata.abstract) md += `\n## Abstract\n\n${metadata.abstract}\n\n`; 569 md += '---\n\n## Full Text\n\n' + fullText; 570 571 if (citations.length > 0) { 572 md += '\n\n---\n\n## References\n\n'; 573 citations.forEach((c, i) => { 574 md += `${i + 1}. ${c.raw}\n`; 575 if (c.doi) md += ` - DOI: [${c.doi}](https://doi.org/${c.doi})\n`; 576 }); 577 } 578 579 downloadBlob(md, 'text/markdown;charset=utf-8', baseFilename() + '.md'); 580} 581 582function bibEscape(value) { 583 return String(value).replace(/([{}])/g, '\\$1').replace(/([&%$#_])/g, '\\$1'); 584} 585 586function citationKey(metadata) { 587 const first = metadata.authors[0] ? splitName(metadata.authors[0]).family : 'document'; 588 return first.toLowerCase().replace(/[^a-z0-9]/g, '') + (metadata.year || 'nd'); 589} 590 591function downloadAsBibtex() { 592 if (!currentPdfData) return; 593 const { metadata, citations } = currentPdfData; 594 595 let bib = `@article{${citationKey(metadata)},\n`; 596 bib += ` title = {${bibEscape(metadata.title)}},\n`; 597 if (metadata.authors.length > 0) { 598 bib += ` author = {${metadata.authors.map(n => { const s = splitName(n); return s.given ? `${s.family}, ${s.given}` : s.family; }).map(bibEscape).join(' and ')}},\n`; 599 } 600 if (metadata.year) bib += ` year = {${metadata.year}},\n`; 601 if (metadata.doi) bib += ` doi = {${metadata.doi}},\n`; 602 if (metadata.arxiv) bib += ` eprint = {${metadata.arxiv}},\n archivePrefix = {arXiv},\n`; 603 if (metadata.pmid) bib += ` pmid = {${metadata.pmid}},\n`; 604 if (metadata.isbn) bib += ` isbn = {${metadata.isbn}},\n`; 605 if (metadata.abstract) bib += ` abstract = {${bibEscape(metadata.abstract)}},\n`; 606 if (metadata.keywords.length > 0) bib += ` keywords = {${bibEscape(metadata.keywords.join(', '))}},\n`; 607 bib += ` pagetotal = {${metadata.pageCount}}\n}\n\n`; 608 609 // References: raw strings go into a note so nothing is invented; DOI/year added when found. 610 citations.forEach((c, i) => { 611 bib += `@misc{ref${i + 1},\n`; 612 if (c.doi) bib += ` doi = {${c.doi}},\n`; 613 if (c.year) bib += ` year = {${c.year}},\n`; 614 if (c.url) bib += ` url = {${c.url}},\n`; 615 bib += ` note = {${bibEscape(c.raw)}}\n}\n\n`; 616 }); 617 618 downloadBlob(bib, 'application/x-bibtex;charset=utf-8', baseFilename() + '.bib'); 619} 620 621function downloadAsRis() { 622 if (!currentPdfData) return; 623 const { metadata, citations } = currentPdfData; 624 const line = (tag, value) => `${tag} - ${String(value).replace(/\s+/g, ' ').trim()}\r\n`; 625 626 let ris = line('TY', 'JOUR'); 627 ris += line('TI', metadata.title);
628 metadata.authors.forEach(n => { 629 const s = splitName(n); 630 ris += line('AU', s.given ? `${s.family}, ${s.given}` : s.family); 631 }); 632 if (metadata.year) ris += line('PY', metadata.year); 633 if (metadata.doi) ris += line('DO', metadata.doi); 634 if (metadata.isbn) ris += line('SN', metadata.isbn); 635 if (metadata.arxiv) ris += line('UR', `https://arxiv.org/abs/${metadata.arxiv}`); 636 if (metadata.abstract) ris += line('AB', metadata.abstract); 637 metadata.keywords.forEach(k => { ris += line('KW', k); }); 638 ris += line('ER', '') + '\r\n'; 639 640 citations.forEach(c => { 641 ris += line('TY', 'GEN'); 642 ris += line('TI', c.raw); 643 if (c.year) ris += line('PY', c.year); 644 if (c.doi) ris += line('DO', c.doi); 645 if (c.url) ris += line('UR', c.url); 646 ris += line('N1', 'Unparsed reference string extracted from PDF; verify before use.'); 647 ris += line('ER', '') + '\r\n'; 648 }); 649 650 downloadBlob(ris, 'application/x-research-info-systems;charset=utf-8', baseFilename() + '.ris'); 651} 652 653function downloadAsCslJson() { 654 if (!currentPdfData) return; 655 const { metadata, citations } = currentPdfData; 656 657 const main = { 658 id: citationKey(metadata), 659 type: 'article-journal', 660 title: metadata.title 661 }; 662 if (metadata.authors.length > 0) main.author = metadata.authors.map(splitName); 663 if (metadata.year) main.issued = { 'date-parts': [[parseInt(metadata.year, 10)]] }; 664 if (metadata.doi) main.DOI = metadata.doi; 665 if (metadata.isbn) main.ISBN = metadata.isbn; 666 if (metadata.pmid) main.PMID = metadata.pmid; 667 if (metadata.arxiv) main.URL = `https://arxiv.org/abs/${metadata.arxiv}`; 668 if (metadata.abstract) main.abstract = metadata.abstract; 669 if (metadata.keywords.length > 0) main.keyword = metadata.keywords.join(', '); 670 main['number-of-pages'] = String(metadata.pageCount); 671 672 const refs = citations.map((c, i) => { 673 const item = { id: `ref${i + 1}`, type: 'document', title: c.raw, note: 'Unparsed reference string extracted from PDF' }; 674 if (c.year) item.issued = { 'date-parts': [[parseInt(c.year, 10)]] }; 675 if (c.doi) item.DOI = c.doi; 676 if (c.url) item.URL = c.url; 677 return item; 678 }); 679 680 downloadBlob(JSON.stringify([main, ...refs], null, 2), 'application/vnd.citationstyles.csl+json;charset=utf-8', baseFilename() + '.csl.json'); 681} 682 683// ---------- reset ---------- 684 685function reset() { 686 uploadSection.classList.remove('hidden'); 687 resultsSection.classList.add('hidden'); 688 dropZoneContent.innerHTML = DROP_ZONE_DEFAULT; 689 fileInput.value = ''; 690 currentPdfData = null; 691 window.scrollTo({ top: 0, behavior: 'smooth' }); 692} 693 694init();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.