“写笔记”支持四种格式——Word 文档、Excel 表格、Markdown、纯文本,起稿或二次编辑时都能随时切换,同一篇笔记想用哪种形态来记,都由你说了算。
md、txt、csv、json 这类纯文本则原样载入,不做多余加工。拿一张现成的表倒进来、改几笔、再导出去,等于白用一台免费的格式转换器。
要带走就在右上角点“下载”,可导出 PDF、Word、Markdown、Excel、TXT 等格式;列表卡片“⋯”菜单里,也有同样的下载入口。
在“工具”页点“+ 上传工具”即可发布:填好名称与链接,再用 Markdown 把使用方法写清楚——能解决什么问题、怎么装、怎么用,比堆介绍实在。
要分发安装包就一并上传压缩包(ZIP、RAR、7Z、TAR.GZ,最大 35MB),别人在详情页一键下载;只放链接不带附件也可以。
工具按大家的收藏热度排序,好用的自然会被顶上来。发布后可在详情页或卡片菜单里编辑、下架。
写笔记时勾上“隐藏”,这篇就只存在于你自己的账号里:不进列表、不进搜索、不上首页精选,也不会出现在任何公开的页面,链接发给别人同样打不开。
适合放密码、草稿、日记这类只给自己看的内容;想公开,去“发布”打开它,把“隐藏”的勾去掉再保存,之后编辑会默认保持原状态,不会悄悄变回公开。
你的内容会同时保存在多个副本上,系统定期做备份与完整性校验,再配合异地容灾机制:就算某台机器出问题,数据也不会丢,可以长期放心存放;特别重要的资料,仍建议你另外再留一份备份。
全站跑在容器化、模块化的现代架构上,更新、部署、回滚都很快,扩展性和稳定性都按长期运营的标准来设计(Built for reliability, designed to scale)。
这个网站最早只是一个人的笔记仓库,后来慢慢长成现在的知识中枢。设计上很克制——没有广告、没有追踪、没有推荐算法,只是干干净净地存放一些东西;既然做好了,就公开出来,万一有人用得上呢。
不做大而全,不做平台梦,保持简单、保持克制、保持好奇。所有内容都由用户贡献、由用户维护:不会突然冒出付费墙,不会在角落塞广告位,也不会把你的数据卖给第三方。
产品会持续迭代,站内日志页记录着每一次改动,改了什么都有迹可循;想了解这个站是怎么一步步走到今天的,翻翻日志就能看到来龙去脉。
如果在这里看到涉嫌违规的内容,点对应卡片右侧的“举报”按钮就能提交,我们会尽快核实处理;也谢谢你花一点时间,一起把这里维护干净。
How to Build a Browser-Based PDF Analyzer Using JavaScript
好的,没问题。这篇原文信息量不小,我们把它拆开揉碎了,用更深入、更接地气的方式重新组织一遍。我会尽量站在一个开发者的角度,把每个技术点背后的“为什么”和“怎么用”都讲清楚。
先说几句废话:为啥要折腾一个纯前端的PDF分析器?
可能有人会问,分析个PDF而已,Adobe Acrobat不香吗?或者用Python的PyMuPDF、PDFPlumber不也行吗?没错,它们功能都很强。但这里有个核心痛点:隐私和便捷性。
想象一下,你是一个律师,收到了一份客户发来的机密合同PDF。你想快速知道它有没有密码保护、是谁创建的、用了什么字体。传统的做法是:下载 -> 打开Acrobat -> 右键属性 -> 翻找各种选项卡。如果文件一多,这过程能把人逼疯。更别提,有些在线分析工具需要你把文件上传到他们的服务器,对于敏感文档,这简直是噩梦。
所以我们这个项目的核心价值就是:一切都在浏览器里完成,你的文件永远不会离开你的电脑。 速度快、零延迟、完全离线可用(除了OCR可能需要联网下载语言包)。对于开发者来说,这也是一个绝佳的技术实践,让我们能深入理解PDF这个看似简单实则复杂的文件格式。
项目开搞:从零搭一个PDF分析器
咱们不整那些花里胡哨的脚手架,就用最纯粹的HTML + CSS + JavaScript,三个文件搞定。
pdf-analyzer/
│── index.html
│── style.css
│── script.js
就这仨,简单粗暴。
依赖库:我们不是一个人在战斗
PDF分析是个系统工程,没有一个库能包打天下。我们需要几个各怀绝技的“帮手”。
<!-- 在 index.html 的 <head> 或 <body> 底部引入 -->
<script src="https://unpkg.com/pdf-lib"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
我来挨个说说为啥选它们,以及它们各自扮演什么角色:
pdf-lib:这是我们的“文档解剖师”。它的强项是读取和修改PDF的“元数据”和“结构”,比如标题、作者、创建日期、页面数量、甚至是否加密。它不擅长渲染页面让你看,但擅长把PDF的“内脏”翻出来给你看。注意,它也能创建和修改PDF,但我们这次只用它的读取能力。pdf.js:这是我们的“页面渲染师”。由Mozilla出品,几乎可以说是浏览器端解析和渲染PDF的事实标准。它的核心功能是把PDF的每一页“画”成一个Canvas元素,这样用户就能在页面上预览PDF长啥样。我们用它来做页面缩略图。tesseract.js:这是我们的“文字识别师”。专门处理扫描件。如果用户上传的是一个扫描版的PDF(本质上是图片),普通库是提取不出文字的。Tesseract.js可以把图片里的文字“认”出来,这就是OCR(光学字符识别)。它完全在浏览器里运行,基于Tesseract OCR引擎的WebAssembly版本。chart.js:这是我们的“数据可视化师”。分析完PDF后,我们得到一堆枯燥的数字,比如每页字数、情感得分。Chart.js能把这些数据变成漂亮的柱状图、饼图,让报告更直观。
这四个库配合起来,就构成了一个完整的分析流水线。
第一步:打造一个靠谱的上传界面
用户跟应用打交道的第一个入口,不能拉胯。我们需要一个既支持点击上传,又支持拖拽上传的界面。
核心思路:
- 一个隐藏的
<input type="file">元素。 - 一个漂亮的拖拽区域,监听
dragover和drop事件。 - 当文件被选择或拖入时,触发文件读取逻辑。
代码实现(script.js 片段):
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const fileInfo = document.getElementById('file-info');
// 点击触发文件选择
dropZone.addEventListener('click', () => fileInput.click());
// 监听文件选择变化
fileInput.addEventListener('change', (event) => {
const files = event.target.files;
handleFiles(files);
});
// 拖拽事件处理
dropZone.addEventListener('dragover', (event) => {
event.preventDefault();
dropZone.classList.add('dragover'); // 给个视觉反馈
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (event) => {
event.preventDefault();
dropZone.classList.remove('dragover');
const files = event.dataTransfer.files;
handleFiles(files);
});
function handleFiles(files) {
if (files.length === 0) return;
const file = files[0]; // 我们先只处理单个文件
// 这里可以加一个简单的文件类型校验
if (file.type !== 'application/pdf') {
alert('请上传PDF文件!');
return;
}
// 显示文件名和大小
fileInfo.textContent = `已选择: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`;
// 将文件对象存储起来,供后续分析使用
window.currentPDFFile = file;
// 接下来可以触发页面预览等后续操作
loadPDFPreview(file);
}
第二步:预览PDF页面,让用户心里有数
用户上传后,最好能立刻看到PDF的缩略图,确认“我传对了”。这就要用到 pdf.js 了。
核心原理:pdf.js 通过 pdfjsLib.getDocument() 加载文件数据(可以是ArrayBuffer),返回一个 PDFDocumentProxy 对象。通过这个对象,我们可以获取页面总数,并调用 getPage(pageNum) 来渲染每一页。
代码实现(script.js 片段):
// 设置 pdf.js 的 worker 路径(必须)
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.worker.min.js';
async function loadPDFPreview(file) {
const previewContainer = document.getElementById('preview-container');
previewContainer.innerHTML = ''; // 清空之前的预览
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const totalPages = pdf.numPages;
document.getElementById('page-count').textContent = `总页数: ${totalPages}`;
for (let i = 1; i <= Math.min(totalPages, 5); i++) { // 先只预览前5页,防止卡死
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 0.5 }); // 缩略图比例
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
// 给每个canvas加个页码标签
const pageWrapper = document.createElement('div');
pageWrapper.classList.add('page-thumb');
pageWrapper.appendChild(canvas);
const label = document.createElement('span');
label.textContent = `Page ${i}`;
pageWrapper.appendChild(label);
previewContainer.appendChild(pageWrapper);
}
}
补充说明: viewport 是 pdf.js 的核心概念,它定义了页面在特定缩放比例下的尺寸和布局。我们通过 getViewport({scale: 0.5}) 来生成一个半尺寸的预览图,这样加载速度快,也不会太占内存。
第三步:配置分析选项,给用户选择权
不是每次分析都需要把所有功能跑一遍。比如,用户只想知道元数据,那OCR和情感分析这种耗时操作就可以跳过。所以我们需要一个配置面板。
界面设计(index.html 片段):
<div id="analysis-options">
<h3>分析选项</h3>
<label>
<input type="checkbox" id="opt-metadata" checked> 提取元数据
</label>
<label>
<input type="checkbox" id="opt-text-stats" checked> 文本统计 (字数/字符)
</label>
<label>
<input type="checkbox" id="opt-image-info"> 图片信息
</label>
<label>
<input type="checkbox" id="opt-font-info"> 字体信息
</label>
<label>
<input type="checkbox" id="opt-sentiment"> 情感分析 (需要OCR)
</label>
<label>
<input type="checkbox" id="opt-ocr"> 启用OCR (扫描件识别)
</label>
<button id="analyze-btn">开始分析</button>
</div>
逻辑处理(script.js 片段):
document.getElementById('analyze-btn').addEventListener('click', async () => {
if (!window.currentPDFFile) {
alert('请先上传一个PDF文件!');
return;
}
// 收集用户选项
const options = {
metadata: document.getElementById('opt-metadata').checked,
textStats: document.getElementById('opt-text-stats').checked,
imageInfo: document.getElementById('opt-image-info').checked,
fontInfo: document.getElementById('opt-font-info').checked,
sentiment: document.getElementById('opt-sentiment').checked,
ocr: document.getElementById('opt-ocr').checked,
};
// 显示加载状态
document.getElementById('result-area').innerHTML = '<p>正在分析,请稍候...</p>';
// 开始分析
const report = await analyzePDF(window.currentPDFFile, options);
// 显示报告
displayReport(report);
});
第四步:核心分析引擎,深入PDF的“五脏六腑”
这是整个应用最核心、最复杂的地方。analyzePDF 函数需要根据用户选项,调用不同的库来获取信息。
4.1 提取元数据 (使用 pdf-lib)
pdf-lib 加载PDF后,通过 PDFDocument.load() 得到一个 PDFDocument 对象。它的 getTitle(), getAuthor(), getSubject() 等方法可以直接获取元数据。安全属性则需要通过 pdfDoc.getEncryptionInfo() 来判断。
代码实现:
async function extractMetadata(file) {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFLib.PDFDocument.load(arrayBuffer, {
ignoreEncryption: true // 即使加密也尝试加载(但可能失败)
});
const metadata = {
title: pdfDoc.getTitle() || 'N/A',
author: pdfDoc.getAuthor() || 'N/A',
subject: pdfDoc.getSubject() || 'N/A',
keywords: pdfDoc.getKeywords() || 'N/A',
creator: pdfDoc.getCreator() || 'N/A',
producer: pdfDoc.getProducer() || 'N/A',
creationDate: pdfDoc.getCreationDate() ? pdfDoc.getCreationDate().toISOString() : 'N/A',
modificationDate: pdfDoc.getModificationDate() ? pdfDoc.getModificationDate().toISOString() : 'N/A',
pageCount: pdfDoc.getPageCount(),
// 安全信息需要额外处理
};
// 检查加密状态
const encryptionInfo = pdfDoc.getEncryptionInfo();
if (encryptionInfo) {
metadata.isEncrypted = true;
metadata.canPrint = encryptionInfo.allowPrinting;
metadata.canCopy = encryptionInfo.allowCopying;
metadata.canModify = encryptionInfo.allowModifying;
} else {
metadata.isEncrypted = false;
metadata.canPrint = true;
metadata.canCopy = true;
metadata.canModify = true;
}
return metadata;
}
关键点: ignoreEncryption: true 参数很关键。它告诉 pdf-lib 即使文档有加密(比如打开密码),也尝试加载它的元数据结构。如果文档有打开密码,加载会直接抛出异常,我们需要在 analyzePDF 函数里用 try...catch 捕获并提示用户。
4.2 文本统计与情感分析 (使用 pdf.js + Tesseract.js)
这一步最耗时。我们需要逐页提取文本。
- 对于普通PDF:用
pdf.js的page.getTextContent()方法。这个方法返回一个包含所有文本块(TextItem)的数组。我们可以遍历这些TextItem,拼接出整页文本。 - 对于扫描件:如果用户启用了OCR,我们先用
pdf.js把页面渲染成Canvas(就像预览时那样),然后把Canvas的图片数据传给Tesseract.js进行识别。
代码实现(文本提取核心逻辑):
async function extractTextFromPage(pdfDocProxy, pageNum, useOCR) {
const page = await pdfDocProxy.getPage(pageNum);
if (useOCR) {
// OCR模式:先渲染成图片,再识别
const viewport = page.getViewport({ scale: 2 }); // 高分辨率识别更准
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
const imageData = canvas.toDataURL('image/png');
const { data: { text } } = await Tesseract.recognize(imageData, 'eng', {
logger: m => console.log(m) // 可以显示OCR进度
});
return text;
} else {
// 普通模式:直接提取文本
const textContent = await page.getTextContent();
return textContent.items.map(item => item.str).join(' ');
}
}
async function calculateTextStats(text) {
const words = text.match(/\b\w+\b/g) || [];
const characters = text.replace(/\s/g, '').length;
const wordCount = words.length;
// 阅读时间:平均每分钟200-250词
const readingTimeMinutes = wordCount / 200;
// 演讲时间:平均每分钟130-160词
const speakingTimeMinutes = wordCount / 130;
return {
wordCount,
characterCount: characters,
readingTime: `${Math.ceil(readingTimeMinutes)} 分钟`,
speakingTime: `${Math.ceil(speakingTimeMinutes)} 分钟`,
};
}
// 情感分析是个简化版,用关键词匹配(真实的应该用NLP库)
function analyzeSentiment(text) {
const positiveWords = ['good', 'great', 'excellent', 'happy', 'success', 'agree'];
const negativeWords = ['bad', 'terrible', 'fail', 'angry', 'disagree', 'problem'];
const words = text.toLowerCase().match(/\b\w+\b/g) || [];
let positiveScore = 0;
let negativeScore = 0;
words.forEach(word => {
if (positiveWords.includes(word)) positiveScore++;
if (negativeWords.includes(word)) negativeScore++;
});
if (positiveScore > negativeScore) return '正面';
if (negativeScore > positiveScore) return '负面';
return '中性';
}
4.3 提取图片和字体信息 (使用 pdf-lib)
pdf-lib 可以遍历PDF的页面,获取页面上的图片和字体对象。但这部分API相对底层,需要理解PDF的“资源字典”(Resources Dictionary)。
简化思路: 对于图片,我们可以通过 pdfDoc.getPage(pageIndex).node 获取底层的PDF节点对象,然后递归查找 /XObject 字典中的 /Image 类型对象。同样,字体信息藏在 /Font 字典里。
代码实现(图片信息):
async function extractImageInfo(pdfDoc) {
const pages = pdfDoc.getPages();
let totalImages = 0;
let embeddedImages = 0;
pages.forEach((page, index) => {
// 这是一个简化的遍历,实际生产环境需要更健壮的递归
const resources = page.node.Resources;
if (resources && resources.XObject) {
const xObjects = resources.XObject.dict;
xObjects.forEach((value, key) => {
if (value && value.Subtype === 'Image') {
totalImages++;
// 检查是否是内嵌图片(通常都是)
if (value.stream) {
embeddedImages++;
}
}
});
}
});
return { totalImages, embeddedImages };
}
字体信息(简化版):
async function extractFontInfo(pdfDoc) {
const fontsUsed = new Set();
const pages = pdfDoc.getPages();
pages.forEach(page => {
const resources = page.node.Resources;
if (resources && resources.Font) {
const fontDict = resources.Font.dict;
fontDict.forEach((value, key) => {
if (value && value.name) {
fontsUsed.add(value.name);
}
});
}
});
return { fonts: Array.from(fontsUsed) };
}
注意: 这部分代码用了 pdf-lib 的底层API,需要你对PDF的内部结构(如对象、字典、流)有一定了解。pdf-lib 的文档对于这部分讲得比较简略,需要自己去读源码或实验。
第五步:组装报告,让数据说话
所有数据提取完成后,我们需要把它们组装成一个结构化的报告对象。
async function analyzePDF(file, options) {
const arrayBuffer = await file.arrayBuffer();
const report = { fileName: file.name, fileSize: `${(file.size / 1024).toFixed(2)} KB` };
// 1. 提取元数据 (使用 pdf-lib)
if (options.metadata) {
report.metadata = await extractMetadata(file);
}
// 2. 加载 pdf.js 文档代理 (用于文本和预览)
const pdfDocProxy = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
// 3. 逐页分析
let fullText = '';
const pageTexts = [];
const pageStats = [];
for (let i = 1; i <= pdfDocProxy.numPages; i++) {
const pageText = await extractTextFromPage(pdfDocProxy, i, options.ocr);
pageTexts.push({ pageNum: i, text: pageText });
fullText += pageText + ' ';
if (options.textStats) {
const stats = await calculateTextStats(pageText);
pageStats.push({ pageNum: i, ...stats });
}
}
// 4. 汇总文本统计
if (options.textStats) {
const totalStats = await calculateTextStats(fullText);
report.textStats = { total: totalStats, perPage: pageStats };
}
// 5. 情感分析
if (options.sentiment) {
report.sentiment = analyzeSentiment(fullText);
}
// 6. 图片信息 (使用 pdf-lib)
if (options.imageInfo) {
const pdfLibDoc = await PDFLib.PDFDocument.load(arrayBuffer, { ignoreEncryption: true });
report.imageInfo = await extractImageInfo(pdfLibDoc);
}
// 7. 字体信息
if (options.fontInfo) {
const pdfLibDoc = await PDFLib.PDFDocument.load(arrayBuffer, { ignoreEncryption: true });
report.fontInfo = await extractFontInfo(pdfLibDoc);
}
return report;
}
第六步:展示报告,可视化与导出
报告生成后,我们需要一个漂亮的UI来展示它。可以用Chart.js生成图表,然后把所有数据放在一个可折叠的卡片式布局里。
显示报告(script.js 片段):
function displayReport(report) {
const resultArea = document.getElementById('result-area');
let html = `<h2>分析报告: ${report.fileName}</h2>`;
html += `<p>文件大小: ${report.fileSize}</p>`;
if (report.metadata) {
html += `<div class="report-section"><h3>元数据</h3>`;
html += `<p>标题: ${report.metadata.title}</p>`;
html += `<p>作者: ${report.metadata.author}</p>`;
// ... 其他元数据
html += `<p>是否加密: ${report.metadata.isEncrypted ? '是' : '否'}</p>`;
html += `</div>`;
}
if (report.textStats) {
html += `<div class="report-section"><h3>文本统计</h3>`;
html += `<p>总字数: ${report.textStats.total.wordCount}</p>`;
html += `<p>总字符: ${report.textStats.total.characterCount}</p>`;
html += `<p>估计阅读时间: ${report.textStats.total.readingTime}</p>`;
html += `<canvas id="wordCountChart" width="400" height="200"></canvas>`;
html += `</div>`;
// 渲染图表
setTimeout(() => {
const ctx = document.getElementById('wordCountChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: report.textStats.perPage.map(p => `Page ${p.pageNum}`),
datasets: [{
label: '字数',
data: report.textStats.perPage.map(p => p.wordCount),
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}]
}
});
}, 100);
}
// ... 其他部分类似
resultArea.innerHTML = html;
}
导出报告: 我们可以把报告对象 JSON.stringify 成JSON文件,或者手动拼接成CSV/TXT。导出为PDF则可以用 pdf-lib 的创建功能,把报告内容写进去。这里不展开,但思路很直接。
关于真实世界中的一些坑(Important Notes)
- 内存管理:大PDF文件(比如几百页、几百MB)在浏览器里解析会占用大量内存。
pdf.js和pdf-lib都需要把整个文件或大部分文件加载到内存。对于超大文件,最好提示用户,或者分页处理,避免浏览器崩溃。 - OCR的局限性:Tesseract.js 在浏览器里跑,速度远不如后端。一个10页的扫描件OCR可能需要几分钟。而且识别准确率受图片质量、字体、语言影响很大。一定要给用户显示进度条。
- 加密PDF的处理:
pdf-lib可以读取加密PDF的元数据(如果设置了ignoreEncryption),但无法提取文本或页面内容。如果用户上传了加密文档,我们需要在分析前就检测出来并明确告知。 - 字体信息的准确性:用
pdf-lib的底层API提取字体名,可能遇到字体名是乱码或标准名(如ABCDEE+TimesNewRoman)的情况。这涉及到PDF字体子集化的问题,解析起来非常复杂。 - 跨域与CORS:如果你把
pdf.js的worker文件放在CDN上,而你的页面是通过file://协议打开的,可能会遇到CORS问题。最好把worker文件下载到本地,或者通过一个简单的本地服务器(如python -m http.server)来运行。
常见错误,引以为戒
- 忘记设置
pdf.js的 worker 路径:这是最经典的错误。如果不设置GlobalWorkerOptions.workerSrc,pdf.js会在后台默默失败,不报任何错误,但页面就是渲染不出来。 - 把
File对象直接传给pdf-lib:pdf-lib的PDFDocument.load()方法接收的是ArrayBuffer或Uint8Array,不是File对象。一定要先通过file.arrayBuffer()转换。 - 在循环中同步使用
await:提取文本时,如果一页一页地await,性能会很差。可以考虑使用Promise.all来并发处理多个页面(但要小心内存)。 - 忽略用户选项:一定要在分析前检查用户勾选了哪些选项,不要一股脑把所有分析都跑一遍。这既是用户体验问题,也是性能问题。
- 不处理错误:用户可能上传损坏的PDF、加密的PDF、或者根本不是PDF的文件。所有
await操作都应该用try...catch包起来,给出友好的错误提示,而不是让控制台一片红。
最后说两句
好了,一个功能完整的纯浏览器端PDF分析器就搭好了。它可能没有Acrobat那么强大,但对于日常的文档快速检查、隐私敏感场景,以及作为学习PDF内部结构的实践项目,已经非常够用了。
你可以把它部署到GitHub Pages上,或者集成到你自己的Web应用里。整个过程中,你其实已经接触到了PDF格式的几个核心概念:元数据、页面树、内容流、资源字典。这才是最有价值的部分。
如果对某个库的API有疑问,强烈建议直接去翻它们的官方文档和源码。pdf-lib 的源码写得非常清晰,是学习PDF底层操作的好材料。