Merge branch 'main' into add-farsi-translation

This commit is contained in:
Kouros Zanbouri
2026-08-30 22:50:46 +01:00
289 changed files with 31055 additions and 3595 deletions
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -6,11 +6,7 @@ import { icons, createIcons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
import type { CropBox } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import './utils/setup-pdf-worker.js';
const editorState: {
pdf: PDFDocumentProxy | null;
+53
View File
@@ -0,0 +1,53 @@
const FONT_CDN =
import.meta.env.VITE_EMBEDPDF_FONTS_URL ||
'https://cdn.jsdelivr.net/npm/@embedpdf';
const CHARSET = {
ANSI: 0,
DEFAULT: 1,
SHIFTJIS: 128,
HANGEUL: 129,
GB2312: 134,
CHINESEBIG5: 136,
GREEK: 161,
TURKISH: 162,
VIETNAMESE: 163,
HEBREW: 177,
ARABIC: 178,
BALTIC: 186,
RUSSIAN: 204,
THAI: 222,
EASTEUROPE: 238,
} as const;
const PACK = {
jp: 'fonts-jp@1.0.0/fonts/NotoSansJP-Regular.otf',
kr: 'fonts-kr@1.0.0/fonts/NotoSansKR-Regular.otf',
sc: 'fonts-sc@1.0.0/fonts/NotoSansHans-Regular.otf',
tc: 'fonts-tc@1.0.0/fonts/NotoSansHant-Regular.otf',
arabic: 'fonts-arabic@1.0.0/fonts/NotoNaskhArabic-Regular.ttf',
hebrew: 'fonts-hebrew@1.0.0/fonts/NotoSansHebrew-Regular.ttf',
latin: 'fonts-latin@1.0.0/fonts/NotoSans-Regular.ttf',
} as const;
export const editorFontFallback = {
baseUrl: FONT_CDN,
fonts: {
[CHARSET.SHIFTJIS]: PACK.jp,
[CHARSET.HANGEUL]: PACK.kr,
[CHARSET.GB2312]: PACK.sc,
[CHARSET.CHINESEBIG5]: PACK.tc,
[CHARSET.ARABIC]: PACK.arabic,
[CHARSET.HEBREW]: PACK.hebrew,
[CHARSET.GREEK]: PACK.latin,
[CHARSET.TURKISH]: PACK.latin,
[CHARSET.VIETNAMESE]: PACK.latin,
[CHARSET.BALTIC]: PACK.latin,
[CHARSET.RUSSIAN]: PACK.latin,
[CHARSET.EASTEUROPE]: PACK.latin,
[CHARSET.THAI]: PACK.latin,
[CHARSET.ANSI]: PACK.latin,
[CHARSET.DEFAULT]: PACK.latin,
},
defaultFont: PACK.latin,
};
+24 -3
View File
@@ -3,6 +3,13 @@ const baseCategories = [
{
name: 'Popular Tools',
tools: [
{
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 + 'pdf-workflow.html',
name: 'PDF Workflow Builder',
@@ -15,7 +22,7 @@ const baseCategories = [
name: 'PDF Multi Tool',
icon: 'ph-pencil-ruler',
subtitle:
'Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in an unified interface.',
'Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in a unified interface.',
},
{
href: import.meta.env.BASE_URL + 'merge-pdf.html',
@@ -40,7 +47,7 @@ const baseCategories = [
name: 'PDF Editor',
icon: 'ph-pencil-simple',
subtitle:
'Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs',
'Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs.',
},
{
href: import.meta.env.BASE_URL + 'jpg-to-pdf.html',
@@ -90,6 +97,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',
@@ -537,7 +551,14 @@ const baseCategories = [
name: 'Alternate & Mix Pages',
icon: 'ph-shuffle',
subtitle:
'Merge PDFs by alternating pages from each PDF. Preserves Bookmarks',
'Merge PDFs by alternating pages from each PDF. Preserves Bookmarks.',
},
{
href: import.meta.env.BASE_URL + 'duplex-collate.html',
name: 'Duplex Collate',
icon: 'ph-arrows-vertical',
subtitle:
'Reorder front/back scan blocks into collated page order in one PDF.',
},
{
href: import.meta.env.BASE_URL + 'organize-pdf.html',
+10263
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
import createModule from 'bentopdf-pdfium';
const inBrowser = typeof window !== 'undefined';
let ENGINE_VERSION = 'dev';
try {
ENGINE_VERSION = __ENGINE_VERSION__;
} catch {
ENGINE_VERSION = 'dev';
}
function resolveWasmUrl() {
if (inBrowser) {
const url = new URL('bentopdf-pdfium/editcore.wasm', import.meta.url);
if (import.meta.env?.DEV) {
url.searchParams.set('v', ENGINE_VERSION);
}
return url.href;
}
const resolve = import.meta.resolve;
if (typeof resolve !== 'function') return null;
return new URL(resolve('bentopdf-pdfium/editcore.wasm')).pathname;
}
const wasmUrl = resolveWasmUrl();
export const ENGINE_BUILD = `bentopdf-pdfium@${ENGINE_VERSION}`;
export function createEngineModule(options) {
return createModule({
...(options ?? {}),
locateFile: (file, prefix) =>
file.endsWith('.wasm')
? (wasmUrl ?? `${prefix}editcore.wasm`)
: `${prefix}${file}`,
});
}
+796
View File
@@ -0,0 +1,796 @@
export const bytesHave = (u8, needle) => {
const n = needle.length;
const end = u8.length;
if (!n || end < n) return false;
const first = needle.charCodeAt(0);
outer: for (let i = 0; i + n <= end; i++) {
if (u8[i] !== first) continue;
for (let j = 1; j < n; j++) {
if (u8[i + j] !== needle.charCodeAt(j)) continue outer;
}
return true;
}
return false;
};
const PROBE_NEEDLES = [
['pattern', '/Pattern'],
['shading', '/Shading'],
['type3', '/Type3'],
['form', '/Form'],
['quartz', 'Quartz PDFContext'],
['objStm', '/ObjStm'],
];
const probeMemo = new WeakMap();
export const probePdfBytes = (u8) => {
const cached = probeMemo.get(u8);
if (cached) return cached;
const out = {
pattern: false,
shading: false,
type3: false,
form: false,
quartz: false,
objStm: false,
contentsArray: false,
subsetFont: false,
};
const end = u8.length;
const match = (at, text) => {
if (at + text.length > end) return false;
for (let j = 1; j < text.length; j++) {
if (u8[at + j] !== text.charCodeAt(j)) return false;
}
return true;
};
const skipWs = (at) => {
while (
at < end &&
(u8[at] === 32 || u8[at] === 10 || u8[at] === 13 || u8[at] === 9)
)
at++;
return at;
};
for (let i = 0; i < end; i++) {
const c = u8[i];
if (c === 81) {
if (!out.quartz && match(i, 'Quartz PDFContext')) out.quartz = true;
continue;
}
if (c !== 47) continue;
for (const [key, text] of PROBE_NEEDLES) {
if (!out[key] && match(i, text)) out[key] = true;
}
if (!out.contentsArray && match(i, '/Contents')) {
if (u8[skipWs(i + 9)] === 91) out.contentsArray = true;
}
if (!out.subsetFont && match(i, '/BaseFont')) {
let k = skipWs(i + 9);
if (u8[k] === 47) {
k++;
let upper = k + 6 <= end;
for (let t = 0; upper && t < 6; t++) {
if (u8[k + t] < 65 || u8[k + t] > 90) upper = false;
}
if (upper && u8[k + 6] === 43) out.subsetFont = true;
}
}
}
probeMemo.set(u8, out);
return out;
};
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;
};
const parsePdfMemo = new Map();
export const parsePdf = (src) => {
const hit = parsePdfMemo.get(src);
if (hit !== undefined) return hit;
const built = parsePdfUncached(src);
if (parsePdfMemo.size > 4) parsePdfMemo.clear();
parsePdfMemo.set(src, built);
return built;
};
const parsePdfUncached = (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;
};
const objStmMemo = new WeakMap();
const objStmIndexCached = async (bytes, src) => {
const hit = objStmMemo.get(bytes);
if (hit) return hit;
const built = await objStmIndex(bytes, src);
objStmMemo.set(bytes, built);
return built;
};
export const withObjStm = async (bytes, src, op) => {
if (!op) return op;
const idx = await objStmIndexCached(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 probe = probePdfBytes(originalBytes);
if (!probe.pattern && !probe.objStm) return false;
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 probe = probePdfBytes(bytes);
if (!probe.pattern && !probe.shading && !probe.objStm) return null;
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;
}
+779
View File
@@ -0,0 +1,779 @@
import {
latin,
parsePdf,
streamData,
withObjStm,
dictAt,
probePdfBytes,
} 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) {
if (!probePdfBytes(bytes).type3) return null;
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.trim();
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) {
if (!probePdfBytes(bytes).quartz) return null;
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.trim();
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) {
if (!probePdfBytes(bytes).contentsArray && bytes.length > 4 << 20)
return null;
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;
}
+1 -5
View File
@@ -29,11 +29,7 @@ import {
} from '../config/pdf-tools.js';
import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
export {
getRotationState,
+13 -3
View File
@@ -1,5 +1,8 @@
import i18next from 'i18next';
import HttpBackend from 'i18next-http-backend';
import { getStoredItem, setStoredItem } from '../utils/safe-storage.js';
import enCommon from '../../../public/locales/en/common.json';
import enTools from '../../../public/locales/en/tools.json';
// Supported languages
export const supportedLanguages = [
@@ -75,7 +78,7 @@ export const getLanguageFromUrl = (): SupportedLanguage => {
return langMatch[1] as SupportedLanguage;
}
const storedLang = localStorage.getItem('i18nextLng');
const storedLang = getStoredItem('i18nextLng');
if (
storedLang &&
supportedLanguages.includes(storedLang as SupportedLanguage)
@@ -112,7 +115,7 @@ export const initI18n = async (): Promise<typeof i18next> => {
const currentLang = getLanguageFromUrl();
localStorage.setItem('i18nextLng', currentLang);
setStoredItem('i18nextLng', currentLang);
await i18next.use(HttpBackend).init({
lng: currentLang,
@@ -121,6 +124,13 @@ export const initI18n = async (): Promise<typeof i18next> => {
ns: ['common', 'tools'],
defaultNS: 'common',
preload: [currentLang],
partialBundledLanguages: true,
resources: {
en: {
common: enCommon,
tools: enTools,
},
},
backend: {
loadPath: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}locales/{{lng}}/{{ns}}.json`,
},
@@ -141,7 +151,7 @@ export const t = (key: string, options?: Record<string, unknown>): string => {
export const changeLanguage = (lang: SupportedLanguage): void => {
if (!supportedLanguages.includes(lang)) return;
localStorage.setItem('i18nextLng', lang);
setStoredItem('i18nextLng', lang);
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '');
let relativePath = window.location.pathname;
+256 -6
View File
@@ -11,16 +11,17 @@ import {
addTextWatermark,
addImageWatermark,
parsePageRange,
computeTileCenters,
} from '../utils/pdf-operations.js';
import { AddWatermarkState, PageWatermarkConfig } from '@/types';
import {
AddWatermarkState,
PageWatermarkConfig,
WatermarkLayout,
} from '@/types';
import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
const pageState: AddWatermarkState = {
file: null,
@@ -31,9 +32,15 @@ const pageState: AddWatermarkState = {
watermarkY: 0.5,
};
const WATERMARK_FONT_STACK =
'"Noto Sans SC", "Noto Sans JP", "Noto Sans KR", "Noto Sans Arabic", Arial, sans-serif';
let watermarkType: 'text' | 'image' = 'text';
let watermarkLayout: WatermarkLayout = 'single';
let imageWatermarkDataUrl: string | null = null;
let imageWatermarkFile: File | null = null;
let imageWatermarkBitmap: HTMLImageElement | null = null;
let measureCanvas: HTMLCanvasElement | null = null;
let isDragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
@@ -186,6 +193,7 @@ function resetState() {
pageState.watermarkY = 0.5;
imageWatermarkDataUrl = null;
imageWatermarkFile = null;
imageWatermarkBitmap = null;
cachedPdfjsDoc = null;
currentPageNum = 1;
totalPageCount = 1;
@@ -273,9 +281,19 @@ async function changePage(newPageNum: number) {
updateWatermarkOverlay();
}
function readNumberInput(id: string, fallback: number): number {
const input = document.getElementById(id) as HTMLInputElement | null;
if (!input) return fallback;
const value = parseFloat(input.value);
return Number.isFinite(value) ? value : fallback;
}
function getDefaultConfig(): PageWatermarkConfig {
return {
type: 'text',
layout: 'single',
tileGapX: 25,
tileGapY: 75,
x: 0.5,
y: 0.5,
text: '',
@@ -294,6 +312,9 @@ function getDefaultConfig(): PageWatermarkConfig {
function getCurrentConfig(): PageWatermarkConfig {
return {
type: watermarkType,
layout: watermarkLayout,
tileGapX: readNumberInput('tile-gap-x', 25),
tileGapY: readNumberInput('tile-gap-y', 75),
x: pageState.watermarkX,
y: pageState.watermarkY,
text:
@@ -345,11 +366,27 @@ function loadPageConfig(pageNum: number) {
}
watermarkType = config.type;
watermarkLayout = config.layout;
pageState.watermarkX = config.x;
pageState.watermarkY = config.y;
if (config.imageDataUrl !== imageWatermarkDataUrl) {
loadWatermarkBitmap(config.imageDataUrl);
}
imageWatermarkDataUrl = config.imageDataUrl;
imageWatermarkFile = config.imageFile;
const tileGapX = document.getElementById('tile-gap-x') as HTMLInputElement;
const tileGapY = document.getElementById('tile-gap-y') as HTMLInputElement;
const tileGapXValue = document.getElementById('tile-gap-x-value');
const tileGapYValue = document.getElementById('tile-gap-y-value');
if (tileGapX) tileGapX.value = String(config.tileGapX);
if (tileGapY) tileGapY.value = String(config.tileGapY);
if (tileGapXValue) tileGapXValue.textContent = String(config.tileGapX);
if (tileGapYValue) tileGapYValue.textContent = String(config.tileGapY);
applyLayoutUI();
const typeTextBtn = document.getElementById('type-text-btn');
const typeImageBtn = document.getElementById('type-image-btn');
const textOptions = document.getElementById('text-watermark-options');
@@ -511,6 +548,36 @@ function setupEditorControls() {
updateWatermarkOverlay();
});
const layoutSingleBtn = document.getElementById('layout-single-btn');
const layoutTileBtn = document.getElementById('layout-tile-btn');
layoutSingleBtn?.addEventListener('click', () => {
watermarkLayout = 'single';
applyLayoutUI();
updateWatermarkOverlay();
});
layoutTileBtn?.addEventListener('click', () => {
watermarkLayout = 'tile';
applyLayoutUI();
updateWatermarkOverlay();
});
const tileGapX = document.getElementById('tile-gap-x') as HTMLInputElement;
const tileGapY = document.getElementById('tile-gap-y') as HTMLInputElement;
const tileGapXValue = document.getElementById('tile-gap-x-value');
const tileGapYValue = document.getElementById('tile-gap-y-value');
tileGapX?.addEventListener('input', () => {
if (tileGapXValue) tileGapXValue.textContent = tileGapX.value;
updateWatermarkOverlay();
});
tileGapY?.addEventListener('input', () => {
if (tileGapYValue) tileGapYValue.textContent = tileGapY.value;
updateWatermarkOverlay();
});
const watermarkText = document.getElementById(
'watermark-text'
) as HTMLInputElement;
@@ -571,6 +638,7 @@ function setupEditorControls() {
const reader = new FileReader();
reader.onload = () => {
imageWatermarkDataUrl = reader.result as string;
loadWatermarkBitmap(imageWatermarkDataUrl);
updateWatermarkOverlay();
};
reader.readAsDataURL(file);
@@ -604,6 +672,161 @@ function updatePresetHighlight(x: number, y: number) {
});
}
function loadWatermarkBitmap(dataUrl: string | null) {
if (!dataUrl) {
imageWatermarkBitmap = null;
return;
}
const image = new Image();
image.onload = () => {
if (imageWatermarkDataUrl !== dataUrl) return;
imageWatermarkBitmap = image;
updateWatermarkOverlay();
};
image.src = dataUrl;
}
function applyLayoutUI() {
const singleBtn = document.getElementById('layout-single-btn');
const tileBtn = document.getElementById('layout-tile-btn');
const tileOptions = document.getElementById('tile-options');
const positionSection = document.getElementById('position-section');
const dragHint = document.getElementById('drag-hint');
const activeClass =
'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors';
const inactiveClass =
'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors';
const isTile = watermarkLayout === 'tile';
if (singleBtn) singleBtn.className = isTile ? inactiveClass : activeClass;
if (tileBtn) tileBtn.className = isTile ? activeClass : inactiveClass;
tileOptions?.classList.toggle('hidden', !isTile);
positionSection?.classList.toggle('hidden', isTile);
dragHint?.classList.toggle('sm:inline', !isTile);
}
function measureTextWidth(text: string, fontSize: number): number {
if (!measureCanvas) measureCanvas = document.createElement('canvas');
const ctx = measureCanvas.getContext('2d');
if (!ctx) return 0;
ctx.font = `bold ${fontSize}px ${WATERMARK_FONT_STACK}`;
return ctx.measureText(text).width + 2;
}
function renderTilePreview() {
const canvas = document.getElementById(
'tile-preview-canvas'
) as HTMLCanvasElement;
const container = document.getElementById('preview-container');
if (!canvas || !container) return;
const displayWidth = container.clientWidth;
const displayHeight = container.clientHeight;
if (!displayWidth || !displayHeight || !pdfPageWidth || !pdfPageHeight)
return;
const dpr = 2;
canvas.width = Math.round(displayWidth * dpr);
canvas.height = Math.round(displayHeight * dpr);
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.scale(dpr, dpr);
const gapX = readNumberInput('tile-gap-x', 25) / 100;
const gapY = readNumberInput('tile-gap-y', 75) / 100;
let itemWidth: number;
let itemHeight: number;
let uiAngle: number;
let opacity: number;
if (watermarkType === 'text') {
const text =
(document.getElementById('watermark-text') as HTMLInputElement)?.value ||
'CONFIDENTIAL';
const fontSize = readNumberInput('font-size', 72);
uiAngle = readNumberInput('angle-text', 0);
opacity = readNumberInput('opacity-text', 0.3);
itemWidth = measureTextWidth(text, fontSize);
itemHeight = fontSize * 1.4;
const centers = computeTileCenters({
pageWidth: pdfPageWidth,
pageHeight: pdfPageHeight,
itemWidth,
itemHeight,
angle: -uiAngle,
gapX,
gapY,
});
ctx.globalAlpha = opacity;
ctx.fillStyle =
(document.getElementById('text-color') as HTMLInputElement)?.value ||
'#888888';
ctx.font = `bold ${fontSize * previewScale}px ${WATERMARK_FONT_STACK}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const center of centers) {
ctx.save();
ctx.translate(
center.x * previewScale,
(pdfPageHeight - center.y) * previewScale
);
ctx.rotate((uiAngle * Math.PI) / 180);
ctx.fillText(text, 0, 0);
ctx.restore();
}
return;
}
const bitmap = imageWatermarkBitmap;
if (!bitmap || !bitmap.naturalWidth || !bitmap.naturalHeight) return;
const scale = readNumberInput('image-scale', 100) / 100;
uiAngle = readNumberInput('angle-image', 0);
opacity = readNumberInput('opacity-image', 0.3);
itemWidth = bitmap.naturalWidth * scale;
itemHeight = bitmap.naturalHeight * scale;
const centers = computeTileCenters({
pageWidth: pdfPageWidth,
pageHeight: pdfPageHeight,
itemWidth,
itemHeight,
angle: -uiAngle,
gapX,
gapY,
});
ctx.globalAlpha = opacity;
const drawWidth = itemWidth * previewScale;
const drawHeight = itemHeight * previewScale;
for (const center of centers) {
ctx.save();
ctx.translate(
center.x * previewScale,
(pdfPageHeight - center.y) * previewScale
);
ctx.rotate((uiAngle * Math.PI) / 180);
ctx.drawImage(
bitmap,
-drawWidth / 2,
-drawHeight / 2,
drawWidth,
drawHeight
);
ctx.restore();
}
}
function updateWatermarkOverlay() {
const box = document.getElementById('watermark-box') as HTMLElement;
const textOverlay = document.getElementById(
@@ -619,6 +842,18 @@ function updateWatermarkOverlay() {
const containerW = container.clientWidth;
const containerH = container.clientHeight;
const tileCanvas = document.getElementById('tile-preview-canvas');
if (watermarkLayout === 'tile') {
box.classList.add('hidden');
textOverlay.classList.add('hidden');
imageOverlay.classList.add('hidden');
tileCanvas?.classList.remove('hidden');
renderTilePreview();
return;
}
tileCanvas?.classList.add('hidden');
if (watermarkType === 'text') {
box.classList.remove('hidden');
@@ -846,6 +1081,9 @@ async function applyWatermark() {
x: config.x,
y: posY,
pageIndices,
tile: config.layout === 'tile',
tileGapX: config.tileGapX / 100,
tileGapY: config.tileGapY / 100,
})
);
} else {
@@ -875,6 +1113,9 @@ async function applyWatermark() {
x: config.x,
y: posY,
pageIndices,
tile: config.layout === 'tile',
tileGapX: config.tileGapX / 100,
tileGapY: config.tileGapY / 100,
})
);
}
@@ -896,6 +1137,9 @@ async function applyWatermark() {
const key = JSON.stringify({
type: config.type,
layout: config.layout,
tileGapX: config.tileGapX,
tileGapY: config.tileGapY,
x: config.x,
y: config.y,
text: config.text,
@@ -930,6 +1174,9 @@ async function applyWatermark() {
x: config.x,
y: posY,
pageIndices: indices,
tile: config.layout === 'tile',
tileGapX: config.tileGapX / 100,
tileGapY: config.tileGapY / 100,
})
);
} else {
@@ -955,6 +1202,9 @@ async function applyWatermark() {
x: config.x,
y: posY,
pageIndices: indices,
tile: config.layout === 'tile',
tileGapX: config.tileGapX / 100,
tileGapY: config.tileGapY / 100,
})
);
}
+1 -5
View File
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import type { AdjustColorsSettings } from '../types/adjust-colors-type.js';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
let files: File[] = [];
let cachedBaselineData: ImageData | null = null;
+1 -5
View File
@@ -14,6 +14,7 @@ import {
} from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
import {
BookmarkNode,
BookmarkTree,
@@ -32,11 +33,6 @@ import {
PDF_COLOR_MAP,
} from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
const modalContainer = document.getElementById(
'modal-container'
) as HTMLElement | null;
+1 -5
View File
@@ -11,11 +11,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { CombineSinglePageState } from '@/types';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
const pageState: CombineSinglePageState = {
file: null,
+1 -5
View File
@@ -19,17 +19,13 @@ import type {
import { exportComparePdf } from '../compare/reporting/export-compare-pdf.ts';
import { LRUCache } from '../compare/lru-cache.ts';
import { COMPARE_CACHE_MAX_SIZE } from '../compare/config.ts';
import '../utils/setup-pdf-worker.js';
import {
getElement,
computeComparisonForPair,
getComparisonCacheKey,
} from './compare-render.ts';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
const pageState: CompareState = {
pdfDoc1: null,
pdfDoc2: null,
+1 -5
View File
@@ -13,11 +13,7 @@ import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
import * as pdfjsLib from 'pdfjs-dist';
import type { PDFDocumentProxy } from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
const CONDENSE_PRESETS = {
light: {
+1 -5
View File
@@ -7,11 +7,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { CropperState, CropPercentages } from '@/types';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
const cropperState: CropperState = {
pdfDoc: null,
+1 -5
View File
@@ -10,14 +10,10 @@ import * as pdfjsLib from 'pdfjs-dist';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
// --- Global State for the Cropper Tool ---
import type { CropPercentages } from '@/types';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import '../utils/setup-pdf-worker.js';
const cropperState: {
pdfDoc: PDFDocumentProxy | null;
+1 -5
View File
@@ -10,11 +10,7 @@ import { deletePdfPages } from '../utils/pdf-operations.js';
import * as pdfjsLib from 'pdfjs-dist';
import { DeletePagesState } from '@/types';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
const deleteState: DeletePagesState = {
file: null,
+1 -1
View File
@@ -120,7 +120,7 @@ const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
*
* This client-side HMAC provides limited protection (deters casual abuse)
* but should NOT be considered secure against determined attackers. BentoPDF
* accepts this tradeoff because of it's client side architecture.
* accepts this tradeoff because of its client-side architecture.
*
* To enable (optional):
* 1. Generate a secret: openssl rand -hex 32
+664
View File
@@ -0,0 +1,664 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { formatBytes, downloadFile } from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { t } from '../i18n/index.js';
import { PDFDocument } from 'pdf-lib';
import JSZip from 'jszip';
interface DuplexState {
file: File | null;
pdfDoc: PDFDocument | null;
totalPages: number;
}
const duplexState: DuplexState = {
file: null,
pdfDoc: null,
totalPages: 0,
};
const translate = (
key: string,
fallback: string,
options?: Record<string, unknown>
) => {
const translation = t(key, options);
return translation && translation !== key ? translation : fallback;
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
/**
* Wires duplex-collate page event handlers and initial UI behavior.
*/
function initializePage() {
createIcons({ icons });
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const autoSplitBtn = document.getElementById('auto-split-btn');
if (fileInput) {
fileInput.addEventListener('change', handleFileUpload);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handleFile(droppedFiles[0]);
}
});
}
processBtn?.addEventListener('click', processDuplexCollate);
autoSplitBtn?.addEventListener('click', () => {
const splitInput = document.getElementById(
'split-page'
) as HTMLInputElement | null;
if (!splitInput || duplexState.totalPages < 2) return;
splitInput.value = Math.ceil(duplexState.totalPages / 2).toString();
updatePreviewSummary();
});
document
.getElementById('split-page')
?.addEventListener('input', updatePreviewSummary);
document
.getElementById('back-order')
?.addEventListener('change', updatePreviewSummary);
document
.getElementById('export-grouped')
?.addEventListener('change', toggleGroupedOptions);
document.getElementById('back-to-tools')?.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
/**
* Handles file input selection events and forwards the chosen file for parsing.
*/
function handleFileUpload(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleFile(input.files[0]);
}
}
/**
* Loads and validates the uploaded PDF, then prepares UI state for duplex collation.
*/
async function handleFile(file: File) {
if (
file.type !== 'application/pdf' &&
!file.name.toLowerCase().endsWith('.pdf')
) {
showAlert(
translate('tools:duplexCollate.invalidFileTitle', 'Invalid File'),
translate(
'tools:duplexCollate.invalidFileMessage',
'Please select a PDF file.'
)
);
return;
}
showLoader(translate('tools:duplexCollate.loadingPdf', 'Loading PDF...'));
try {
const result = await loadPdfWithPasswordPrompt(file);
if (!result) {
hideLoader();
return;
}
result.pdf.destroy();
duplexState.file = result.file;
duplexState.pdfDoc = await loadPdfDocument(result.bytes);
duplexState.totalPages = duplexState.pdfDoc.getPageCount();
updateFileDisplay();
showOptions();
showOddPageWarning();
applyDefaultSplitPoint();
updatePreviewSummary();
hideLoader();
} catch (error) {
console.error('Error loading PDF:', error);
hideLoader();
showAlert(
translate('common.error', 'Error'),
translate(
'tools:duplexCollate.loadErrorMessage',
'Failed to load PDF file.'
)
);
}
}
/**
* Removes the .pdf extension from a filename.
*/
function stripPdfExtension(name: string): string {
return name.replace(/\.pdf$/i, '');
}
/**
* Resolves a safe base filename for generated outputs.
*/
function getBaseFilename(): string {
return stripPdfExtension(duplexState.file?.name || 'document');
}
/**
* Renders the selected file metadata and remove action in the file list area.
*/
function updateFileDisplay() {
const fileDisplayArea = document.getElementById('file-display-area');
if (!fileDisplayArea || !duplexState.file) return;
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = duplexState.file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = translate(
'tools:duplexCollate.fileMeta',
`${formatBytes(duplexState.file.size)} - ${duplexState.totalPages} pages`,
{
size: formatBytes(duplexState.file.size),
count: duplexState.totalPages,
}
);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = resetState;
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
}
/**
* Reveals duplex options and updates the total page counter.
*/
function showOptions() {
const options = document.getElementById('duplex-options');
const totalPagesEl = document.getElementById('total-pages');
options?.classList.remove('hidden');
if (totalPagesEl) {
totalPagesEl.textContent = duplexState.totalPages.toString();
}
}
/**
* Shows a warning when the uploaded document has an odd page count.
*/
function showOddPageWarning() {
const banner = document.getElementById('odd-page-banner');
if (!banner) return;
if (duplexState.totalPages % 2 !== 0) {
banner.classList.remove('hidden');
banner.textContent = translate(
'tools:duplexCollate.oddPageWarning',
`Odd page count (${duplexState.totalPages}). A duplex scan should have an even number of pages; you may have a missing or extra page.`,
{
count: duplexState.totalPages,
}
);
} else {
banner.classList.add('hidden');
banner.textContent = '';
}
}
/**
* Sets the split input to the midpoint of the uploaded document.
*/
function applyDefaultSplitPoint() {
const splitInput = document.getElementById(
'split-page'
) as HTMLInputElement | null;
if (!splitInput) return;
splitInput.value = Math.ceil(duplexState.totalPages / 2).toString();
}
/**
* Toggles grouped-export inputs based on the grouped export checkbox.
*/
function toggleGroupedOptions() {
const grouped = document.getElementById(
'export-grouped'
) as HTMLInputElement | null;
const groupPanel = document.getElementById('grouped-options');
if (!grouped || !groupPanel) return;
if (grouped.checked) {
groupPanel.classList.remove('hidden');
} else {
groupPanel.classList.add('hidden');
}
}
/**
* Returns a clamped split point derived from the current UI input.
*/
function getSplitPoint(): number {
const splitInput = document.getElementById(
'split-page'
) as HTMLInputElement | null;
const fallback = Math.ceil(duplexState.totalPages / 2);
if (!splitInput) return fallback;
const parsed = Number.parseInt(splitInput.value, 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(1, Math.min(parsed, Math.max(duplexState.totalPages - 1, 1)));
}
/**
* Returns the selected back-page ordering strategy.
*/
function getBackOrder(): 'reverse' | 'keep' {
const backOrder = document.getElementById(
'back-order'
) as HTMLSelectElement | null;
return backOrder?.value === 'keep' ? 'keep' : 'reverse';
}
/**
* Builds the final page index order by interleaving front and back page blocks.
*/
export function buildDuplexOrder(
totalPages: number,
splitPoint: number,
backOrder: 'reverse' | 'keep'
) {
const fronts = Array.from({ length: splitPoint }, (_, i) => i);
const backs = Array.from(
{ length: totalPages - splitPoint },
(_, i) => splitPoint + i
);
if (backOrder === 'reverse') {
backs.reverse();
}
const order: number[] = [];
const pairCount = Math.max(fronts.length, backs.length);
for (let i = 0; i < pairCount; i++) {
if (fronts[i] !== undefined) order.push(fronts[i]);
if (backs[i] !== undefined) order.push(backs[i]);
}
return {
order,
frontCount: fronts.length,
backCount: backs.length,
};
}
/**
* Refreshes the preview summary and warnings for the current collation settings.
*/
function updatePreviewSummary() {
const summary = document.getElementById('duplex-preview-summary');
const warning = document.getElementById('duplex-warning');
if (!summary || !warning || duplexState.totalPages === 0) return;
const splitPoint = getSplitPoint();
const backOrder = getBackOrder();
const { order, frontCount, backCount } = buildDuplexOrder(
duplexState.totalPages,
splitPoint,
backOrder
);
summary.textContent = translate(
'tools:duplexCollate.previewSummary',
`Front block: ${frontCount} page(s), back block: ${backCount} page(s). Output: ${order.length} page(s), in front/back sequence.`,
{
frontCount,
backCount,
outputCount: order.length,
}
);
if (frontCount !== backCount) {
warning.classList.remove('hidden');
warning.textContent = translate(
'tools:duplexCollate.previewMismatchWarning',
`Front and back block lengths differ (${frontCount} front vs ${backCount} back). The ${Math.abs(frontCount - backCount)} extra page(s) on the longer side will be appended unpaired at the end.`,
{
frontCount,
backCount,
extraCount: Math.abs(frontCount - backCount),
}
);
} else {
warning.classList.add('hidden');
warning.textContent = '';
}
}
/**
* Shows an in-page confirmation dialog and resolves with the user's choice.
*/
function showConfirm(message: string): Promise<boolean> {
const modal = document.getElementById('confirm-modal');
const messageEl = document.getElementById('confirm-message');
const cancelBtn = document.getElementById('confirm-cancel');
const proceedBtn = document.getElementById('confirm-proceed');
if (!modal || !messageEl || !cancelBtn || !proceedBtn) {
return Promise.resolve(window.confirm(message));
}
messageEl.textContent = message;
modal.classList.remove('hidden');
const previousActive = document.activeElement as HTMLElement | null;
return new Promise<boolean>((resolve) => {
const close = (result: boolean) => {
modal.classList.add('hidden');
cancelBtn.removeEventListener('click', onCancel);
proceedBtn.removeEventListener('click', onProceed);
modal.removeEventListener('click', onBackdrop);
document.removeEventListener('keydown', onKeydown);
previousActive?.focus();
resolve(result);
};
const onCancel = () => close(false);
const onProceed = () => close(true);
const onBackdrop = (e: MouseEvent) => {
if (e.target === modal) close(false);
};
const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') close(false);
};
cancelBtn.addEventListener('click', onCancel);
proceedBtn.addEventListener('click', onProceed);
modal.addEventListener('click', onBackdrop);
document.addEventListener('keydown', onKeydown);
cancelBtn.focus();
});
}
/**
* Creates a new PDF containing the source pages referenced by index.
*/
async function createPdfFromIndices(
sourceDoc: PDFDocument,
indices: number[]
): Promise<Uint8Array> {
const outDoc = await PDFDocument.create();
const copiedPages = await outDoc.copyPages(sourceDoc, indices);
copiedPages.forEach((page) => outDoc.addPage(page));
return new Uint8Array(await outDoc.save());
}
/**
* Converts Uint8Array output into an ArrayBuffer suitable for Blob construction.
*/
function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const { buffer, byteOffset, byteLength } = bytes;
if (buffer instanceof ArrayBuffer) {
return buffer.slice(byteOffset, byteOffset + byteLength);
}
const copy = new Uint8Array(byteLength);
copy.set(bytes);
return copy.buffer;
}
/**
* Runs duplex collation and downloads either a single file or grouped ZIP output.
*/
async function processDuplexCollate() {
if (!duplexState.file || !duplexState.pdfDoc || duplexState.totalPages < 2) {
showAlert(
translate('tools:duplexCollate.missingFileTitle', 'Missing File'),
translate(
'tools:duplexCollate.missingFileMessage',
'Please upload a PDF with at least 2 pages before processing.'
)
);
return;
}
const splitPoint = getSplitPoint();
if (splitPoint <= 0 || splitPoint >= duplexState.totalPages) {
showAlert(
translate('tools:duplexCollate.invalidSplitTitle', 'Invalid Split Point'),
translate(
'tools:duplexCollate.invalidSplitMessage',
`Split point must be between page 1 and page ${duplexState.totalPages - 1}.`,
{
maxSplit: duplexState.totalPages - 1,
}
)
);
return;
}
const backOrder = getBackOrder();
// Warn explicitly if the blocks will be unequal (likely a scan error)
const { frontCount, backCount } = buildDuplexOrder(
duplexState.totalPages,
splitPoint,
backOrder
);
if (frontCount !== backCount) {
const proceed = await showConfirm(
translate(
'tools:duplexCollate.unevenConfirmMessage',
`The front block has ${frontCount} page(s) and the back block has ${backCount} page(s).\nThis usually means a page was scanned twice or is missing.\n\nThe ${Math.abs(frontCount - backCount)} unpaired page(s) will be appended at the end.\n\nContinue anyway?`,
{
frontCount,
backCount,
extraCount: Math.abs(frontCount - backCount),
}
)
);
if (!proceed) return;
}
const groupedCheckbox = document.getElementById(
'export-grouped'
) as HTMLInputElement | null;
const pagesPerDocInput = document.getElementById(
'pages-per-document'
) as HTMLInputElement | null;
const exportGrouped = groupedCheckbox?.checked === true;
const pagesPerDocument = Number.parseInt(pagesPerDocInput?.value || '0', 10);
if (
exportGrouped &&
(!Number.isFinite(pagesPerDocument) || pagesPerDocument < 1)
) {
showAlert(
translate('tools:duplexCollate.invalidGroupTitle', 'Invalid Group Size'),
translate(
'tools:duplexCollate.invalidGroupMessage',
'Please enter a valid number of pages per original document.'
)
);
return;
}
showLoader(
translate('tools:duplexCollate.collatingLoader', 'Collating duplex scan...')
);
try {
const { order } = buildDuplexOrder(
duplexState.totalPages,
splitPoint,
backOrder
);
const baseName = getBaseFilename();
if (!exportGrouped) {
const bytes = await createPdfFromIndices(duplexState.pdfDoc, order);
const blob = new Blob([bytesToArrayBuffer(bytes)], {
type: 'application/pdf',
});
downloadFile(blob, `${baseName}_collated.pdf`);
hideLoader();
showAlert(
translate('common.success', 'Success'),
translate(
'tools:duplexCollate.successMessage',
'Collated PDF generated successfully.'
),
'success'
);
return;
}
const documents: Array<{ name: string; bytes: Uint8Array }> = [];
for (let start = 0; start < order.length; start += pagesPerDocument) {
const chunk = order.slice(start, start + pagesPerDocument);
if (chunk.length === 0) continue;
const chunkBytes = await createPdfFromIndices(duplexState.pdfDoc, chunk);
documents.push({
name: `${baseName}_doc_${documents.length + 1}.pdf`,
bytes: chunkBytes,
});
}
const fileCount = documents.length;
if (fileCount === 0) {
hideLoader();
showAlert(
translate('common.error', 'Error'),
translate(
'tools:duplexCollate.processErrorMessage',
'Failed to collate PDF.'
)
);
return;
}
if (fileCount === 1) {
const blob = new Blob([bytesToArrayBuffer(documents[0].bytes)], {
type: 'application/pdf',
});
downloadFile(blob, documents[0].name);
} else {
const zip = new JSZip();
for (const { name, bytes } of documents) {
zip.file(name, bytes);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${baseName}_collated_grouped.zip`);
}
hideLoader();
showAlert(
translate('common.success', 'Success'),
translate(
'tools:duplexCollate.successGroupedMessage',
`Collation complete. Generated ${fileCount} grouped file(s).`,
{
fileCount,
}
),
'success'
);
} catch (error) {
console.error('Duplex collate error:', error);
hideLoader();
showAlert(
translate('common.error', 'Error'),
translate(
'tools:duplexCollate.processErrorMessage',
'Failed to collate PDF.'
)
);
}
}
/**
* Clears selected file data and restores the page UI to its initial state.
*/
function resetState() {
duplexState.file = null;
duplexState.pdfDoc = null;
duplexState.totalPages = 0;
const options = document.getElementById('duplex-options');
const fileDisplayArea = document.getElementById('file-display-area');
const oddBanner = document.getElementById('odd-page-banner');
if (oddBanner) {
oddBanner.classList.add('hidden');
oddBanner.textContent = '';
}
const splitInput = document.getElementById(
'split-page'
) as HTMLInputElement | null;
const groupedCheckbox = document.getElementById(
'export-grouped'
) as HTMLInputElement | null;
const pagesPerDocInput = document.getElementById(
'pages-per-document'
) as HTMLInputElement | null;
const warning = document.getElementById('duplex-warning');
const summary = document.getElementById('duplex-preview-summary');
options?.classList.add('hidden');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (splitInput) splitInput.value = '';
if (groupedCheckbox) groupedCheckbox.checked = false;
if (pagesPerDocInput) pagesPerDocInput.value = '2';
if (warning) {
warning.classList.add('hidden');
warning.textContent = '';
}
if (summary) summary.textContent = '';
toggleGroupedOptions();
}
+1 -5
View File
@@ -10,11 +10,7 @@ import Sortable from 'sortablejs';
import { icons, createIcons } from 'lucide';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
const duplicateOrganizeState: {
sortableInstances: { pageGrid?: Sortable };
+90 -5
View File
@@ -5,14 +5,55 @@ import { formatBytes, downloadFile } from '../utils/helpers.js';
import { makeUniqueFileKey } from '../utils/deduplicate-filename.js';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { getEditorDisabledCategories } from '../utils/disabled-tools.js';
import { editorFontFallback } from '../config/editor-fonts.js';
import { needsFontEmbedding } from '../utils/freetext-script.js';
const embedPdfWasmUrl = new URL(
'embedpdf-snippet/dist/pdfium.wasm',
'bentopdf-pdfium/editcore.wasm',
import.meta.url
).href;
import type { EmbedPdfContainer } from 'embedpdf-snippet';
import type { DocManagerPlugin } from '@/types';
import type { EmbedPdfContainer } from 'bentopdf-viewer';
import type {
AnnotationPluginLite,
DocManagerPlugin,
FreeTextSystemFontAnnotation,
} from '@/types';
const FREETEXT_SUBTYPE = 3;
function collectAllFreeTexts(
annotationPlugin: AnnotationPluginLite | null
): FreeTextSystemFontAnnotation[] {
if (!annotationPlugin) return [];
try {
const state = annotationPlugin.getState();
const out: FreeTextSystemFontAnnotation[] = [];
for (const tracked of Object.values(state.byUid)) {
const obj = tracked.object;
if (obj.type !== FREETEXT_SUBTYPE) continue;
if (obj.intent === 'FreeTextCallout') continue;
if (!obj.id || obj.pageIndex == null || !obj.rect) continue;
out.push({
id: obj.id,
pageIndex: obj.pageIndex,
contents: obj.contents ?? '',
fontSize: obj.fontSize ?? 12,
fontColor: obj.fontColor ?? '#000000',
textAlign: obj.textAlign ?? 0,
verticalAlign: obj.verticalAlign ?? 0,
opacity: obj.opacity ?? 1,
backgroundColor: obj.color ?? obj.backgroundColor,
rect: obj.rect,
fontPostScriptName: obj.fontPostScriptName ?? '',
rotation: obj.rotation ?? 0,
});
}
return out;
} catch {
return [];
}
}
let viewerInstance: EmbedPdfContainer | null = null;
let docManagerPlugin: DocManagerPlugin | null = null;
@@ -135,7 +176,7 @@ async function handleFiles(files: FileList) {
pdfContainer.textContent = '';
pdfWrapper.classList.remove('hidden');
const { default: EmbedPDF } = await import('embedpdf-snippet');
const { default: EmbedPDF } = await import('bentopdf-viewer');
const disabledCategories = getEditorDisabledCategories();
viewerInstance = EmbedPDF.init({
disabledCategories,
@@ -143,6 +184,7 @@ async function handleFiles(files: FileList) {
target: pdfContainer,
worker: true,
wasmUrl: embedPdfWasmUrl,
fontFallback: editorFontFallback,
export: {
defaultFileName: firstFile.name,
},
@@ -218,7 +260,50 @@ async function handleFiles(files: FileList) {
try {
const exportPlugin = registry.getPlugin('export').provides();
const arrayBuffer = await exportPlugin.saveAsCopy().toPromise();
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
let outBytes = new Uint8Array(arrayBuffer);
let annotationPlugin: AnnotationPluginLite | null = null;
try {
annotationPlugin = registry
.getPlugin('annotation')
.provides() as unknown as AnnotationPluginLite;
} catch {
annotationPlugin = null;
}
const allFreeTexts = collectAllFreeTexts(annotationPlugin);
let pending = allFreeTexts;
if (pending.length > 0) {
try {
const { flattenFreeTextToPageText } =
await import('../utils/freetext-flatten.js');
const res = await flattenFreeTextToPageText(outBytes, pending);
if (res.flattened > 0) {
outBytes = res.bytes;
pending = [];
}
} catch (err) {
console.error('Flatten pass failed:', err);
}
}
const customFontAnnots = pending.filter(
(a) =>
a.fontPostScriptName.trim() !== '' ||
needsFontEmbedding(a.contents)
);
if (customFontAnnots.length > 0) {
try {
const { embedFreeTextSystemFonts } =
await import('../utils/freetext-font-embed.js');
outBytes = await embedFreeTextSystemFonts(
outBytes,
customFontAnnots
);
} catch (err) {
console.error('Font embed pass failed:', err);
}
}
const blob = new Blob([new Uint8Array(outBytes)], {
type: 'application/pdf',
});
downloadFile(blob, currentFileName);
} catch (err) {
console.error('Error downloading PDF:', err);
+840
View File
@@ -0,0 +1,840 @@
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 dismissKeyboard = (): void => {
if (!window.matchMedia('(pointer: coarse)').matches) return;
const a = document.activeElement;
if (a instanceof HTMLElement && a.isContentEditable) a.blur();
};
const openSec = (spec: SectionSpec) => {
dismissKeyboard();
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 = () => {
dismissKeyboard();
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 !== 'idle') {
chipsWrap.appendChild(
toolChip('done', 'ph-check', 'Done — back to tools', () => {
closePanel();
closeSheet();
editBtn.click();
})
);
}
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();
}
const publishDockHeight = () => {
app.style.setProperty(
'--ecdock',
`${Math.round(inspector.getBoundingClientRect().height)}px`
);
};
new ResizeObserver(publishDockHeight).observe(inspector);
publishDockHeight();
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'] });
}
+891
View File
@@ -0,0 +1,891 @@
import '@phosphor-icons/web/regular';
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { t } from '../i18n/i18n';
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;
setOnSaved: (fn: ((kb: number, fileName: string) => void) | null) => void;
}
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;
function closeEditor() {
document.getElementById('text-editor-app')?.setAttribute('hidden', '');
document.getElementById('uploader')?.classList.remove('hidden');
document.getElementById('tool-landing')?.classList.remove('hidden');
}
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;
appModule.setOnSaved((kb) => {
showAlert(
t('common.success'),
t('tools:editPdfText.saved', { size: kb }),
'success',
() => {
closeEditor();
}
);
});
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(t('common.error'), t('tools:editPdfText.failedToLoad'));
closeEditor();
} 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 -4
View File
@@ -30,10 +30,6 @@ import * as bwipjs from 'bwip-js/browser';
import 'pdfjs-dist/web/pdf_viewer.css';
// Initialize PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import {
ExtractExistingFieldsResult,
@@ -43,6 +39,7 @@ import {
} from '@/types';
import { extractExistingFields as extractExistingPdfFields } from './form-creator-extraction.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
let fields: FormField[] = [];
let selectedField: FormField | null = null;
+1 -5
View File
@@ -7,11 +7,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { InvertColorsState } from '@/types';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
const pageState: InvertColorsState = { file: null, pdfDoc: null };
+6 -5
View File
@@ -17,11 +17,7 @@ import { createIcons, icons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
import Sortable from 'sortablejs';
import type { MergeJob, MergeFile, MergeMessage, MergeResponse } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
interface MergeState {
pdfDocs: Record<string, pdfjsLib.PDFDocumentProxy>;
@@ -389,12 +385,17 @@ export async function merge() {
'retain-page-labels'
) as HTMLInputElement | null;
const removeDuplicateFontsCheckbox = document.getElementById(
'remove-duplicate-fonts'
) as HTMLInputElement | null;
const message: MergeMessage = {
command: 'merge',
files: filesToMerge,
jobs: jobs,
cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js',
retainPageLabels: retainCheckbox?.checked ?? false,
removeDuplicateFonts: removeDuplicateFontsCheckbox?.checked ?? false,
};
mergeWorker.postMessage(
+1 -5
View File
@@ -7,11 +7,7 @@ import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import * as pdfjsLib from 'pdfjs-dist';
import Sortable from 'sortablejs';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
interface OrganizeState {
file: File | null;
+1 -5
View File
@@ -5,11 +5,7 @@ import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
interface BookletState {
file: File | null;
+1 -5
View File
@@ -15,13 +15,9 @@ import { repairPdfFile } from './repair-pdf.js';
import { partitionIncomingFiles } from '../utils/multi-tool-file-input.js';
import { convertImagesToPdfFile } from '../utils/images-to-pdf-lib.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import { t } from '../i18n/i18n';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
interface PageData {
id: string; // Unique ID for DOM reconciliation
+1 -5
View File
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
let files: File[] = [];
+1 -5
View File
@@ -18,11 +18,7 @@ import {
} from '../utils/comic-info.js';
import type { CbzOptions, ComicMetadata } from '@/types';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
let files: File[] = [];
+1 -5
View File
@@ -11,11 +11,7 @@ import { applyGreyscale } from '../utils/image-effects.js';
import * as pdfjsLib from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
let files: File[] = [];
+1 -5
View File
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
let files: File[] = [];
+1 -5
View File
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
let files: File[] = [];
+139 -102
View File
@@ -15,11 +15,7 @@ import type Vips from 'wasm-vips';
import wasmUrl from 'wasm-vips/vips.wasm?url';
import type { TiffOptions } from '@/types';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
let files: File[] = [];
let vipsInstance: typeof Vips | null = null;
@@ -36,6 +32,7 @@ async function getVips(): Promise<typeof Vips> {
return fileName;
},
});
vipsInstance.Cache.max(0);
return vipsInstance;
}
@@ -145,7 +142,11 @@ async function renderPageToRgba(
}).promise;
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
return { rgba: imageData.data, width: canvas.width, height: canvas.height };
const { width, height } = canvas;
page.cleanup();
canvas.width = 0;
canvas.height = 0;
return { rgba: imageData.data, width, height };
}
function encodePageToTiff(
@@ -155,52 +156,60 @@ function encodePageToTiff(
height: number,
options: TiffOptions
): Uint8Array {
let image = vips.Image.newFromMemory(
new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength),
width,
height,
4,
vips.BandFormat.uchar
);
image = image.copy();
const pixelsPerMm = options.dpi / 25.4;
image.setDouble('xres', pixelsPerMm);
image.setDouble('yres', pixelsPerMm);
if (options.colorMode === 'greyscale' || options.colorMode === 'bw') {
if (image.bands === 4) {
image = image.flatten({ background: [255, 255, 255] });
}
image = image.colourspace(vips.Interpretation.b_w);
} else {
if (image.bands === 4) {
image = image.flatten({ background: [255, 255, 255] });
}
}
const tiffOptions: Parameters<typeof image.tiffsaveBuffer>[0] = {
compression: options.compression as Vips.Enum,
resunit: vips.ForeignTiffResunit.inch,
xres: options.dpi / 25.4,
yres: options.dpi / 25.4,
predictor:
options.compression === 'lzw' || options.compression === 'deflate'
? vips.ForeignTiffPredictor.horizontal
: vips.ForeignTiffPredictor.none,
const intermediates: Vips.Image[] = [];
const track = (img: Vips.Image): Vips.Image => {
intermediates.push(img);
return img;
};
if (options.colorMode === 'bw') {
tiffOptions.bitdepth = 1;
}
try {
let image = track(
vips.Image.newFromMemory(
new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength),
width,
height,
4,
vips.BandFormat.uchar
)
);
if (options.compression === 'jpeg') {
tiffOptions.Q = 85;
}
image = track(image.copy());
const pixelsPerMm = options.dpi / 25.4;
image.setDouble('xres', pixelsPerMm);
image.setDouble('yres', pixelsPerMm);
const buffer = image.tiffsaveBuffer(tiffOptions);
image.delete();
return buffer;
if (image.bands === 4) {
image = track(image.flatten({ background: [255, 255, 255] }));
}
if (options.colorMode === 'greyscale' || options.colorMode === 'bw') {
image = track(image.colourspace(vips.Interpretation.b_w));
}
const tiffOptions: Parameters<typeof image.tiffsaveBuffer>[0] = {
compression: options.compression as Vips.Enum,
resunit: vips.ForeignTiffResunit.inch,
xres: options.dpi / 25.4,
yres: options.dpi / 25.4,
predictor:
options.compression === 'lzw' || options.compression === 'deflate'
? vips.ForeignTiffPredictor.horizontal
: vips.ForeignTiffPredictor.none,
};
if (options.colorMode === 'bw') {
tiffOptions.bitdepth = 1;
}
if (options.compression === 'jpeg') {
tiffOptions.Q = 85;
}
return image.tiffsaveBuffer(tiffOptions);
} finally {
for (const img of intermediates) {
if (!img.isDeleted()) img.delete();
}
}
}
async function convert() {
@@ -239,68 +248,88 @@ async function convert() {
if (options.multiPage && pdf.numPages > 1) {
const pages: Vips.Image[] = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const { rgba, width, height } = await renderPageToRgba(
page,
options.dpi
);
try {
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const { rgba, width, height } = await renderPageToRgba(
page,
options.dpi
);
let img = vips.Image.newFromMemory(
new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength),
width,
height,
4,
vips.BandFormat.uchar
);
const intermediates: Vips.Image[] = [];
const track = (image: Vips.Image): Vips.Image => {
intermediates.push(image);
return image;
};
if (options.colorMode === 'greyscale' || options.colorMode === 'bw') {
if (img.bands === 4) {
img = img.flatten({ background: [255, 255, 255] });
}
img = img.colourspace(vips.Interpretation.b_w);
} else {
if (img.bands === 4) {
img = img.flatten({ background: [255, 255, 255] });
try {
let img = track(
vips.Image.newFromMemory(
new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength),
width,
height,
4,
vips.BandFormat.uchar
)
);
if (img.bands === 4) {
img = track(img.flatten({ background: [255, 255, 255] }));
}
if (
options.colorMode === 'greyscale' ||
options.colorMode === 'bw'
) {
img = track(img.colourspace(vips.Interpretation.b_w));
}
pages.push(img.copyMemory());
} finally {
for (const img of intermediates) {
if (!img.isDeleted()) img.delete();
}
}
}
pages.push(img);
}
const firstPage = pages[0];
let joined = firstPage;
if (pages.length > 1) {
joined = vips.Image.arrayjoin(pages, { across: 1 });
}
const firstPage = pages[0];
let joined = firstPage;
if (pages.length > 1) {
joined = vips.Image.arrayjoin(pages, { across: 1 });
}
try {
const tiffOptions: Parameters<typeof joined.tiffsaveBuffer>[0] = {
compression: options.compression as Vips.Enum,
resunit: vips.ForeignTiffResunit.inch,
xres: options.dpi / 25.4,
yres: options.dpi / 25.4,
page_height: firstPage.height,
predictor:
options.compression === 'lzw' || options.compression === 'deflate'
? vips.ForeignTiffPredictor.horizontal
: vips.ForeignTiffPredictor.none,
};
const tiffOptions: Parameters<typeof joined.tiffsaveBuffer>[0] = {
compression: options.compression as Vips.Enum,
resunit: vips.ForeignTiffResunit.inch,
xres: options.dpi / 25.4,
yres: options.dpi / 25.4,
page_height: firstPage.height,
predictor:
options.compression === 'lzw' || options.compression === 'deflate'
? vips.ForeignTiffPredictor.horizontal
: vips.ForeignTiffPredictor.none,
};
if (options.colorMode === 'bw') {
tiffOptions.bitdepth = 1;
}
if (options.colorMode === 'bw') {
tiffOptions.bitdepth = 1;
}
if (options.compression === 'jpeg') {
tiffOptions.Q = 85;
}
if (options.compression === 'jpeg') {
tiffOptions.Q = 85;
}
const buffer = joined.tiffsaveBuffer(tiffOptions);
const blob = new Blob([new Uint8Array(buffer)], { type: 'image/tiff' });
downloadFile(blob, getCleanPdfFilename(files[0].name) + '.tiff');
joined.delete();
for (const p of pages) {
if (!p.isDeleted()) p.delete();
const buffer = joined.tiffsaveBuffer(tiffOptions);
const blob = new Blob([new Uint8Array(buffer)], {
type: 'image/tiff',
});
downloadFile(blob, getCleanPdfFilename(files[0].name) + '.tiff');
} finally {
if (joined !== firstPage && !joined.isDeleted()) joined.delete();
}
} finally {
for (const p of pages) {
if (!p.isDeleted()) p.delete();
}
}
} else if (pdf.numPages === 1) {
const page = await pdf.getPage(1);
@@ -334,7 +363,15 @@ async function convert() {
);
} catch (e) {
console.error(e);
showAlert(t('common.error'), t('tools:pdfToTiff.alert.conversionError'));
const message = e instanceof Error ? e.message : String(e);
if (/memory/i.test(message)) {
showAlert(
t('common.error'),
t('tools:pdfToTiff.alert.conversionOutOfMemory')
);
} else {
showAlert(t('common.error'), t('tools:pdfToTiff.alert.conversionError'));
}
} finally {
hideLoader();
}
+1 -5
View File
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
let files: File[] = [];
+1 -5
View File
@@ -9,11 +9,7 @@ import { PDFDocument, PageSizes } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { createIcons, icons } from 'lucide';
import { PosterizeState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
const pageState: PosterizeState = {
file: null,
+1 -5
View File
@@ -5,11 +5,7 @@ import { initPagePreview } from '../utils/page-preview.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { escapeHtml } from '../utils/helpers.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
// State
const pageState: {
+1 -5
View File
@@ -9,6 +9,7 @@ import {
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import '../utils/setup-pdf-worker.js';
import {
ROTATION_MIN,
ROTATION_MAX,
@@ -18,11 +19,6 @@ import {
parseAngleInput,
} from '../utils/rotation-utils.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
interface RotateState {
file: File | null;
pdfDoc: PDFLibDocument | null;
+1 -5
View File
@@ -10,11 +10,7 @@ import { rotatePdfPages } from '../utils/pdf-operations.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
interface RotateState {
file: File | null;
+1 -1
View File
@@ -1,5 +1,5 @@
// This is essentially the same as image-to-pdf.
// Note: This is a legacy stub - scan-to-pdf functionality is now handled by image-to-pdf page
// Note: This is a legacy stub - scan-to-pdf functionality is now handled by the image-to-pdf page
export const scanToPdf = () => {
console.warn('scan-to-pdf is deprecated, redirecting to image-to-pdf page');
};
+1 -5
View File
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import type { ScanSettings } from '../types/scanner-effect-type.js';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
let files: File[] = [];
let cachedBaselineData: ImageData | null = null;
+1 -5
View File
@@ -20,6 +20,7 @@ import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
import JSZip from 'jszip';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import type { QpdfInstanceExtended } from '@/types';
import '../utils/setup-pdf-worker.js';
import {
parseRangeGroups,
evenOddIndices,
@@ -31,11 +32,6 @@ import {
extractPagesWithQpdf,
} from '../utils/split-pdf-helpers.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
document.addEventListener('DOMContentLoaded', () => {
let visualSelectorRendered = false;
let isSplitting = false;
+1 -5
View File
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { TextColorState } from '@/types';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import '../utils/setup-pdf-worker.js';
const pageState: TextColorState = { file: null, pdfDoc: null };
+1 -1
View File
@@ -1,4 +1,4 @@
// NOTE: This is a work in progress and does not work correctly as of yet
// NOTE: This is a work in progress and does not work correctly yet
import DOMPurify from 'dompurify';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { readFileAsArrayBuffer } from '../utils/helpers.js';
+21 -15
View File
@@ -1,3 +1,5 @@
import './utils/map-upsert-polyfill.js';
import './utils/setup-pdf-worker.js';
import { categories } from './config/tools.js';
import { dom, switchView, hideAlert } from './ui.js';
import { ShortcutsManager } from './logic/shortcuts.js';
@@ -22,6 +24,11 @@ import {
isToolDisabled,
isCurrentPageDisabled,
} from './utils/disabled-tools.js';
import {
getStoredItem,
setStoredItem,
removeStoredItem,
} from './utils/safe-storage.js';
declare const __BRAND_NAME__: string;
const init = async () => {
@@ -49,10 +56,6 @@ const init = async () => {
return;
}
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
if (__SIMPLE_MODE__) {
const hideBrandingSections = () => {
const heroSection = document.getElementById('hero-section');
@@ -171,6 +174,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',
@@ -211,6 +215,7 @@ const init = async () => {
'PDF to JSON': 'tools:pdfToJson',
'OCR PDF': 'tools:ocrPdf',
'Alternate & Mix Pages': 'tools:alternateMerge',
'Duplex Collate': 'tools:duplexCollate',
'PDF Overlay': 'tools:pdfOverlay',
'Organize & Duplicate': 'tools:duplicateOrganize',
'Add Attachments': 'tools:addAttachments',
@@ -290,17 +295,14 @@ const init = async () => {
let collapsedCategories: string[] = [];
try {
const stored = localStorage.getItem('collapsedCategories');
const stored = getStoredItem('collapsedCategories');
if (stored) collapsedCategories = JSON.parse(stored);
} catch {
localStorage.removeItem('collapsedCategories');
removeStoredItem('collapsedCategories');
}
function saveCollapsedCategories() {
localStorage.setItem(
'collapsedCategories',
JSON.stringify(collapsedCategories)
);
setStoredItem('collapsedCategories', JSON.stringify(collapsedCategories));
}
const filteredCategories = categories
@@ -545,7 +547,11 @@ const init = async () => {
document.getElementById('github-stars-mobile'),
];
if (githubStarsElements.some((el) => el) && !__SIMPLE_MODE__) {
if (
githubStarsElements.some((el) => el) &&
!__SIMPLE_MODE__ &&
!__DISABLE_GITHUB_STARS__
) {
fetch('https://api.github.com/repos/alam00000/bentopdf')
.then((response) => response.json())
.then((data) => {
@@ -611,7 +617,7 @@ const init = async () => {
) as HTMLInputElement;
const toolInterface = document.getElementById('tool-interface');
const savedFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
const savedFullWidth = getStoredItem('fullWidthMode') !== 'false';
if (fullWidthToggle) {
fullWidthToggle.checked = savedFullWidth;
applyFullWidthMode(savedFullWidth);
@@ -648,7 +654,7 @@ const init = async () => {
if (fullWidthToggle) {
fullWidthToggle.addEventListener('change', (e) => {
const enabled = (e.target as HTMLInputElement).checked;
localStorage.setItem('fullWidthMode', enabled.toString());
setStoredItem('fullWidthMode', enabled.toString());
applyFullWidthMode(enabled);
});
}
@@ -657,7 +663,7 @@ const init = async () => {
'compact-mode-toggle'
) as HTMLInputElement;
const savedCompactMode = localStorage.getItem('compactMode') === 'true';
const savedCompactMode = getStoredItem('compactMode') === 'true';
if (compactModeToggle) {
compactModeToggle.checked = savedCompactMode;
}
@@ -677,7 +683,7 @@ const init = async () => {
if (compactModeToggle) {
compactModeToggle.addEventListener('change', (e) => {
const enabled = (e.target as HTMLInputElement).checked;
localStorage.setItem('compactMode', enabled.toString());
setStoredItem('compactMode', enabled.toString());
applyCompactMode(enabled);
});
}
+2
View File
@@ -0,0 +1,2 @@
import './utils/map-upsert-polyfill.js';
import 'pdfjs-dist/build/pdf.worker.min.mjs';
+5
View File
@@ -9,8 +9,13 @@ export interface AddWatermarkState {
watermarkY: number; // 0–1, percentage from top (flipped to bottom for PDF)
}
export type WatermarkLayout = 'single' | 'tile';
export interface PageWatermarkConfig {
type: 'text' | 'image';
layout: WatermarkLayout;
tileGapX: number;
tileGapY: number;
x: number;
y: number;
text: string;
+46
View File
@@ -13,3 +13,49 @@ export interface DocManagerPlugin {
closeDocument: (id: string) => void;
saveAsCopy: (id: string) => Promise<Uint8Array>;
}
export interface EditorAnnotationRect {
origin: { x: number; y: number };
size: { width: number; height: number };
}
export interface EditorAnnotationObjectLite {
id?: string;
pageIndex?: number;
type?: number;
intent?: string;
contents?: string;
fontSize?: number;
fontColor?: string;
textAlign?: number;
verticalAlign?: number;
opacity?: number;
color?: string;
backgroundColor?: string;
rect?: EditorAnnotationRect;
rotation?: number;
fontFamilyName?: string;
fontPostScriptName?: string;
fontFaceName?: string;
}
export interface AnnotationPluginLite {
getState: () => {
byUid: Record<string, { object: EditorAnnotationObjectLite }>;
};
}
export interface FreeTextSystemFontAnnotation {
id: string;
pageIndex: number;
contents: string;
fontSize: number;
fontColor: string;
textAlign: number;
verticalAlign: number;
opacity: number;
backgroundColor?: string;
rect: EditorAnnotationRect;
fontPostScriptName: string;
rotation?: number;
}
+1
View File
@@ -18,6 +18,7 @@ export interface MergeMessage {
jobs: MergeJob[];
cpdfUrl?: string;
retainPageLabels?: boolean;
removeDuplicateFonts?: boolean;
}
export interface MergeSuccessResponse {
+2 -6
View File
@@ -14,11 +14,7 @@ import {
import * as pdfjsLib from 'pdfjs-dist';
import { t } from './i18n/i18n';
import type { FileInputOptions } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import './utils/setup-pdf-worker.js';
// Centralizing DOM element selection
export const dom = {
@@ -486,7 +482,7 @@ const createFileInputHTML = (options: FileInputOptions = {}) => {
return `
<div id="drop-zone" class="relative flex flex-col items-center justify-center w-full h-48 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>
<i class="ph ph-upload-simple text-4xl mb-3 text-gray-400"></i>
<p class="mb-2 text-sm text-gray-400"><span class="font-semibold">${t('upload.clickToSelect')}</span> ${t('upload.orDragAndDrop')}</p>
<p class="text-xs text-gray-500">${multiple ? t('upload.pdfOrImages') : 'A single PDF file'}</p>
<p class="text-xs text-gray-500">${t('upload.filesNeverLeave')}</p>
+306
View File
@@ -0,0 +1,306 @@
import type { FreeTextSystemFontAnnotation } from '@/types';
import { needsFontEmbedding } from './freetext-script.js';
export const FPDF_ANNOT_FREETEXT = 3;
export function hexToRgba(hex: string): number {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex ?? '');
if (!m) return 0x000000ff;
const r = parseInt(m[1], 16);
const g = parseInt(m[2], 16);
const b = parseInt(m[3], 16);
return ((r << 24) | (g << 16) | (b << 8) | 0xff) >>> 0;
}
export type FlattenInput = FreeTextSystemFontAnnotation;
interface EngineModule {
_malloc: (n: number) => number;
_free: (p: number) => void;
HEAPF32: Float32Array;
HEAPU16: Uint16Array;
_FPDFPage_GetAnnotCount: (page: number) => number;
_FPDFPage_GetAnnot: (page: number, i: number) => number;
_FPDFPage_RemoveAnnot: (page: number, i: number) => number;
_FPDFAnnot_GetSubtype: (annot: number) => number;
_FPDFAnnot_GetRect: (annot: number, out: number) => number;
_FPDFAnnot_GetStringValue: (
annot: number,
key: number,
buf: number,
len: number
) => number;
stringToUTF8: (s: string, p: number, n: number) => void;
lengthBytesUTF8: (s: string) => number;
}
interface EngineLike {
M: EngineModule;
page: number;
pageCount: number;
open: (b: Uint8Array) => void;
loadPage: (i: number) => void;
addParagraph: (
x: number,
yTop: number,
width: number,
runs: unknown[],
fmt: unknown
) => unknown;
saveSpliced: (opts?: unknown) => Promise<Uint8Array | null>;
}
function readAnnotString(M: EngineModule, annot: number, key: string): string {
const kn = M.lengthBytesUTF8(key) + 1;
const kp = M._malloc(kn);
M.stringToUTF8(key, kp, kn);
const need = M._FPDFAnnot_GetStringValue(annot, kp, 0, 0);
if (need <= 2) {
M._free(kp);
return '';
}
const bp = M._malloc(need);
M._FPDFAnnot_GetStringValue(annot, kp, bp, need);
let out = '';
for (let i = 0; i + 1 < need; i += 2) {
const c = M.HEAPU16[(bp + i) >> 1];
if (!c) break;
out += String.fromCharCode(c);
}
M._free(bp);
M._free(kp);
return out;
}
interface LocalFontRecord {
family: string;
style: string;
postscriptName: string;
blob: () => Promise<Blob>;
}
async function primeScriptFonts(
mod: {
PdfEngine: { localFonts: Map<string, Uint8Array> };
scriptFallbackFamilies: (cps: number[]) => string[];
},
texts: string[]
): Promise<void> {
const qlf = (
globalThis as unknown as {
queryLocalFonts?: () => Promise<LocalFontRecord[]>;
}
).queryLocalFonts;
if (typeof qlf !== 'function') return;
const cps = new Set<number>();
for (const t of texts) {
for (const ch of t) {
if (!needsFontEmbedding(ch)) continue;
const cp = ch.codePointAt(0);
if (cp) cps.add(cp);
}
}
if (cps.size === 0) return;
let wanted: string[];
try {
wanted = mod.scriptFallbackFamilies([...cps]);
} catch (err) {
console.warn('flatten: script fallback lookup failed', err);
return;
}
if (wanted.length === 0) return;
let list: LocalFontRecord[];
try {
list = await qlf();
} catch (err) {
console.warn('flatten: local font access denied', err);
return;
}
const byFamily = new Map<string, LocalFontRecord>();
for (const f of list) {
const key = f.family.toLowerCase();
const cur = byFamily.get(key);
const plain = /^(regular|book|normal)$/i.test(f.style ?? '');
if (!cur || plain) byFamily.set(key, f);
}
const MAX_FACE_BYTES = 40 << 20;
for (const family of wanted) {
if (mod.PdfEngine.localFonts.has(family)) continue;
const rec = byFamily.get(family.toLowerCase());
if (!rec) continue;
try {
const blob = await rec.blob();
if (blob.size === 0 || blob.size > MAX_FACE_BYTES) continue;
const bytes = new Uint8Array(await blob.arrayBuffer());
mod.PdfEngine.localFonts.set(family, bytes);
mod.PdfEngine.localFonts.set(family + '|00', bytes);
} catch (err) {
console.warn('flatten: font unavailable', family, err);
}
}
}
function buildRuns(
contents: string,
style: FreeTextSystemFontAnnotation | undefined
) {
const size = style && style.fontSize > 0 ? style.fontSize : 12;
return [
{
text: contents,
family: 'Helvetica',
size,
rgba: hexToRgba(style?.fontColor ?? '#000000'),
bold: false,
italic: false,
underline: false,
strike: false,
script: 0,
renderMode: 0,
strokeRgba: 0,
strokeWidth: 1,
hScale: 1,
rise: 0,
sourceIndex: -1,
},
];
}
const FLAT_FMT = {
align: 0,
lineSpacing: 1.18,
charSpacing: 0,
paraSpacing: 0,
wordSpacing: 0,
firstIndent: 0,
hangIndent: 0,
dir: 0,
listLevel: 0,
};
export async function flattenFreeTextToPageText(
bytes: Uint8Array,
annotations: FreeTextSystemFontAnnotation[],
pageCountHint = 0
): Promise<{ bytes: Uint8Array; flattened: number }> {
if (annotations.length === 0 && pageCountHint <= 0) {
return { bytes, flattened: 0 };
}
const mod = (await import('../editcore/core.js')) as unknown as {
PdfEngine: {
create: () => Promise<EngineLike>;
localFonts: Map<string, Uint8Array>;
};
scriptFallbackFamilies: (cps: number[]) => string[];
};
const eng = await mod.PdfEngine.create();
try {
eng.open(bytes);
} catch (err) {
console.error('flatten: could not open document', err);
return { bytes, flattened: 0 };
}
const styleById = new Map<string, FreeTextSystemFontAnnotation>();
const pages = new Set<number>();
for (const a of annotations) {
styleById.set(a.id, a);
pages.add(a.pageIndex);
}
for (let i = 0; i < eng.pageCount; i++) pages.add(i);
const M = eng.M;
const rectPtr = M._malloc(16);
let flattened = 0;
const found: string[] = [];
for (const pageIndex of [...pages].sort((x, y) => x - y)) {
if (pageIndex < 0 || pageIndex >= eng.pageCount) continue;
try {
eng.loadPage(pageIndex);
} catch (err) {
console.warn('flatten: could not scan page', pageIndex, err);
continue;
}
const scanPage = eng.page;
for (let i = M._FPDFPage_GetAnnotCount(scanPage) - 1; i >= 0; i--) {
const annot = M._FPDFPage_GetAnnot(scanPage, i);
if (!annot) continue;
if (M._FPDFAnnot_GetSubtype(annot) !== FPDF_ANNOT_FREETEXT) continue;
const style = styleById.get(readAnnotString(M, annot, 'NM'));
const text = style?.contents?.trim()
? style.contents
: readAnnotString(M, annot, 'Contents');
if (text.trim()) found.push(text);
}
}
if (found.length === 0) {
M._free(rectPtr);
return { bytes, flattened: 0 };
}
await primeScriptFonts(mod, found);
for (const pageIndex of [...pages].sort((x, y) => x - y)) {
if (pageIndex < 0 || pageIndex >= eng.pageCount) continue;
try {
eng.loadPage(pageIndex);
} catch (err) {
console.warn('flatten: could not load page', pageIndex, err);
continue;
}
const page = eng.page;
for (let i = M._FPDFPage_GetAnnotCount(page) - 1; i >= 0; i--) {
const annot = M._FPDFPage_GetAnnot(page, i);
if (!annot) continue;
if (M._FPDFAnnot_GetSubtype(annot) !== FPDF_ANNOT_FREETEXT) continue;
const name = readAnnotString(M, annot, 'NM');
const style = styleById.get(name);
const contents = style?.contents?.trim()
? style.contents
: readAnnotString(M, annot, 'Contents');
if (!contents.trim()) continue;
if (!M._FPDFAnnot_GetRect(annot, rectPtr)) continue;
const left = M.HEAPF32[rectPtr >> 2];
const bottom = M.HEAPF32[(rectPtr + 4) >> 2];
const right = M.HEAPF32[(rectPtr + 8) >> 2];
const top = M.HEAPF32[(rectPtr + 12) >> 2];
const width = Math.abs(right - left);
if (!(width > 1)) continue;
let created: unknown;
try {
created = eng.addParagraph(
Math.min(left, right),
Math.max(bottom, top),
width,
buildRuns(contents, style),
{ ...FLAT_FMT, align: (style?.textAlign ?? 0) | 0 }
);
} catch (err) {
console.warn('flatten: addParagraph failed', name, err);
created = null;
}
if (!created) continue;
M._FPDFPage_RemoveAnnot(page, i);
flattened++;
}
}
M._free(rectPtr);
if (flattened === 0) return { bytes, flattened: 0 };
try {
const out = await eng.saveSpliced();
if (out && out.length > 0) return { bytes: out, flattened };
} catch (err) {
console.error('flatten: save failed', err);
}
return { bytes, flattened: 0 };
}
+540
View File
@@ -0,0 +1,540 @@
import {
PDFDocument,
PDFDict,
PDFName,
PDFString,
PDFHexString,
PDFFont,
PDFOperator,
beginText,
endText,
moveText,
setFontAndSize,
setFillingRgbColor,
showText,
pushGraphicsState,
popGraphicsState,
rectangle,
fill,
} from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import type { FreeTextSystemFontAnnotation } from '@/types';
import { needsFontEmbedding, SCRIPT_FONTS } from './freetext-script.js';
interface LocalFontQueryResult {
postscriptName: string;
blob: () => Promise<Blob>;
}
type QueryLocalFonts = (opts?: {
postscriptNames?: string[];
}) => Promise<LocalFontQueryResult[]>;
export function extractTtcFace(
bytes: Uint8Array,
postscriptName: string
): Uint8Array {
const u16 = (o: number) => ((bytes[o] ?? 0) << 8) | (bytes[o + 1] ?? 0);
const u32 = (o: number) =>
(((bytes[o] ?? 0) << 24) |
((bytes[o + 1] ?? 0) << 16) |
((bytes[o + 2] ?? 0) << 8) |
(bytes[o + 3] ?? 0)) >>>
0;
if (bytes.length < 16 || u32(0) !== 0x74746366) return bytes;
const numFonts = u32(8);
if (numFonts === 0 || numFonts > 64) return bytes;
const want = postscriptName.replace(/[\s-]/g, '').toLowerCase();
const nameOf = (faceOff: number, nameId: number): string => {
const numTables = u16(faceOff + 4);
for (let i = 0; i < numTables; i++) {
const rec = faceOff + 12 + i * 16;
const tag = String.fromCharCode(
bytes[rec] ?? 0,
bytes[rec + 1] ?? 0,
bytes[rec + 2] ?? 0,
bytes[rec + 3] ?? 0
);
if (tag !== 'name') continue;
const nt = u32(rec + 8);
const count = u16(nt + 2);
const strBase = nt + u16(nt + 4);
for (let r = 0; r < count; r++) {
const nr = nt + 6 + r * 12;
if (u16(nr + 6) !== nameId) continue;
const platform = u16(nr);
const len = u16(nr + 8);
const off = strBase + u16(nr + 10);
if (platform === 3 || platform === 0) {
let out = '';
for (let k = 0; k + 1 < len; k += 2)
out += String.fromCharCode(u16(off + k));
return out;
}
let out = '';
for (let k = 0; k < len; k++)
out += String.fromCharCode(bytes[off + k] ?? 0);
return out;
}
}
return '';
};
let best = u32(12);
let bestScore = -1;
for (let f = 0; f < numFonts; f++) {
const off = u32(12 + f * 4);
if (off + 12 > bytes.length) continue;
const ps = nameOf(off, 6).replace(/[\s-]/g, '').toLowerCase();
const fam = nameOf(off, 1).replace(/[\s-]/g, '').toLowerCase();
let score = 0;
if (want && ps === want) score = 4;
else if (want && fam && (want.startsWith(fam) || fam.startsWith(want)))
score = 2;
if (score > bestScore) {
bestScore = score;
best = off;
}
}
const numTables = u16(best + 4);
if (numTables === 0 || numTables > 64) return bytes;
const records: Array<{ tag: string; sum: number; off: number; len: number }> =
[];
for (let i = 0; i < numTables; i++) {
const rec = best + 12 + i * 16;
records.push({
tag: String.fromCharCode(
bytes[rec] ?? 0,
bytes[rec + 1] ?? 0,
bytes[rec + 2] ?? 0,
bytes[rec + 3] ?? 0
),
sum: u32(rec + 4),
off: u32(rec + 8),
len: u32(rec + 12),
});
}
const headerSize = 12 + numTables * 16;
let total = headerSize;
const placed = new Map<string, number>();
for (const r of records) {
const key = r.off + ':' + r.len;
if (!placed.has(key)) {
placed.set(key, total);
total += (r.len + 3) & ~3;
}
}
const out = new Uint8Array(total);
const w16 = (o: number, v: number) => {
out[o] = (v >> 8) & 0xff;
out[o + 1] = v & 0xff;
};
const w32 = (o: number, v: number) => {
out[o] = (v >>> 24) & 0xff;
out[o + 1] = (v >>> 16) & 0xff;
out[o + 2] = (v >>> 8) & 0xff;
out[o + 3] = v & 0xff;
};
w32(0, u32(best));
w16(4, numTables);
let pow2 = 1;
let entSel = 0;
while (pow2 * 2 <= numTables) {
pow2 *= 2;
entSel++;
}
w16(6, pow2 * 16);
w16(8, entSel);
w16(10, (numTables - pow2) * 16);
records.forEach((r, i) => {
const rec = 12 + i * 16;
for (let k = 0; k < 4; k++) out[rec + k] = r.tag.charCodeAt(k);
w32(rec + 4, r.sum);
const newOff = placed.get(r.off + ':' + r.len);
if (newOff === undefined) return;
w32(rec + 8, newOff);
w32(rec + 12, r.len);
out.set(bytes.subarray(r.off, r.off + r.len), newOff);
if (r.tag === 'head' && r.len >= 12) w32(newOff + 8, 0);
});
return out;
}
let localFontIndex: Map<string, string> | null = null;
async function localPostScriptNames(): Promise<Map<string, string>> {
if (localFontIndex) return localFontIndex;
const index = new Map<string, string>();
const qlf = (window as unknown as { queryLocalFonts?: QueryLocalFonts })
.queryLocalFonts;
if (typeof qlf === 'function') {
try {
for (const f of await qlf()) {
index.set(f.postscriptName.toLowerCase(), f.postscriptName);
}
} catch (err) {
console.warn('freetext fonts: local font access denied', err);
}
}
localFontIndex = index;
return index;
}
export async function pickFontsForText(text: string): Promise<string[]> {
if (!needsFontEmbedding(text)) return [];
const index = await localPostScriptNames();
if (index.size === 0) return [];
const wanted: string[] = [];
for (const [re, families] of SCRIPT_FONTS) {
if (re.test(text)) wanted.push(...families);
}
wanted.push('ArialUnicodeMS', 'NotoSans-Regular', 'LucidaGrande');
const out: string[] = [];
for (const name of wanted) {
const hit = index.get(name.toLowerCase());
if (hit && !out.includes(hit)) out.push(hit);
}
return out;
}
export async function pickFontForText(text: string): Promise<string> {
return (await pickFontsForText(text))[0] ?? '';
}
async function fetchFontBytes(
postscriptName: string
): Promise<Uint8Array | null> {
const qlf = (window as unknown as { queryLocalFonts?: QueryLocalFonts })
.queryLocalFonts;
if (typeof qlf !== 'function') return null;
try {
const list = await qlf({ postscriptNames: [postscriptName] });
if (list.length === 0) return null;
const blob = await list[0].blob();
return extractTtcFace(
new Uint8Array(await blob.arrayBuffer()),
postscriptName
);
} catch {
return null;
}
}
function subsetPrefix(postscriptName: string): string {
let h = 0x811c9dc5;
for (const c of postscriptName) {
h = Math.imul(h ^ c.charCodeAt(0), 0x01000193);
}
let prefix = '';
for (let i = 0; i < 6; i++) {
prefix += String.fromCharCode(65 + (((h >>> (i * 4)) & 0x0f) % 26));
h = Math.imul(h, 0x01000193);
}
return prefix;
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
return m
? {
r: parseInt(m[1], 16) / 255,
g: parseInt(m[2], 16) / 255,
b: parseInt(m[3], 16) / 255,
}
: { r: 0, g: 0, b: 0 };
}
interface EmbeddedFontMetrics {
ascent: number;
descent: number;
unitsPerEm: number;
}
function fontMetrics(font: PDFFont): EmbeddedFontMetrics {
const carrier = font as unknown as {
embedder?: {
font?: { ascent?: number; descent?: number; unitsPerEm?: number };
};
};
const f = carrier.embedder?.font;
const unitsPerEm =
typeof f?.unitsPerEm === 'number' && f.unitsPerEm > 0 ? f.unitsPerEm : 1000;
const ascent =
typeof f?.ascent === 'number' && f.ascent > 0 ? f.ascent : unitsPerEm * 0.8;
const descent =
typeof f?.descent === 'number' && f.descent <= 0
? f.descent
: -unitsPerEm * 0.2;
return { ascent, descent, unitsPerEm };
}
function breakLongToken(
font: PDFFont,
token: string,
size: number,
maxWidth: number,
out: string[]
): string {
let cur = '';
for (const ch of token) {
const candidate = cur + ch;
if (cur !== '' && font.widthOfTextAtSize(candidate, size) > maxWidth) {
out.push(cur);
cur = ch;
} else {
cur = candidate;
}
}
return cur;
}
function wrapLine(
font: PDFFont,
text: string,
size: number,
maxWidth: number
): string[] {
if (!text) return [''];
const tokens = text.match(/\s+|\S+/g) ?? [];
const out: string[] = [];
let cur = '';
for (const token of tokens) {
if (/^\s+$/.test(token)) {
cur += token;
continue;
}
const candidate = cur + token;
if (font.widthOfTextAtSize(candidate.trimEnd(), size) <= maxWidth) {
cur = candidate;
continue;
}
if (cur.trim() !== '') {
out.push(cur.trimEnd());
}
if (font.widthOfTextAtSize(token, size) <= maxWidth) {
cur = token;
} else {
cur = breakLongToken(font, token, size, maxWidth, out);
}
}
out.push(cur.trimEnd());
return out;
}
function findFreeTextDict(
doc: PDFDocument,
pageIndex: number,
id: string
): PDFDict | null {
if (pageIndex < 0 || pageIndex >= doc.getPageCount()) return null;
const annots = doc.getPage(pageIndex).node.Annots();
if (!annots) return null;
for (let i = 0; i < annots.size(); i++) {
const obj = annots.lookup(i);
if (!(obj instanceof PDFDict)) continue;
const subtype = obj.lookup(PDFName.of('Subtype'));
if (!(subtype instanceof PDFName) || subtype.decodeText() !== 'FreeText') {
continue;
}
const nm = obj.lookup(PDFName.of('NM'));
const nmStr =
nm instanceof PDFString
? nm.asString()
: nm instanceof PDFHexString
? nm.decodeText()
: '';
if (nmStr === id) return obj;
}
return null;
}
function bakeAppearance(
doc: PDFDocument,
dict: PDFDict,
annot: FreeTextSystemFontAnnotation,
font: PDFFont
): void {
const w = annot.rect.size.width;
const h = annot.rect.size.height;
if (!(w > 0) || !(h > 0)) return;
const size = annot.fontSize > 0 ? annot.fontSize : 12;
const lineHeight = size * 1.18;
const metrics = fontMetrics(font);
const scalePerUnit = size / metrics.unitsPerEm;
const maxWidth = Math.max(w, size);
const lines: string[] = [];
for (const paragraph of (annot.contents ?? '').split(/\r\n?|\n/)) {
lines.push(...wrapLine(font, paragraph, size, maxWidth));
}
const blockHeight = lines.length * lineHeight;
const vOffset =
annot.verticalAlign === 1
? Math.max(0, (h - blockHeight) / 2)
: annot.verticalAlign === 2
? Math.max(0, h - blockHeight)
: 0;
const contentHeight = (metrics.ascent - metrics.descent) * scalePerUnit;
const halfLeading = (lineHeight - contentHeight) / 2;
const firstBaselineFromTop =
vOffset + halfLeading + metrics.ascent * scalePerUnit;
const color = hexToRgb(annot.fontColor || '#000000');
const bg =
annot.backgroundColor &&
/^#?[a-f\d]{6}$/i.test(annot.backgroundColor.trim())
? hexToRgb(annot.backgroundColor.trim())
: null;
const ops: PDFOperator[] = [pushGraphicsState()];
if (bg) {
ops.push(
setFillingRgbColor(bg.r, bg.g, bg.b),
rectangle(0, 0, w, h),
fill()
);
}
ops.push(
beginText(),
setFontAndSize('F0', size),
setFillingRgbColor(color.r, color.g, color.b)
);
let prevX = 0;
let prevY = 0;
lines.forEach((line, i) => {
if (!line) return;
const lineWidth = font.widthOfTextAtSize(line, size);
const x =
annot.textAlign === 1
? Math.max(0, (w - lineWidth) / 2)
: annot.textAlign === 2
? Math.max(0, w - lineWidth)
: 0;
const y = h - (firstBaselineFromTop + i * lineHeight);
ops.push(moveText(x - prevX, y - prevY), showText(font.encodeText(line)));
prevX = x;
prevY = y;
});
ops.push(endText(), popGraphicsState());
const deg = (((annot.rotation ?? 0) % 360) + 360) % 360;
const rad = (deg * Math.PI) / 180;
const c = Math.cos(rad);
const s = Math.sin(rad);
const xs = [0, w * c, -h * s, w * c - h * s];
const ys = [0, w * s, h * c, w * s + h * c];
const xobj = doc.context.formXObject(
ops,
deg === 0
? { BBox: [0, 0, w, h], Resources: { Font: { F0: font.ref } } }
: {
BBox: [0, 0, w, h],
Matrix: [c, s, -s, c, -Math.min(...xs), -Math.min(...ys)],
Resources: { Font: { F0: font.ref } },
}
);
const ref = doc.context.register(xobj);
dict.set(PDFName.of('AP'), doc.context.obj({ N: ref }));
}
export async function embedFreeTextSystemFonts(
bytes: Uint8Array,
annotations: FreeTextSystemFontAnnotation[]
): Promise<Uint8Array> {
const candidates: FreeTextSystemFontAnnotation[] = [];
const autoCandidates = new Map<FreeTextSystemFontAnnotation, string[]>();
for (const a of annotations) {
if (a.fontPostScriptName.trim() !== '') {
candidates.push(a);
continue;
}
if (!needsFontEmbedding(a.contents)) continue;
autoCandidates.set(a, await pickFontsForText(a.contents));
if (autoCandidates.get(a)?.length) candidates.push(a);
}
if (candidates.length === 0) return bytes;
const doc = await PDFDocument.load(bytes, {
ignoreEncryption: true,
updateMetadata: false,
});
doc.registerFontkit(fontkit);
const fontCache = new Map<string, PDFFont | null>();
const resolveFont = async (
postscriptName: string,
sampleText: string
): Promise<PDFFont | null> => {
const cached = fontCache.get(postscriptName);
if (cached !== undefined) return cached;
const fontBytes = await fetchFontBytes(postscriptName);
if (!fontBytes) {
fontCache.set(postscriptName, null);
return null;
}
const customName = `${subsetPrefix(postscriptName)}+${postscriptName}`;
try {
const probe = await PDFDocument.create();
probe.registerFontkit(fontkit);
const probeFont = await probe.embedFont(fontBytes, {
subset: true,
customName,
});
probe.addPage([200, 100]).drawText(sampleText.slice(0, 64), {
x: 4,
y: 40,
size: 12,
font: probeFont,
});
await probe.save();
} catch {
fontCache.set(postscriptName, null);
return null;
}
try {
const font = await doc.embedFont(fontBytes, {
subset: true,
customName,
});
fontCache.set(postscriptName, font);
return font;
} catch {
fontCache.set(postscriptName, null);
return null;
}
};
let touched = false;
for (const annot of candidates) {
try {
const names = autoCandidates.get(annot) ?? [annot.fontPostScriptName];
let font: PDFFont | null = null;
let usedName = '';
for (const name of names) {
font = await resolveFont(name, annot.contents);
if (font) {
usedName = name;
break;
}
}
if (!font || !usedName) continue;
const dict = findFreeTextDict(doc, annot.pageIndex, annot.id);
if (!dict) continue;
bakeAppearance(doc, dict, annot, font);
touched = true;
} catch {
continue;
}
}
if (!touched) return bytes;
try {
return await doc.save();
} catch {
return bytes;
}
}
Binary file not shown.
+37 -2
View File
@@ -21,6 +21,30 @@ export type ProgressCallback = (progress: LoadProgress) => void;
// Singleton for converter instance
let converterInstance: LibreOfficeConverter | null = null;
const GZIP_MAGIC_FIRST = 0x1f;
const GZIP_MAGIC_SECOND = 0x8b;
async function fetchAsDecompressedUrl(
url: string,
mimeType: string
): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
}
let blob = await response.blob();
const head = new Uint8Array(await blob.slice(0, 2).arrayBuffer());
if (head[0] === GZIP_MAGIC_FIRST && head[1] === GZIP_MAGIC_SECOND) {
const decompressed = blob
.stream()
.pipeThrough(new DecompressionStream('gzip'));
blob = await new Response(decompressed).blob();
}
return URL.createObjectURL(new Blob([blob], { type: mimeType }));
}
export class LibreOfficeConverter {
private converter: WorkerBrowserConverter | null = null;
private initialized = false;
@@ -51,10 +75,21 @@ export class LibreOfficeConverter {
message: 'Loading conversion engine...',
});
const [sofficeWasmUrl, sofficeDataUrl] = await Promise.all([
fetchAsDecompressedUrl(
`${this.basePath}soffice.wasm.gz`,
'application/wasm'
),
fetchAsDecompressedUrl(
`${this.basePath}soffice.data.gz`,
'application/octet-stream'
),
]);
this.converter = new WorkerBrowserConverter({
sofficeJs: `${this.basePath}soffice.js`,
sofficeWasm: `${this.basePath}soffice.wasm.gz`,
sofficeData: `${this.basePath}soffice.data.gz`,
sofficeWasm: sofficeWasmUrl,
sofficeData: sofficeDataUrl,
sofficeWorkerJs: `${this.basePath}soffice.worker.js`,
browserWorkerJs: `${this.basePath}browser.worker.global.js`,
verbose: false,
+47
View File
@@ -0,0 +1,47 @@
type MapPrototypeWithUpsert = typeof Map.prototype & {
getOrInsert?: (key: unknown, value: unknown) => unknown;
getOrInsertComputed?: (
key: unknown,
callback: (key: unknown) => unknown
) => unknown;
};
const mapPrototype = Map.prototype as MapPrototypeWithUpsert;
const mapHas = Map.prototype.has;
const mapGet = Map.prototype.get;
const mapSet = Map.prototype.set;
if (typeof mapPrototype.getOrInsert !== 'function') {
Object.defineProperty(mapPrototype, 'getOrInsert', {
configurable: true,
writable: true,
value: function getOrInsert(key: unknown, value: unknown) {
if (mapHas.call(this, key)) return mapGet.call(this, key);
mapSet.call(this, key, value);
return value;
},
});
}
if (typeof mapPrototype.getOrInsertComputed !== 'function') {
Object.defineProperty(mapPrototype, 'getOrInsertComputed', {
configurable: true,
writable: true,
value: function getOrInsertComputed(
key: unknown,
callback: (key: unknown) => unknown
) {
const hasKey = mapHas.call(this, key);
if (typeof callback !== 'function') {
throw new TypeError('callback must be a function');
}
if (hasKey) return mapGet.call(this, key);
const value = callback(key);
mapSet.call(this, key, value);
return value;
},
});
}
export {};
+1 -5
View File
@@ -1,11 +1,7 @@
import * as pdfjsLib from 'pdfjs-dist';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import { PreviewState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import './setup-pdf-worker.js';
const state: PreviewState = {
modal: null,
+124 -20
View File
@@ -182,6 +182,82 @@ export function parseDeletePages(str: string, totalPages: number): Set<number> {
return pages;
}
export interface TileLayoutOptions {
pageWidth: number;
pageHeight: number;
itemWidth: number;
itemHeight: number;
angle: number;
gapX: number;
gapY: number;
}
export const DEFAULT_TILE_GAP_X = 0.25;
export const DEFAULT_TILE_GAP_Y = 0.75;
const MAX_TILES = 1500;
export function computeTileCenters(
options: TileLayoutOptions
): { x: number; y: number }[] {
const { pageWidth, pageHeight, itemWidth, itemHeight } = options;
if (
!(pageWidth > 0) ||
!(pageHeight > 0) ||
!(itemWidth > 0) ||
!(itemHeight > 0) ||
!Number.isFinite(options.angle)
) {
return [];
}
const rad = (options.angle * Math.PI) / 180;
const ux = Math.cos(rad);
const uy = Math.sin(rad);
const vx = -Math.sin(rad);
const vy = Math.cos(rad);
let stepX = Math.max(itemWidth * (1 + Math.max(0, options.gapX)), 1);
let stepY = Math.max(itemHeight * (1 + Math.max(0, options.gapY)), 1);
const density = (pageWidth * pageHeight) / (stepX * stepY);
if (density > MAX_TILES) {
const factor = Math.sqrt(density / MAX_TILES);
stepX *= factor;
stepY *= factor;
}
const centerX = pageWidth / 2;
const centerY = pageHeight / 2;
const reach = Math.hypot(pageWidth, pageHeight) / 2;
const radius = Math.hypot(itemWidth, itemHeight) / 2;
const halfExtentX =
(Math.abs(ux) * itemWidth + Math.abs(vx) * itemHeight) / 2;
const halfExtentY =
(Math.abs(uy) * itemWidth + Math.abs(vy) * itemHeight) / 2;
const iMax = Math.ceil((reach + radius) / stepX);
const jMax = Math.ceil((reach + radius) / stepY);
const centers: { x: number; y: number }[] = [];
for (let j = -jMax; j <= jMax; j++) {
for (let i = -iMax; i <= iMax; i++) {
const x = centerX + i * stepX * ux + j * stepY * vx;
const y = centerY + i * stepX * uy + j * stepY * vy;
if (
x + halfExtentX < 0 ||
x - halfExtentX > pageWidth ||
y + halfExtentY < 0 ||
y - halfExtentY > pageHeight
) {
continue;
}
centers.push({ x, y });
if (centers.length >= MAX_TILES * 2) return centers;
}
}
return centers;
}
export interface TextWatermarkOptions {
text: string;
fontSize: number;
@@ -191,6 +267,9 @@ export interface TextWatermarkOptions {
x?: number;
y?: number;
pageIndices?: number[];
tile?: boolean;
tileGapX?: number;
tileGapY?: number;
}
export async function addTextWatermark(
@@ -244,17 +323,28 @@ export async function addTextWatermark(
const page = pages[idx];
if (!page) continue;
const { width, height } = page.getSize();
const cx = posX * width;
const cy = posY * height;
const centers = options.tile
? computeTileCenters({
pageWidth: width,
pageHeight: height,
itemWidth: imgWidth,
itemHeight: imgHeight,
angle: options.angle,
gapX: options.tileGapX ?? DEFAULT_TILE_GAP_X,
gapY: options.tileGapY ?? DEFAULT_TILE_GAP_Y,
})
: [{ x: posX * width, y: posY * height }];
page.drawImage(image, {
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
width: imgWidth,
height: imgHeight,
opacity: options.opacity,
rotate: degrees(options.angle),
});
for (const { x: cx, y: cy } of centers) {
page.drawImage(image, {
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
width: imgWidth,
height: imgHeight,
opacity: options.opacity,
rotate: degrees(options.angle),
});
}
}
return new Uint8Array(await pdfDoc.save());
@@ -269,6 +359,9 @@ export interface ImageWatermarkOptions {
x?: number;
y?: number;
pageIndices?: number[];
tile?: boolean;
tileGapX?: number;
tileGapY?: number;
}
export async function addImageWatermark(
@@ -295,17 +388,28 @@ export async function addImageWatermark(
const page = pages[idx];
if (!page) continue;
const { width, height } = page.getSize();
const cx = posX * width;
const cy = posY * height;
const centers = options.tile
? computeTileCenters({
pageWidth: width,
pageHeight: height,
itemWidth: imgWidth,
itemHeight: imgHeight,
angle: options.angle,
gapX: options.tileGapX ?? DEFAULT_TILE_GAP_X,
gapY: options.tileGapY ?? DEFAULT_TILE_GAP_Y,
})
: [{ x: posX * width, y: posY * height }];
page.drawImage(image, {
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
width: imgWidth,
height: imgHeight,
opacity: options.opacity,
rotate: degrees(options.angle),
});
for (const { x: cx, y: cy } of centers) {
page.drawImage(image, {
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
width: imgWidth,
height: imgHeight,
opacity: options.opacity,
rotate: degrees(options.angle),
});
}
}
return new Uint8Array(await pdfDoc.save());
+1 -5
View File
@@ -1,9 +1,5 @@
import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
import './setup-pdf-worker.js';
/**
* Configuration for progressive rendering
+25
View File
@@ -0,0 +1,25 @@
export const getStoredItem = (key: string): string | null => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
export const setStoredItem = (key: string, value: string): boolean => {
try {
localStorage.setItem(key, value);
return true;
} catch {
return false;
}
};
export const removeStoredItem = (key: string): boolean => {
try {
localStorage.removeItem(key);
return true;
} catch {
return false;
}
};
+4
View File
@@ -0,0 +1,4 @@
import * as pdfjsLib from 'pdfjs-dist';
import pdfWorkerUrl from '../pdf.worker?worker&url';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
+19
View File
@@ -46,6 +46,18 @@ export class WatermarkNode extends BaseWorkflowNode {
'position',
new ClassicPreset.InputControl('text', { initial: 'center' })
);
this.addControl(
'tile',
new ClassicPreset.InputControl('text', { initial: 'no' })
);
this.addControl(
'tileGapX',
new ClassicPreset.InputControl('number', { initial: 25 })
);
this.addControl(
'tileGapY',
new ClassicPreset.InputControl('number', { initial: 75 })
);
this.addControl(
'pages',
new ClassicPreset.InputControl('text', { initial: 'all' })
@@ -96,6 +108,10 @@ export class WatermarkNode extends BaseWorkflowNode {
const posKey = getText('position', 'center').trim().toLowerCase();
const { x, y } = positionPresets[posKey] ?? positionPresets['center'];
const tile = getText('tile', 'no').trim().toLowerCase() === 'yes';
const tileGapX = Math.max(0, getNum('tileGapX', 25)) / 100;
const tileGapY = Math.max(0, getNum('tileGapY', 75)) / 100;
const pagesStr = getText('pages', 'all').trim().toLowerCase();
const shouldFlatten =
getText('flatten', 'no').trim().toLowerCase() === 'yes';
@@ -117,6 +133,9 @@ export class WatermarkNode extends BaseWorkflowNode {
x,
y: 1 - y,
pageIndices,
tile,
tileGapX,
tileGapY,
});
if (shouldFlatten) {
+13 -19
View File
@@ -13,10 +13,7 @@
name="description"
content="★ Add Attachments online free - Add attachments PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="keywords"
content="add attachments, add attachments, online pdf"
/>
<meta name="keywords" content="add attachments, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -57,7 +54,7 @@
<meta name="twitter:title" content="Add Attachments Free" />
<meta
name="twitter:description"
content="★ Add Attachments online free - Add attachments PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in bro"
content="★ Add Attachments online free - Add attachments PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser"
/>
<meta
name="twitter:image"
@@ -140,10 +137,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 PDF</span
@@ -350,36 +344,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+13 -19
View File
@@ -13,10 +13,7 @@
name="description"
content="★ Add Blank Page online free - Add blank page PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="keywords"
content="add blank page, add blank page, online pdf"
/>
<meta name="keywords" content="add blank page, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -54,7 +51,7 @@
<meta name="twitter:title" content="Add Blank Page Free" />
<meta
name="twitter:description"
content="★ Add Blank Page online free - Add blank page PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in brows"
content="★ Add Blank Page online free - Add blank page PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser"
/>
<meta
name="twitter:image"
@@ -136,10 +133,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
@@ -338,36 +332,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+1 -4
View File
@@ -116,10 +116,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
+13 -16
View File
@@ -13,7 +13,7 @@
name="description"
content="★ Add Stamps online free - Add stamps PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="add stamps, add stamps, online pdf" />
<meta name="keywords" content="add stamps, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -48,7 +48,7 @@
<meta name="twitter:title" content="Add Stamps Free" />
<meta
name="twitter:description"
content="★ Add Stamps online free - Add stamps PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fas"
content="★ Add Stamps online free - Add stamps PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -148,10 +148,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
@@ -269,36 +266,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+80 -17
View File
@@ -13,7 +13,7 @@
name="description"
content="★ Add Watermark online free - Add watermark PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="add watermark, add watermark, online pdf" />
<meta name="keywords" content="add watermark, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -130,10 +130,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
@@ -212,7 +209,9 @@
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</button>
</div>
<span class="text-xs text-gray-500 hidden sm:inline"
<span
id="drag-hint"
class="text-xs text-gray-500 hidden sm:inline"
>Drag watermark to position</span
>
</div>
@@ -226,6 +225,10 @@
style="touch-action: none"
>
<canvas id="preview-canvas"></canvas>
<canvas
id="tile-preview-canvas"
class="hidden absolute top-0 left-0 pointer-events-none"
></canvas>
<div
id="watermark-box"
class="absolute pointer-events-auto"
@@ -284,6 +287,29 @@
</button>
</div>
<div>
<label class="block mb-1.5 text-sm font-medium text-gray-300"
>Layout</label
>
<div class="flex gap-2">
<button
id="layout-single-btn"
class="flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors"
>
Single
</button>
<button
id="layout-tile-btn"
class="flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors"
>
Tiled
</button>
</div>
<p class="text-xs text-gray-500 mt-1.5">
Tiled repeats the watermark across the whole page.
</p>
</div>
<div
class="flex items-center gap-2 bg-gray-900 rounded-lg px-3 py-2"
>
@@ -470,7 +496,44 @@
</div>
</div>
<div>
<div id="tile-options" class="hidden space-y-4">
<div>
<label
for="tile-gap-x"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Horizontal gap: <span id="tile-gap-x-value">25</span>%
</label>
<input
type="range"
id="tile-gap-x"
min="0"
max="200"
step="5"
value="25"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
<div>
<label
for="tile-gap-y"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Vertical gap: <span id="tile-gap-y-value">75</span>%
</label>
<input
type="range"
id="tile-gap-y"
min="0"
max="200"
step="5"
value="75"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
</div>
<div id="position-section">
<label class="block mb-1.5 text-sm font-medium text-gray-300"
>Position</label
>
@@ -668,36 +731,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+1 -4
View File
@@ -126,10 +126,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
+13 -19
View File
@@ -13,10 +13,7 @@
name="description"
content="★ Alternate Merge online free - Alternate merge PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="keywords"
content="alternate merge, alternate merge, online pdf"
/>
<meta name="keywords" content="alternate merge, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -57,7 +54,7 @@
<meta name="twitter:title" content="Alternate Merge Free" />
<meta
name="twitter:description"
content="★ Alternate Merge online free - Alternate merge PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in bro"
content="★ Alternate Merge online free - Alternate merge PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser"
/>
<meta
name="twitter:image"
@@ -139,10 +136,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 files</span
@@ -321,36 +315,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+13 -19
View File
@@ -13,10 +13,7 @@
name="description"
content="★ Background Color online free - Background color PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="keywords"
content="background color, background color, online pdf"
/>
<meta name="keywords" content="background color, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -57,7 +54,7 @@
<meta name="twitter:title" content="Background Color Free" />
<meta
name="twitter:description"
content="★ Background Color online free - Background color PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in b"
content="★ Background Color online free - Background color PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser"
/>
<meta
name="twitter:image"
@@ -142,10 +139,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
@@ -293,36 +287,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+1 -4
View File
@@ -96,10 +96,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 files</span
+21 -24
View File
@@ -7,13 +7,13 @@
<!-- Primary Meta Tags -->
<meta
name="title"
content="Bmp To Pdf Online Free - Bmp To Pdf Tool | BentoPDF"
content="BMP to PDF Online Free - BMP to PDF Tool | BentoPDF"
/>
<meta
name="description"
content="★ Bmp To Pdf online free - Bmp to pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ BMP to PDF online free - BMP to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="bmp to pdf, bmp to pdf, online pdf" />
<meta name="keywords" content="bmp to pdf, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -28,11 +28,11 @@
<meta property="og:url" content="https://www.bentopdf.com/bmp-to-pdf" />
<meta
property="og:title"
content="Bmp To Pdf Online Free - Bmp To Pdf Tool | BentoPDF"
content="BMP to PDF Online Free - BMP to PDF Tool | BentoPDF"
/>
<meta
property="og:description"
content="★ Bmp To Pdf online free - Bmp to pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ BMP to PDF online free - BMP to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
property="og:image"
@@ -45,10 +45,10 @@
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://www.bentopdf.com/bmp-to-pdf" />
<meta name="twitter:title" content="Bmp To Pdf Free" />
<meta name="twitter:title" content="BMP to PDF Free" />
<meta
name="twitter:description"
content="★ Bmp To Pdf online free - Bmp to pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fas"
content="★ BMP to PDF online free - BMP to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -59,7 +59,7 @@
<!-- Mobile Web App -->
<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="Bmp To Pdf" />
<meta name="apple-mobile-web-app-title" content="BMP to PDF" />
<title>BMP to PDF - BentoPDF</title>
<meta
@@ -130,10 +130,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 files</span
@@ -284,36 +281,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
@@ -378,7 +375,7 @@
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Bmp To Pdf - BentoPDF",
"name": "BMP to PDF - BentoPDF",
"applicationCategory": "PDF Tool",
"operatingSystem": "Any - Web Browser",
"offers": {
@@ -432,7 +429,7 @@
{
"@type": "ListItem",
"position": 2,
"name": "Bmp To Pdf",
"name": "BMP to PDF",
"item": "https://www.bentopdf.com/bmp-to-pdf"
}
]
+13 -16
View File
@@ -13,7 +13,7 @@
name="description"
content="★ Bookmark online free - Bookmark PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="bookmark, bookmark, online pdf" />
<meta name="keywords" content="bookmark, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -48,7 +48,7 @@
<meta name="twitter:title" content="Bookmark Free" />
<meta
name="twitter:description"
content="★ Bookmark online free - Bookmark PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & "
content="★ Bookmark online free - Bookmark PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -126,10 +126,7 @@
class="relative flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-700 hover:bg-gray-600 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>
<i class="ph ph-upload-simple text-4xl mb-3 text-gray-400"></i>
<p class="mb-2 text-sm text-gray-300">
<span class="font-semibold" data-i18n="upload.clickToSelect"
>Click to select a file</span
@@ -745,36 +742,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+21 -24
View File
@@ -7,13 +7,13 @@
<!-- Primary Meta Tags -->
<meta
name="title"
content="Cbz To Pdf Online Free - Cbz To Pdf Tool | BentoPDF"
content="CBZ to PDF Online Free - CBZ to PDF Tool | BentoPDF"
/>
<meta
name="description"
content="★ Cbz To Pdf online free - Cbz to pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ CBZ to PDF online free - CBZ to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="cbz to pdf, cbz to pdf, online pdf" />
<meta name="keywords" content="cbz to pdf, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -28,11 +28,11 @@
<meta property="og:url" content="https://www.bentopdf.com/cbz-to-pdf" />
<meta
property="og:title"
content="Cbz To Pdf Online Free - Cbz To Pdf Tool | BentoPDF"
content="CBZ to PDF Online Free - CBZ to PDF Tool | BentoPDF"
/>
<meta
property="og:description"
content="★ Cbz To Pdf online free - Cbz to pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ CBZ to PDF online free - CBZ to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
property="og:image"
@@ -45,10 +45,10 @@
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://www.bentopdf.com/cbz-to-pdf" />
<meta name="twitter:title" content="Cbz To Pdf Free" />
<meta name="twitter:title" content="CBZ to PDF Free" />
<meta
name="twitter:description"
content="★ Cbz To Pdf online free - Cbz to pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fas"
content="★ CBZ to PDF online free - CBZ to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -59,7 +59,7 @@
<!-- Mobile Web App -->
<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="Cbz To Pdf" />
<meta name="apple-mobile-web-app-title" content="CBZ to PDF" />
<title>CBZ to PDF - Convert XPS/OCBZ to PDF - BentoPDF</title>
<meta
@@ -131,10 +131,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 files</span
@@ -291,36 +288,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
@@ -385,7 +382,7 @@
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Cbz To Pdf - BentoPDF",
"name": "CBZ to PDF - BentoPDF",
"applicationCategory": "PDF Tool",
"operatingSystem": "Any - Web Browser",
"offers": {
@@ -439,7 +436,7 @@
{
"@type": "ListItem",
"position": 2,
"name": "Cbz To Pdf",
"name": "CBZ to PDF",
"item": "https://www.bentopdf.com/cbz-to-pdf"
}
]
+13 -19
View File
@@ -13,10 +13,7 @@
name="description"
content="★ Change Permissions online free - Change permissions PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="keywords"
content="change permissions, change permissions, online pdf"
/>
<meta name="keywords" content="change permissions, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -57,7 +54,7 @@
<meta name="twitter:title" content="Change Permissions Free" />
<meta
name="twitter:description"
content="★ Change Permissions online free - Change permissions PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works "
content="★ Change Permissions online free - Change permissions PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser"
/>
<meta
name="twitter:image"
@@ -142,10 +139,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 PDF</span
@@ -406,36 +400,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+13 -19
View File
@@ -13,10 +13,7 @@
name="description"
content="★ Combine Single Page online free - Combine single page PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="keywords"
content="combine single page, combine single page, online pdf"
/>
<meta name="keywords" content="combine single page, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -57,7 +54,7 @@
<meta name="twitter:title" content="Combine Single Page Free" />
<meta
name="twitter:description"
content="★ Combine Single Page online free - Combine single page PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Work"
content="★ Combine Single Page online free - Combine single page PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser"
/>
<meta
name="twitter:image"
@@ -142,10 +139,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
@@ -370,36 +364,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+20 -20
View File
@@ -7,13 +7,13 @@
<!-- Primary Meta Tags -->
<meta
name="title"
content="Compare Pdfs Online Free - Compare Pdfs Tool | BentoPDF"
content="Compare PDFs Online Free - Compare PDFs Tool | BentoPDF"
/>
<meta
name="description"
content="★ Compare Pdfs online free - Compare pdfs PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ Compare PDFs online free - Compare PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="compare pdfs, compare pdfs, online pdf" />
<meta name="keywords" content="compare pdfs, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -28,11 +28,11 @@
<meta property="og:url" content="https://www.bentopdf.com/compare-pdfs" />
<meta
property="og:title"
content="Compare Pdfs Online Free - Compare Pdfs Tool | BentoPDF"
content="Compare PDFs Online Free - Compare PDFs Tool | BentoPDF"
/>
<meta
property="og:description"
content="★ Compare Pdfs online free - Compare pdfs PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ Compare PDFs online free - Compare PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
property="og:image"
@@ -45,10 +45,10 @@
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://www.bentopdf.com/compare-pdfs" />
<meta name="twitter:title" content="Compare Pdfs Free" />
<meta name="twitter:title" content="Compare PDFs Free" />
<meta
name="twitter:description"
content="★ Compare Pdfs online free - Compare pdfs PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★"
content="★ Compare PDFs online free - Compare PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -59,7 +59,7 @@
<!-- Mobile Web App -->
<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="Compare Pdfs" />
<meta name="apple-mobile-web-app-title" content="Compare PDFs" />
<title>Compare PDFs - BentoPDF</title>
<meta
@@ -1115,36 +1115,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
@@ -1210,7 +1210,7 @@
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Compare Pdfs - BentoPDF",
"name": "Compare PDFs - BentoPDF",
"applicationCategory": "PDF Tool",
"operatingSystem": "Any - Web Browser",
"offers": {
@@ -1264,7 +1264,7 @@
{
"@type": "ListItem",
"position": 2,
"name": "Compare Pdfs",
"name": "Compare PDFs",
"item": "https://www.bentopdf.com/compare-pdfs"
}
]
+7 -10
View File
@@ -130,10 +130,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 files</span
@@ -499,22 +496,22 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="pdf-to-jpg.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 To Jpg</h3>
<p class="text-gray-400 text-sm">Free online pdf to jpg tool</p>
<h3 class="text-white font-semibold mb-1">PDF to JPG</h3>
<p class="text-gray-400 text-sm">Free online PDF to JPG tool</p>
</a>
</div>
</section>
+21 -24
View File
@@ -7,13 +7,13 @@
<!-- Primary Meta Tags -->
<meta
name="title"
content="Crop Pdf Online Free - Crop Pdf Tool | BentoPDF"
content="Crop PDF Online Free - Crop PDF Tool | BentoPDF"
/>
<meta
name="description"
content="★ Crop Pdf online free - Crop pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ Crop PDF online free - Crop PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="crop pdf, crop pdf, online pdf" />
<meta name="keywords" content="crop pdf, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -28,11 +28,11 @@
<meta property="og:url" content="https://www.bentopdf.com/crop-pdf" />
<meta
property="og:title"
content="Crop Pdf Online Free - Crop Pdf Tool | BentoPDF"
content="Crop PDF Online Free - Crop PDF Tool | BentoPDF"
/>
<meta
property="og:description"
content="★ Crop Pdf online free - Crop pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ Crop PDF online free - Crop PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
property="og:image"
@@ -45,10 +45,10 @@
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://www.bentopdf.com/crop-pdf" />
<meta name="twitter:title" content="Crop Pdf Free" />
<meta name="twitter:title" content="Crop PDF Free" />
<meta
name="twitter:description"
content="★ Crop Pdf online free - Crop pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & "
content="★ Crop PDF online free - Crop PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -59,7 +59,7 @@
<!-- Mobile Web App -->
<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="Crop Pdf" />
<meta name="apple-mobile-web-app-title" content="Crop PDF" />
<title>Crop PDF - BentoPDF</title>
<meta
@@ -136,10 +136,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
@@ -339,36 +336,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
@@ -433,7 +430,7 @@
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Crop Pdf - BentoPDF",
"name": "Crop PDF - BentoPDF",
"applicationCategory": "PDF Tool",
"operatingSystem": "Any - Web Browser",
"offers": {
@@ -487,7 +484,7 @@
{
"@type": "ListItem",
"position": 2,
"name": "Crop Pdf",
"name": "Crop PDF",
"item": "https://www.bentopdf.com/crop-pdf"
}
]
+21 -24
View File
@@ -7,13 +7,13 @@
<!-- Primary Meta Tags -->
<meta
name="title"
content="Csv To Pdf Online Free - Csv To Pdf Tool | BentoPDF"
content="CSV to PDF Online Free - CSV to PDF Tool | BentoPDF"
/>
<meta
name="description"
content="★ Csv To Pdf online free - Csv to pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ CSV to PDF online free - CSV to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="csv to pdf, csv to pdf, online pdf" />
<meta name="keywords" content="csv to pdf, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -28,11 +28,11 @@
<meta property="og:url" content="https://www.bentopdf.com/csv-to-pdf" />
<meta
property="og:title"
content="Csv To Pdf Online Free - Csv To Pdf Tool | BentoPDF"
content="CSV to PDF Online Free - CSV to PDF Tool | BentoPDF"
/>
<meta
property="og:description"
content="★ Csv To Pdf online free - Csv to pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ CSV to PDF online free - CSV to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
property="og:image"
@@ -45,10 +45,10 @@
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://www.bentopdf.com/csv-to-pdf" />
<meta name="twitter:title" content="Csv To Pdf Free" />
<meta name="twitter:title" content="CSV to PDF Free" />
<meta
name="twitter:description"
content="★ Csv To Pdf online free - Csv to pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fas"
content="★ CSV to PDF online free - CSV to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -59,7 +59,7 @@
<!-- Mobile Web App -->
<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="Csv To Pdf" />
<meta name="apple-mobile-web-app-title" content="CSV to PDF" />
<title>CSV to PDF - Convert Spreadsheets to PDF - BentoPDF</title>
<meta
@@ -130,10 +130,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 files</span
@@ -289,36 +286,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
@@ -383,7 +380,7 @@
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Csv To Pdf - BentoPDF",
"name": "CSV to PDF - BentoPDF",
"applicationCategory": "PDF Tool",
"operatingSystem": "Any - Web Browser",
"offers": {
@@ -437,7 +434,7 @@
{
"@type": "ListItem",
"position": 2,
"name": "Csv To Pdf",
"name": "CSV to PDF",
"item": "https://www.bentopdf.com/csv-to-pdf"
}
]
+12 -15
View File
@@ -48,7 +48,7 @@
<meta name="twitter:title" content="Decrypt PDF Free" />
<meta
name="twitter:description"
content="★ Decrypt PDF online free - Decrypt PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast "
content="★ Decrypt PDF online free - Decrypt PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -130,10 +130,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 PDF</span
@@ -314,36 +311,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+13 -16
View File
@@ -13,7 +13,7 @@
name="description"
content="★ Delete Pages online free - Delete pages PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="delete pages, delete pages, online pdf" />
<meta name="keywords" content="delete pages, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -48,7 +48,7 @@
<meta name="twitter:title" content="Delete Pages Free" />
<meta
name="twitter:description"
content="★ Delete Pages online free - Delete pages PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★"
content="★ Delete Pages online free - Delete pages PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -128,10 +128,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
@@ -287,36 +284,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+1 -4
View File
@@ -119,10 +119,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 files</span
+1 -4
View File
@@ -155,10 +155,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
+13 -16
View File
@@ -13,7 +13,7 @@
name="description"
content="★ Divide Pages online free - Divide pages PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="divide pages, divide pages, online pdf" />
<meta name="keywords" content="divide pages, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -48,7 +48,7 @@
<meta name="twitter:title" content="Divide Pages Free" />
<meta
name="twitter:description"
content="★ Divide Pages online free - Divide pages PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★"
content="★ Divide Pages online free - Divide pages PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -130,10 +130,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
@@ -329,36 +326,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+643
View File
@@ -0,0 +1,643 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Duplex Collate - Reorder Front/Back Scan Blocks | BentoPDF</title>
<meta
name="title"
content="Duplex Collate - Reorder Front/Back Scan Blocks | BentoPDF"
/>
<meta
name="description"
content="Reorder duplex scan PDFs that contain front pages and back pages in separate blocks. Create one collated PDF or grouped output files, directly in your browser."
/>
<meta
name="keywords"
content="duplex collate, adf scan reorder, front back pdf, collate scanned pdf, duplex sort pdf"
/>
<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/duplex-collate" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://www.bentopdf.com/duplex-collate" />
<meta
property="og:title"
content="Duplex Collate - Reorder Front/Back Scan Blocks"
/>
<meta
property="og:description"
content="Turn front-block/back-block scan output into a correctly collated PDF."
/>
<meta
property="og:image"
content="https://www.bentopdf.com/images/og-pdf-workflow.png"
/>
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:url"
content="https://www.bentopdf.com/duplex-collate"
/>
<meta name="twitter:title" content="Duplex Collate" />
<meta
name="twitter:description"
content="Reorder front/back scan blocks into collated page order."
/>
<link href="/src/css/styles.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">
<!-- htmlhint spec-char-escape:false -->
{{> navbar }}
<!-- htmlhint spec-char-escape:true -->
<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-gray-200 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:duplexCollate.name"
>
Duplex Collate
</h1>
<p class="text-gray-400 mb-6" data-i18n="tools:duplexCollate.subtitle">
Reorder a PDF scanned as two blocks (all fronts, then all backs) into
proper front/back order.
</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.singlePdf">
A single 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 id="file-display-area" class="mt-4 space-y-2"></div>
<div
id="odd-page-banner"
class="hidden mt-3 p-3 bg-yellow-900/50 border border-yellow-600 text-yellow-300 text-sm rounded-lg"
></div>
<div id="duplex-options" class="hidden mt-6 space-y-4">
<div class="p-3 bg-gray-900 rounded-lg border border-gray-700">
<p class="text-sm text-gray-300">
<span data-i18n="tools:duplexCollate.totalPagesLabel"
>Total pages:</span
>
<span id="total-pages" class="text-white">0</span>
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label
for="split-page"
class="block mb-2 text-sm font-medium text-gray-300"
data-i18n="tools:duplexCollate.splitAfterPageLabel"
>Split after page</label
>
<input
id="split-page"
type="number"
min="1"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5"
placeholder="e.g. 50"
data-i18n-placeholder="tools:duplexCollate.splitAfterPagePlaceholder"
/>
</div>
<div>
<label
for="back-order"
class="block mb-2 text-sm font-medium text-gray-300"
data-i18n="tools:duplexCollate.backBlockOrderLabel"
>Back block order</label
>
<select
id="back-order"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5"
>
<option
value="reverse"
selected
data-i18n="tools:duplexCollate.backBlockReverseOption"
>
Reverse back block (common for flipped stacks)
</option>
<option
value="keep"
data-i18n="tools:duplexCollate.backBlockKeepOption"
>
Keep back block as-is
</option>
</select>
</div>
</div>
<button
id="auto-split-btn"
class="w-full bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
data-i18n="tools:duplexCollate.autoSplitButton"
>
Auto-detect split (half)
</button>
<div class="p-3 bg-gray-900 rounded-lg border border-gray-700">
<p id="duplex-preview-summary" class="text-sm text-gray-300"></p>
<p
id="duplex-warning"
class="text-sm text-yellow-400 mt-2 hidden"
></p>
</div>
<label
class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer"
>
<input
id="export-grouped"
type="checkbox"
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 rounded"
/>
<span data-i18n="tools:duplexCollate.exportGroupedLabel"
>Export grouped files by original document length</span
>
</label>
<div id="grouped-options" class="hidden">
<label
for="pages-per-document"
class="block mb-2 text-sm font-medium text-gray-300"
data-i18n="tools:duplexCollate.pagesPerDocumentLabel"
>Pages per original document</label
>
<input
id="pages-per-document"
type="number"
min="1"
value="2"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5"
/>
<p
class="text-xs text-gray-400 mt-2"
data-i18n="tools:duplexCollate.groupedHelp"
>
If enabled, output is a ZIP containing one PDF per grouped
document.
</p>
</div>
<button
id="process-btn"
class="btn-gradient w-full mt-2"
data-i18n="tools:duplexCollate.collateButton"
>
Collate PDF
</button>
</div>
</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">
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>
<div
id="confirm-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="confirm-title"
class="text-xl font-bold text-white mb-2"
data-i18n="tools:duplexCollate.unevenConfirmTitle"
>
Uneven Blocks
</h3>
<p
id="confirm-message"
class="text-gray-300 mb-6 whitespace-pre-line"
></p>
<div class="flex gap-3">
<button
id="confirm-cancel"
class="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
data-i18n="common.cancel"
>
Cancel
</button>
<button
id="confirm-proceed"
class="flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
data-i18n="warning.proceed"
>
Proceed
</button>
</div>
</div>
</div>
<!-- How It Works Section -->
<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"
data-i18n="tools:duplexCollate.howItWorksStep1Title"
>
Upload the scan
</h3>
<p
class="text-gray-400"
data-i18n="tools:duplexCollate.howItWorksStep1"
>
Drop in the single PDF that holds all the front sides followed by
all the back sides.
</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"
data-i18n="tools:duplexCollate.howItWorksStep2Title"
>
Set the split point
</h3>
<p
class="text-gray-400"
data-i18n="tools:duplexCollate.howItWorksStep2"
>
Mark where the front block ends, then choose whether the back
block needs reversing.
</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"
data-i18n="tools:duplexCollate.howItWorksStep3Title"
>
Collate and download
</h3>
<p
class="text-gray-400"
data-i18n="tools:duplexCollate.howItWorksStep3"
>
Get one interleaved PDF, or a ZIP with a separate file per
original document.
</p>
</div>
</div>
</div>
</section>
<!-- Related Tools 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="split-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">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="merge-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">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</p>
</a>
<a
href="alternate-merge.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">Alternate Merge</h3>
<p class="text-gray-400 text-sm">Free online alternate merge tool</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">Free online organize PDF tool</p>
</a>
<a
href="reverse-pages.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">Reverse Pages</h3>
<p class="text-gray-400 text-sm">Free online reverse pages tool</p>
</a>
<a
href="extract-pages.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">Extract Pages</h3>
<p class="text-gray-400 text-sm">Free online extract pages tool</p>
</a>
</div>
</section>
<!-- FAQ 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"
>
<span data-i18n="tools:duplexCollate.faqOddPagesQuestion"
>What if my scan has an odd number of pages?</span
>
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p
class="mt-3 text-gray-400"
data-i18n="tools:duplexCollate.faqOddPagesAnswer"
>
A duplex scan should have an even page count. If it does not, a page
was probably scanned twice or missed. The tool warns you before
processing, and any unpaired pages are appended at the end so
nothing is lost.
</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"
>
<span data-i18n="tools:duplexCollate.faqBackOrderQuestion"
>Why would I keep the back block as-is?</span
>
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p
class="mt-3 text-gray-400"
data-i18n="tools:duplexCollate.faqBackOrderAnswer"
>
It depends on how the stack was fed for the second pass. Flipping
the whole stack over produces back sides in reverse order, which is
the default. If your scanner returned them in the same order as the
fronts, choose to keep the block as-is.
</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"
>
<span data-i18n="faq.isFree.question"
>Is BentoPDF really free?</span
>
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400" data-i18n="faq.isFree.answer">
Yes, absolutely. All tools on BentoPDF are 100% free to use, with no
file limits, no sign-ups, and no watermarks. We believe everyone
deserves access to simple, powerful PDF tools without a paywall.
</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"
>
<span data-i18n="faq.areFilesSecure.question"
>Are my files secure? Where are they processed?</span
>
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400" data-i18n="faq.areFilesSecure.answer">
Your files are as secure as possible because they never leave your
computer. All processing happens directly in your web browser
(client-side). We never upload your files to a server, so you
maintain complete privacy and control over your documents.
</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"
>
<span data-i18n="faq.platforms.question"
>Does it work on Mac, Windows, and Mobile?</span
>
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400" data-i18n="faq.platforms.answer">
Yes! Since BentoPDF runs entirely in your browser, it works on any
operating system with a modern web browser, including Windows,
macOS, Linux, iOS, and Android.
</p>
</details>
</div>
</section>
<!-- htmlhint spec-char-escape:false -->
{{> footer }}
<!-- htmlhint spec-char-escape:true -->
<script type="module" src="/src/js/utils/lucide-init.ts"></script>
<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/duplex-collate-page.ts"></script>
<script type="module" src="/src/js/mobileMenu.ts"></script>
<script type="module" src="/src/js/main.ts"></script>
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Duplex Collate - BentoPDF",
"applicationCategory": "PDF Tool",
"operatingSystem": "Any - Web Browser",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "HowTo",
"name": "How to collate a duplex scan online",
"description": "Learn how to reorder a two-block duplex scan using BentoPDF",
"step": [
{
"@type": "HowToStep",
"position": 1,
"name": "Upload the scan",
"text": "Drop in the single PDF that holds all the front sides followed by all the back sides."
},
{
"@type": "HowToStep",
"position": 2,
"name": "Set the split point",
"text": "Mark where the front block ends, then choose whether the back block needs reversing."
},
{
"@type": "HowToStep",
"position": 3,
"name": "Collate and download",
"text": "Get one interleaved PDF, or a ZIP with a separate file per original document."
}
]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://www.bentopdf.com"
},
{
"@type": "ListItem",
"position": 2,
"name": "Duplex Collate",
"item": "https://www.bentopdf.com/duplex-collate"
}
]
}
</script>
</body>
</html>
+13 -19
View File
@@ -13,10 +13,7 @@
name="description"
content="★ Edit Attachments online free - Edit attachments PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="keywords"
content="edit attachments, edit attachments, online pdf"
/>
<meta name="keywords" content="edit attachments, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -57,7 +54,7 @@
<meta name="twitter:title" content="Edit Attachments Free" />
<meta
name="twitter:description"
content="★ Edit Attachments online free - Edit attachments PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in b"
content="★ Edit Attachments online free - Edit attachments PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser"
/>
<meta
name="twitter:image"
@@ -142,10 +139,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 PDF</span
@@ -286,36 +280,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+12 -15
View File
@@ -13,7 +13,7 @@
name="description"
content="★ Edit Metadata online free - Edit metadata PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="edit metadata, edit metadata, online pdf" />
<meta name="keywords" content="edit metadata, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -130,10 +130,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
@@ -394,36 +391,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
File diff suppressed because it is too large Load Diff
+8 -11
View File
@@ -51,7 +51,7 @@
<meta name="twitter:title" content="Edit PDF Free" />
<meta
name="twitter:description"
content="★ Edit PDF online free - Edit PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secu"
content="★ Edit PDF online free - Edit PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -134,10 +134,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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
@@ -274,22 +271,22 @@
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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
<a
href="delete-pages.html"
+4 -7
View File
@@ -134,10 +134,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 files</span
@@ -349,7 +346,7 @@
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">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<p class="text-gray-400 text-sm">Free online merge PDF tool</p>
</a>
<a
href="word-to-pdf.html"
@@ -363,7 +360,7 @@
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">Free online compress pdf tool</p>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="txt-to-pdf.html"
@@ -377,7 +374,7 @@
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">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
</div>
</section>
+12 -15
View File
@@ -51,7 +51,7 @@
<meta name="twitter:title" content="Encrypt PDF Free" />
<meta
name="twitter:description"
content="★ Encrypt PDF online free - Encrypt PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast "
content="★ Encrypt PDF online free - Encrypt PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -133,10 +133,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 PDF</span
@@ -322,36 +319,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
+21 -24
View File
@@ -7,13 +7,13 @@
<!-- Primary Meta Tags -->
<meta
name="title"
content="Epub To Pdf Online Free - Epub To Pdf Tool | BentoPDF"
content="EPUB to PDF Online Free - EPUB to PDF Tool | BentoPDF"
/>
<meta
name="description"
content="★ Epub To Pdf online free - Epub to pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ EPUB to PDF online free - EPUB to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="epub to pdf, epub to pdf, online pdf" />
<meta name="keywords" content="epub to pdf, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -28,11 +28,11 @@
<meta property="og:url" content="https://www.bentopdf.com/epub-to-pdf" />
<meta
property="og:title"
content="Epub To Pdf Online Free - Epub To Pdf Tool | BentoPDF"
content="EPUB to PDF Online Free - EPUB to PDF Tool | BentoPDF"
/>
<meta
property="og:description"
content="★ Epub To Pdf online free - Epub to pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ EPUB to PDF online free - EPUB to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
property="og:image"
@@ -45,10 +45,10 @@
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://www.bentopdf.com/epub-to-pdf" />
<meta name="twitter:title" content="Epub To Pdf Free" />
<meta name="twitter:title" content="EPUB to PDF Free" />
<meta
name="twitter:description"
content="★ Epub To Pdf online free - Epub to pdf PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ F"
content="★ EPUB to PDF online free - EPUB to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="twitter:image"
@@ -59,7 +59,7 @@
<!-- Mobile Web App -->
<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="Epub To Pdf" />
<meta name="apple-mobile-web-app-title" content="EPUB to PDF" />
<title>EPUB to PDF - Convert EPUB to PDF - BentoPDF</title>
<meta
@@ -130,10 +130,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 files</span
@@ -292,36 +289,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>
@@ -386,7 +383,7 @@
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Epub To Pdf - BentoPDF",
"name": "EPUB to PDF - BentoPDF",
"applicationCategory": "PDF Tool",
"operatingSystem": "Any - Web Browser",
"offers": {
@@ -440,7 +437,7 @@
{
"@type": "ListItem",
"position": 2,
"name": "Epub To Pdf",
"name": "EPUB to PDF",
"item": "https://www.bentopdf.com/epub-to-pdf"
}
]
+14 -17
View File
@@ -11,7 +11,7 @@
/>
<meta
name="description"
content="★ Excel to PDF online free - Convert Excel to PDF PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ Excel to PDF online free - Convert Excel to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="keywords"
@@ -35,7 +35,7 @@
/>
<meta
property="og:description"
content="★ Excel to PDF online free - Convert Excel to PDF PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ Excel to PDF online free - Convert Excel to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
property="og:image"
@@ -51,7 +51,7 @@
<meta name="twitter:title" content="Excel to PDF Free" />
<meta
name="twitter:description"
content="★ Excel to PDF online free - Convert Excel to PDF PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in b"
content="★ Excel to PDF online free - Convert Excel to PDF easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser"
/>
<meta
name="twitter:image"
@@ -134,10 +134,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 files</span
@@ -296,36 +293,36 @@
href="pdf-to-excel.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 To Excel</h3>
<p class="text-gray-400 text-sm">Free online pdf to excel tool</p>
<h3 class="text-white font-semibold mb-1">PDF to Excel</h3>
<p class="text-gray-400 text-sm">Free online PDF to Excel tool</p>
</a>
<a
href="word-to-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">Word To Pdf</h3>
<p class="text-gray-400 text-sm">Free online word to pdf tool</p>
<h3 class="text-white font-semibold mb-1">Word to PDF</h3>
<p class="text-gray-400 text-sm">Free online Word to PDF tool</p>
</a>
<a
href="csv-to-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">Csv To Pdf</h3>
<p class="text-gray-400 text-sm">Free online csv to pdf tool</p>
<h3 class="text-white font-semibold mb-1">CSV to PDF</h3>
<p class="text-gray-400 text-sm">Free online CSV to PDF tool</p>
</a>
<a
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
</div>
</section>
+15 -21
View File
@@ -11,12 +11,9 @@
/>
<meta
name="description"
content="★ Extract Attachments online free - Extract attachments PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
name="keywords"
content="extract attachments, extract attachments, online pdf"
content="★ Extract Attachments online free - Extract PDF attachments easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta name="keywords" content="extract attachments, online pdf" />
<meta name="author" content="BentoPDF" />
<meta
name="robots"
@@ -38,7 +35,7 @@
/>
<meta
property="og:description"
content="★ Extract Attachments online free - Extract attachments PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
content="★ Extract Attachments online free - Extract PDF attachments easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure"
/>
<meta
property="og:image"
@@ -57,7 +54,7 @@
<meta name="twitter:title" content="Extract Attachments Free" />
<meta
name="twitter:description"
content="★ Extract Attachments online free - Extract attachments PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Work"
content="★ Extract Attachments online free - Extract PDF attachments easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser"
/>
<meta
name="twitter:image"
@@ -142,10 +139,7 @@
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>
<i class="ph ph-upload-simple text-4xl 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 PDFs</span
@@ -295,36 +289,36 @@
href="merge-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">Merge Pdf</h3>
<p class="text-gray-400 text-sm">Free online merge pdf tool</p>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Free online merge PDF tool</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">Free online compress pdf tool</p>
<h3 class="text-white font-semibold mb-1">Compress PDF</h3>
<p class="text-gray-400 text-sm">Free online compress PDF tool</p>
</a>
<a
href="split-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">Split Pdf</h3>
<p class="text-gray-400 text-sm">Free online split pdf tool</p>
<h3 class="text-white font-semibold mb-1">Split PDF</h3>
<p class="text-gray-400 text-sm">Free online split PDF tool</p>
</a>
<a
href="edit-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">Edit Pdf</h3>
<p class="text-gray-400 text-sm">Free online edit pdf tool</p>
<h3 class="text-white font-semibold mb-1">Edit PDF</h3>
<p class="text-gray-400 text-sm">Free online edit PDF tool</p>
</a>
<a
href="rotate-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">Rotate Pdf</h3>
<p class="text-gray-400 text-sm">Free online rotate pdf tool</p>
<h3 class="text-white font-semibold mb-1">Rotate PDF</h3>
<p class="text-gray-400 text-sm">Free online rotate PDF tool</p>
</a>
</div>
</section>

Some files were not shown because too many files have changed in this diff Show More