Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save chanphiromsok/f1b98b4a7a3269463a9a73b09284f88e to your computer and use it in GitHub Desktop.

Select an option

Save chanphiromsok/f1b98b4a7a3269463a9a73b09284f88e to your computer and use it in GitHub Desktop.
import { useState, useCallback, useRef, useEffect } from "react";
const ACCENT = "#2563EB";
const BORDER = "#E2E8F0";
const TEXT = "#0F172A";
const MUTED = "#64748B";
const SUCCESS = "#16A34A";
const DANGER = "#DC2626";
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) return resolve();
const s = document.createElement("script");
s.src = src;
s.onload = resolve;
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
}
function parsePageRange(str, total) {
if (!str.trim()) {
const all = new Set();
for (let i = 1; i <= total; i++) all.add(i);
return all;
}
const pages = new Set();
str.split(",").map(s => s.trim()).forEach(part => {
if (part.includes("-")) {
const [a, b] = part.split("-").map(Number);
for (let i = a; i <= Math.min(b, total); i++) if (i >= 1) pages.add(i);
} else {
const n = Number(part);
if (n >= 1 && n <= total) pages.add(n);
}
});
return pages;
}
let ruleCounter = 2;
export default function App() {
const [pdfFile, setPdfFile] = useState(null);
const [dragging, setDragging] = useState(false);
const [rules, setRules] = useState([{ id: 1, pages: "", find: "", replace: "" }]);
const [status, setStatus] = useState(null);
const [libsReady, setLibsReady] = useState(false);
const [downloadUrl, setDownloadUrl] = useState(null);
const [resultName, setResultName] = useState("");
const fileRef = useRef();
const prevUrlRef = useRef(null);
useEffect(() => {
// Load pdf-lib first, then pdfjs with disableWorker to avoid worker issues in sandbox
loadScript("https://cdnjs.cloudflare.com/ajax/libs/pdf-lib/1.17.1/pdf-lib.min.js")
.then(() => loadScript("https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"))
.then(() => {
// Disable worker to avoid cross-origin worker issues in iframe sandbox
if (window.pdfjsLib) {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = "";
}
setLibsReady(true);
})
.catch(() => {
setStatus({ type: "error", msg: "Failed to load PDF libraries. Check your connection and refresh." });
});
}, []);
// Cleanup old blob URL when a new one is created
useEffect(() => {
return () => {
if (prevUrlRef.current) URL.revokeObjectURL(prevUrlRef.current);
};
}, []);
const handleFile = useCallback((file) => {
if (!file || file.type !== "application/pdf") {
setStatus({ type: "error", msg: "Please upload a valid PDF file." });
return;
}
setPdfFile(file);
setDownloadUrl(null);
setStatus(null);
}, []);
const addRule = () => setRules(r => [...r, { id: ruleCounter++, pages: "", find: "", replace: "" }]);
const removeRule = (id) => setRules(r => r.filter(x => x.id !== id));
const updateRule = (id, field, val) => setRules(r => r.map(x => x.id === id ? { ...x, [field]: val } : x));
const process = async () => {
if (!pdfFile) return setStatus({ type: "error", msg: "Upload a PDF first." });
const valid = rules.filter(r => r.find.trim());
if (!valid.length) return setStatus({ type: "error", msg: "Add at least one Find text." });
if (!libsReady) return setStatus({ type: "error", msg: "Libraries still loading, please wait." });
setDownloadUrl(null);
setStatus({ type: "loading", msg: "Reading PDF…" });
try {
const { PDFDocument, rgb, StandardFonts } = window.PDFLib;
const pdfjs = window.pdfjsLib;
const arrayBuffer = await pdfFile.arrayBuffer();
const uint8 = new Uint8Array(arrayBuffer);
setStatus({ type: "loading", msg: "Extracting text positions…" });
// pdfjs: use fake worker (runs in main thread) to avoid sandbox issues
const pdfJSDoc = await pdfjs.getDocument({
data: uint8.slice(),
useWorkerFetch: false,
isEvalSupported: false,
useSystemFonts: true,
}).promise;
// pdf-lib: load for editing
const pdfDoc = await PDFDocument.load(uint8.slice(), { ignoreEncryption: true });
const pages = pdfDoc.getPages();
const totalPages = pages.length;
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
let totalHits = 0;
setStatus({ type: "loading", msg: "Replacing text…" });
for (const rule of valid) {
const targetPages = parsePageRange(rule.pages, totalPages);
for (const pageNum of targetPages) {
const idx = pageNum - 1;
if (idx < 0 || idx >= totalPages) continue;
const pdfLibPage = pages[idx];
const { width, height } = pdfLibPage.getSize();
const jsPage = await pdfJSDoc.getPage(pageNum);
const textContent = await jsPage.getTextContent();
for (const item of textContent.items) {
if (!item.str || !item.str.includes(rule.find)) continue;
// item.transform = [scaleX, skewX, skewY, scaleY, x, y]
// pdfjs uses same PDF coordinate system as pdf-lib (bottom-left origin)
const [sx, , , sy, tx, ty] = item.transform;
const x = tx;
const y = ty;
const fontSize = Math.abs(sy) || Math.abs(sx) || 12;
const itemWidth = item.width > 0 ? item.width : fontSize * item.str.length * 0.55;
// White rectangle to erase original text
pdfLibPage.drawRectangle({
x: x - 1,
y: y - fontSize * 0.3,
width: itemWidth + 6,
height: fontSize * 1.5,
color: rgb(1, 1, 1),
opacity: 1,
});
// Draw replacement
const newStr = item.str.replaceAll(rule.find, rule.replace);
try {
pdfLibPage.drawText(newStr, {
x,
y,
size: fontSize,
font: helvetica,
color: rgb(0, 0, 0),
});
} catch (_) {
// skip items with unsupported chars
}
totalHits++;
}
}
}
setStatus({ type: "loading", msg: "Saving…" });
const savedBytes = await pdfDoc.save();
const blob = new Blob([savedBytes], { type: "application/pdf" });
// Revoke previous URL
if (prevUrlRef.current) URL.revokeObjectURL(prevUrlRef.current);
const url = URL.createObjectURL(blob);
prevUrlRef.current = url;
const name = `modified_${pdfFile.name}`;
setResultName(name);
setDownloadUrl(url);
setStatus({
type: "success",
msg: `Done! Replaced ${totalHits} text segment${totalHits !== 1 ? "s" : ""}. Click the button below to download.`,
});
} catch (err) {
console.error(err);
setStatus({ type: "error", msg: `Error: ${err.message}` });
}
};
return (
<div style={{ minHeight: "100vh", background: "#F8FAFC", fontFamily: "system-ui, -apple-system, sans-serif", color: TEXT }}>
{/* Header */}
<div style={{ background: "#fff", borderBottom: `1px solid ${BORDER}`, padding: "0 20px" }}>
<div style={{ maxWidth: 700, margin: "0 auto", display: "flex", alignItems: "center", height: 56, gap: 10 }}>
<div style={{ width: 30, height: 30, background: ACCENT, borderRadius: 7, display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ fontSize: 14 }}>πŸ“</span>
</div>
<strong style={{ fontSize: 16 }}>PDF Text Replacer</strong>
<span style={{
marginLeft: "auto", fontSize: 11, color: libsReady ? SUCCESS : MUTED,
background: "#F1F5F9", border: `1px solid ${BORDER}`, borderRadius: 5, padding: "2px 8px"
}}>
{libsReady ? "βœ“ Ready" : "⏳ Loading libs…"}
</span>
</div>
</div>
<div style={{ maxWidth: 700, margin: "0 auto", padding: "28px 20px 60px" }}>
{/* Drop zone */}
<div
onDragOver={e => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={e => { e.preventDefault(); setDragging(false); handleFile(e.dataTransfer.files[0]); }}
onClick={() => fileRef.current.click()}
style={{
border: `2px dashed ${dragging ? ACCENT : pdfFile ? "#4ADE80" : "#CBD5E1"}`,
borderRadius: 12,
background: dragging ? "#EFF6FF" : pdfFile ? "#F0FDF4" : "#fff",
padding: "32px 20px",
textAlign: "center",
cursor: "pointer",
transition: "all 0.15s",
marginBottom: 24,
}}
>
<input ref={fileRef} type="file" accept=".pdf" style={{ display: "none" }}
onChange={e => handleFile(e.target.files[0])} />
{pdfFile ? (
<>
<div style={{ fontSize: 26, marginBottom: 4 }}>πŸ“„</div>
<div style={{ fontWeight: 600, color: SUCCESS, fontSize: 14 }}>{pdfFile.name}</div>
<div style={{ fontSize: 12, color: MUTED, marginTop: 3 }}>
{(pdfFile.size / 1024).toFixed(1)} KB Β· click to change
</div>
</>
) : (
<>
<div style={{ fontSize: 30, marginBottom: 8 }}>πŸ“‚</div>
<div style={{ fontWeight: 600 }}>Drop PDF here or click to browse</div>
<div style={{ fontSize: 13, color: MUTED, marginTop: 3 }}>Text-based PDFs only</div>
</>
)}
</div>
{/* Rules */}
<div style={{ marginBottom: 20 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<span style={{ fontWeight: 700, fontSize: 14 }}>Replacement Rules</span>
<button onClick={addRule} style={btnOutline}>+ Add Rule</button>
</div>
{rules.map((rule, i) => (
<div key={rule.id} style={{
background: "#fff", border: `1px solid ${BORDER}`, borderRadius: 10,
padding: "14px 16px", marginBottom: 10,
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: MUTED, textTransform: "uppercase", letterSpacing: "0.5px" }}>
Rule {i + 1}
</span>
{rules.length > 1 && (
<button onClick={() => removeRule(rule.id)}
style={{ background: "none", border: "none", color: DANGER, cursor: "pointer", fontSize: 18, padding: "0 2px", lineHeight: 1 }}>
Γ—
</button>
)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10 }}>
<Field label="Pages" placeholder="e.g. 1,3,5-7 (blank=all)" value={rule.pages} onChange={v => updateRule(rule.id, "pages", v)} />
<Field label="Find text" placeholder="Exact text to find" value={rule.find} onChange={v => updateRule(rule.id, "find", v)} />
<Field label="Replace with" placeholder="New text" value={rule.replace} onChange={v => updateRule(rule.id, "replace", v)} />
</div>
</div>
))}
</div>
{/* Info */}
<div style={{ background: "#EFF6FF", border: "1px solid #BFDBFE", borderRadius: 8, padding: "10px 14px", fontSize: 13, color: "#1E40AF", marginBottom: 20 }}>
<strong>Note:</strong> Original text is covered with a white box and redrawn in Helvetica at the same size/position. Font style may differ slightly from the source.
</div>
{/* Status message */}
{status && status.type !== "loading" && (
<div style={{
background: status.type === "success" ? "#F0FDF4" : "#FEF2F2",
border: `1px solid ${status.type === "success" ? "#86EFAC" : "#FECACA"}`,
borderRadius: 8, padding: "10px 14px", fontSize: 14,
color: status.type === "success" ? SUCCESS : DANGER,
marginBottom: 16,
}}>
{status.type === "success" ? "βœ… " : "❌ "}{status.msg}
</div>
)}
{/* Process button */}
<button
onClick={process}
disabled={status?.type === "loading" || !libsReady}
style={{
width: "100%", padding: "13px", borderRadius: 9, border: "none",
background: (status?.type === "loading" || !libsReady) ? "#93C5FD" : ACCENT,
color: "#fff", fontSize: 15, fontWeight: 700,
cursor: (status?.type === "loading" || !libsReady) ? "not-allowed" : "pointer",
marginBottom: 12,
}}
>
{status?.type === "loading" ? `⏳ ${status.msg}` : "πŸ”„ Process PDF"}
</button>
{/* Download link β€” shown after success */}
{downloadUrl && (
<a
href={downloadUrl}
download={resultName}
style={{
display: "block", width: "100%", boxSizing: "border-box",
padding: "13px", borderRadius: 9, border: "none",
background: SUCCESS, color: "#fff", fontSize: 15, fontWeight: 700,
textAlign: "center", textDecoration: "none", marginBottom: 12,
}}
>
⬇️ Download {resultName}
</a>
)}
<p style={{ textAlign: "center", fontSize: 12, color: MUTED, marginTop: 4 }}>
All processing happens in your browser β€” your file is never uploaded.
</p>
</div>
</div>
);
}
function Field({ label, placeholder, value, onChange }) {
return (
<div>
<label style={{
display: "block", fontSize: 11, fontWeight: 700, color: MUTED,
textTransform: "uppercase", letterSpacing: "0.4px", marginBottom: 4,
}}>
{label}
</label>
<input
placeholder={placeholder}
value={value}
onChange={e => onChange(e.target.value)}
style={{
width: "100%", boxSizing: "border-box", border: `1px solid #CBD5E1`,
borderRadius: 7, padding: "7px 9px", fontSize: 13,
fontFamily: "inherit", color: TEXT, background: "#FAFAFA", outline: "none",
}}
/>
</div>
);
}
const btnOutline = {
background: "none", border: `1px solid ${BORDER}`, borderRadius: 7,
padding: "5px 12px", fontSize: 13, fontWeight: 600,
cursor: "pointer", color: ACCENT,
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment