feat: add Edit PDF Text tool and related functionality

This commit is contained in:
alam00000
2026-08-18 20:04:25 +05:30
parent 6529b0e9b7
commit 75e76c9619
43 changed files with 19326 additions and 1 deletions
+11
View File
@@ -26,6 +26,7 @@ export default [
],
},
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
@@ -89,4 +90,14 @@ export default [
'no-misleading-character-class': 'warn',
},
},
{
files: ['src/js/editcore/**/*.js'],
rules: {
'no-useless-assignment': 'off',
'security/detect-unsafe-regex': 'off',
'security/detect-object-injection': 'off',
'no-unsanitized/property': 'off',
'no-empty': 'off',
},
},
];
+8 -1
View File
@@ -27,6 +27,7 @@
"@types/node-forge": "^1.3.14",
"@types/papaparse": "^5.5.2",
"archiver": "^7.0.1",
"bentopdf-pdfium": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-0.0.6.tgz",
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-2.9.1.tgz",
"blob-stream": "^0.1.3",
"bwip-js": "^4.8.0",
@@ -4269,10 +4270,16 @@
],
"license": "MIT"
},
"node_modules/bentopdf-pdfium": {
"version": "0.0.6",
"resolved": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-0.0.6.tgz",
"integrity": "sha512-aCFpvPArlkjc5rVtxN/GW9MALjSnjzc7sXf/CqIY5MiVujbhyrMWKnXDtahUiX/O4OWhcneJXCi398mS4/FB9Q==",
"license": "AGPL-3.0"
},
"node_modules/bentopdf-viewer": {
"version": "2.9.1",
"resolved": "file:vendor/bentopdf-viewer/bentopdf-viewer-2.9.1.tgz",
"integrity": "sha512-WCKztFUzEuWpqvLu8UeXN1aEWgUUNHmV0K+d1PPS9DwCXEoc0d8K0qO59fMHxim3Xr2jaD7i8gECW6iiEcmBBA==",
"integrity": "sha512-CHrH1YcodFmR6PpaeBdK5K585rsF5ec+NU42xkLU2y8el/L4Y7lyAVgGFiNtRHLwzwaNpuRGs72DVCCZdCcJuw==",
"license": "MIT",
"dependencies": {
"preact": "^10.17.0",
+1
View File
@@ -90,6 +90,7 @@
"@types/node-forge": "^1.3.14",
"@types/papaparse": "^5.5.2",
"archiver": "^7.0.1",
"bentopdf-pdfium": "file:vendor/bentopdf-pdfium/bentopdf-pdfium-0.0.6.tgz",
"bentopdf-viewer": "file:vendor/bentopdf-viewer/bentopdf-viewer-2.9.1.tgz",
"blob-stream": "^0.1.3",
"bwip-js": "^4.8.0",
+5
View File
@@ -116,6 +116,11 @@
<h3 class="font-semibold text-white">Edit PDF</h3>
<p class="text-gray-400 text-sm">Comprehensive editing</p>
</a>
<a href="/edit-pdf-text" class="tool-card">
<i class="ph ph-cursor-text text-4xl text-indigo-400 mb-2"></i>
<h3 class="font-semibold text-white">Edit PDF Text</h3>
<p class="text-gray-400 text-sm">Rewrite text in place</p>
</a>
<a href="/compress-pdf" class="tool-card">
<i class="ph ph-lightning text-4xl text-indigo-400 mb-2"></i>
<h3 class="font-semibold text-white">Compress PDF</h3>
+4
View File
@@ -55,6 +55,10 @@
"name": "محرر PDF",
"subtitle": "تعليق، تمييز، تنقيح، تعليقات، إضافة أشكال/صور، بحث وعرض ملفات PDF."
},
"editPdfText": {
"name": "تحرير نص PDF",
"subtitle": "انقر على أي فقرة وحرّرها في مكانها مع إعادة تدفق فوري للنص والخطوط والأنماط."
},
"jpgToPdf": {
"name": "JPG إلى PDF",
"subtitle": "إنشاء PDF من صور JPG وJPEG وJPEG2000 (JP2/JPX)."
+4
View File
@@ -55,6 +55,10 @@
"name": "Рэдактар PDF",
"subtitle": "Анатаваць, вылучыць, зацямніць, дадаць фігуры/відарысы, каментарыі, пошук і прагляд PDF."
},
"editPdfText": {
"name": "Рэдагаваць тэкст PDF",
"subtitle": "Націсніце на любы абзац і рэдагуйце яго на месцы з імгненнай перакампаноўкай тэксту, шрыфтамі і стылямі."
},
"jpgToPdf": {
"name": "JPG у PDF",
"subtitle": "Стварыць PDF з відарысаў JPG, JPEG і JPEG2000 (JP2/JPX)."
+4
View File
@@ -55,6 +55,10 @@
"name": "PDF-editor",
"subtitle": "Annotér, fremhæv, redigér, kommentér, tilføj former/billeder, søg og vis PDF’er."
},
"editPdfText": {
"name": "Redigér PDF-tekst",
"subtitle": "Klik på et hvilket som helst afsnit, og redigér det direkte med live-ombrydning, skrifttyper og formatering."
},
"jpgToPdf": {
"name": "JPG til PDF",
"subtitle": "Opret en PDF fra JPG, JPEG og JPEG2000 (JP2/JPX) billeder."
+4
View File
@@ -55,6 +55,10 @@
"name": "PDF-Editor",
"subtitle": "Annotieren, hervorheben, schwärzen, kommentieren, Formen/Bilder hinzufügen, suchen und PDFs anzeigen."
},
"editPdfText": {
"name": "PDF-Text bearbeiten",
"subtitle": "Auf einen beliebigen Absatz klicken und ihn direkt bearbeiten – mit Live-Umbruch, Schriftarten und Formatierung."
},
"jpgToPdf": {
"name": "JPG zu PDF",
"subtitle": "Eine PDF aus JPG, JPEG und JPEG2000 (JP2/JPX) Bildern erstellen."
+4
View File
@@ -56,6 +56,10 @@
"name": "PDF Editor",
"subtitle": "Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs."
},
"editPdfText": {
"name": "Edit PDF Text",
"subtitle": "Click any paragraph and edit it in place with live reflow, fonts, and styling."
},
"jpgToPdf": {
"name": "JPG to PDF",
"subtitle": "Create a PDF from JPG, JPEG, and JPEG2000 (JP2/JPX) images."
+4
View File
@@ -55,6 +55,10 @@
"name": "Editor PDF",
"subtitle": "Anotar, resaltar, redactar, comentar, agregar formas/imágenes, buscar y ver PDFs."
},
"editPdfText": {
"name": "Editar texto PDF",
"subtitle": "Haz clic en cualquier párrafo y edítalo directamente con reajuste de texto en vivo, fuentes y estilos."
},
"jpgToPdf": {
"name": "JPG a PDF",
"subtitle": "Crea un PDF desde imágenes JPG, JPEG y JPEG2000 (JP2/JPX)."
+4
View File
@@ -55,6 +55,10 @@
"name": "Éditeur PDF",
"subtitle": "Annoter, surligner, masquer, commenter, ajouter des formes ou images, rechercher et afficher des PDF."
},
"editPdfText": {
"name": "Modifier le texte d'un PDF",
"subtitle": "Cliquer sur un paragraphe et le modifier directement, avec remise en page instantanée, polices et styles."
},
"jpgToPdf": {
"name": "JPG vers PDF",
"subtitle": "Créer un PDF à partir d’une ou plusieurs images JPG."
+4
View File
@@ -55,6 +55,10 @@
"name": "Editor PDF",
"subtitle": "Anotasi, sorot, redaksi, komentar, tambah bentuk/gambar, cari, dan lihat PDF."
},
"editPdfText": {
"name": "Edit Teks PDF",
"subtitle": "Klik paragraf mana pun dan edit langsung di tempat dengan penyusunan ulang teks secara langsung, font, dan gaya."
},
"jpgToPdf": {
"name": "JPG ke PDF",
"subtitle": "Buat PDF dari gambar JPG, JPEG, dan JPEG2000 (JP2/JPX)."
+4
View File
@@ -55,6 +55,10 @@
"name": "Editor PDF",
"subtitle": "Annota, evidenzia, redigi, commenta, aggiungi forme/immagini, cerca e visualizza PDF."
},
"editPdfText": {
"name": "Modifica testo PDF",
"subtitle": "Clicca su un paragrafo qualsiasi e modificalo direttamente con ricomposizione del testo in tempo reale, caratteri e stili."
},
"jpgToPdf": {
"name": "JPG in PDF",
"subtitle": "Crea un PDF da immagini JPG, JPEG e JPEG2000 (JP2/JPX)."
+4
View File
@@ -55,6 +55,10 @@
"name": "PDFエディター",
"subtitle": "PDFへの注釈追加、ハイライト、黒塗り、コメント、図形や画像の挿入、検索、および表示が可能です。"
},
"editPdfText": {
"name": "PDFテキストの編集",
"subtitle": "段落をクリックしてその場で編集。テキストは自動で再配置され、フォントやスタイルも変更できます。"
},
"jpgToPdf": {
"name": "JPGからPDF",
"subtitle": "JPG、JPEG、およびJPEG2000(JP2/JPX)形式の画像からPDFを作成します。"
+4
View File
@@ -55,6 +55,10 @@
"name": "PDF 편집기",
"subtitle": "주석, 하이라이트, 마스킹, 댓글, 도형/이미지 추가, 검색 및 보기."
},
"editPdfText": {
"name": "PDF 텍스트 편집",
"subtitle": "아무 단락이나 클릭해 실시간 리플로우, 글꼴, 스타일과 함께 바로 편집하세요."
},
"jpgToPdf": {
"name": "JPG를 PDF로",
"subtitle": "JPG, JPEG, JPEG2000(JP2/JPX) 이미지로 PDF를 만듭니다."
+4
View File
@@ -55,6 +55,10 @@
"name": "PDF Bewerken",
"subtitle": "PDF's annoteren, markeren, redigeren, commentaar toevoegen, vormen/afbeelding toevoegen, doorzoeken and weergeven."
},
"editPdfText": {
"name": "PDF-tekst Bewerken",
"subtitle": "Klik op een alinea en bewerk deze direct, met live tekstomloop, lettertypen en opmaak."
},
"jpgToPdf": {
"name": "JPG naar PDF",
"subtitle": "Een of meer JPG-afbeeldingen opslaan als PDF."
+4
View File
@@ -55,6 +55,10 @@
"name": "Editor de PDF",
"subtitle": "Anotar, destacar, redigir, comentar, adicionar formas/imagens, pesquisar e visualizar PDFs."
},
"editPdfText": {
"name": "Editar texto do PDF",
"subtitle": "Clique em qualquer parágrafo e edite-o no local com reorganização do texto em tempo real, fontes e estilos."
},
"jpgToPdf": {
"name": "JPG para PDF",
"subtitle": "Crie um PDF a partir de uma ou mais imagens JPG."
+4
View File
@@ -55,6 +55,10 @@
"name": "Редактор PDF",
"subtitle": "Добавить аннотации, выделения и комментарии, вымарать текст, добавить фигуры/изображения, найти в тексте, просмотреть документ."
},
"editPdfText": {
"name": "Редактирование текста PDF",
"subtitle": "Нажать на любой абзац и отредактировать его на месте — с мгновенной перекомпоновкой текста, шрифтами и стилями."
},
"jpgToPdf": {
"name": "JPG в PDF",
"subtitle": "Создать PDF из JPG, JPEG или JPEG2000 (JP2/JPX)."
+4
View File
@@ -55,6 +55,10 @@
"name": "PDF Editor",
"subtitle": "Anotovať, zvýrazňovať, redigovať, komentovať, pridávať tvary/obrázky, vyhľadávať a prezerať PDF."
},
"editPdfText": {
"name": "Upraviť text PDF",
"subtitle": "Kliknite na ľubovoľný odsek a upravte ho priamo na mieste s okamžitým preformátovaním textu, písmami a štýlmi."
},
"jpgToPdf": {
"name": "JPG do PDF",
"subtitle": "Vytvoriť PDF z obrázkov JPG, JPEG a JPEG2000 (JP2/JPX)."
+4
View File
@@ -55,6 +55,10 @@
"name": "PDF-redigerare",
"subtitle": "Kommentera, markera, redigera, kommentera, lägg till former/bilder, sök och visa PDF:er."
},
"editPdfText": {
"name": "Redigera PDF-text",
"subtitle": "Klicka på valfritt stycke och redigera det på plats med direkt textomflöde, typsnitt och stilar."
},
"jpgToPdf": {
"name": "JPG till PDF",
"subtitle": "Skapa en PDF från JPG, JPEG och JPEG2000 (JP2/JPX)-bilder."
+4
View File
@@ -55,6 +55,10 @@
"name": "PDF Düzenleyici",
"subtitle": "Açıklama ekleyin, vurgulayın, düzenleyin, yorum yapın, şekil/resim ekleyin, arama yapın ve PDF'leri görüntüleyin."
},
"editPdfText": {
"name": "PDF Metni Düzenle",
"subtitle": "Herhangi bir paragrafa tıklayın ve canlı yeniden akış, yazı tipleri ve stillerle yerinde düzenleyin."
},
"jpgToPdf": {
"name": "JPG'den PDF'ye",
"subtitle": "Bir veya daha fazla JPG görselinden PDF oluşturun."
+4
View File
@@ -55,6 +55,10 @@
"name": "Редактор PDF",
"subtitle": "Додавати анотації, виділяти, редагувати, коментувати, додавати фігури/зображення, шукати та переглядати PDF."
},
"editPdfText": {
"name": "Редагувати текст PDF",
"subtitle": "Натисніть на будь-який абзац і редагуйте його на місці з миттєвим перекомпонуванням тексту, шрифтами та стилями."
},
"jpgToPdf": {
"name": "JPG у PDF",
"subtitle": "Створити PDF із зображень JPG, JPEG та JPEG2000 (JP2/JPX)."
+4
View File
@@ -55,6 +55,10 @@
"name": "Trình chỉnh sửa PDF",
"subtitle": "Ghi chú, tô sáng, chỉnh sửa, bình luận, thêm hình dạng/hình ảnh, tìm kiếm và xem PDF."
},
"editPdfText": {
"name": "Chỉnh sửa văn bản PDF",
"subtitle": "Nhấp vào bất kỳ đoạn văn nào và chỉnh sửa ngay tại chỗ với tính năng tự động dàn lại văn bản, phông chữ và kiểu dáng."
},
"jpgToPdf": {
"name": "JPG sang PDF",
"subtitle": "Tạo PDF từ hình ảnh JPG, JPEG và JPEG2000 (JP2/JPX)."
+4
View File
@@ -55,6 +55,10 @@
"name": "PDF 編輯器",
"subtitle": "註解、螢光標註、塗黑、評論、新增圖形或圖片、搜尋與檢視 PDF。"
},
"editPdfText": {
"name": "編輯 PDF 文字",
"subtitle": "點擊任意段落即可就地編輯,支援即時重排、字型與樣式設定。"
},
"jpgToPdf": {
"name": "JPG 轉 PDF",
"subtitle": "從一張或多張 JPG、JPEG 及 JPEG2000(JP2/JPX)圖片建立 PDF。"
+4
View File
@@ -55,6 +55,10 @@
"name": "PDF 编辑器",
"subtitle": "注释、高亮、涂黑、评论、添加形状/图片、搜索和查看 PDF。"
},
"editPdfText": {
"name": "编辑 PDF 文本",
"subtitle": "点击任意段落即可就地编辑,支持实时重排、字体和样式设置。"
},
"jpgToPdf": {
"name": "JPG 转 PDF",
"subtitle": "从 JPG、JPEG 和 JPEG2000 (JP2/JPX) 图片创建 PDF。"
File diff suppressed because it is too large Load Diff
+7
View File
@@ -90,6 +90,13 @@ const baseCategories = [
subtitle:
'Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs.',
},
{
href: import.meta.env.BASE_URL + 'edit-pdf-text.html',
name: 'Edit PDF Text',
icon: 'ph-cursor-text',
subtitle:
'Click any paragraph and edit it in place with live reflow, fonts, and styling.',
},
{
href: import.meta.env.BASE_URL + 'bookmark.html',
name: 'Edit Bookmarks',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
import createModule from 'bentopdf-pdfium';
const inBrowser = typeof window !== 'undefined';
const wasmUrl = inBrowser
? new URL('bentopdf-pdfium/editcore.wasm', import.meta.url).href
: new URL(import.meta.resolve('bentopdf-pdfium/editcore.wasm')).pathname;
export const ENGINE_BUILD = 'bentopdf-pdfium';
export function createEngineModule(options) {
return createModule({
...(options ?? {}),
locateFile: (file, prefix) =>
file.endsWith('.wasm') ? wasmUrl : `${prefix}${file}`,
});
}
+687
View File
@@ -0,0 +1,687 @@
const latinMemo = new WeakMap();
export const latin = (u8) => {
const hit = latinMemo.get(u8);
if (hit) return hit;
let s = '';
for (let i = 0; i < u8.length; i += 0x8000) {
s += String.fromCharCode.apply(
null,
u8.subarray(i, Math.min(i + 0x8000, u8.length))
);
}
latinMemo.set(u8, s);
return s;
};
export const parsePdf = (src) => {
const objAt = new Map();
for (const m of src.matchAll(/(\d+)\s+0\s+obj\b/g)) {
if (m.index > 0 && src[m.index - 1] >= '0' && src[m.index - 1] <= '9')
continue;
objAt.set(parseInt(m[1], 10), m.index);
}
const objBody = (num) => {
const at = objAt.get(num);
if (at == null) return null;
const start = src.indexOf('obj', at) + 3;
const end = src.indexOf('endobj', start);
return end < 0 ? null : { start, end, body: src.slice(start, end) };
};
const dictRef = (body, key) => {
const m = body.match(new RegExp('/' + key + '\\s+(\\d+)\\s+\\d+\\s+R'));
return m ? parseInt(m[1], 10) : -1;
};
let rootNum = -1;
let size = -1;
for (let tAt = src.length; rootNum < 0; ) {
tAt = src.lastIndexOf('trailer', tAt - 1);
if (tAt < 0) break;
const trailer = src.slice(tAt, tAt + 400);
if (rootNum < 0) rootNum = dictRef(trailer, 'Root');
}
for (const m of src.matchAll(/\/Size\s+(\d+)/g))
size = Math.max(size, parseInt(m[1], 10));
if (rootNum < 0) {
const m = src.match(/\/Root\s+(\d+)\s+\d+\s+R/);
if (m) rootNum = parseInt(m[1], 10);
}
if (rootNum < 0 || size < 0) return null;
let pageList = null;
const pageNum = (pageIndex) => {
if (!pageList) {
pageList = [];
const root = objBody(rootNum);
if (!root) return -1;
const seen = new Set();
const walk = (num) => {
if (seen.has(num) || pageList.length > 8192) return;
seen.add(num);
const o = objBody(num);
if (!o) return;
if (/\/Type\s*\/Page\b(?!s)/.test(o.body)) {
pageList.push(num);
return;
}
const kids = o.body.match(/\/Kids\s*\[([^\]]*)\]/);
if (!kids) return;
for (const r of kids[1].matchAll(/(\d+)\s+\d+\s+R/g))
walk(parseInt(r[1], 10));
};
walk(dictRef(root.body, 'Pages'));
}
return pageIndex < pageList.length ? pageList[pageIndex] : -1;
};
return { objBody, dictRef, rootNum, size, pageNum };
};
export const streamData = async (bytes, src, obj) => {
const si = src.indexOf('stream', obj.start);
if (si < 0 || si > obj.end) return null;
let ds = si + 6;
if (src[ds] === '\r') ds++;
if (src[ds] === '\n') ds++;
const indirect = obj.body.match(/\/Length\s+(\d+)\s+\d+\s+R/);
let len = -1;
if (indirect) {
const rx = new RegExp(
'(?:^|[^0-9])' + indirect[1] + '\\s+0\\s+obj\\s*(\\d+)'
);
const rm = src.match(rx);
if (rm) len = parseInt(rm[1], 10);
} else {
const lm = obj.body.match(/\/Length\s+(\d+)/);
if (lm) len = parseInt(lm[1], 10);
}
const de = len >= 0 ? ds + len : src.indexOf('endstream', ds);
const raw = bytes.subarray(ds, de);
if (!/\/Filter\s*\/FlateDecode/.test(obj.body)) {
return /\/Filter/.test(obj.body) ? null : latin(raw);
}
try {
const stream = new Blob([raw])
.stream()
.pipeThrough(new DecompressionStream('deflate'));
const buf = new Uint8Array(await new Response(stream).arrayBuffer());
return latin(buf);
} catch {
return null;
}
};
const objStmIndex = async (bytes, src) => {
const out = new Map();
for (const m of src.matchAll(/(\d+)\s+0\s+obj\b/g)) {
const start = src.indexOf('obj', m.index) + 3;
const end = src.indexOf('endobj', start);
if (end < 0) continue;
const body = src.slice(start, end);
if (!/\/Type\s*\/ObjStm/.test(body)) continue;
const data = await streamData(bytes, src, { start, end, body });
if (!data) continue;
const n = parseInt((body.match(/\/N\s+(\d+)/) || [])[1], 10);
const first = parseInt((body.match(/\/First\s+(\d+)/) || [])[1], 10);
if (!Number.isFinite(n) || !Number.isFinite(first)) continue;
const nums = data.slice(0, first).trim().split(/\s+/).map(Number);
for (let i = 0; i < n; i++) {
const num = nums[i * 2],
off = nums[i * 2 + 1];
if (!Number.isFinite(num) || !Number.isFinite(off)) continue;
const nextOff = i + 1 < n ? nums[i * 2 + 3] : data.length - first;
out.set(
num,
data.slice(
first + off,
first + (Number.isFinite(nextOff) ? nextOff : data.length - first)
)
);
}
}
return out;
};
export const withObjStm = async (bytes, src, op) => {
if (!op) return op;
const idx = await objStmIndex(bytes, src);
if (!idx.size) return op;
const base = op.objBody;
const objBody = (num) =>
base(num) ||
(idx.has(num) ? { start: -1, end: -1, body: idx.get(num) } : null);
const dictRef = op.dictRef;
let pageList = null;
const pageNum = (index) => {
if (!pageList) {
pageList = [];
const root = objBody(op.rootNum);
if (!root) return -1;
const seen = new Set();
const walk = (num, depth) => {
if (num < 0 || depth > 32 || seen.has(num) || pageList.length > 8192)
return;
seen.add(num);
const o = objBody(num);
if (!o) return;
if (/\/Type\s*\/Page(?![sA-Za-z])/.test(o.body)) {
pageList.push(num);
return;
}
const kids = o.body.match(/\/Kids\s*\[([^\]]*)\]/);
if (!kids) return;
for (const km of kids[1].matchAll(/(\d+)\s+\d+\s+R/g))
walk(parseInt(km[1], 10), depth + 1);
};
walk(dictRef(root.body, 'Pages'), 0);
}
return index < pageList.length ? pageList[index] : -1;
};
return { ...op, objBody, pageNum };
};
const openQBefore = (content, pos, windowBytes) => {
const backStart = Math.max(0, pos - windowBytes);
const back = content.slice(backStart, pos);
let depth = 0;
for (let i = back.length - 1; i >= 0; i--) {
const c = back[i];
if (c !== 'q' && c !== 'Q') continue;
const prev = i > 0 ? back[i - 1] : ' ';
const next = i + 1 < back.length ? back[i + 1] : ' ';
if (!/[\s>\]]/.test(prev) || !/[\s\/<[(%]/.test(next)) continue;
if (c === 'Q') depth++;
else if (depth > 0) depth--;
else return backStart + i;
}
return -1;
};
const STATE_OPS = new Set([
'm',
'l',
'c',
'v',
'y',
're',
'h',
'W',
'W*',
'n',
'cm',
'gs',
'ri',
'i',
'j',
'J',
'M',
'd',
'w',
'cs',
'CS',
'sc',
'scn',
'SC',
'SCN',
'g',
'G',
'rg',
'RG',
'k',
'K',
'BX',
'EX',
]);
const gapIsStateOnly = (gap) => {
const stripped = gap
.replace(/%[^\r\n]*/g, ' ')
.replace(/\((?:\\.|[^\\)])*\)/g, ' ')
.replace(/<<[\s\S]*?>>/g, ' ')
.replace(/<[0-9A-Fa-f\s]*>/g, ' ')
.replace(/\/[A-Za-z0-9_.#-]*/g, ' ')
.replace(/[+-]?\d*\.?\d+/g, ' ')
.replace(/[[\]]/g, ' ');
for (const tok of stripped.split(/\s+/)) {
if (tok && !STATE_OPS.has(tok)) return false;
}
return true;
};
const shBlocks = (content) => {
const blocks = [];
for (const m of content.matchAll(
/\/[A-Za-z0-9_.]+\s+(?:sh|scn)(?![a-zA-Z])/g
)) {
let qAt = openQBefore(content, m.index, 4096);
let endAt = -1;
const fwd = content.slice(m.index, m.index + 2048);
const qm = fwd.match(/[\s>\]]Q(?=[\s\/<[(%]|$)/);
if (qm) endAt = m.index + qm.index + qm[0].length;
if (qAt < 0 || endAt <= qAt) continue;
const innerStart = qAt,
innerEnd = endAt;
let ops = content.slice(qAt, endAt);
for (let lvl = 0; lvl < 4; lvl++) {
const outer = openQBefore(content, qAt, 8192);
if (outer < 0) break;
const gap = content.slice(outer + 1, qAt);
if (!gapIsStateOnly(gap)) break;
ops = content.slice(outer, qAt) + ops + '\nQ';
qAt = outer;
}
ops = stripTextObjects(ops);
if (!/\b(?:sh|scn)(?![a-zA-Z])/.test(ops)) continue;
blocks.push({
ops,
at: m.index,
endAt,
innerStart,
innerEnd,
isPattern: /scn$/.test(m[0].trim()),
});
}
return blocks;
};
const stripTextObjects = (ops) => {
const tok = /(?:^|[\s>\]<)(])(BT|ET)(?=[\s/<[(]|$)/g;
let out = '',
i = 0,
depth = 0;
for (const m of ops.matchAll(tok)) {
const at = m.index + (m[0].length - 2);
if (m[1] === 'BT') {
if (depth === 0) out += ops.slice(i, at);
depth++;
} else if (depth > 0) {
depth--;
if (depth === 0) i = at + 2;
}
}
if (depth === 0) out += ops.slice(i);
return out;
};
const classifyShadings = (content, blocks) => {
const btPos = content.search(/(?:^|[^A-Za-z0-9_])BT(?=[^A-Za-z0-9_]|$)/);
const doM = content.match(/\/[A-Za-z0-9_.]+\s+Do(?![a-zA-Z])/);
const doPos = doM ? doM.index : -1;
const firstContent = Math.min(
...[btPos, doPos].filter((p) => p >= 0).concat([Infinity])
);
return blocks.map((b) => ({ isBackground: b.at < firstContent }));
};
const pageContent = async (bytes, src, parsed, pageIndex) => {
const pgNum = parsed.pageNum(pageIndex);
if (pgNum < 0) return null;
const pg = parsed.objBody(pgNum);
if (!pg) return null;
const one = pg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
const arr = pg.body.match(/\/Contents\s*\[([^\]]*)\]/);
const refs = one
? [parseInt(one[1], 10)]
: arr
? [...arr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) => parseInt(r[1], 10))
: [];
let content = '';
const spans = [];
for (const rn of refs) {
const o = parsed.objBody(rn);
const d = o && (await streamData(bytes, src, o));
if (d == null) return null;
spans.push({ start: content.length, end: content.length + d.length });
content += d + '\n';
}
return { content, spans };
};
export async function shadingsForPage(originalBytes, pageIndex) {
const orig = latin(originalBytes);
const op = await withObjStm(originalBytes, orig, parsePdf(orig));
if (!op) return [];
const pc = await pageContent(originalBytes, orig, op, pageIndex);
if (pc == null) return [];
const blocks = shBlocks(pc.content);
if (!blocks.length) return [];
return classifyShadings(pc.content, blocks);
}
export async function applyShadingSurgery(savedBytes, originalBytes, pages) {
if (!Array.isArray(pages)) pages = pages ? [pages] : [];
pages = pages.filter(Boolean);
if (!pages.length) return null;
const saved = latin(savedBytes);
const sp = await withObjStm(savedBytes, saved, parsePdf(saved));
if (!sp) return null;
const sx = saved.lastIndexOf('startxref');
const sxNum = parseInt(saved.slice(sx + 9).trim(), 10);
if (!Number.isFinite(sxNum) || saved.slice(sxNum, sxNum + 4) !== 'xref')
return null;
const orig = latin(originalBytes);
const op = await withObjStm(originalBytes, orig, parsePdf(orig));
if (!op) return null;
let next = sp.size;
const pageRewrites = [];
const contentObjs = [];
for (const pg of pages) {
const pageIndex = pg.pageIndex ?? 0;
const pc = await pageContent(originalBytes, orig, op, pageIndex);
if (pc == null) continue;
const { content, spans } = pc;
const allBlocks = shBlocks(content);
const blocks = allBlocks.filter((b) => b.isPattern);
if (!blocks.length) continue;
let shadings = pg.shadings;
if (!shadings || !shadings.length)
shadings = classifyShadings(content, blocks);
if (shadings.length !== blocks.length)
shadings = classifyShadings(content, blocks);
if (!shadings.length || blocks.length < shadings.length) continue;
const spgNum = sp.pageNum(pageIndex);
if (spgNum < 0) continue;
const spg = sp.objBody(spgNum);
let resBody = '';
const rref = spg.body.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
if (rref) resBody = sp.objBody(parseInt(rref[1], 10))?.body ?? '';
else {
const k = spg.body.indexOf('/Resources');
resBody = k >= 0 ? spg.body.slice(k, k + 4000) : '';
}
const used = shadings.map((s2, k) => ({ ...s2, ops: blocks[k].ops }));
let resOk = true;
for (const u of used) {
for (const nm of u.ops.matchAll(
/\/([A-Za-z0-9_.]+)\s+(?:gs|sh|scn|SCN|Do)\b/g
)) {
if (!resBody.includes('/' + nm[1])) {
console.warn(
`[shading] resource /${nm[1]} missing from saved page ${pageIndex} — page skipped`
);
resOk = false;
break;
}
}
if (!resOk) break;
}
if (!resOk) continue;
const cOne = spg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
const cArr = spg.body.match(/\/Contents\s*\[([^\]]*)\]/);
const savedRefs = cOne
? [cOne[1] + ' 0 R']
: cArr
? [...cArr[1].matchAll(/\d+\s+\d+\s+R/g)].map((r) => r[0])
: [];
if (!savedRefs.length) continue;
const PAINT_RE =
/(?:^|[^A-Za-z0-9_*'"])(?:BT|Do|sh|EI|f\*?|F|B\*?|b\*?|S|s)(?![A-Za-z0-9_])/g;
const paints = (s2) => {
PAINT_RE.lastIndex = 0;
let n2 = 0;
while (PAINT_RE.exec(s2)) n2++;
return n2;
};
const slots = new Map();
const patternOps = [];
for (let k2 = 0; k2 < used.length; k2++) {
const u = used[k2];
const at = blocks[k2].at;
const si = spans.findIndex((s2) => at >= s2.start && at < s2.end);
const matched = si >= 0 && spans.length === savedRefs.length;
const before =
matched &&
paints(content.slice(spans[si].start, at)) <=
paints(content.slice(at, spans[si].end));
if (blocks[k2].isPattern) {
patternOps.push(u.ops);
continue;
}
let slot;
if (!matched) {
const textBefore = /(?:^|[^A-Za-z0-9_*'"])BT(?![A-Za-z0-9_])/.test(
content.slice(0, at)
);
slot = textBefore ? 2 * savedRefs.length : 0;
} else {
slot = before ? 2 * si : 2 * si + 2;
}
if (!slots.has(slot)) slots.set(slot, []);
slots.get(slot).push(u.ops);
}
let splitRefs = null;
if (patternOps.length && savedRefs.length === 1) {
const rnum = parseInt(savedRefs[0], 10);
const sobj = sp.objBody(rnum);
const sdata = sobj && (await streamData(savedBytes, saved, sobj));
let cut = -1;
const rectM = patternOps
.join('\n')
.match(/scn\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+re/);
if (sdata != null && rectM) {
const want = rectM.slice(1, 5).map(Number);
for (const rm of sdata.matchAll(
/(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+re\b/g
)) {
const got = rm.slice(1, 5).map(Number);
if (!want.every((v, i) => Math.abs(v - got[i]) < 0.05)) continue;
const after = sdata.slice(rm.index);
const pm = after.match(
/(?:^|[^A-Za-z0-9_*'"])(?:f\*?|F|B\*?|b\*?|S|s|n)(?![A-Za-z0-9_])/
);
if (pm) cut = rm.index + pm.index + pm[0].length;
}
}
if (sdata != null && cut > 0) {
const mk = (body2) => {
const num = next++;
contentObjs.push({
num,
body: `<< /Length ${body2.length + 1} >>\nstream\n${body2}\nendstream`,
});
return `${num} 0 R`;
};
splitRefs = [
mk(sdata.slice(0, cut)),
mk(patternOps.join('\n')),
mk(sdata.slice(cut)),
];
}
}
if (patternOps.length && !splitRefs) {
const slot = 0;
if (!slots.has(slot)) slots.set(slot, []);
slots.get(slot).push(...patternOps);
}
const refs = [];
if (splitRefs) refs.push(...splitRefs);
else
for (let i2 = 0; i2 <= savedRefs.length; i2++) {
const ops = slots.get(2 * i2);
if (ops && ops.length) {
const body2 = ops.join('\n');
const num = next++;
contentObjs.push({
num,
body: `<< /Length ${body2.length + 1} >>\nstream\n${body2}\nendstream`,
});
refs.push(`${num} 0 R`);
}
if (i2 < savedRefs.length) refs.push(savedRefs[i2]);
}
const newPage = spg.body.replace(
cOne ? cOne[0] : cArr[0],
`/Contents [ ${refs.join(' ')} ]`
);
pageRewrites.push({ num: spgNum, body: newPage });
}
if (!pageRewrites.length) return null;
let out = saved;
if (!out.endsWith('\n')) out += '\n';
const offsets = new Map();
const emit = (num, body) => {
offsets.set(num, out.length);
out += `${num} 0 obj\n${body.trim()}\nendobj\n`;
};
for (const o of pageRewrites) emit(o.num, o.body);
for (const o of contentObjs) emit(o.num, o.body);
const xrefAt = out.length;
const nums = [...offsets.keys()].sort((a, b) => a - b);
let xref = 'xref\n';
for (let i = 0; i < nums.length; ) {
let j = i;
while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
xref += `${nums[i]} ${j - i + 1}\n`;
for (let k = i; k <= j; k++)
xref += String(offsets.get(nums[k])).padStart(10, '0') + ' 00000 n \n';
i = j + 1;
}
out += xref;
const infoM = saved
.slice(saved.lastIndexOf('trailer'))
.match(/\/Info\s+(\d+)\s+\d+\s+R/);
out +=
`trailer\n<< /Size ${next} /Root ${sp.rootNum} 0 R` +
(infoM ? ` /Info ${infoM[1]} 0 R` : '') +
` /Prev ${sxNum} >>\nstartxref\n${xrefAt}\n%%EOF\n`;
const u8 = new Uint8Array(out.length);
for (let i = 0; i < out.length; i++) u8[i] = out.charCodeAt(i) & 0xff;
return u8;
}
export async function pageHasPatternFill(originalBytes, pageIndex) {
try {
const orig = latin(originalBytes);
const op = await withObjStm(originalBytes, orig, parsePdf(orig));
if (!op) return false;
const pc = await pageContent(originalBytes, orig, op, pageIndex);
if (pc == null) return false;
return shBlocks(pc.content).some((b) => b.isPattern);
} catch {
return false;
}
}
export const dictAt = (body, at) => {
const open = body.indexOf('<<', at);
if (open < 0) return null;
let depth = 0;
for (let i = open; i < body.length - 1; i++) {
if (body[i] === '<' && body[i + 1] === '<') {
depth++;
i++;
continue;
}
if (body[i] === '>' && body[i + 1] === '>') {
depth--;
i++;
if (!depth) return body.slice(open, i + 1);
}
}
return null;
};
export async function protectPatternArtwork(bytes) {
const src = latin(bytes);
const op = await withObjStm(bytes, src, parsePdf(src));
if (!op) return null;
let next = op.size;
const newObjs = [],
pageRewrites = [];
for (let pi = 0; pi < 4096; pi++) {
const pgNum = op.pageNum(pi);
if (pgNum < 0) break;
const pg = op.objBody(pgNum);
if (!pg) continue;
const cOne = pg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
if (!cOne) continue;
const pc = await pageContent(bytes, src, op, pi);
if (pc == null) continue;
const pats = shBlocks(pc.content).filter((b) => b.isPattern);
if (!pats.length) continue;
const rref = pg.body.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
const resDict = rref
? dictAt(op.objBody(parseInt(rref[1], 10))?.body ?? '', 0)
: dictAt(pg.body, pg.body.indexOf('/Resources'));
if (!resDict) continue;
const box = pg.body.match(/\/MediaBox\s*\[([^\]]*)\]/);
if (!box) continue;
let content = pc.content;
const added = [];
for (const b of [...pats].sort((x, y) => y.innerStart - x.innerStart)) {
const ops = content.slice(b.innerStart, b.innerEnd);
const num = next++;
const name = `ECBG${num}`;
newObjs.push({
num,
body:
`<< /Type /XObject /Subtype /Form /FormType 1 /BBox [ ${box[1].trim()} ]` +
` /Resources ${resDict} /Length ${ops.length + 1} >>\nstream\n${ops}\nendstream`,
});
content =
content.slice(0, b.innerStart) +
`/${name} Do\n` +
content.slice(b.innerEnd);
added.push({ name, num });
}
const entries = added.map((a) => `/${a.name} ${a.num} 0 R`).join(' ');
let newRes;
const xo = resDict.match(/\/XObject\s*<<([\s\S]*?)>>/);
if (xo)
newRes = resDict.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`);
else newRes = resDict.replace(/^<</, `<< /XObject << ${entries} >> `);
const cnum = next++;
newObjs.push({
num: cnum,
body: `<< /Length ${content.length + 1} >>\nstream\n${content}\nendstream`,
});
let newPage = pg.body.replace(cOne[0], `/Contents ${cnum} 0 R`);
newPage = rref
? newPage.replace(rref[0], `/Resources ${newRes}`)
: newPage.replace(resDict, newRes);
pageRewrites.push({ num: pgNum, body: newPage });
}
if (!pageRewrites.length) return null;
let out = src;
if (!out.endsWith('\n')) out += '\n';
const offsets = new Map();
const emit = (num, body) => {
offsets.set(num, out.length);
out += `${num} 0 obj\n${body.trim()}\nendobj\n`;
};
for (const o of pageRewrites) emit(o.num, o.body);
for (const o of newObjs) emit(o.num, o.body);
const xrefAt = out.length;
const nums = [...offsets.keys()].sort((a, b) => a - b);
let xref = 'xref\n';
for (let i = 0; i < nums.length; ) {
let j = i;
while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
xref += `${nums[i]} ${j - i + 1}\n`;
for (let k = i; k <= j; k++)
xref += String(offsets.get(nums[k])).padStart(10, '0') + ' 00000 n \n';
i = j + 1;
}
out += xref;
const prev = src.lastIndexOf('startxref');
const prevNum = parseInt(src.slice(prev + 9).trim(), 10);
const infoM = src
.slice(src.lastIndexOf('trailer'))
.match(/\/Info\s+(\d+)\s+\d+\s+R/);
out +=
`trailer\n<< /Size ${next} /Root ${op.rootNum} 0 R` +
(infoM ? ` /Info ${infoM[1]} 0 R` : '') +
(Number.isFinite(prevNum) ? ` /Prev ${prevNum}` : '') +
` >>\nstartxref\n${xrefAt}\n%%EOF\n`;
const u8 = new Uint8Array(out.length);
for (let i = 0; i < out.length; i++) u8[i] = out.charCodeAt(i) & 0xff;
return u8;
}
+796
View File
@@ -0,0 +1,796 @@
import { latin, parsePdf, streamData, withObjStm } from './shadingsurgery.js';
function scanShowOperand(txt, at, len) {
const body = txt.slice(at, at + len);
const codes = [];
const slots = [];
let i = 0;
while (i < body.length) {
const ch = body[i];
if (ch === '<') {
const end = body.indexOf('>', i);
if (end < 0) return null;
const rawHex = body.slice(i + 1, end);
const hex = rawHex.replace(/\s+/g, '');
if (hex.length % 4 !== 0) return null;
if (/\s/.test(rawHex)) return null;
for (let k = 0; k < hex.length; k += 4) {
codes.push(parseInt(hex.slice(k, k + 4), 16));
slots.push({ at: at + i + 1 + k, len: 4, index: codes.length - 1 });
}
i = end + 1;
continue;
}
if (ch === '(') {
i++;
let depth = 1;
while (i < body.length) {
const c = body[i];
if (c === '\\') {
const oct = body.slice(i + 1).match(/^[0-7]{1,3}/);
const span = oct ? 1 + oct[0].length : 2;
codes.push(
oct
? parseInt(oct[0], 8)
: (ESCAPES[body[i + 1]] ?? body.charCodeAt(i + 1))
);
slots.push({ at: at + i, len: span, index: codes.length - 1 });
i += span;
continue;
}
if (c === '(') depth++;
else if (c === ')') {
depth--;
if (!depth) {
i++;
break;
}
}
codes.push(body.charCodeAt(i));
slots.push({ at: at + i, len: 1, index: codes.length - 1 });
i++;
}
if (depth) return null;
continue;
}
i++;
}
return codes.length ? { codes, slots } : null;
}
const ESCAPES = { n: 10, r: 13, t: 9, b: 8, f: 12, '(': 40, ')': 41, '\\': 92 };
function showOperators(txt) {
const re =
/\[(?:[^\][]|\\.)*\]\s*TJ|\((?:[^()\\]|\\.)*\)\s*Tj|<[0-9A-Fa-f\s]*>\s*Tj/g;
return [...txt.matchAll(re)].map((m) => ({ at: m.index, len: m[0].length }));
}
const TM_RE =
/(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+Tm/g;
function lastTmMatch(txt, at) {
let last = null;
for (const m of txt.slice(0, at).matchAll(TM_RE)) last = m;
return last;
}
function tmBefore(txt, at) {
const m = lastTmMatch(txt, at);
return m ? m.slice(1, 7).map(Number) : null;
}
function activeSpacing(txt, at) {
let tc = 0,
tw = 0;
for (const m of txt.slice(0, at).matchAll(/(-?[\d.]+)\s+(Tc|Tw)\b/g)) {
if (m[2] === 'Tc') tc = Number(m[1]);
else tw = Number(m[1]);
}
return Math.abs(tc) > 1e-6 || Math.abs(tw) > 1e-6;
}
function buildTJ(op, wide) {
const { after, pos, adv, size } = op;
if (
!after?.length ||
pos?.length !== after.length ||
adv?.length !== after.length
)
return null;
if (!(size > 0)) return null;
if (wide == null) wide = after.some((c) => c > 255);
if (!wide && after.some((c) => c > 255)) return null;
const hex = (c) =>
(wide
? c.toString(16).padStart(4, '0')
: c.toString(16).padStart(2, '0')
).toUpperCase();
let out = '[';
let run = '';
let pen = pos[0];
for (let i = 0; i < after.length; i++) {
if (i > 0) {
const kern = ((pen - pos[i]) * 1000) / size;
if (Math.abs(kern) > 1e-4) {
if (run) {
out += `<${run}>`;
run = '';
}
out += fmt(kern);
}
}
run += hex(after[i]);
pen = pos[i] + adv[i];
}
if (run) out += `<${run}>`;
return out + '] TJ';
}
function inlineDict(body, key) {
const m = body.match(new RegExp(`/${key}\\s*<<`));
if (!m) return null;
let i = m.index + m[0].length,
depth = 1;
for (let k = i; k < body.length - 1; k++) {
if (body[k] === '<' && body[k + 1] === '<') {
depth++;
k++;
} else if (body[k] === '>' && body[k + 1] === '>') {
depth--;
if (!depth) return { at: m.index, inner: body.slice(i, k), end: k + 2 };
k++;
}
}
return null;
}
const col = (rgb) => rgb.map((v) => fmt(v / 255)).join(' ');
const STD14 = new Set([
'Helvetica',
'Helvetica-Bold',
'Helvetica-Oblique',
'Helvetica-BoldOblique',
'Courier',
'Courier-Bold',
'Courier-Oblique',
'Courier-BoldOblique',
'Times-Roman',
'Times-Bold',
'Times-Italic',
'Times-BoldItalic',
'Symbol',
'ZapfDingbats',
]);
function resolveFontNames(
pdf,
pageRec,
pageNum,
needed,
std14Needed,
startNum
) {
const resNum = pdf.dictRef(pageRec.body, 'Resources');
let resBody = null,
resInline = null;
if (resNum >= 0) resBody = pdf.objBody(resNum)?.body;
else {
resInline = inlineDict(pageRec.body, 'Resources');
resBody = resInline?.inner;
}
if (resBody == null) return null;
const fontNum = pdf.dictRef(resBody, 'Font');
let fontBody = null,
fontInline = null;
if (fontNum >= 0) fontBody = pdf.objBody(fontNum)?.body;
else {
fontInline = inlineDict(resBody, 'Font');
fontBody = fontInline ? fontInline.inner : '';
}
if (fontBody == null) return null;
const nameOf = new Map();
const std14NameOf = new Map();
const extraObjs = [];
const used = new Set();
for (const m of fontBody.matchAll(/\/([^\s/<>[\]()]+)\s+(\d+)\s+0\s+R/g)) {
used.add(m[1]);
if (!nameOf.has(+m[2])) nameOf.set(+m[2], m[1]);
}
let adds = '';
let seq = 1;
for (const num of needed) {
if (!nameOf.has(num) && !pdf.objBody(num)) return null;
if (nameOf.has(num)) continue;
let nm;
do {
nm = `ECW${seq++}`;
} while (used.has(nm));
used.add(nm);
nameOf.set(num, nm);
adds += ` /${nm} ${num} 0 R`;
}
let free = startNum;
for (const base of std14Needed || []) {
if (!STD14.has(base)) return null;
let nm;
do {
nm = `ECW${seq++}`;
} while (used.has(nm));
used.add(nm);
const objn = free++;
std14NameOf.set(base, nm);
extraObjs.push({
num: objn,
body: `<< /Type /Font /Subtype /Type1 /BaseFont /${base} /Encoding /WinAnsiEncoding >>`,
});
adds += ` /${nm} ${objn} 0 R`;
}
const updates = [];
if (adds) {
if (fontNum >= 0) {
const fb = pdf.objBody(fontNum).body.trim();
if (!fb.endsWith('>>')) return null;
updates.push({ num: fontNum, body: fb.slice(0, -2) + adds + ' >>' });
} else if (resNum >= 0) {
const rb = pdf.objBody(resNum).body.trim();
if (!rb.endsWith('>>')) return null;
let nb;
if (fontInline) {
const inner2 = rb.slice(2, -2);
const fi = inlineDict(inner2, 'Font');
if (!fi) return null;
nb =
'<<' +
inner2.slice(0, fi.at) +
`/Font <<${fi.inner}${adds} >>` +
inner2.slice(fi.end) +
'>>';
} else {
nb = rb.slice(0, -2) + ` /Font <<${adds} >> >>`;
}
updates.push({ num: resNum, body: nb });
} else {
const pb = pageRec.body;
let nb;
const fi = inlineDict(pb, 'Font');
if (fi) {
nb =
pb.slice(0, fi.at) +
`/Font <<${fi.inner}${adds} >>` +
pb.slice(fi.end);
} else {
const ri = inlineDict(pb, 'Resources');
if (!ri) return null;
nb =
pb.slice(0, ri.at) +
`/Resources <<${ri.inner} /Font <<${adds} >> >>` +
pb.slice(ri.end);
}
updates.push({ num: pageNum, body: nb.trim() });
}
}
return { nameOf, std14NameOf, updates, extraObjs, nextFree: free };
}
function danglingCtm(txt) {
let depth = 0;
let m = [1, 0, 0, 1, 0, 0];
const re =
/(?:^|[\s>\]])((?:-?[\d.]+\s+){6})cm\b|(?:^|[\s>\])])(q)\b|(?:^|[\s>\])])(Q)\b/g;
for (const t of txt.matchAll(re)) {
if (t[2]) depth++;
else if (t[3]) depth = Math.max(0, depth - 1);
else if (t[1] && depth === 0) {
const v = t[1].trim().split(/\s+/).map(Number);
if (v.length === 6 && v.every((x) => Number.isFinite(x))) {
const [a, b, c, d, e, f] = v,
[A, B, C, D, E, F] = m;
m = [
a * A + b * C,
a * B + b * D,
c * A + d * C,
c * B + d * D,
e * A + f * C + E,
e * B + f * D + F,
];
}
}
}
return m;
}
function invertMatrix(m) {
const [a, b, c, d, e, f] = m;
const det = a * d - b * c;
if (Math.abs(det) < 1e-9) return null;
return [
d / det,
-b / det,
-c / det,
a / det,
(c * f - d * e) / det,
(b * e - a * f) / det,
];
}
function buildDrawBlock(drawOps, nameOf, std14NameOf, ctm) {
let out = '';
for (const op of drawOps) {
if (op.kind === 'rectdraw') {
out += `${col(op.rgb)} rg\n${fmt(op.x)} ${fmt(op.y)} ${fmt(op.w)} ${fmt(op.h)} re f\n`;
continue;
}
const name = op.std14 ? std14NameOf.get(op.std14) : nameOf.get(op.font);
if (!name) return null;
const tj = buildTJ(
{ after: op.codes, pos: op.pos, adv: op.adv, size: op.size },
!!op.wide
);
if (tj == null) return null;
out += 'BT\n' + `${col(op.rgb)} rg\n`;
if (op.mode) out += `${op.mode} Tr\n`;
if (op.mode === 1 || op.mode === 2 || op.mode === 5 || op.mode === 6)
out += `${col(op.srgb)} RG\n${fmt(op.sw)} w\n`;
out +=
`/${name} ${fmt(op.size)} Tf\n` +
op.tm.map(fmt).join(' ') +
' Tm\n' +
tj +
'\nET\n';
}
if (!out) return '';
let pre = '';
if (
ctm &&
(Math.abs(ctm[0] - 1) > 1e-6 ||
Math.abs(ctm[1]) > 1e-6 ||
Math.abs(ctm[2]) > 1e-6 ||
Math.abs(ctm[3] - 1) > 1e-6 ||
Math.abs(ctm[4]) > 1e-6 ||
Math.abs(ctm[5]) > 1e-6)
) {
const inv = invertMatrix(ctm);
if (!inv) return null;
pre = inv.map(fmt).join(' ') + ' cm\n';
}
return 'q\n' + pre + out + 'Q\n';
}
function fmt(v) {
let s = v.toFixed(5).replace(/0+$/, '').replace(/\.$/, '');
return s === '-0' ? '0' : s;
}
function locate(txt, want, tm) {
const hits = [];
for (const op of showOperators(txt)) {
const s = scanShowOperand(txt, op.at, op.len);
if (!s || s.codes.length !== want.length) continue;
if (!s.codes.every((c, i) => c === want[i])) continue;
hits.push({ op, scan: s, tm: tmBefore(txt, op.at) });
}
if (!hits.length) return null;
if (hits.length === 1) return hits[0];
const near = hits.filter(
(h) =>
h.tm &&
tm &&
Math.abs(h.tm[4] - tm[4]) < 0.01 &&
Math.abs(h.tm[5] - tm[5]) < 0.01
);
return near.length === 1 ? near[0] : null;
}
async function findHolderStream(bytes, src, pdf, pageRec, codes) {
const resNum = pdf.dictRef(pageRec.body, 'Resources');
const resBody =
resNum >= 0
? pdf.objBody(resNum)?.body
: pageRec.body.match(/\/Resources\s*<<([\s\S]*?)>>/)?.[1];
if (!resBody) return null;
const xoNum = pdf.dictRef(resBody, 'XObject');
const xoBody =
xoNum >= 0
? pdf.objBody(xoNum)?.body
: resBody.match(/\/XObject\s*<<([\s\S]*?)>>/)?.[1];
if (!xoBody) return null;
for (const m of xoBody.matchAll(/\/([^\s/<>[\]()]+)\s+(\d+)\s+0\s+R/g)) {
const num = +m[2];
const rec = pdf.objBody(num);
if (!rec || !/\/Subtype\s*\/Form/.test(rec.body)) continue;
const data = await streamData(bytes, src, { num, ...rec });
if (!data) continue;
const text = typeof data === 'string' ? data : latin(data);
if (!locate(text, codes, null)) continue;
const refs = [...src.matchAll(new RegExp(`\\b${num}\\s+0\\s+R\\b`, 'g'))]
.length;
let pagesUsing = 0;
if (resNum >= 0) {
for (const pm of src.matchAll(/\/Type\s*\/Page\b/g)) {
const start = src.lastIndexOf('obj', pm.index);
const end = src.indexOf('endobj', pm.index);
if (start < 0 || end < 0) continue;
if (
new RegExp(`/Resources\\s+${resNum}\\s+0\\s+R`).test(
src.slice(start, end)
)
)
pagesUsing++;
}
}
return {
num,
rec,
text,
name: m[1],
xoNum,
xoBody,
resNum,
shared: refs > 1 || pagesUsing > 1,
};
}
return null;
}
export async function applyPlan(originalBytes, pageIndex, plan) {
if (
!plan ||
!plan.ops?.length ||
(plan.kind !== 'delete' && plan.kind !== 'splice' && plan.kind !== 'write')
)
return null;
const src = latin(originalBytes);
if (/\/Encrypt\s+\d+\s+\d+\s+R|\/Encrypt\s*<</.test(src)) return null;
let pdf = parsePdf(src);
if (!pdf) return null;
pdf = await withObjStm(originalBytes, src, pdf);
if (!pdf) return null;
const pageNum = pdf.pageNum(pageIndex);
if (pageNum == null || pageNum < 0) return null;
const pageRec = pdf.objBody(pageNum);
if (!pageRec) return null;
const cm = pageRec.body.match(/\/Contents\s+(\d+)\s+0\s+R/);
const cArr = pageRec.body.match(/\/Contents\s*\[([^\]]*)\]/);
const isWritePlan = plan.ops.some(
(o) => o.kind === 'blank' || o.kind === 'draw' || o.kind === 'rectdraw'
);
let cNum,
cRec,
cow = null,
txt;
let emptyMembers = [];
if (cm) {
cNum = +cm[1];
cRec = pdf.objBody(cNum);
if (!cRec) return null;
const raw = await streamData(originalBytes, src, { num: cNum, ...cRec });
if (!raw) return null;
txt = typeof raw === 'string' ? raw : latin(raw);
} else if (cArr && isWritePlan) {
const refs = [...cArr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((m) => +m[1]);
if (!refs.length) return null;
let concat = '';
for (const n of refs) {
const rec = pdf.objBody(n);
if (!rec) return null;
const raw = await streamData(originalBytes, src, { num: n, ...rec });
if (raw == null) return null;
concat +=
(concat ? '\n' : '') + (typeof raw === 'string' ? raw : latin(raw));
}
cNum = refs[0];
cRec = pdf.objBody(cNum);
txt = concat;
emptyMembers = refs.slice(1);
} else {
return null;
}
const firstCodes = plan.ops.find((o) => o.codes)?.codes;
const isWrite = plan.ops.some(
(o) => o.kind === 'blank' || o.kind === 'draw' || o.kind === 'rectdraw'
);
if (!isWrite && firstCodes && !locate(txt, firstCodes, plan.ops[0].tm)) {
const holder = await findHolderStream(
originalBytes,
src,
pdf,
pageRec,
firstCodes
);
if (!holder) return null;
({ num: cNum, rec: cRec, text: txt } = holder);
if (holder.shared) cow = { holder, pageNum, pageRec };
}
const edits = [];
const drawOps = [];
const blankOps = plan.ops.filter((o) => o.kind === 'blank');
if (blankOps.length) {
const all = showOperators(txt).map((o) => ({
...o,
scan: scanShowOperand(txt, o.at, o.len),
}));
const matches = (o, codes) =>
o.scan &&
o.scan.codes.length === codes.length &&
o.scan.codes.every((c, i) => c === codes[i]);
const chainFrom = (start) => {
const picks = [];
let k = start;
for (const op of blankOps) {
while (k < all.length && !matches(all[k], op.codes)) k++;
if (k >= all.length) return null;
picks.push(all[k]);
k++;
}
return picks;
};
let chain = null;
for (let a = 0; a < all.length; a++) {
if (!matches(all[a], blankOps[0].codes)) continue;
const c = chainFrom(a);
if (c) {
if (chain) {
chain = null;
break;
}
chain = c;
for (let a2 = a + 1; a2 < all.length; a2++) {
if (!matches(all[a2], blankOps[0].codes)) continue;
const c2 = chainFrom(a2);
if (c2 && c2[0].at !== c[0].at && c2[0].at > c[c.length - 1].at) {
chain = null;
}
break;
}
break;
}
}
if (!chain) return null;
for (const pick of chain)
edits.push({ at: pick.at, len: pick.len, text: '[] TJ' });
}
for (const op of plan.ops) {
if (op.kind === 'blank') continue;
if (op.kind === 'draw' || op.kind === 'rectdraw') {
drawOps.push(op);
continue;
}
const hit = locate(txt, op.codes, op.tm);
if (!hit) return null;
if (op.kind === 'rebuild') {
if (activeSpacing(txt, hit.op.at)) return null;
const wide = hit.scan.slots.some((s2) => s2.len === 4);
const arr = buildTJ(op, wide);
if (arr == null) return null;
edits.push({ at: hit.op.at, len: hit.op.len, text: arr });
if (op.dx || op.dy) {
const m = lastTmMatch(txt, hit.op.at);
if (!m) return null;
const e = Number(m[5]) + (op.dx || 0);
const f = Number(m[6]) + (op.dy || 0);
edits.push({
at: m.index,
len: m[0].length,
text: `${m[1]} ${m[2]} ${m[3]} ${m[4]} ${fmt(e)} ${fmt(f)} Tm`,
});
}
continue;
}
if (op.kind === 'tm') {
const m = lastTmMatch(txt, hit.op.at);
if (!m) return null;
const e = Number(m[5]) + (op.dx || 0);
const f = Number(m[6]) + (op.dy || 0);
const text = `${m[1]} ${m[2]} ${m[3]} ${m[4]} ${fmt(e)} ${fmt(f)} Tm`;
edits.push({ at: m.index, len: m[0].length, text });
continue;
}
for (const idx of op.drop) {
const slot = hit.scan.slots[idx];
if (!slot) return null;
edits.push({ at: slot.at, len: slot.len, text: '' });
}
}
if (!edits.length && !drawOps.length) return null;
const sorted = [...edits].sort((a, b) => a.at - b.at);
for (let i = 1; i < sorted.length; i++)
if (sorted[i].at < sorted[i - 1].at + sorted[i - 1].len) return null;
sorted.reverse();
for (const e of sorted)
txt = txt.slice(0, e.at) + e.text + txt.slice(e.at + e.len);
let resourceUpdates = [];
let extraFontObjs = [];
if (drawOps.length) {
if (cow) return null;
const needed = new Set(
drawOps.filter((o) => o.kind === 'draw' && !o.std14).map((o) => o.font)
);
const std14Needed = new Set(
drawOps.filter((o) => o.std14).map((o) => o.std14)
);
const res = resolveFontNames(
pdf,
pageRec,
pageNum,
needed,
std14Needed,
pdf.size
);
if (!res) return null;
const block = buildDrawBlock(
drawOps,
res.nameOf,
res.std14NameOf,
danglingCtm(txt)
);
if (block == null) return null;
txt += '\n' + block;
resourceUpdates = res.updates;
extraFontObjs = res.extraObjs || [];
}
for (const n of emptyMembers) {
const rec = pdf.objBody(n);
if (!rec) return null;
const dict = rec.body
.slice(0, rec.body.indexOf('stream'))
.replace(/\/Filter\s*\/\w+/g, '')
.replace(/\/Filter\s*\[[^\]]*\]/g, '')
.replace(/\/DecodeParms\s*<<[^>]*>>/g, '')
.replace(/\/Length\s+\d+(\s+\d+\s+R)?/g, '')
.replace(/>>\s*$/, '')
.replace(/^\s*<</, '');
extraFontObjs.push({ num: n, stream: '', dict });
}
return rewriteStream(originalBytes, src, pdf, cNum, cRec, txt, cow, [
...resourceUpdates,
...extraFontObjs,
]);
}
export const spliceDeletion = applyPlan;
const bin = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 0xff);
function xrefStyle(src) {
const i = src.lastIndexOf('startxref');
if (i < 0) return 'classic';
const m = src.slice(i).match(/startxref\s+(\d+)/);
if (!m) return 'classic';
const at = +m[1];
return /^\s*xref/.test(src.slice(at, at + 8)) ? 'classic' : 'stream';
}
function rewriteStream(originalBytes, src, pdf, num, rec, text, cow, moreObjs) {
let targetNum = num;
const extra = [];
let nextFree = Math.max(pdf.size, num + 1);
for (const e of moreObjs || [])
nextFree = Math.max(nextFree, (e.num || 0) + 1);
if (cow) {
const h = cow.holder;
if (h.xoNum < 0 || h.resNum < 0) return null;
targetNum = nextFree++;
const newXo = nextFree++;
const newRes = nextFree++;
const xo = h.xoBody.replace(
new RegExp(`(/${h.name}\\s+)\\d+(\\s+0\\s+R)`),
`$1${targetNum}$2`
);
if (xo === h.xoBody) return null;
extra.push({ num: newXo, body: `<<${xo}>>` });
const resRec = pdf.objBody(h.resNum);
if (!resRec) return null;
const res = resRec.body.replace(
new RegExp(`(/XObject\\s+)${h.xoNum}(\\s+0\\s+R)`),
`$1${newXo}$2`
);
if (res === resRec.body) return null;
extra.push({ num: newRes, body: res.trim() });
const pageBody = cow.pageRec.body.replace(
new RegExp(`(/Resources\\s+)${h.resNum}(\\s+0\\s+R)`),
`$1${newRes}$2`
);
if (pageBody === cow.pageRec.body) return null;
extra.push({ num: cow.pageNum, body: pageBody.trim() });
}
for (const e of moreObjs || []) extra.push(e);
const out = bin(text);
const dict = rec.body.slice(0, rec.body.indexOf('stream'));
let nd = dict
.replace(/\/Filter\s*\/\w+/g, '')
.replace(/\/Filter\s*\[[^\]]*\]/g, '')
.replace(/\/DecodeParms\s*<<[^>]*>>/g, '')
.replace(/\/Length\s+\d+(\s+\d+\s+R)?/g, '');
nd = nd.replace(/>>\s*$/, '').replace(/^\s*<</, '');
const bytes = [];
const push = (s2) => {
for (const ch of bin(s2)) bytes.push(ch);
};
const base = originalBytes.length;
const offsets = new Map();
push('\n');
offsets.set(targetNum, base + bytes.length);
push(`${targetNum} 0 obj\n<<${nd} /Length ${out.length}>>\nstream\n`);
for (const b2 of out) bytes.push(b2);
push('\nendstream\nendobj\n');
for (const e of extra) {
offsets.set(e.num, base + bytes.length);
if (e.stream !== undefined) {
const sb = bin(e.stream);
push(
`${e.num} 0 obj\n<<${e.dict || ''} /Length ${sb.length}>>\nstream\n`
);
for (const b2 of sb) bytes.push(b2);
push('\nendstream\nendobj\n');
} else {
push(`${e.num} 0 obj\n${e.body}\nendobj\n`);
}
}
const prev = prevStartXref(src);
const style = xrefStyle(src);
if (style === 'classic') {
const xrefAt = base + bytes.length;
const nums = [...offsets.keys()].sort((x, y) => x - y);
push('xref\n0 1\n0000000000 65535 f \n');
for (const n of nums)
push(
`${n} 1\n` + String(offsets.get(n)).padStart(10, '0') + ' 00000 n \n'
);
const size = Math.max(pdf.size, Math.max(...nums) + 1);
push(
`trailer\n<< /Size ${size} /Root ${pdf.rootNum} 0 R /Prev ${prev} >>\n`
);
push(`startxref\n${xrefAt}\n%%EOF\n`);
} else {
const xrefNum = nextFree++;
const xrefAt = base + bytes.length;
offsets.set(xrefNum, xrefAt);
const nums = [...offsets.keys()].sort((x, y) => x - y);
const size = Math.max(pdf.size, Math.max(...nums) + 1);
let index = '';
const rows = [];
for (const n of nums) {
index += `${n} 1 `;
const off = offsets.get(n);
rows.push(
1,
(off >>> 24) & 0xff,
(off >>> 16) & 0xff,
(off >>> 8) & 0xff,
off & 0xff,
0,
0
);
}
push(
`${xrefNum} 0 obj\n<< /Type /XRef /Size ${size} /Index [ ${index}] ` +
`/W [1 4 2] /Root ${pdf.rootNum} 0 R /Prev ${prev} ` +
`/Length ${rows.length} >>\nstream\n`
);
for (const b2 of rows) bytes.push(b2);
push('\nendstream\nendobj\n');
push(`startxref\n${xrefAt}\n%%EOF\n`);
}
const res2 = new Uint8Array(base + bytes.length);
res2.set(originalBytes, 0);
res2.set(Uint8Array.from(bytes), base);
return res2;
}
function prevStartXref(src) {
const i = src.lastIndexOf('startxref');
if (i < 0) return 0;
const m = src.slice(i).match(/startxref\s+(\d+)/);
return m ? +m[1] : 0;
}
+253
View File
@@ -0,0 +1,253 @@
const latin = (u8) => {
let s = '';
const CH = 0x8000;
for (let i = 0; i < u8.length; i += CH) {
s += String.fromCharCode.apply(
null,
u8.subarray(i, Math.min(i + CH, u8.length))
);
}
return s;
};
export function applyTagSurgery(
bytes,
{ pageIndex = 0, newMarks = [], altText = [] } = {}
) {
if (!newMarks.length && !altText.length) return bytes;
const src = latin(bytes);
const sx = src.lastIndexOf('startxref');
if (sx < 0) return bytes;
const sxNum = parseInt(src.slice(sx + 9).trim(), 10);
if (!Number.isFinite(sxNum) || src.slice(sxNum, sxNum + 4) !== 'xref') {
console.warn('[tags] non-classic xref — surgery skipped');
return bytes;
}
const trailerAt = src.lastIndexOf('trailer');
if (trailerAt < 0) return bytes;
const trailer = src.slice(trailerAt, sx);
const tref = (key) => {
const m = trailer.match(new RegExp('/' + key + '\\s+(\\d+)\\s+\\d+\\s+R'));
return m ? parseInt(m[1], 10) : -1;
};
const tint = (key) => {
const m = trailer.match(new RegExp('/' + key + '\\s+(\\d+)'));
return m ? parseInt(m[1], 10) : -1;
};
const rootNum = tref('Root');
const size = tint('Size');
if (rootNum < 0 || size < 0) return bytes;
const objBody = (num) => {
let at = -1;
for (const m of src.matchAll(
new RegExp('(?:^|[^0-9])' + num + '\\s+0\\s+obj\\b', 'g')
)) {
at = m.index;
}
if (at < 0) return null;
const start = src.indexOf('obj', at) + 3;
const end = src.indexOf('endobj', start);
if (end < 0) return null;
return { start, end, body: src.slice(start, end) };
};
const dictRef = (body, key) => {
const m = body.match(new RegExp('/' + key + '\\s+(\\d+)\\s+\\d+\\s+R'));
return m ? parseInt(m[1], 10) : -1;
};
const dictInt = (body, key) => {
const m = body.match(new RegExp('/' + key + '\\s+(-?\\d+)'));
return m ? parseInt(m[1], 10) : -1;
};
const root = objBody(rootNum);
if (!root) return bytes;
const strNum = dictRef(root.body, 'StructTreeRoot');
if (strNum < 0) return bytes;
const str = objBody(strNum);
if (!str) return bytes;
const parentTreeNum = dictRef(str.body, 'ParentTree');
const pagesNum = dictRef(root.body, 'Pages');
let pageNum = -1;
{
let remaining = pageIndex;
const walk = (num) => {
const o = objBody(num);
if (!o) return -1;
if (/\/Type\s*\/Page\b(?!s)/.test(o.body)) {
if (remaining === 0) return num;
remaining--;
return -1;
}
const kids = o.body.match(/\/Kids\s*\[([^\]]*)\]/);
if (!kids) return -1;
for (const r of kids[1].matchAll(/(\d+)\s+\d+\s+R/g)) {
const hit = walk(parseInt(r[1], 10));
if (hit >= 0) return hit;
}
return -1;
};
pageNum = walk(pagesNum);
}
if (pageNum < 0) return bytes;
const page = objBody(pageNum);
const structParents = page ? dictInt(page.body, 'StructParents') : -1;
if (structParents < 0) {
console.warn('[tags] page has no /StructParents — surgery skipped');
return bytes;
}
let ptArrayNum = -1;
{
const visit = (num) => {
const o = objBody(num);
if (!o) return;
const kids = o.body.match(/\/Kids\s*\[([^\]]*)\]/);
if (kids) {
for (const r of kids[1].matchAll(/(\d+)\s+\d+\s+R/g))
visit(parseInt(r[1], 10));
}
const nums = o.body.match(/\/Nums\s*\[([\s\S]*?)\]/);
if (!nums) return;
for (const t of nums[1].matchAll(/(\d+)\s+(\d+)\s+\d+\s+R/g)) {
if (parseInt(t[1], 10) === structParents)
ptArrayNum = parseInt(t[2], 10);
}
};
if (parentTreeNum >= 0) visit(parentTreeNum);
else {
const nums = str.body.match(
/\/ParentTree\s*<<[\s\S]*?\/Nums\s*\[([\s\S]*?)\]/
);
if (nums) {
for (const t of nums[1].matchAll(/(\d+)\s+(\d+)\s+\d+\s+R/g)) {
if (parseInt(t[1], 10) === structParents)
ptArrayNum = parseInt(t[2], 10);
}
}
}
}
if (ptArrayNum < 0) {
console.warn('[tags] ParentTree entry for the page not found — skipped');
return bytes;
}
const ptArr = objBody(ptArrayNum);
if (!ptArr) return bytes;
const arrM = ptArr.body.match(/\[([\s\S]*?)\]/);
if (!arrM) return bytes;
const items = [];
for (const t of arrM[1].matchAll(/(\d+\s+\d+\s+R|null)/g))
items.push(t[1].replace(/\s+/g, ' '));
let parentNum = strNum;
{
const kRef = str.body.match(/\/K\s+(\d+)\s+\d+\s+R/);
const kArr = str.body.match(/\/K\s*\[\s*(\d+)\s+\d+\s+R/);
const cand = kRef
? parseInt(kRef[1], 10)
: kArr
? parseInt(kArr[1], 10)
: -1;
if (cand >= 0) {
const co = objBody(cand);
const sName = co && co.body.match(/\/S\s*\/(\w+)/);
if (sName && /^(Document|Part|Art|Sect|Div)$/.test(sName[1])) {
parentNum = cand;
}
}
}
const parent = objBody(parentNum);
if (!parent) return bytes;
let next = size;
const newObjs = [];
const updated = new Map();
const esc = (t) =>
t.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
const uniq = [...new Map(newMarks.map((m) => [m.mcid, m])).values()].sort(
(a, b) => a.mcid - b.mcid
);
const addedRefs = [];
for (const mk of uniq) {
const alt = altText.find((a) => a.mcid === mk.mcid);
const num = next++;
newObjs.push({
num,
body:
`<< /Type /StructElem /S /${mk.type || 'P'} /P ${parentNum} 0 R ` +
`/Pg ${pageNum} 0 R /K ${mk.mcid}` +
(alt ? ` /Alt (${esc(alt.text)})` : '') +
` >>`,
});
addedRefs.push(`${num} 0 R`);
while (items.length <= mk.mcid) items.push('null');
items[mk.mcid] = `${num} 0 R`;
}
for (const a of altText) {
if (uniq.some((m) => m.mcid === a.mcid)) continue;
const entry = items[a.mcid];
const m = entry && entry.match(/(\d+)\s+0\s+R/);
if (!m) continue;
const elemNum = parseInt(m[1], 10);
const elem = objBody(elemNum);
if (!elem) continue;
let body = elem.body;
body = /\/Alt\s*\([^)]*\)/.test(body)
? body.replace(/\/Alt\s*\([^)]*\)/, `/Alt (${esc(a.text)})`)
: body.replace(/>>\s*$/, `/Alt (${esc(a.text)}) >>`);
updated.set(elemNum, body);
}
if (addedRefs.length) {
let pb = parent.body;
const kArr = pb.match(/\/K\s*\[([\s\S]*?)\]/);
if (kArr) {
pb = pb.replace(kArr[0], `/K [${kArr[1]} ${addedRefs.join(' ')}]`);
} else {
const kOne = pb.match(/\/K\s+((?:\d+\s+\d+\s+R)|\d+)/);
if (!kOne) return bytes;
pb = pb.replace(kOne[0], `/K [${kOne[1]} ${addedRefs.join(' ')}]`);
}
updated.set(parentNum, pb);
updated.set(ptArrayNum, `[ ${items.join(' ')} ]`);
}
if (!newObjs.length && !updated.size) return bytes;
let out = src;
if (!out.endsWith('\n')) out += '\n';
const offsets = new Map();
const emit = (num, body) => {
offsets.set(num, out.length);
out += `${num} 0 obj\n${body.trim()}\nendobj\n`;
};
for (const [num, body] of updated) emit(num, body);
for (const o of newObjs) emit(o.num, o.body);
const xrefAt = out.length;
const nums = [...offsets.keys()].sort((a, b) => a - b);
let xref = 'xref\n';
for (let i = 0; i < nums.length; ) {
let j = i;
while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
xref += `${nums[i]} ${j - i + 1}\n`;
for (let k = i; k <= j; k++) {
xref += String(offsets.get(nums[k])).padStart(10, '0') + ' 00000 n \n';
}
i = j + 1;
}
out += xref;
const infoNum = tref('Info');
out +=
`trailer\n<< /Size ${next} /Root ${rootNum} 0 R` +
(infoNum >= 0 ? ` /Info ${infoNum} 0 R` : '') +
` /Prev ${sxNum} >>\nstartxref\n${xrefAt}\n%%EOF\n`;
const u8 = new Uint8Array(out.length);
for (let i = 0; i < out.length; i++) u8[i] = out.charCodeAt(i) & 0xff;
return u8;
}
+774
View File
@@ -0,0 +1,774 @@
import {
latin,
parsePdf,
streamData,
withObjStm,
dictAt,
} from './shadingsurgery.js';
const mul = (m, n) => [
m[0] * n[0] + m[1] * n[2],
m[0] * n[1] + m[1] * n[3],
m[2] * n[0] + m[3] * n[2],
m[2] * n[1] + m[3] * n[3],
m[4] * n[0] + m[5] * n[2] + n[4],
m[4] * n[1] + m[5] * n[3] + n[5],
];
const I = [1, 0, 0, 1, 0, 0];
const TOKEN_RE =
/\((?:\\.|[^\\)])*\)|<<[\s\S]*?>>|<[0-9A-Fa-f\s]*>|\[(?:\((?:\\.|[^\\)])*\)|<[0-9A-Fa-f\s]*>|[^\]])*\]|\/[^\s/<>()[\]{}%]*|[^\s/<>()[\]{}%]+/g;
const OPERATOR = /^[A-Za-z'"][A-Za-z0-9*'"]*$/;
function analyzeContent(content, fontIsT3) {
const blocks = [];
const tjs = [];
const stack = [];
let ctm = I;
let fill = null,
strokeC = null,
gsOp = null;
let tf = null,
tfSize = 0,
tc = null,
tw = null,
tz = null,
tl = null,
ts = null,
trm = null;
let btEntry = null;
let tm = I,
tlm = I;
let blk = null;
let pend = [];
const hexCodes = (tok) => {
const out = [];
if (tok[0] === '<') {
const h = tok.slice(1, -1).replace(/\s+/g, '');
for (let i = 0; i < h.length; i += 2) {
const b = h.slice(i, i + 2);
out.push(parseInt(b.length === 2 ? b : b + '0', 16));
}
} else if (tok[0] === '(') {
const s = tok.slice(1, -1);
for (let i = 0; i < s.length; i++) {
let c = s[i];
if (c === '\\') {
const n = s[i + 1];
if (n >= '0' && n <= '7') {
let oct = '';
while (oct.length < 3 && s[i + 1] >= '0' && s[i + 1] <= '7')
oct += s[++i];
out.push(parseInt(oct, 8));
continue;
}
i++;
c = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f' }[n] ?? n;
}
out.push(c.charCodeAt(0) & 0xff);
}
}
return out;
};
const show = (tok) => {
if (tok == null) return;
let codes = [];
if (tok[0] === '[') {
for (const el of tok
.slice(1, -1)
.matchAll(/\((?:\\.|[^\\)])*\)|<[0-9A-Fa-f\s]*>/g))
codes = codes.concat(hexCodes(el[0]));
} else codes = hexCodes(tok);
const dev = mul(tm, ctm);
tjs.push({
e: dev[4],
f: dev[5],
font: tf,
size: tfSize,
codes,
block: blocks.length,
});
if (blk) {
blk.shows.push(tjs.length - 1);
if (tf) blk.fonts.add(tf);
else blk.selfContained = false;
}
};
for (const m of content.matchAll(TOKEN_RE)) {
const t = m[0],
at = m.index;
if (!OPERATOR.test(t) || /^[\d.]/.test(t)) {
pend.push(t);
continue;
}
const nums = () => pend.map(Number);
switch (t) {
case 'q':
stack.push({
ctm,
fill,
strokeC,
gsOp,
tf,
tfSize,
tc,
tw,
tz,
tl,
ts,
trm,
});
break;
case 'Q': {
const s = stack.pop();
if (s)
({ ctm, fill, strokeC, gsOp, tf, tfSize, tc, tw, tz, tl, ts, trm } =
s);
break;
}
case 'cm': {
const n = nums();
if (n.length === 6) ctm = mul(n, ctm);
break;
}
case 'gs':
gsOp = pend[0] + ' gs';
break;
case 'g':
case 'rg':
case 'k':
fill = pend.join(' ') + ' ' + t;
break;
case 'G':
case 'RG':
case 'K':
strokeC = pend.join(' ') + ' ' + t;
break;
case 'sc':
case 'scn':
fill = pend.join(' ') + ' ' + t;
break;
case 'SC':
case 'SCN':
strokeC = pend.join(' ') + ' ' + t;
break;
case 'BT':
tm = I;
tlm = I;
btEntry = { fill, strokeC, gsOp, tf, tfSize, tc, tw, tz, tl, ts, trm };
blk = {
start: at,
end: -1,
fonts: new Set(),
shows: [],
selfContained: true,
entry: btEntry,
};
break;
case 'ET':
if (blk) {
blk.end = at + 2;
blocks.push(blk);
}
blk = null;
break;
case 'Tf':
tf = pend[0] ? pend[0].slice(1) : null;
tfSize = Number(pend[1]) || 0;
break;
case 'Tm': {
const n = nums();
if (n.length === 6) {
tm = n;
tlm = n;
}
break;
}
case 'Td': {
const n = nums();
tlm = mul([1, 0, 0, 1, n[0] || 0, n[1] || 0], tlm);
tm = tlm;
break;
}
case 'TD': {
const n = nums();
tl = -(n[1] || 0);
tlm = mul([1, 0, 0, 1, n[0] || 0, n[1] || 0], tlm);
tm = tlm;
break;
}
case 'T*':
tlm = mul([1, 0, 0, 1, 0, -(tl || 0)], tlm);
tm = tlm;
break;
case 'TL':
tl = Number(pend[0]) || 0;
break;
case 'Tc':
tc = pend[0] + ' Tc';
break;
case 'Tw':
tw = pend[0] + ' Tw';
break;
case 'Tz':
tz = pend[0] + ' Tz';
break;
case 'Ts':
ts = pend[0] + ' Ts';
break;
case 'Tr':
trm = pend[0] + ' Tr';
break;
case 'Tj':
show(pend[pend.length - 1]);
break;
case 'TJ':
show(pend[pend.length - 1]);
break;
case "'":
tlm = mul([1, 0, 0, 1, 0, -(tl || 0)], tlm);
tm = tlm;
show(pend[pend.length - 1]);
break;
case '"':
tlm = mul([1, 0, 0, 1, 0, -(tl || 0)], tlm);
tm = tlm;
show(pend[pend.length - 1]);
break;
default:
break;
}
pend = [];
}
for (const b of blocks) {
if (!b.shows.length) b.selfContained = false;
for (const si of b.shows) {
const tj = tjs[si];
if (!tj.font || !fontIsT3(tj.font)) b.selfContained = false;
}
}
return { blocks, tjs };
}
function pageT3Fonts(op, pgBody) {
let resBody;
const rref = pgBody.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
if (rref) resBody = op.objBody(parseInt(rref[1], 10))?.body ?? '';
else resBody = dictAt(pgBody, pgBody.indexOf('/Resources')) ?? '';
const fontDict = resBody && dictAt(resBody, resBody.indexOf('/Font'));
if (!fontDict) return null;
const t3 = new Set();
const nums = {};
for (const m of fontDict.matchAll(
/\/([A-Za-z0-9_.#-]+)\s+(\d+)\s+\d+\s+R/g
)) {
const num = parseInt(m[2], 10);
nums[m[1]] = num;
const fo = op.objBody(num);
if (fo && /\/Subtype\s*\/Type3\b/.test(fo.body)) t3.add(m[1]);
}
return t3.size
? { t3, nums, resBody, resRef: rref ? parseInt(rref[1], 10) : -1 }
: null;
}
export async function protectType3Text(bytes) {
const src = latin(bytes);
if (!/\/Subtype\s*\/Type3\b/.test(src)) return null;
const op = await withObjStm(bytes, src, parsePdf(src));
if (!op) return null;
let next = op.size;
const newObjs = [],
pageRewrites = [];
const seg = {};
for (let pi = 0; pi < 4096; pi++) {
const pgNum = op.pageNum(pi);
if (pgNum < 0) break;
const pg = op.objBody(pgNum);
if (!pg) continue;
const fonts = pageT3Fonts(op, pg.body);
if (!fonts) continue;
const cOne = pg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
const cArr = pg.body.match(/\/Contents\s*\[([^\]]*)\]/);
const refs = cOne
? [parseInt(cOne[1], 10)]
: cArr
? [...cArr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) =>
parseInt(r[1], 10)
)
: [];
if (!refs.length) continue;
let content = '';
let ok = true;
for (const rn of refs) {
const o = op.objBody(rn);
const d = o && (await streamData(bytes, src, o));
if (d == null) {
ok = false;
break;
}
content += d + '\n';
}
if (!ok) continue;
const { blocks, tjs } = analyzeContent(content, (n) => fonts.t3.has(n));
seg[pi] = {
fonts: fonts.nums,
t3: [...fonts.t3],
tjs: tjs.map((j) => ({
e: j.e,
f: j.f,
font: j.font,
size: j.size,
codes: j.codes,
})),
};
const liftable = blocks.filter((b) => b.selfContained);
if (!liftable.length) continue;
const resNum = next++;
newObjs.push({ num: resNum, body: fonts.resBody.trim() || '<< >>' });
const added = [];
for (const b of [...liftable].sort((x, y) => y.start - x.start)) {
const en = b.entry;
const pre = [en.fill, en.strokeC, en.gsOp].filter(Boolean).join('\n');
const tstate = [
en.tf ? `/${en.tf} ${en.tfSize} Tf` : null,
en.tc,
en.tw,
en.tz,
en.ts,
en.trm,
en.tl != null ? `${en.tl} TL` : null,
]
.filter(Boolean)
.join(' ');
let ops = content.slice(b.start, b.end);
if (tstate) ops = ops.replace(/^BT\b/, 'BT ' + tstate);
if (pre) ops = pre + '\n' + ops;
const num = next++;
const name = `ECT3${num}`;
newObjs.push({
num,
body:
`<< /Type /XObject /Subtype /Form /FormType 1` +
` /BBox [ -100000 -100000 100000 100000 ]` +
` /Resources ${resNum} 0 R /Length ${ops.length + 1} >>\nstream\n${ops}\nendstream`,
});
content =
content.slice(0, b.start) + `/${name} Do\n` + content.slice(b.end);
added.push({ name, num });
}
const cnum = next++;
newObjs.push({
num: cnum,
body: `<< /Length ${content.length + 1} >>\nstream\n${content}\nendstream`,
});
const entries = added.map((a) => `/${a.name} ${a.num} 0 R`).join(' ');
let newPage = pg.body.replace(
cOne ? cOne[0] : cArr[0],
`/Contents ${cnum} 0 R`
);
if (fonts.resRef >= 0) {
let res = fonts.resBody;
const xo = res.match(/\/XObject\s*<<([\s\S]*?)>>/);
res = xo
? res.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`)
: res.replace(/^<</, `<< /XObject << ${entries} >> `);
pageRewrites.push({ num: fonts.resRef, body: res });
} else {
const resDict = dictAt(pg.body, pg.body.indexOf('/Resources'));
const xo = resDict.match(/\/XObject\s*<<([\s\S]*?)>>/);
const newRes = xo
? resDict.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`)
: resDict.replace(/^<</, `<< /XObject << ${entries} >> `);
newPage = newPage.replace(resDict, newRes);
}
pageRewrites.push({ num: pgNum, body: newPage });
}
if (!pageRewrites.length) return null;
const out = appendRevision(src, op.rootNum, next, pageRewrites, newObjs);
return { bytes: out, seg };
}
export async function protectFragileText(bytes) {
const src = latin(bytes);
if (!/Quartz PDFContext/.test(src)) return null;
if (!/\/BaseFont\s*\/[A-Z]{6}\+/.test(src)) return null;
const op = await withObjStm(bytes, src, parsePdf(src));
if (!op) return null;
let next = op.size;
const newObjs = [],
pageRewrites = [];
const pages = [];
for (let pi = 0; pi < 4096; pi++) {
const pgNum = op.pageNum(pi);
if (pgNum < 0) break;
const pg = op.objBody(pgNum);
if (!pg) continue;
let resBody;
const rref = pg.body.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
if (rref) resBody = op.objBody(parseInt(rref[1], 10))?.body ?? '';
else resBody = dictAt(pg.body, pg.body.indexOf('/Resources')) ?? '';
const fontDict = resBody && dictAt(resBody, resBody.indexOf('/Font'));
if (!fontDict || !/\/[A-Za-z0-9_.#-]+\s+\d+\s+\d+\s+R/.test(fontDict))
continue;
const cOne = pg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
const cArr = pg.body.match(/\/Contents\s*\[([^\]]*)\]/);
const refs = cOne
? [parseInt(cOne[1], 10)]
: cArr
? [...cArr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) =>
parseInt(r[1], 10)
)
: [];
if (!refs.length) continue;
let content = '';
let ok = true;
for (const rn of refs) {
const o = op.objBody(rn);
const d = o && (await streamData(bytes, src, o));
if (d == null) {
ok = false;
break;
}
content += d + '\n';
}
if (!ok) continue;
const { blocks } = analyzeContent(content, () => true);
const liftable = blocks.filter((b) => b.selfContained);
if (!liftable.length) continue;
const resNum = next++;
newObjs.push({ num: resNum, body: resBody.trim() || '<< >>' });
const added = [];
for (const b of [...liftable].sort((x, y) => y.start - x.start)) {
const en = b.entry;
const pre = [en.fill, en.strokeC, en.gsOp].filter(Boolean).join('\n');
const tstate = [
en.tf ? `/${en.tf} ${en.tfSize} Tf` : null,
en.tc,
en.tw,
en.tz,
en.ts,
en.trm,
en.tl != null ? `${en.tl} TL` : null,
]
.filter(Boolean)
.join(' ');
let ops = content.slice(b.start, b.end);
if (tstate) ops = ops.replace(/^BT\b/, 'BT ' + tstate);
if (pre) ops = pre + '\n' + ops;
const num = next++;
const name = `ECFT${num}`;
newObjs.push({
num,
body:
`<< /Type /XObject /Subtype /Form /FormType 1` +
` /BBox [ -100000 -100000 100000 100000 ]` +
` /Resources ${resNum} 0 R /Length ${ops.length + 1} >>\nstream\n${ops}\nendstream`,
});
content =
content.slice(0, b.start) + `/${name} Do\n` + content.slice(b.end);
added.push({ name, num });
}
const cnum = next++;
newObjs.push({
num: cnum,
body: `<< /Length ${content.length + 1} >>\nstream\n${content}\nendstream`,
});
const entries = added.map((a) => `/${a.name} ${a.num} 0 R`).join(' ');
let newPage = pg.body.replace(
cOne ? cOne[0] : cArr[0],
`/Contents ${cnum} 0 R`
);
if (rref) {
let res = resBody;
const xo = res.match(/\/XObject\s*<<([\s\S]*?)>>/);
res = xo
? res.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`)
: res.replace(/^<</, `<< /XObject << ${entries} >> `);
pageRewrites.push({ num: parseInt(rref[1], 10), body: res });
} else {
const resDict = dictAt(pg.body, pg.body.indexOf('/Resources'));
const xo = resDict.match(/\/XObject\s*<<([\s\S]*?)>>/);
const newRes = xo
? resDict.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`)
: resDict.replace(/^<</, `<< /XObject << ${entries} >> `);
newPage = newPage.replace(resDict, newRes);
}
pageRewrites.push({ num: pgNum, body: newPage });
pages.push(pi);
}
if (!pageRewrites.length) return null;
return {
bytes: appendRevision(src, op.rootNum, next, pageRewrites, newObjs),
pages,
};
}
export async function consolidateContentArrays(bytes) {
const src = latin(bytes);
const rawHasArrays = /\/Contents\s*\[/.test(src);
if (!rawHasArrays && bytes.length > 4 << 20) return null;
const op = await withObjStm(bytes, src, parsePdf(src));
if (!op) return null;
let next = op.size;
const newObjs = [],
pageRewrites = [];
for (let pi = 0; pi < 4096; pi++) {
const pgNum = op.pageNum(pi);
if (pgNum < 0) break;
const pg = op.objBody(pgNum);
if (!pg) continue;
const cArr = pg.body.match(/\/Contents\s*\[([^\]]*)\]/);
if (!cArr) continue;
const refs = [...cArr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) =>
parseInt(r[1], 10)
);
if (refs.length < 2) continue;
let content = '';
let ok = true;
for (const rn of refs) {
const o = op.objBody(rn);
const d = o && (await streamData(bytes, src, o));
if (d == null) {
ok = false;
break;
}
content += d + '\n';
}
if (!ok) continue;
const cnum = next++;
newObjs.push({
num: cnum,
body: `<< /Length ${content.length} >>\nstream\n${content}endstream`,
});
pageRewrites.push({
num: pgNum,
body: pg.body.replace(cArr[0], `/Contents ${cnum} 0 R`),
});
}
if (!pageRewrites.length) return null;
return appendRevision(src, op.rootNum, next, pageRewrites, newObjs);
}
export function applyType3LeftoverSurgery(savedBytes, jobs) {
jobs = (jobs || []).filter(
(j) =>
j &&
j.seg &&
(j.state || []).some((o) => o.t3 && o.glyphs && o.glyphs.length)
);
if (!jobs.length) return null;
const saved = latin(savedBytes);
const sp = parsePdf(saved);
if (!sp) return null;
let next = sp.size;
const newObjs = [],
pageRewrites = [];
const injected = {};
for (const job of jobs) {
const leftovers = job.state.filter(
(o) => o.t3 && o.glyphs && o.glyphs.length
);
leftovers.sort((a, b) => {
const band = (o) => Math.round(-o.m[5] / 6);
return band(a) - band(b) || a.m[4] - b.m[4];
});
const tjs = job.seg.tjs || [];
const anchors = job.anchors || {};
const usedTj = new Set();
const lines = [];
const injectedTjs = [];
for (const o of leftovers) {
let name = anchors[o.font] ?? null;
const oCodes = o.glyphs.map((g) => g[0]);
if (!name) {
const sameCodes = (tj) =>
tj.codes.length === oCodes.length &&
tj.codes.every((c, k) => c === oCodes[k]);
let hit = tjs.findIndex(
(tj, i) =>
!usedTj.has(i) &&
Math.abs(tj.e - o.m[4]) < 0.05 &&
Math.abs(tj.f - o.m[5]) < 0.05 &&
sameCodes(tj)
);
if (hit < 0)
hit = tjs.findIndex((tj, i) => !usedTj.has(i) && sameCodes(tj));
if (hit < 0) {
hit = tjs.findIndex(
(tj, i) =>
!usedTj.has(i) &&
tj.codes.length >= oCodes.length &&
oCodes.every((c, k) => c === tj.codes[k])
);
}
if (hit < 0) {
console.warn('[type3] leftover glyph unmatched — skipped');
continue;
}
usedTj.add(hit);
name = tjs[hit].font;
}
const shownCodes = [];
const num6 = (v) => (Math.round(v * 10000) / 10000).toString();
const hex = (c) =>
'<' +
(c > 255
? c.toString(16).padStart(4, '0')
: c.toString(16).padStart(2, '0')) +
'>';
let ops = 'q ';
ops += `${num6(o.fill[0] / 255)} ${num6(o.fill[1] / 255)} ${num6(o.fill[2] / 255)} rg `;
ops += `${num6(o.stroke[0] / 255)} ${num6(o.stroke[1] / 255)} ${num6(o.stroke[2] / 255)} RG `;
if (o.sw > 0) ops += `${num6(o.sw)} w `;
ops += `BT ${o.m.map(num6).join(' ')} Tm /${name} ${num6(o.size)} Tf `;
if (o.tr) ops += `${o.tr} Tr `;
const known = new Set();
for (const tj of tjs)
if (tj.font === name) tj.codes.forEach((c) => known.add(c));
let px = 0,
py = 0;
o.glyphs.forEach(([c, gx, gy], gi) => {
if (gi === 0) {
if (gx || gy) ops += `${num6(gx)} ${num6(gy)} Td `;
} else ops += `${num6(gx - px)} ${num6(gy - py)} Td `;
px = gx;
py = gy;
if (known.has(c)) {
ops += hex(c) + ' Tj ';
shownCodes.push(c);
}
});
ops += 'ET Q';
injectedTjs.push({
e: o.m[4],
f: o.m[5],
font: name,
size: o.size,
codes: shownCodes,
});
lines.push(ops);
}
if (!lines.length) continue;
const prefix = 'ET Q\n'.repeat(leftovers.length);
const body = prefix + lines.join('\n') + '\n';
const pgNum = sp.pageNum(job.pageIndex);
if (pgNum < 0) continue;
const spg = sp.objBody(pgNum);
if (!spg) continue;
const cOne = spg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
const cArr = spg.body.match(/\/Contents\s*\[([^\]]*)\]/);
if (!cOne && !cArr) continue;
const snum = next++;
newObjs.push({
num: snum,
body: `<< /Length ${body.length} >>\nstream\n${body}endstream`,
});
const refList = cOne ? `${cOne[1]} 0 R` : cArr[1].trim();
let newPage = spg.body.replace(
cOne ? cOne[0] : cArr[0],
`/Contents [ ${refList} ${snum} 0 R ]`
);
const usedNames = new Set(
(lines.join(' ').match(/\/([A-Za-z0-9_.#-]+)\s+[\d.]+\s+Tf/g) ?? []).map(
(s) => s.match(/\/([A-Za-z0-9_.#-]+)/)[1]
)
);
const rref = newPage.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
if (rref) {
const rnum = parseInt(rref[1], 10);
const ro = sp.objBody(rnum);
if (ro) {
const res = mergeFonts(ro.body, usedNames, job.seg.fonts);
if (res !== ro.body) pageRewrites.push({ num: rnum, body: res });
}
} else {
newPage = mergeFonts(newPage, usedNames, job.seg.fonts);
}
pageRewrites.push({ num: pgNum, body: newPage });
injected[job.pageIndex] = injectedTjs;
}
if (!newObjs.length) return null;
return {
bytes: appendRevision(saved, sp.rootNum, next, pageRewrites, newObjs),
injected,
};
}
function mergeFonts(body, usedNames, fontNums) {
const resAt = body.indexOf('/Resources');
const resDict = resAt >= 0 ? dictAt(body, resAt) : dictAt(body, 0);
if (!resDict) return body;
const fontDict = dictAt(resDict, resDict.indexOf('/Font'));
const missing = [...usedNames].filter(
(n) =>
fontNums[n] != null &&
!(fontDict || '').match(
new RegExp('/' + n.replace(/[^A-Za-z0-9_]/g, '\\$&') + '[\\s/]')
)
);
if (!missing.length) return body;
const entries = missing.map((n) => `/${n} ${fontNums[n]} 0 R`).join(' ');
let newRes;
if (fontDict)
newRes = resDict.replace(
fontDict,
fontDict.replace(/^<</, `<< ${entries} `)
);
else newRes = resDict.replace(/^<</, `<< /Font << ${entries} >> `);
return body.replace(resDict, newRes);
}
function appendRevision(src, rootNum, size, pageRewrites, newObjs) {
let out = src;
if (!out.endsWith('\n')) out += '\n';
const offsets = new Map();
const emit = (num, body) => {
offsets.set(num, out.length);
out += `${num} 0 obj\n${body.trim()}\nendobj\n`;
};
for (const o of pageRewrites) emit(o.num, o.body);
for (const o of newObjs) emit(o.num, o.body);
const xrefAt = out.length;
const nums = [...offsets.keys()].sort((a, b) => a - b);
let xref = 'xref\n';
for (let i = 0; i < nums.length; ) {
let j = i;
while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
xref += `${nums[i]} ${j - i + 1}\n`;
for (let k = i; k <= j; k++)
xref += String(offsets.get(nums[k])).padStart(10, '0') + ' 00000 n \n';
i = j + 1;
}
out += xref;
const prevAt = src.lastIndexOf('startxref');
const prevNum = parseInt(src.slice(prevAt + 9).trim(), 10);
const infoM = src
.slice(src.lastIndexOf('trailer'))
.match(/\/Info\s+(\d+)\s+\d+\s+R/);
out +=
`trailer\n<< /Size ${size} /Root ${rootNum} 0 R` +
(infoM ? ` /Info ${infoM[1]} 0 R` : '') +
(Number.isFinite(prevNum) ? ` /Prev ${prevNum}` : '') +
` >>\nstartxref\n${xrefAt}\n%%EOF\n`;
const u8 = new Uint8Array(out.length);
for (let i = 0; i < out.length; i++) u8[i] = out.charCodeAt(i) & 0xff;
return u8;
}
+814
View File
@@ -0,0 +1,814 @@
type SectionSpec = { id: string; title: string; icon: string };
type RangeLink = { range: HTMLInputElement; num: HTMLInputElement };
const TEXT_SECTIONS: SectionSpec[] = [
{ id: 'font', title: 'Font', icon: 'ph-text-aa' },
{ id: 'size', title: 'Font Size', icon: 'ph-text-t' },
{ id: 'color', title: 'Text Color', icon: 'ph-palette' },
{ id: 'align', title: 'Alignment', icon: 'ph-text-align-left' },
{ id: 'spacing', title: 'Spacing', icon: 'ph-arrows-out-line-vertical' },
{ id: 'lists', title: 'Lists', icon: 'ph-list-bullets' },
{ id: 'more', title: 'More', icon: 'ph-dots-three' },
];
const SCOPE_SECTION: SectionSpec = {
id: 'scope',
title: 'Edit Scope',
icon: 'ph-pencil-simple',
};
const STROKE_SECTION: SectionSpec = {
id: 'stroke',
title: 'Path Stroke',
icon: 'ph-path',
};
const MULTI_SECTION: SectionSpec = {
id: 'alignMulti',
title: 'Align Objects',
icon: 'ph-align-left',
};
const OBJ_BTN_IDS = [
'rotL',
'rotR',
'flipH',
'flipV',
'replImg',
'extEdit',
'altText',
'front',
'back',
'dupe',
];
const SLIDER_FIELDS: [string, string][] = [
['fLine', 'Line'],
['fPara', 'Paragraph'],
['fChar', 'Character'],
['fWordSp', 'Word'],
];
const MORE_FIELDS: [string, string][] = [
['fHScale', 'H-Scale %'],
['fFirstInd', 'First Indent'],
['fHangInd', 'Hanging Indent'],
];
const q = (id: string): HTMLElement | null => document.getElementById(id);
const ph = (name: string): HTMLElement => {
const i = document.createElement('i');
i.className = `ph ${name}`;
return i;
};
const div = (cls: string): HTMLDivElement => {
const d = document.createElement('div');
d.className = cls;
return d;
};
const mkBtn = (cls: string, label: string): HTMLButtonElement => {
const b = document.createElement('button');
b.type = 'button';
b.className = cls;
b.title = label;
b.setAttribute('aria-label', label);
b.addEventListener('mousedown', (e) => e.preventDefault());
return b;
};
const rowOf = (el: Element | null): HTMLElement | null => {
const r = el?.closest('.row');
return r instanceof HTMLElement ? r : null;
};
const stepperOf = (el: Element | null): HTMLElement | null => {
const s = el?.closest('.stepper');
return s instanceof HTMLElement ? s : null;
};
const numInput = (id: string): HTMLInputElement | null => {
const el = q(id);
return el instanceof HTMLInputElement ? el : null;
};
function setupPagePill(): void {
const save = q('save');
const pageLabel = q('pageLabel');
const app = q('text-editor-app');
if (!save || !pageLabel || !app || !save.parentElement) return;
const pill = mkBtn('dpagepill', 'Go to page');
pill.textContent = '– / –';
save.parentElement.insertBefore(pill, save);
const syncPill = () => {
pill.textContent = pageLabel.textContent ?? '';
};
new MutationObserver(syncPill).observe(pageLabel, {
childList: true,
characterData: true,
subtree: true,
});
syncPill();
const parse = () => {
const parts = (pageLabel.textContent ?? '').split('/');
return {
current: parseInt(parts[0] ?? '', 10),
total: parseInt(parts[1] ?? '', 10),
};
};
let menu: HTMLDivElement | null = null;
const closeMenu = () => {
menu?.remove();
menu = null;
};
pill.addEventListener('click', () => {
if (menu) {
closeMenu();
return;
}
menu = div('ecmenu dpagemenu');
const rect = pill.getBoundingClientRect();
menu.style.top = `${rect.bottom + 6}px`;
menu.style.right = '8px';
menu.addEventListener('click', (e) => e.stopPropagation());
const nav = div('dpagenav');
const prevB = mkBtn('dnavbtn', 'Previous page');
prevB.appendChild(ph('ph-caret-left'));
prevB.addEventListener('click', () => q('prev')?.click());
const nextB = mkBtn('dnavbtn', 'Next page');
nextB.appendChild(ph('ph-caret-right'));
nextB.addEventListener('click', () => q('next')?.click());
const inp = document.createElement('input');
inp.type = 'number';
inp.min = '1';
inp.step = '1';
inp.className = 'dpageinp';
inp.setAttribute('aria-label', 'Page number');
const st = parse();
if (Number.isFinite(st.current)) inp.value = String(st.current);
if (Number.isFinite(st.total)) inp.max = String(st.total);
const go = () => {
const p = parse();
const target = parseInt(inp.value, 10);
if (
!Number.isFinite(p.current) ||
!Number.isFinite(p.total) ||
!Number.isFinite(target)
)
return;
const clamped = Math.min(Math.max(target, 1), p.total);
const delta = clamped - p.current;
const btn = q(delta > 0 ? 'next' : 'prev');
for (let i = 0; i < Math.abs(delta); i++) btn?.click();
closeMenu();
};
inp.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
go();
});
const goBtn = mkBtn('dnavbtn dgo', 'Go to page');
goBtn.textContent = 'Go';
goBtn.addEventListener('click', go);
nav.append(prevB, inp, goBtn, nextB);
menu.appendChild(nav);
menu.appendChild(document.createElement('hr'));
const props = mkBtn('ecmenu-item', 'Document properties');
const propLabel = document.createElement('span');
propLabel.textContent = 'Document properties';
props.append(ph('ph-info'), propLabel);
props.addEventListener('click', () => {
closeMenu();
q('docInfoBtn')?.click();
});
menu.appendChild(props);
app.appendChild(menu);
const dismiss = (e: MouseEvent) => {
if (e.target === pill || pill.contains(e.target as Node)) return;
closeMenu();
window.removeEventListener('click', dismiss);
};
window.setTimeout(() => window.addEventListener('click', dismiss), 0);
});
}
export function setupFormatDock(): void {
const inspector = q('inspector');
const app = q('text-editor-app');
const contextbar = q('contextbar');
const alignTools = q('alignTools');
const pStrokeRow = q('pStrokeRow');
const scopeSel = q('editScope');
const editBtn =
document.querySelector<HTMLButtonElement>('[data-tool="edit"]');
if (
!inspector ||
!app ||
!contextbar ||
!alignTools ||
!pStrokeRow ||
!editBtn ||
!(scopeSel instanceof HTMLSelectElement)
)
return;
inspector.classList.remove('collapsed');
const rangeLinks: RangeLink[] = [];
const panel = div('dockpanel');
const clip = div('dockclip');
const body = div('dockbody');
const head = div('dockhead');
const dtitle = div('dtitle');
const dticon = document.createElement('span');
const dtext = document.createElement('span');
dtitle.append(dticon, dtext);
const dclose = mkBtn('dclose', 'Close panel');
dclose.appendChild(ph('ph-x'));
head.append(dtitle, dclose);
const slotsWrap = div('dockslots');
body.append(head, slotsWrap);
clip.appendChild(body);
panel.appendChild(clip);
const rail = div('dockrail');
const chipsWrap = div('dockchips');
const acts = div('dockacts');
const delChip = mkBtn('dchip ddel', 'Delete');
delChip.appendChild(ph('ph-trash'));
delChip.addEventListener('click', () => q('del')?.click());
acts.appendChild(delChip);
rail.append(chipsWrap, acts);
inspector.append(panel, rail);
const slots = new Map<string, HTMLElement>();
const mkSlot = (id: string): HTMLElement => {
const s = div('dslot');
s.dataset.sec = id;
slotsWrap.appendChild(s);
slots.set(id, s);
return s;
};
const sliderRow = (id: string, label: string): HTMLElement | null => {
const num = numInput(id);
if (!num) return null;
const stepper = stepperOf(num);
const row = div('dfield');
const lab = div('dlab');
lab.textContent = label;
const range = document.createElement('input');
range.type = 'range';
range.min = num.min;
range.max = num.max;
range.step = num.step;
range.value = num.value || num.min;
range.setAttribute('aria-label', label);
range.addEventListener('pointerdown', () => {
range.dataset.drag = '1';
});
range.addEventListener('pointerup', () => {
delete range.dataset.drag;
});
range.addEventListener('input', () => {
num.value = range.value;
});
range.addEventListener('change', () => {
delete range.dataset.drag;
num.value = range.value;
num.dispatchEvent(new Event('change'));
});
rangeLinks.push({ range, num });
row.append(lab, range, stepper ?? num);
return row;
};
const sizeSlot = mkSlot('size');
const fSize = numInput('fSize');
if (fSize) {
const sizer = div('dsizer');
const step = (dir: -1 | 1, icon: string, label: string) => {
const b = mkBtn('dstep', label);
b.appendChild(ph(icon));
b.addEventListener('click', () => {
if (fSize.disabled || fSize.value === '') return;
if (dir > 0) fSize.stepUp();
else fSize.stepDown();
fSize.dispatchEvent(new Event('change'));
});
return b;
};
const minus = step(-1, 'ph-minus', 'Decrease size');
const plus = step(1, 'ph-plus', 'Increase size');
const host = stepperOf(fSize) ?? fSize;
sizer.append(minus, host, plus);
sizeSlot.appendChild(sizer);
}
const colorSlot = mkSlot('color');
const colorRow = rowOf(q('fColor'));
if (colorRow) colorSlot.appendChild(colorRow);
const alignSlot = mkSlot('align');
const alignRow = rowOf(q('fAlign'));
if (alignRow) alignSlot.appendChild(alignRow);
const dirLab = div('dlab dsublab');
dirLab.textContent = 'Direction';
alignSlot.appendChild(dirLab);
const dirRow = rowOf(q('fDir'));
if (dirRow) alignSlot.appendChild(dirRow);
const spacingSlot = mkSlot('spacing');
for (const [id, label] of SLIDER_FIELDS) {
const row = sliderRow(id, label);
if (row) spacingSlot.appendChild(row);
}
const listsSlot = mkSlot('lists');
const listsRow = rowOf(q('fList'));
if (listsRow) listsSlot.appendChild(listsRow);
const moreSlot = mkSlot('more');
const outLab = div('dlab dsublab');
outLab.textContent = 'Outline';
moreSlot.appendChild(outLab);
const outlineRow = rowOf(q('fStrokeColor'));
if (outlineRow) moreSlot.appendChild(outlineRow);
for (const [id, label] of MORE_FIELDS) {
const row = sliderRow(id, label);
if (row) moreSlot.appendChild(row);
}
const strokeSlot = mkSlot('stroke');
strokeSlot.appendChild(pStrokeRow);
const multiSlot = mkSlot('alignMulti');
multiSlot.appendChild(alignTools);
const scopeSlot = mkSlot('scope');
const scopeWrap = div('dscope');
scopeSlot.appendChild(scopeWrap);
let picked = new Set<string>(['all']);
const scopeDefs: [string, string][] = [
['all', 'All'],
['text', 'Text'],
['image', 'Images'],
['shape', 'Shapes'],
];
const renderScope = () => {
scopeWrap.replaceChildren();
for (const [key, label] of scopeDefs) {
const b = mkBtn(`dscopebtn${picked.has(key) ? ' on' : ''}`, label);
b.textContent = label;
b.addEventListener('click', () => {
if (key === 'all') picked = new Set(['all']);
else {
if (picked.has('all')) picked.clear();
if (picked.has(key)) picked.delete(key);
else picked.add(key);
if (picked.size === 0 || picked.size === 3) picked = new Set(['all']);
}
applyScope();
});
scopeWrap.appendChild(b);
}
};
const applyScope = () => {
const value = [...picked].join(',');
if (!scopeSel.querySelector(`option[value="${value}"]`)) {
const o = document.createElement('option');
o.value = value;
scopeSel.appendChild(o);
}
scopeSel.value = value;
scopeSel.dispatchEvent(new Event('change'));
renderScope();
};
renderScope();
let activeSec: string | null = null;
let syncTimer = 0;
const stopSync = () => {
window.clearInterval(syncTimer);
syncTimer = 0;
};
const startSync = () => {
stopSync();
syncTimer = window.setInterval(() => {
for (const l of rangeLinks) {
if (l.range.dataset.drag) continue;
if (l.num.value !== '' && l.range.value !== l.num.value)
l.range.value = l.num.value;
}
}, 400);
};
const syncRangesNow = () => {
for (const l of rangeLinks) {
if (l.num.value !== '') l.range.value = l.num.value;
}
};
const setHead = (spec: SectionSpec) => {
dticon.replaceChildren(ph(spec.icon));
dtext.textContent = spec.title;
};
const showSlot = (id: string) => {
for (const [k, s] of slots) s.classList.toggle('act', k === id);
};
const closePanel = () => {
panel.classList.remove('open');
activeSec = null;
stopSync();
markActive();
};
const openSec = (spec: SectionSpec) => {
const isOpen = panel.classList.contains('open');
if (isOpen && activeSec === spec.id) {
closePanel();
return;
}
if (isOpen) {
body.classList.add('fading');
window.setTimeout(() => {
setHead(spec);
showSlot(spec.id);
body.classList.remove('fading');
}, 120);
} else {
setHead(spec);
showSlot(spec.id);
panel.classList.add('open');
}
activeSec = spec.id;
syncRangesNow();
startSync();
markActive();
};
dclose.addEventListener('click', closePanel);
const dim = div('ddim');
const sheet = div('dsheet');
const grab = div('dgrab');
grab.appendChild(document.createElement('span'));
const shead = div('dsheethead');
shead.append(ph('ph-text-aa'), document.createTextNode(' Select Font'));
const fontListEl = div('dfontlist');
sheet.append(grab, shead, fontListEl);
inspector.append(dim, sheet);
let sheetOpen = false;
const rebuildFonts = () => {
const fam = q('fFamily');
if (!(fam instanceof HTMLSelectElement)) return;
fontListEl.replaceChildren();
const current = fam.value;
for (const opt of Array.from(fam.options)) {
const b = mkBtn(`dfrow${opt.value === current ? ' on' : ''}`, opt.value);
b.style.fontFamily = `"${opt.value.replace(/"/g, '')}", sans-serif`;
const name = document.createElement('span');
name.textContent = opt.value;
const chk = ph('ph-check');
chk.classList.add('dfchk');
b.append(name, chk);
b.addEventListener('click', () => {
fam.value = opt.value;
fam.dispatchEvent(new Event('change'));
rebuildFonts();
window.setTimeout(closeSheet, 180);
});
fontListEl.appendChild(b);
}
if ('queryLocalFonts' in window) {
fontListEl.appendChild(div('ddivider'));
const sys = mkBtn('dfrow dfsys', 'Load system fonts');
const label = document.createElement('span');
label.textContent = 'Load system fonts…';
sys.appendChild(label);
sys.addEventListener('click', () => q('sysFonts')?.click());
fontListEl.appendChild(sys);
}
};
const openSheet = () => {
rebuildFonts();
sheetOpen = true;
dim.classList.add('show');
sheet.classList.add('open');
markActive();
};
const closeSheet = () => {
if (!sheetOpen) return;
sheetOpen = false;
dim.classList.remove('show');
sheet.classList.remove('open');
sheet.style.transform = '';
markActive();
};
dim.addEventListener('click', closeSheet);
let dragY: number | null = null;
grab.addEventListener('pointerdown', (e) => {
dragY = e.clientY;
sheet.classList.add('drag');
grab.setPointerCapture(e.pointerId);
});
grab.addEventListener('pointermove', (e) => {
if (dragY === null) return;
const dy = Math.max(0, e.clientY - dragY);
sheet.style.transform = `translateY(${dy}px)`;
});
const endDrag = (e: PointerEvent) => {
if (dragY === null) return;
const dy = Math.max(0, e.clientY - dragY);
dragY = null;
sheet.classList.remove('drag');
sheet.style.transform = '';
if (dy > 90) closeSheet();
};
grab.addEventListener('pointerup', endDrag);
grab.addEventListener('pointercancel', endDrag);
const famSel = q('fFamily');
if (famSel) {
new MutationObserver(() => {
if (sheetOpen) rebuildFonts();
}).observe(famSel, { childList: true });
}
const visibleObjBtns = (): HTMLButtonElement[] => {
const out: HTMLButtonElement[] = [];
for (const id of OBJ_BTN_IDS) {
const b = q(id);
if (b instanceof HTMLButtonElement && !b.hidden) out.push(b);
}
return out;
};
const computeMode = (): 'text' | 'object' | 'idle' => {
if (!inspector.hasAttribute('disabled')) return 'text';
if (visibleObjBtns().length || !alignTools.hidden) return 'object';
return 'idle';
};
const secChip = (spec: SectionSpec): HTMLButtonElement => {
const b = mkBtn('dchip', spec.title);
b.dataset.sec = spec.id;
b.appendChild(ph(spec.icon));
b.addEventListener('click', () => {
if (spec.id === 'font') {
if (sheetOpen) closeSheet();
else openSheet();
} else openSec(spec);
});
return b;
};
const proxyChip = (src: HTMLButtonElement): HTMLButtonElement => {
const b = mkBtn('dchip', src.title || 'Action');
b.dataset.proxy = src.id;
const icon = src.querySelector('i');
b.appendChild(
icon ? (icon.cloneNode(true) as HTMLElement) : ph('ph-circle')
);
b.addEventListener('click', () => src.click());
return b;
};
const toolChip = (
id: string,
icon: string,
label: string,
onTap: () => void
): HTMLButtonElement => {
const b = mkBtn('dchip', label);
b.dataset.tool = id;
b.appendChild(ph(icon));
b.addEventListener('click', onTap);
return b;
};
let lastMode: 'text' | 'object' | 'idle' | null = null;
const togglesRow = q('fToggles');
const toggleChip = (src: HTMLButtonElement): HTMLButtonElement => {
const b = mkBtn('dchip', src.title || 'Toggle style');
b.dataset.tog = src.dataset.t ?? '';
const icon = src.querySelector('i');
b.appendChild(
icon ? (icon.cloneNode(true) as HTMLElement) : ph('ph-circle')
);
b.addEventListener('click', () => src.click());
return b;
};
const buildRail = () => {
const mode = computeMode();
chipsWrap.replaceChildren();
if (mode === 'text') {
for (const s of TEXT_SECTIONS) {
chipsWrap.appendChild(secChip(s));
if (s.id === 'color' && togglesRow) {
for (const t of togglesRow.children) {
if (t instanceof HTMLButtonElement)
chipsWrap.appendChild(toggleChip(t));
}
}
}
} else if (mode === 'idle') {
chipsWrap.appendChild(
toolChip('edit', 'ph-pencil-simple', 'Edit tool', () => {
if (editBtn.classList.contains('on')) openSec(SCOPE_SECTION);
else {
closePanel();
applyScope();
}
})
);
chipsWrap.appendChild(
toolChip('addText', 'ph-textbox', 'Add a text box', () => {
closePanel();
document
.querySelector<HTMLButtonElement>('[data-tool="addText"]')
?.click();
})
);
chipsWrap.appendChild(
toolChip('addImage', 'ph-image', 'Add an image', () => {
closePanel();
q('addImage')?.click();
})
);
chipsWrap.appendChild(
toolChip('find', 'ph-magnifying-glass', 'Find & replace', () => {
closePanel();
q('find')?.click();
})
);
}
if (!alignTools.hidden) chipsWrap.appendChild(secChip(MULTI_SECTION));
if (!pStrokeRow.hidden) chipsWrap.appendChild(secChip(STROKE_SECTION));
for (const b of visibleObjBtns()) chipsWrap.appendChild(proxyChip(b));
const delBtn = q('del');
acts.hidden = !(delBtn instanceof HTMLButtonElement) || delBtn.hidden;
markActive();
};
function markActive(): void {
const editOn = editBtn.classList.contains('on');
for (const c of chipsWrap.children) {
if (!(c instanceof HTMLElement)) continue;
if (c.dataset.sec) {
c.classList.toggle(
'on',
c.dataset.sec === 'font' ? sheetOpen : c.dataset.sec === activeSec
);
} else if (c.dataset.tog) {
const src = togglesRow?.querySelector(`[data-t="${c.dataset.tog}"]`);
c.classList.toggle('on', !!src?.classList.contains('on'));
} else if (c.dataset.tool === 'edit') {
c.classList.toggle('on', editOn || activeSec === 'scope');
} else if (c.dataset.tool === 'addText') {
c.classList.toggle('on', !editOn);
}
}
}
let raf = 0;
const refresh = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const mode = computeMode();
if (mode !== lastMode) {
closePanel();
closeSheet();
lastMode = mode;
}
buildRail();
if (activeSec && !chipsWrap.querySelector(`[data-sec="${activeSec}"]`))
closePanel();
});
};
new MutationObserver(refresh).observe(inspector, {
attributes: true,
attributeFilter: ['disabled', 'hidden'],
});
new MutationObserver(refresh).observe(contextbar, {
attributes: true,
subtree: true,
attributeFilter: ['hidden'],
});
new MutationObserver(refresh).observe(alignTools, {
attributes: true,
attributeFilter: ['hidden'],
});
new MutationObserver(refresh).observe(pStrokeRow, {
attributes: true,
attributeFilter: ['hidden'],
});
new MutationObserver(() => markActive()).observe(editBtn, {
attributes: true,
attributeFilter: ['class'],
});
if (togglesRow) {
new MutationObserver(() => markActive()).observe(togglesRow, {
attributes: true,
subtree: true,
attributeFilter: ['class'],
});
}
const vv = window.visualViewport;
if (vv) {
const updateOffset = () => {
const off = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
app.style.setProperty('--eckb', `${Math.round(off)}px`);
};
vv.addEventListener('resize', updateOffset);
vv.addEventListener('scroll', updateOffset);
updateOffset();
}
setupPagePill();
refresh();
}
export function setupFindSheet(): void {
const findbar = q('findbar');
if (!findbar) return;
const closeFind = () => q('findDone')?.click();
const grab = div('ecgrab');
grab.appendChild(document.createElement('span'));
findbar.prepend(grab);
let startY = 0;
let lastY = 0;
let lastT = 0;
let vel = 0;
let mode: 'idle' | 'undecided' | 'drag' | 'scroll' = 'idle';
const onStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
startY = lastY = e.touches[0].clientY;
lastT = performance.now();
vel = 0;
const onGrab = (e.target as HTMLElement | null)?.closest('.ecgrab');
mode = onGrab ? 'drag' : 'undecided';
};
const onMove = (e: TouchEvent) => {
if (mode === 'idle' || mode === 'scroll') return;
const y = e.touches[0].clientY;
const dy = y - startY;
if (mode === 'undecided') {
if (Math.abs(dy) < 6) return;
if (dy > 0 && findbar.scrollTop <= 0) mode = 'drag';
else {
mode = 'scroll';
return;
}
}
e.preventDefault();
const now = performance.now();
vel = (y - lastY) / Math.max(1, now - lastT);
lastY = y;
lastT = now;
findbar.style.transform = `translateY(${Math.max(0, y - startY)}px)`;
};
const onEnd = () => {
if (mode !== 'drag') {
mode = 'idle';
return;
}
mode = 'idle';
const dy = Math.max(0, lastY - startY);
findbar.style.transform = '';
if (dy > 90 || vel > 0.55) {
const anim = findbar.animate(
[
{ transform: `translateY(${dy}px)` },
{ transform: 'translateY(110%)' },
],
{ duration: 160, easing: 'ease-in' }
);
anim.onfinish = () => closeFind();
} else if (dy > 0) {
findbar.animate(
[{ transform: `translateY(${dy}px)` }, { transform: 'none' }],
{ duration: 160, easing: 'ease-out' }
);
}
};
findbar.addEventListener('touchstart', onStart, { passive: true });
findbar.addEventListener('touchmove', onMove, { passive: false });
findbar.addEventListener('touchend', onEnd);
findbar.addEventListener('touchcancel', onEnd);
let findWasOpen = !findbar.hidden;
new MutationObserver(() => {
const open = !findbar.hidden;
if (open && !findWasOpen) {
findbar.animate(
[{ transform: 'translateY(40px)' }, { transform: 'none' }],
{ duration: 200, easing: 'ease-out' }
);
}
findWasOpen = open;
}).observe(findbar, { attributes: true, attributeFilter: ['hidden'] });
}
+875
View File
@@ -0,0 +1,875 @@
import '@phosphor-icons/web/regular';
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { setupFormatDock, setupFindSheet } from './edit-pdf-text-dock';
interface DocDescription {
meta: { name: string; size: number; source?: string } | null;
info: { pages: number; signatures: number } | null;
pdfa: boolean;
fields: Record<string, string>;
pageSize: { w: number; h: number } | null;
}
function buildMenu(anchor: HTMLElement, cls: string): HTMLDivElement {
const menu = document.createElement('div');
menu.className = `ecmenu ${cls}`;
const rect = anchor.getBoundingClientRect();
const app = document.getElementById('text-editor-app');
if (!app) return menu;
if (rect.top > window.innerHeight / 2) {
menu.style.bottom = `${window.innerHeight - rect.top + 6}px`;
} else {
menu.style.top = `${rect.bottom + 6}px`;
}
menu.style.left = `${Math.max(8, rect.left)}px`;
menu.addEventListener('click', (e) => e.stopPropagation());
app.appendChild(menu);
const dismiss = (e: MouseEvent) => {
if (e.target === anchor || anchor.contains(e.target as Node)) return;
menu.remove();
window.removeEventListener('click', dismiss);
};
window.setTimeout(() => window.addEventListener('click', dismiss), 0);
return menu;
}
function menuItem(
label: string,
checked: boolean,
onPick: () => void,
icon?: string
): HTMLButtonElement {
const item = document.createElement('button');
item.className = 'ecmenu-item';
const mark = document.createElement('i');
mark.className = checked ? 'ph ph-check' : 'ecmenu-blank';
const text = document.createElement('span');
text.textContent = label;
if (icon) {
const glyph = document.createElement('i');
glyph.className = `ph ${icon} ecmenu-icon`;
item.append(mark, glyph, text);
} else {
item.append(mark, text);
}
item.addEventListener('click', onPick);
return item;
}
const SCOPES: [string, string, string][] = [
['text', 'Edit Text', 'ph-text-t'],
['image', 'Edit Images', 'ph-image'],
['shape', 'Edit Shapes', 'ph-shapes'],
];
function setupEditMenu() {
const editBtn = document.querySelector<HTMLButtonElement>(
'#tools [data-tool="edit"]'
);
const label = document.getElementById('editLabel');
const scopeSel = document.getElementById(
'editScope'
) as HTMLSelectElement | null;
if (!editBtn || !label || !scopeSel) return;
let picked = new Set<string>(['all']);
const apply = () => {
const value = [...picked].join(',');
if (!scopeSel.querySelector(`option[value="${value}"]`)) {
const opt = document.createElement('option');
opt.value = value;
scopeSel.appendChild(opt);
}
scopeSel.value = value;
scopeSel.dispatchEvent(new Event('change'));
label.textContent = picked.has('all')
? 'Edit All'
: 'Edit ' +
SCOPES.filter(([k]) => picked.has(k))
.map(([, l]) => l.replace('Edit ', ''))
.join(' + ');
};
let open: HTMLDivElement | null = null;
const caret = editBtn.querySelector('.editcaret');
const markOpen = (isOpen: boolean) => caret?.classList.toggle('up', isOpen);
editBtn.addEventListener('click', () => {
if (open?.isConnected) {
open.remove();
open = null;
markOpen(false);
return;
}
const menu = buildMenu(editBtn, 'editmenu');
open = menu;
markOpen(true);
const watch = new MutationObserver(() => {
if (!menu.isConnected) {
markOpen(false);
watch.disconnect();
}
});
const host = menu.parentElement;
if (host) watch.observe(host, { childList: true });
const rebuild = () => {
menu.replaceChildren();
menu.appendChild(
menuItem(
'Edit All',
picked.has('all'),
() => {
picked = new Set(['all']);
apply();
rebuild();
},
'ph-selection-all'
)
);
for (const [key, lbl, glyph] of SCOPES) {
menu.appendChild(
menuItem(
lbl,
picked.has(key),
() => {
if (picked.has('all')) picked.clear();
if (picked.has(key)) picked.delete(key);
else picked.add(key);
if (picked.size === 0 || picked.size === SCOPES.length)
picked = new Set(['all']);
apply();
rebuild();
},
glyph
)
);
}
};
rebuild();
});
}
const ZOOM_PRESETS = [50, 75, 100, 125, 150, 200, 400];
function zoomRow(
label: string,
active: boolean,
icon: string | null,
onPick: () => void
) {
const item = document.createElement('button');
item.className = `ecmenu-item${active ? ' active' : ''}`;
if (icon) {
const i = document.createElement('i');
i.className = `ph ${icon}`;
item.appendChild(i);
}
const text = document.createElement('span');
text.textContent = label;
item.appendChild(text);
item.addEventListener('click', onPick);
return item;
}
function fitPage() {
const stage = document.getElementById('stage');
const wrap = document.getElementById('pageWrap');
const mod = appModule;
if (!stage || !wrap || !mod) return;
const z = mod.getZoom();
const pw = wrap.offsetWidth / z;
const ph = wrap.offsetHeight / z;
if (pw <= 0 || ph <= 0) return;
mod.setZoom(
Math.min((stage.clientWidth - 48) / pw, (stage.clientHeight - 48) / ph)
);
}
function setupZoomMenu() {
const btn = document.getElementById('zoomMenuBtn');
const zoomLabel = document.getElementById('zoomLabel');
if (!btn || !zoomLabel) return;
let open: HTMLDivElement | null = null;
btn.addEventListener('click', () => {
if (open?.isConnected) {
open.remove();
open = null;
return;
}
const current = parseInt(zoomLabel.textContent ?? '', 10);
const menu = buildMenu(btn, 'zoommenu');
open = menu;
for (const pct of ZOOM_PRESETS) {
menu.appendChild(
zoomRow(`${pct}%`, current === pct, null, () => {
appModule?.setZoom(pct / 100);
menu.remove();
})
);
}
menu.appendChild(document.createElement('hr'));
menu.appendChild(
zoomRow('Fit to Page', false, 'ph-arrows-out', () => {
fitPage();
menu.remove();
})
);
menu.appendChild(
zoomRow('Fit to Width', false, 'ph-arrows-out-line-horizontal', () => {
document.getElementById('zoomFit')?.click();
menu.remove();
})
);
menu.appendChild(document.createElement('hr'));
const form = document.createElement('form');
form.className = 'zoomcustom';
const inp = document.createElement('input');
inp.type = 'number';
inp.min = '20';
inp.max = '600';
inp.placeholder = 'Custom Zoom';
const pctSpan = document.createElement('span');
pctSpan.textContent = '%';
form.append(inp, pctSpan);
form.addEventListener('submit', (e) => {
e.preventDefault();
const v = parseFloat(inp.value);
if (Number.isFinite(v) && v >= 20 && v <= 600) {
appModule?.setZoom(v / 100);
menu.remove();
}
});
menu.appendChild(form);
window.setTimeout(() => inp.focus(), 0);
});
}
function setupBarCollapse() {
const viewbar = document.getElementById('viewbar');
const collapse = document.getElementById('vbCollapse');
const restore = document.getElementById('vbRestore');
if (!viewbar || !collapse || !restore) return;
collapse.addEventListener('click', () => {
viewbar.setAttribute('hidden', '');
restore.removeAttribute('hidden');
});
restore.addEventListener('click', () => {
viewbar.removeAttribute('hidden');
restore.setAttribute('hidden', '');
});
}
function formatPdfDate(raw: string): string {
const m = raw.match(/D:(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?/);
if (!m) return raw;
const [, y, mo, d, h, mi] = m;
let out = `${y}`;
if (mo) out = `${mo}/${d ?? '01'}/${y}`;
if (h) out += `, ${h}:${mi ?? '00'}`;
return out;
}
interface EditorAppModule {
openFile: (file: File) => Promise<void>;
engineReady: Promise<boolean>;
setZoom: (z: number) => void;
getZoom: () => number;
getDocDescription: () => DocDescription;
}
function relocateAddText() {
const addTextBtn = document.querySelector<HTMLButtonElement>(
'#tools [data-tool="addText"]'
);
const editBtn = document.querySelector<HTMLButtonElement>(
'#tools [data-tool="edit"]'
);
const findBtn = document.getElementById('find');
if (!addTextBtn || !editBtn || !findBtn?.parentElement) return;
addTextBtn.replaceChildren();
const icon = document.createElement('i');
icon.className = 'ph ph-text-t';
addTextBtn.appendChild(icon);
addTextBtn.classList.add('btn', 'icon');
addTextBtn.title = 'Add a text box';
findBtn.parentElement.insertBefore(addTextBtn, findBtn);
const sync = () =>
addTextBtn.classList.toggle('on', !editBtn.classList.contains('on'));
new MutationObserver(sync).observe(editBtn, {
attributes: true,
attributeFilter: ['class'],
});
sync();
}
function setupDocPanel() {
const btn = document.getElementById('docInfoBtn');
const app = document.getElementById('text-editor-app');
if (!btn || !app) return;
let overlay: HTMLDivElement | null = null;
const close = () => {
overlay?.remove();
overlay = null;
};
btn.addEventListener('click', () => {
if (overlay) {
close();
return;
}
const desc = appModule?.getDocDescription();
if (!desc?.meta) return;
overlay = document.createElement('div');
overlay.id = 'docModalOverlay';
overlay.addEventListener('click', close);
const modal = document.createElement('div');
modal.id = 'docModal';
modal.addEventListener('click', (e) => e.stopPropagation());
const head = document.createElement('div');
head.className = 'docmodal-head';
const title = document.createElement('span');
title.textContent = 'Document Properties';
const x = document.createElement('button');
x.className = 'btn icon';
x.title = 'Close';
const xi = document.createElement('i');
xi.className = 'ph ph-x';
x.appendChild(xi);
x.addEventListener('click', close);
head.append(title, x);
modal.appendChild(head);
const f = desc.fields ?? {};
const inch = (pt: number) => (pt / 72).toFixed(2);
const groups: [string, [string, string][]][] = [
[
'Description',
[
['File', desc.meta.name],
['Title', f.Title || '—'],
['Author', f.Author || '—'],
['Subject', f.Subject || '—'],
['Keywords', f.Keywords || '—'],
['Created', f.CreationDate ? formatPdfDate(f.CreationDate) : '—'],
['Modified', f.ModDate ? formatPdfDate(f.ModDate) : '—'],
['Application', f.Creator || '—'],
],
],
[
'Advanced',
[
['PDF Producer', f.Producer || '—'],
['File Size', `${(desc.meta.size / 1024).toFixed(0)} KB`],
[
'Page Size',
desc.pageSize
? `${inch(desc.pageSize.w)} × ${inch(desc.pageSize.h)} in`
: '—',
],
['Number of Pages', desc.info?.pages ? String(desc.info.pages) : '—'],
...(desc.info?.signatures
? ([['Signatures', String(desc.info.signatures)]] as [
string,
string,
][])
: []),
...(desc.pdfa
? ([['Conformance', 'PDF/A']] as [string, string][])
: []),
],
],
];
for (const [name, rows] of groups) {
const sect = document.createElement('section');
const h = document.createElement('h4');
h.textContent = name;
sect.appendChild(h);
const dl = document.createElement('dl');
for (const [k, v] of rows) {
const dt = document.createElement('dt');
dt.textContent = k;
const dd = document.createElement('dd');
dd.textContent = v;
dl.append(dt, dd);
}
sect.appendChild(dl);
modal.appendChild(sect);
}
overlay.appendChild(modal);
app.appendChild(overlay);
});
}
let appModule: EditorAppModule | null = null;
let launching = false;
function fitMobileWidth() {
const mod = appModule;
const stage = document.getElementById('stage');
const wrap = document.getElementById('pageWrap');
if (!mod || !stage || !wrap) return;
const w = wrap.getBoundingClientRect().width;
if (!(w > 0) || !(stage.clientWidth > 0)) return;
const target = Math.min(
6,
Math.max(0.2, mod.getZoom() * ((stage.clientWidth - 20) / w))
);
mod.setZoom(target);
}
async function launchEditor(file: File) {
if (launching) return;
launching = true;
showLoader('Loading PDF Text Editor...');
try {
hideLoader();
const decrypted = await batchDecryptIfNeeded([file]);
if (decrypted.length === 0) return;
showLoader('Loading PDF Text Editor...');
document.getElementById('uploader')?.classList.add('hidden');
document.getElementById('tool-landing')?.classList.add('hidden');
document.getElementById('text-editor-app')?.removeAttribute('hidden');
if (!appModule) {
appModule = (await import('../editcore/app.js')) as EditorAppModule;
relocateAddText();
setupDocPanel();
setupEditMenu();
setupZoomMenu();
setupBarCollapse();
}
const engineOk = await appModule.engineReady;
if (!engineOk) {
throw new Error('WASM engine failed to initialize');
}
await appModule.openFile(decrypted[0]);
if (window.matchMedia('(max-width: 768px)').matches) fitMobileWidth();
} catch (error) {
console.error('Error loading PDF Text Editor:', error);
showAlert('Error', 'Failed to load the PDF Text Editor.');
document.getElementById('text-editor-app')?.setAttribute('hidden', '');
document.getElementById('uploader')?.classList.remove('hidden');
document.getElementById('tool-landing')?.classList.remove('hidden');
} finally {
hideLoader();
launching = false;
}
}
function handleFiles(files: FileList) {
const pdf = Array.from(files).find(
(f) => f.type === 'application/pdf' || /\.pdf$/i.test(f.name)
);
if (!pdf) {
showAlert('Invalid File', 'Please upload a valid PDF file.');
return;
}
void launchEditor(pdf);
}
function attachSteppers() {
const inputs = document.querySelectorAll<HTMLInputElement>(
'#inspector input.num'
);
for (const input of inputs) {
const wrap = document.createElement('div');
wrap.className = 'stepper';
input.replaceWith(wrap);
const mk = (dir: -1 | 1, icon: string) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'stepbtn';
b.tabIndex = -1;
const i = document.createElement('i');
i.className = `ph ${icon}`;
b.appendChild(i);
b.addEventListener('click', () => {
if (input.disabled || input.value === '') return;
if (dir > 0) input.stepUp();
else input.stepDown();
input.dispatchEvent(new Event('change'));
});
return b;
};
const col = document.createElement('div');
col.className = 'stepcol';
col.append(mk(1, 'ph-caret-up'), mk(-1, 'ph-caret-down'));
wrap.append(input, col);
}
}
function showMobileHistory() {
const undo = document.getElementById('undo');
const redo = document.getElementById('redo');
const host = document.getElementById('save')?.parentElement;
if (!undo || !redo || !host) return;
for (const b of [undo, redo]) {
b.removeAttribute('hidden');
b.classList.add('btn', 'icon');
}
host.append(undo, redo);
}
function initializePage() {
createIcons({ icons });
attachSteppers();
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
if (fileInput) {
const onPick = (e: Event) => {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleFiles(input.files);
}
};
fileInput.addEventListener('change', onPick);
fileInput.addEventListener('input', onPick);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-indigo-500');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-indigo-500');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-indigo-500');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFiles(files);
}
});
}
for (const id of ['stage', 'inspector']) {
const el = document.getElementById(id);
if (!el) continue;
let timer = 0;
el.addEventListener(
'scroll',
() => {
el.classList.add('is-scrolling');
window.clearTimeout(timer);
timer = window.setTimeout(
() => el.classList.remove('is-scrolling'),
700
);
},
{ passive: true }
);
}
const inspector = document.getElementById('inspector');
const toggleInspector = document.getElementById('toggleInspector');
if (inspector && toggleInspector) {
toggleInspector.classList.add('on');
toggleInspector.addEventListener('click', () => {
inspector.classList.toggle('collapsed');
toggleInspector.classList.toggle(
'on',
!inspector.classList.contains('collapsed')
);
});
if (window.matchMedia('(max-width: 768px)').matches) {
toggleInspector.classList.remove('on');
setupFormatDock();
setupFindSheet();
showMobileHistory();
}
}
const findBtn = document.getElementById('find');
const findbar = document.getElementById('findbar');
if (findBtn && findbar) {
const sync = () => findBtn.classList.toggle('on', !findbar.hidden);
new MutationObserver(sync).observe(findbar, {
attributes: true,
attributeFilter: ['hidden'],
});
sync();
}
const hud = document.getElementById('zoomHud');
if (hud) {
hud.removeAttribute('hidden');
let hudTimer = 0;
const flash = (text: string) => {
if (!document.getElementById('viewbar')?.hidden) return;
if (!text || text.includes('–')) return;
hud.textContent = text;
hud.classList.add('show');
window.clearTimeout(hudTimer);
hudTimer = window.setTimeout(() => hud.classList.remove('show'), 900);
};
for (const id of ['zoomLabel', 'pageLabel']) {
const label = document.getElementById(id);
if (!label) continue;
let last = label.textContent ?? '';
new MutationObserver(() => {
const now = label.textContent ?? '';
if (now === last) return;
last = now;
if (document.activeElement === label) return;
flash(now);
}).observe(label, {
childList: true,
characterData: true,
subtree: true,
});
}
}
const pageLabel = document.getElementById('pageLabel');
const pageInput = document.getElementById(
'pageInput'
) as HTMLInputElement | null;
const pageTotal = document.getElementById('pageTotal');
if (pageLabel && pageInput && pageTotal) {
const parse = () => {
const parts = (pageLabel.textContent ?? '').split('/');
return {
current: parseInt(parts[0] ?? '', 10),
total: parseInt(parts[1] ?? '', 10),
};
};
const sync = () => {
const { current, total } = parse();
if (!Number.isFinite(current) || !Number.isFinite(total)) return;
if (document.activeElement !== pageInput)
pageInput.value = String(current);
pageInput.max = String(total);
pageTotal.textContent = `/ ${total}`;
};
new MutationObserver(sync).observe(pageLabel, {
childList: true,
characterData: true,
subtree: true,
});
sync();
pageInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
sync();
pageInput.blur();
return;
}
if (e.key !== 'Enter') return;
e.preventDefault();
const { current, total } = parse();
const target = parseInt(pageInput.value, 10);
pageInput.blur();
if (
!Number.isFinite(current) ||
!Number.isFinite(total) ||
!Number.isFinite(target)
) {
sync();
return;
}
const clamped = Math.min(Math.max(target, 1), total);
const delta = clamped - current;
if (delta === 0) {
sync();
return;
}
const btn = document.getElementById(delta > 0 ? 'next' : 'prev');
for (let i = 0; i < Math.abs(delta); i++) btn?.click();
});
pageInput.addEventListener('blur', sync);
}
const findText = document.getElementById(
'findText'
) as HTMLInputElement | null;
if (findText) {
let searchTimer = 0;
findText.addEventListener('input', () => {
window.clearTimeout(searchTimer);
if (!findText.value.trim()) return;
searchTimer = window.setTimeout(() => {
document.getElementById('findNext')?.click();
}, 350);
});
}
const stage = document.getElementById('stage');
const pageWrap = document.getElementById('pageWrap');
if (stage && pageWrap) {
let commitTimer = 0;
let gestureScale = 1;
let active = false;
let focusX = 0;
let focusY = 0;
const beginGesture = (clientX: number, clientY: number) => {
active = true;
gestureScale = 1;
const wrapRect = pageWrap.getBoundingClientRect();
focusX = clientX - wrapRect.left;
focusY = clientY - wrapRect.top;
pageWrap.style.transformOrigin = `${focusX}px ${focusY}px`;
pageWrap.style.willChange = 'transform';
};
const commitGesture = () => {
active = false;
const mod = appModule;
if (!mod) return;
const startZoom = mod.getZoom();
const target = Math.min(6, Math.max(0.2, startZoom * gestureScale));
const applied = target / startZoom;
const preLeft = stage.scrollLeft;
const preTop = stage.scrollTop;
pageWrap.style.transform = '';
pageWrap.style.willChange = '';
mod.setZoom(target);
stage.scrollLeft = preLeft + focusX * (applied - 1);
stage.scrollTop = preTop + focusY * (applied - 1);
gestureScale = 1;
};
stage.addEventListener(
'wheel',
(e) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
if (!appModule) return;
if (!active) beginGesture(e.clientX, e.clientY);
else window.clearTimeout(commitTimer);
gestureScale *= 1 - e.deltaY * 0.01;
gestureScale = Math.max(0.1, Math.min(10, gestureScale));
pageWrap.style.transform = `scale(${gestureScale})`;
commitTimer = window.setTimeout(commitGesture, 150);
},
{ passive: false }
);
let pinchD0 = 0;
let pinchBase = 1;
stage.addEventListener(
'touchstart',
(e) => {
if (e.touches.length !== 2 || !appModule) return;
e.preventDefault();
const [a, b] = [e.touches[0], e.touches[1]];
pinchD0 = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
if (!(pinchD0 > 0)) return;
window.clearTimeout(commitTimer);
if (!active) {
beginGesture(
(a.clientX + b.clientX) / 2,
(a.clientY + b.clientY) / 2
);
}
pinchBase = gestureScale;
},
{ passive: false }
);
stage.addEventListener(
'touchmove',
(e) => {
if (!active || e.touches.length !== 2 || !(pinchD0 > 0)) return;
e.preventDefault();
const [a, b] = [e.touches[0], e.touches[1]];
const d = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
gestureScale = Math.max(0.1, Math.min(10, pinchBase * (d / pinchD0)));
pageWrap.style.transform = `scale(${gestureScale})`;
},
{ passive: false }
);
const endPinch = (e: TouchEvent) => {
if (!active || !(pinchD0 > 0)) return;
if (e.touches.length >= 2) return;
pinchD0 = 0;
window.clearTimeout(commitTimer);
commitTimer = window.setTimeout(commitGesture, 60);
};
stage.addEventListener('touchend', endPinch);
stage.addEventListener('touchcancel', endPinch);
}
const mainEl = document.getElementById('main');
if (mainEl) {
const DRAG_SEL =
'.obj-handle,.rot-handle,.para-handle,.edit-move,.multi-box,.obj-box,.sel-para';
mainEl.addEventListener(
'touchstart',
(e) => {
if (e.touches.length !== 1) return;
const t = e.target as HTMLElement | null;
const grip = t?.closest?.(DRAG_SEL) as HTMLElement | null;
if (!grip) return;
e.preventDefault();
const tt = e.touches[0];
grip.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
clientX: tt.clientX,
clientY: tt.clientY,
buttons: 1,
})
);
const move = (ev: TouchEvent) => {
ev.preventDefault();
const m = ev.touches[0];
if (!m) return;
window.dispatchEvent(
new MouseEvent('mousemove', {
clientX: m.clientX,
clientY: m.clientY,
buttons: 1,
})
);
};
const end = (ev: TouchEvent) => {
const m = ev.changedTouches[0];
window.dispatchEvent(
new MouseEvent('mouseup', {
clientX: m?.clientX ?? tt.clientX,
clientY: m?.clientY ?? tt.clientY,
})
);
grip.removeEventListener('touchmove', move);
grip.removeEventListener('touchend', end);
grip.removeEventListener('touchcancel', end);
};
grip.addEventListener('touchmove', move, { passive: false });
grip.addEventListener('touchend', end);
grip.addEventListener('touchcancel', end);
},
{ passive: false }
);
}
if (window.visualViewport) {
let kbTimer = 0;
let lastVh = window.visualViewport.height;
const app = document.getElementById('text-editor-app');
window.visualViewport.addEventListener('resize', () => {
const vv = window.visualViewport;
if (!vv) return;
const shrunk = vv.height < lastVh - 80;
lastVh = vv.height;
app?.classList.toggle('kb-open', window.innerHeight - vv.height > 150);
if (!shrunk) return;
window.clearTimeout(kbTimer);
kbTimer = window.setTimeout(() => {
const ed = document.querySelector('#overlay .editor');
ed?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 120);
});
}
document.getElementById('back-to-tools')?.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
document.getElementById('exitEditor')?.addEventListener('click', () => {
window.location.reload();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
+1
View File
@@ -176,6 +176,7 @@ const init = async () => {
'Split PDF': 'tools:splitPdf',
'Compress PDF': 'tools:compressPdf',
'PDF Editor': 'tools:pdfEditor',
'Edit PDF Text': 'tools:editPdfText',
'JPG to PDF': 'tools:jpgToPdf',
'Sign PDF': 'tools:signPdf',
'Crop PDF': 'tools:cropPdf',
+931
View File
@@ -0,0 +1,931 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="color-scheme" content="dark" />
<meta
name="title"
content="Edit PDF Text Online Free - PDF Text Editor | BentoPDF"
/>
<meta
name="description"
content="★ Edit PDF text online free - Click any paragraph and edit it in place with live reflow ★ No signup ★ Privacy-first ★ Works in browser"
/>
<meta
name="keywords"
content="edit pdf text, pdf text editor, change pdf text, rewrite pdf paragraph"
/>
<meta name="author" content="BentoPDF" />
<meta
name="robots"
content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"
/>
<link rel="canonical" href="https://www.bentopdf.com/edit-pdf-text" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://www.bentopdf.com/edit-pdf-text" />
<meta
property="og:title"
content="Edit PDF Text Online Free - PDF Text Editor | BentoPDF"
/>
<meta
property="og:description"
content="★ Edit PDF text online free - Click any paragraph and edit it in place with live reflow ★ No signup ★ Privacy-first ★ Works in browser"
/>
<meta property="og:site_name" content="BentoPDF" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://www.bentopdf.com/edit-pdf-text" />
<meta name="twitter:title" content="Edit PDF Text Free" />
<meta
name="twitter:description"
content="Edit PDF text in place with live reflow. Free, private, in your browser."
/>
<meta name="twitter:site" content="@BentoPDF" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Edit PDF Text" />
<title>Edit PDF Text - BentoPDF</title>
<link href="/src/css/styles.css" rel="stylesheet" />
<link href="/src/css/edit-pdf-text.css" rel="stylesheet" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/images/favicon-192x192.png"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/images/favicon-512x512.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/images/apple-touch-icon.png"
/>
<link rel="icon" href="/favicon.ico" sizes="32x32" />
</head>
<body class="antialiased bg-gray-900">
{{> navbar }}
<div
id="uploader"
class="min-h-screen flex flex-col items-center justify-start py-12 p-4 bg-gray-900"
>
<div
id="tool-uploader"
class="bg-gray-800 rounded-xl shadow-xl px-4 py-8 md:p-8 max-w-2xl w-full text-blue-400 border border-gray-700"
>
<button
id="back-to-tools"
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold"
>
<i data-lucide="arrow-left" class="cursor-pointer"></i>
<span class="cursor-pointer" data-i18n="tools.backToTools">
Back to Tools
</span>
</button>
<h1
class="text-2xl font-bold text-white mb-2"
data-i18n="tools:editPdfText.name"
>
Edit PDF Text
</h1>
<p class="text-gray-400 mb-6" data-i18n="tools:editPdfText.subtitle">
Click any paragraph and edit it in place with live reflow. Move and
restyle text, images, and shapes — then save a clean PDF.
</p>
<div
id="drop-zone"
class="relative flex flex-col items-center justify-center w-full h-48 md:h-64 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300"
>
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i
data-lucide="upload-cloud"
class="w-10 h-10 mb-3 text-gray-400"
></i>
<p class="mb-2 text-sm text-gray-400">
<span class="font-semibold" data-i18n="upload.clickToSelect"
>Click to select a file</span
>
<span data-i18n="upload.orDragAndDrop">or drag and drop</span>
</p>
<p class="text-xs text-gray-500" data-i18n="upload.hints.pdfFile">
PDF file
</p>
<p class="text-xs text-gray-500" data-i18n="upload.filesNeverLeave">
Your files never leave your device.
</p>
</div>
<input
id="file-input"
type="file"
class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
accept="application/pdf"
/>
</div>
</div>
</div>
<div id="tool-landing">
<section class="max-w-4xl mx-auto px-4 py-12">
<h2
class="text-2xl md:text-3xl font-bold text-white mb-8 text-center"
data-i18n="howItWorks.title"
>
How It Works
</h2>
<div class="space-y-6">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
>
1
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Upload PDF</h3>
<p class="text-gray-400">
Select or drag and drop the PDF whose text you want to edit
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
>
2
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">
Click a Paragraph
</h3>
<p class="text-gray-400">
Every editable paragraph is outlined — click one and type; text
reflows live as you edit
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
>
3
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">
Restyle Anything
</h3>
<p class="text-gray-400">
Change fonts, size, color, alignment and spacing, move or
replace images, add new text boxes
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
>
4
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Save</h3>
<p class="text-gray-400">
Download a clean PDF with your edits baked in — everything stays
on your device
</p>
</div>
</div>
</div>
</section>
<section class="max-w-6xl mx-auto px-4 py-12">
<h2
class="text-2xl md:text-3xl font-bold text-white mb-6 text-center"
data-i18n="relatedTools.title"
>
Related PDF Tools
</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<a
href="pdf-editor.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">PDF Editor</h3>
<p class="text-gray-400 text-sm">Annotate and mark up PDFs</p>
</a>
<a
href="sign-pdf.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Sign PDF</h3>
<p class="text-gray-400 text-sm">
Draw, type or upload a signature
</p>
</a>
<a
href="add-watermark.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Add Watermark</h3>
<p class="text-gray-400 text-sm">Stamp text or images</p>
</a>
<a
href="add-stamps.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Add Stamps</h3>
<p class="text-gray-400 text-sm">Place stamps on pages</p>
</a>
<a
href="compress-pdf.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Shrink the file size</p>
</a>
<a
href="organize-pdf.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Organize PDF</h3>
<p class="text-gray-400 text-sm">Reorder and manage pages</p>
</a>
</div>
</section>
<section class="max-w-4xl mx-auto px-4 py-12">
<h2
class="text-2xl md:text-3xl font-bold text-white mb-6 text-center"
data-i18n="faq.sectionTitle"
>
Frequently Asked Questions
</h2>
<div class="space-y-4">
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
<summary
class="cursor-pointer font-semibold text-white flex items-center justify-between"
>
Does my PDF leave my device?
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400">
No. The whole editor — including the PDF engine — runs in your
browser. Files are never uploaded anywhere.
</p>
</details>
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
<summary
class="cursor-pointer font-semibold text-white flex items-center justify-between"
>
Why are some paragraphs locked?
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400">
Paragraphs using fragile subset fonts or complex layouts are
protected so your document can't be silently corrupted. Everything
else stays fully editable.
</p>
</details>
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
<summary
class="cursor-pointer font-semibold text-white flex items-center justify-between"
>
Will the fonts match the original?
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400">
Yes — the editor reuses the document's embedded fonts where
possible, and you can also pull in fonts installed on your device.
</p>
</details>
</div>
</section>
{{> footer }}
</div>
<div id="text-editor-app" hidden>
<div id="toolbar">
<div class="tgroup">
<button id="exitEditor" class="btn icon" title="Back to upload">
<i class="ph ph-arrow-left"></i>
</button>
<label class="btn" for="file" title="Open another PDF">
<i class="ph ph-folder-open"></i><span>Open</span>
</label>
<input id="file" type="file" accept="application/pdf" hidden />
<button id="save" class="btn" disabled title="Export (⌘S)">
<i class="ph ph-export"></i><span>Export</span>
</button>
<button id="undo" class="btn icon" disabled hidden title="Undo (⌘Z)">
<i class="ph ph-arrow-u-up-left"></i>
</button>
<button id="redo" class="btn icon" disabled hidden title="Redo (⇧⌘Z)">
<i class="ph ph-arrow-u-up-right"></i>
</button>
</div>
<button
id="docName"
class="docname"
hidden
title="File details"
></button>
<span class="sep"></span>
<div id="tools" class="segmented">
<button data-tool="edit" class="on" title="Edit existing content">
<i class="ph ph-pencil-simple"></i
><span id="editLabel">Edit All</span
><i class="ph ph-caret-down editcaret"></i>
</button>
<select
id="editScope"
hidden
title="What the Edit tool touches — narrow the scope on busy pages"
>
<option value="all" selected>All</option>
<option value="text">Text</option>
<option value="image">Images</option>
<option value="shape">Shapes</option>
</select>
<button data-tool="addText" title="Click the page to add a text box">
<i class="ph ph-textbox"></i><span>Add Text</span>
</button>
</div>
<input id="imgFile" type="file" accept="image/*" hidden />
<div class="tgroup viewopts" hidden>
<label class="chk"
><input id="spellchk" type="checkbox" /> Spelling</label
>
<select id="spellLang" title="Spell-check language">
<option value="">Auto</option>
<option value="en">English (US)</option>
<option value="en-GB">English (UK)</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="es">Español</option>
<option value="it">Italiano</option>
<option value="pt">Português</option>
<option value="ar">العربية</option>
<option value="hi">हिन्दी</option>
</select>
<label class="chk"
><input id="rulersChk" type="checkbox" /> Rulers</label
>
</div>
<span id="info" class="info"></span>
<div class="tgroup toolright">
<button id="find" class="btn icon" title="Find &amp; replace">
<i class="ph ph-magnifying-glass"></i>
</button>
<button id="addImage" class="btn icon" title="Add an image">
<i class="ph ph-image"></i>
</button>
<button
id="copyText"
class="btn icon"
hidden
title="Copy this page's text in reading order"
>
<i class="ph ph-clipboard-text"></i>
</button>
<button
id="spellPage"
class="btn icon"
hidden
title="Check the spelling of this page"
>
<i class="ph ph-text-aa"></i>
</button>
<span class="sep"></span>
<button
id="toggleInspector"
class="btn icon"
title="Show or hide the format panel"
>
<i class="ph ph-sidebar-simple"></i>
</button>
</div>
</div>
<div id="spellbar" hidden>
<i class="ph ph-text-aa barlead"></i>
<span id="spellWord" class="info">—</span>
<input id="spellFix" placeholder="Replace with" />
<button id="spellChange" class="btn">Change</button>
<button id="spellIgnore" class="btn">Ignore</button>
<button id="spellAdd" class="btn" title="Add to your dictionary">
Add
</button>
<button id="spellNext" class="btn">Next</button>
<span id="spellStatus" class="info"></span>
<button id="spellDone" class="btn icon" title="Close">
<i class="ph ph-x"></i>
</button>
</div>
<div id="main">
<div id="findbar" hidden>
<div class="findhead">
<span class="findtitle"
><i class="ph ph-magnifying-glass barlead"></i> Find &amp;
Replace</span
>
<button id="findDone" class="btn icon" title="Close">
<i class="ph ph-x"></i>
</button>
</div>
<div class="findfield">
<input id="findText" placeholder="Find" />
<button id="findPrev" class="btn icon" title="Previous match">
<i class="ph ph-caret-up"></i>
</button>
<button id="findNext" class="btn icon" title="Next match">
<i class="ph ph-caret-down"></i>
</button>
</div>
<input id="replText" placeholder="Replace with" />
<div class="findrow">
<button id="replOne" class="btn grow">Replace</button>
<button id="replAll" class="btn grow">Replace All</button>
</div>
<label class="chk"
><input id="caseSens" type="checkbox" /> Match case</label
>
<label class="chk"
><input id="wholeWord" type="checkbox" /> Whole word</label
>
<label class="chk"
><input id="noAccents" type="checkbox" /> Ignore accents</label
>
<span id="findStatus" class="findstatus"></span>
</div>
<div id="stage">
<div id="empty">
<h1 data-i18n="tools:editPdfText.name">Edit PDF Text</h1>
<p>Open a PDF to edit its text, images and shapes.</p>
<label class="btn big" for="file">Choose a PDF…</label>
<p class="hint">
Everything runs locally in your browser — nothing is uploaded.
</p>
</div>
<div id="pageWrap" hidden>
<canvas id="page"></canvas>
<div id="overlay"></div>
</div>
</div>
<div id="zoomHud" hidden></div>
<div id="contextbar">
<div class="tgroup">
<button
id="rotL"
class="btn icon objbtn"
hidden
title="Rotate left 90°"
>
<i class="ph ph-arrow-counter-clockwise"></i>
</button>
<button
id="rotR"
class="btn icon objbtn"
hidden
title="Rotate right 90°"
>
<i class="ph ph-arrow-clockwise"></i>
</button>
<button
id="flipH"
class="btn icon objbtn"
hidden
title="Flip horizontal"
>
<i class="ph ph-flip-horizontal"></i>
</button>
<button
id="flipV"
class="btn icon objbtn"
hidden
title="Flip vertical"
>
<i class="ph ph-flip-vertical"></i>
</button>
<span class="sep objsep" hidden></span>
<button
id="replImg"
class="btn icon objbtn"
hidden
title="Replace this image, keeping its placement"
>
<i class="ph ph-image-square"></i>
</button>
<button
id="extEdit"
class="btn icon objbtn"
hidden
title="Save this image as a PNG, edit it in any app; saved changes re-import automatically"
>
<i class="ph ph-arrow-square-out"></i>
</button>
<button
id="altText"
class="btn icon objbtn"
hidden
title="Alternate text for accessibility (tagged PDFs)"
>
<i class="ph ph-tag"></i>
</button>
<input id="replImgFile" type="file" accept="image/*" hidden />
<span class="sep objsep" hidden></span>
<button
id="front"
class="btn icon objbtn"
hidden
title="Bring to front"
>
<i class="ph ph-arrow-line-up"></i>
</button>
<button
id="back"
class="btn icon objbtn"
hidden
title="Send to back"
>
<i class="ph ph-arrow-line-down"></i>
</button>
<button id="dupe" class="btn icon objbtn" hidden title="Duplicate">
<i class="ph ph-copy"></i>
</button>
<button id="del" class="btn icon objbtn" hidden title="Delete">
<i class="ph ph-trash"></i>
</button>
</div>
<div id="alignTools" class="segmented" hidden>
<button data-al="left" title="Align left edges">
<i class="ph ph-align-left"></i>
</button>
<button data-al="hcenter" title="Align horizontal centers">
<i class="ph ph-align-center-horizontal"></i>
</button>
<button data-al="right" title="Align right edges">
<i class="ph ph-align-right"></i>
</button>
<button data-al="top" title="Align tops">
<i class="ph ph-align-top"></i>
</button>
<button data-al="vcenter" title="Align vertical centers">
<i class="ph ph-align-center-vertical"></i>
</button>
<button data-al="bottom" title="Align bottoms">
<i class="ph ph-align-bottom"></i>
</button>
<button data-al="disth" title="Distribute horizontally">
<i class="ph ph-columns"></i>
</button>
<button data-al="distv" title="Distribute vertically">
<i class="ph ph-rows"></i>
</button>
</div>
</div>
<aside id="inspector" hidden>
<h2><i class="ph ph-sliders-horizontal"></i> Format</h2>
<h3 class="sect">Font</h3>
<div class="row">
<select id="fFamily"></select>
<input
id="fSize"
type="number"
min="1"
max="400"
step="1"
class="num"
title="Font size"
/>
</div>
<div class="row">
<button
id="sysFonts"
class="btn grow"
title="List fonts installed on this device (Chromium)"
>
<i class="ph ph-download-simple"></i><span>System fonts</span>
</button>
</div>
<h3 class="sect">Color</h3>
<div class="row swatchrow">
<div class="swatches" id="fSwatches"></div>
<input
id="fColor"
type="color"
value="#000000"
title="Custom text color"
/>
</div>
<h3 class="sect">Alignment</h3>
<div class="row">
<div class="segmented small grow" id="fAlign">
<button data-a="0" title="Align left">
<i class="ph ph-text-align-left"></i>
</button>
<button data-a="1" title="Align center">
<i class="ph ph-text-align-center"></i>
</button>
<button data-a="2" title="Align right">
<i class="ph ph-text-align-right"></i>
</button>
<button data-a="3" title="Justify">
<i class="ph ph-text-align-justify"></i>
</button>
</div>
</div>
<h3 class="sect">Style</h3>
<div class="row toggles" id="fToggles">
<button data-t="bold" title="Bold">
<i class="ph ph-text-b"></i>
</button>
<button data-t="italic" title="Italic">
<i class="ph ph-text-italic"></i>
</button>
<button data-t="underline" title="Underline">
<i class="ph ph-text-underline"></i>
</button>
<button data-t="strike" title="Strikethrough">
<i class="ph ph-text-strikethrough"></i>
</button>
<button data-t="sup" title="Superscript">
<i class="ph ph-text-superscript"></i>
</button>
<button data-t="sub" title="Subscript">
<i class="ph ph-text-subscript"></i>
</button>
</div>
<h3 class="sect">Lists</h3>
<div class="row">
<div class="segmented small" id="fList">
<button data-l="bullet" title="Toggle bulleted item">
<i class="ph ph-list-bullets"></i>
</button>
<button data-l="number" title="Toggle numbered item">
<i class="ph ph-list-numbers"></i>
</button>
</div>
<select id="fMarkerStyle" title="Numbered-list marker style">
<option value="1">1. 2. 3.</option>
<option value="a">a. b. c.</option>
<option value="A">A. B. C.</option>
<option value="i">i. ii. iii.</option>
<option value="I">I. II. III.</option>
</select>
<div class="segmented small" id="fLevel">
<button data-lv="-1" title="Decrease list indent (⇧Tab)">
<i class="ph ph-text-outdent"></i>
</button>
<button data-lv="1" title="Increase list indent (Tab)">
<i class="ph ph-text-indent"></i>
</button>
</div>
</div>
<h3 class="sect">Outline</h3>
<div class="row">
<input
id="fStrokeColor"
type="color"
value="#000000"
title="Text outline color"
/>
<input
id="fStrokeW"
type="number"
min="0"
max="20"
step="0.1"
class="num"
title="Outline width (0 = none)"
/>
</div>
<h3 class="sect">Spacing</h3>
<label class="field"
>Line Spacing
<input
id="fLine"
type="number"
min="0.6"
max="4"
step="0.05"
class="num"
/></label>
<label class="field"
>Paragraph Spacing
<input
id="fPara"
type="number"
min="0"
max="200"
step="1"
class="num"
/></label>
<label class="field"
>Character Spacing
<input
id="fChar"
type="number"
min="-3"
max="30"
step="0.1"
class="num"
/></label>
<label class="field"
>Word Spacing
<input
id="fWordSp"
type="number"
min="-10"
max="100"
step="0.5"
class="num"
/></label>
<label class="field"
>Horizontal Scale %
<input
id="fHScale"
type="number"
min="10"
max="400"
step="1"
class="num"
/></label>
<label class="field"
>First-line Indent
<input
id="fFirstInd"
type="number"
min="0"
max="300"
step="1"
class="num"
/></label>
<label class="field"
>Hanging Indent
<input
id="fHangInd"
type="number"
min="0"
max="300"
step="1"
class="num"
/></label>
<h3 class="sect">Direction</h3>
<div class="row">
<div class="segmented small grow" id="fDir">
<button data-d="0" title="From content">Auto</button>
<button data-d="1" title="Left to right">LTR</button>
<button data-d="2" title="Right to left">RTL</button>
</div>
</div>
<div class="row" id="pStrokeRow" hidden>
<span class="chk">Path stroke</span>
<input
id="pStrokeColor"
type="color"
value="#000000"
title="Stroke color"
/>
<input
id="pStrokeW"
type="number"
min="0"
max="40"
step="0.5"
class="num"
title="Stroke width"
/>
</div>
</aside>
</div>
<div id="viewbar">
<div class="vbzoom">
<button id="zoomMenuBtn" class="btn vbzoomlabel" title="Zoom presets">
<span id="zoomLabel" class="label">100%</span
><i class="ph ph-caret-down editcaret"></i>
</button>
<button id="zoomOut" class="btn icon" title="Zoom out">
<i class="ph ph-minus-circle"></i>
</button>
<button id="zoomIn" class="btn icon" title="Zoom in">
<i class="ph ph-plus-circle"></i>
</button>
<button id="zoomFit" class="btn icon" title="Fit page">
<i class="ph ph-frame-corners"></i>
</button>
</div>
<div class="vbcenter">
<button id="prev" class="btn icon" title="Previous page">
<i class="ph ph-caret-left"></i>
</button>
<span id="pageLabel" class="label">– / –</span>
<input
id="pageInput"
class="pagebox"
type="number"
min="1"
step="1"
title="Type a page number and press Enter"
/>
<span id="pageTotal" class="label pagetotal">/ –</span>
<button id="next" class="btn icon" title="Next page">
<i class="ph ph-caret-right"></i>
</button>
</div>
<div class="vbright">
<button id="docInfoBtn" class="btn icon" title="Document properties">
<i class="ph ph-info"></i>
</button>
<button id="vbCollapse" class="btn icon" title="Hide bar">
<i class="ph ph-caret-double-down"></i>
</button>
</div>
</div>
<button id="vbRestore" class="btn icon" hidden title="Show bar">
<i class="ph ph-caret-double-up"></i>
</button>
<div id="toast" hidden></div>
</div>
<div
id="loader-modal"
class="hidden fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50"
>
<div
class="bg-gray-800 p-8 rounded-lg flex flex-col items-center gap-4 border border-gray-700 shadow-xl"
>
<div class="solid-spinner"></div>
<p id="loader-text" class="text-white text-lg font-medium">
Processing...
</p>
</div>
</div>
<div
id="alert-modal"
class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 hidden"
>
<div
class="bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700"
>
<h3
id="alert-title"
class="text-xl font-bold text-white mb-2"
data-i18n="alert.title"
>
Alert
</h3>
<p id="alert-message" class="text-gray-300 mb-6"></p>
<button
id="alert-ok"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
>
OK
</button>
</div>
</div>
<script type="module" src="/src/js/utils/full-width.ts"></script>
<script type="module" src="/src/js/utils/simple-mode-footer.ts"></script>
<script type="module" src="/src/js/logic/edit-pdf-text-page.ts"></script>
<script type="module" src="/src/js/mobileMenu.ts"></script>
<script type="module" src="/src/js/main.ts"></script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Edit PDF Text - BentoPDF",
"applicationCategory": "PDF Tool",
"operatingSystem": "Any - Web Browser",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
}
}
</script>
</body>
</html>
+6
View File
@@ -453,6 +453,12 @@
category: 'editor',
icon: 'pencil-simple',
},
{
name: 'edit-pdf-text',
title: 'Edit PDF Text',
category: 'editor',
icon: 'cursor-text',
},
{
name: 'compress-pdf',
title: 'Compress PDF',
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -643,6 +643,7 @@ export default defineConfig(() => {
'split-pdf': resolve(__dirname, 'src/pages/split-pdf.html'),
'compress-pdf': resolve(__dirname, 'src/pages/compress-pdf.html'),
'edit-pdf': resolve(__dirname, 'src/pages/edit-pdf.html'),
'edit-pdf-text': resolve(__dirname, 'src/pages/edit-pdf-text.html'),
'jpg-to-pdf': resolve(__dirname, 'src/pages/jpg-to-pdf.html'),
'sign-pdf': resolve(__dirname, 'src/pages/sign-pdf.html'),
'crop-pdf': resolve(__dirname, 'src/pages/crop-pdf.html'),