Created
June 17, 2026 02:00
-
-
Save chanphiromsok/f1b98b4a7a3269463a9a73b09284f88e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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