PageSourceSearch

https://grobid.org/js/app.js?v=2

js grobid.org collected 2026-10-06 13:15:04 UTC 29,346 bytes, 694 lines download raw bytes

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, '&lt;')
32        .replace(/>/g, '&gt;')
33        .replace(/"/g, '&quot;')
34        .replace(/'/g, '&#39;');
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.