mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-08 00:50:59 +01:00
Merge branch 'main' into add-farsi-translation
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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}`,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'] });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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');
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import './utils/map-upsert-polyfill.js';
|
||||
import 'pdfjs-dist/build/pdf.worker.min.mjs';
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface MergeMessage {
|
||||
jobs: MergeJob[];
|
||||
cpdfUrl?: string;
|
||||
retainPageLabels?: boolean;
|
||||
removeDuplicateFonts?: boolean;
|
||||
}
|
||||
|
||||
export interface MergeSuccessResponse {
|
||||
|
||||
+2
-6
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
@@ -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,
|
||||
|
||||
@@ -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,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
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import pdfWorkerUrl from '../pdf.worker?worker&url';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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,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
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user