mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-13 09:54:19 +01:00
feat: add Edit PDF Text tool and related functionality
This commit is contained in:
1 parent
6529b0e9b7
commit
75e76c9619
43 files changed
+19326
-1
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
import createModule from 'bentopdf-pdfium';
|
||||
|
||||
const inBrowser = typeof window !== 'undefined';
|
||||
|
||||
const wasmUrl = inBrowser
|
||||
? new URL('bentopdf-pdfium/editcore.wasm', import.meta.url).href
|
||||
: new URL(import.meta.resolve('bentopdf-pdfium/editcore.wasm')).pathname;
|
||||
|
||||
export const ENGINE_BUILD = 'bentopdf-pdfium';
|
||||
|
||||
export function createEngineModule(options) {
|
||||
return createModule({
|
||||
...(options ?? {}),
|
||||
locateFile: (file, prefix) =>
|
||||
file.endsWith('.wasm') ? wasmUrl : `${prefix}${file}`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
const latinMemo = new WeakMap();
|
||||
export const latin = (u8) => {
|
||||
const hit = latinMemo.get(u8);
|
||||
if (hit) return hit;
|
||||
let s = '';
|
||||
for (let i = 0; i < u8.length; i += 0x8000) {
|
||||
s += String.fromCharCode.apply(
|
||||
null,
|
||||
u8.subarray(i, Math.min(i + 0x8000, u8.length))
|
||||
);
|
||||
}
|
||||
latinMemo.set(u8, s);
|
||||
return s;
|
||||
};
|
||||
|
||||
export const parsePdf = (src) => {
|
||||
const objAt = new Map();
|
||||
for (const m of src.matchAll(/(\d+)\s+0\s+obj\b/g)) {
|
||||
if (m.index > 0 && src[m.index - 1] >= '0' && src[m.index - 1] <= '9')
|
||||
continue;
|
||||
objAt.set(parseInt(m[1], 10), m.index);
|
||||
}
|
||||
const objBody = (num) => {
|
||||
const at = objAt.get(num);
|
||||
if (at == null) return null;
|
||||
const start = src.indexOf('obj', at) + 3;
|
||||
const end = src.indexOf('endobj', start);
|
||||
return end < 0 ? null : { start, end, body: src.slice(start, end) };
|
||||
};
|
||||
const dictRef = (body, key) => {
|
||||
const m = body.match(new RegExp('/' + key + '\\s+(\\d+)\\s+\\d+\\s+R'));
|
||||
return m ? parseInt(m[1], 10) : -1;
|
||||
};
|
||||
let rootNum = -1;
|
||||
let size = -1;
|
||||
for (let tAt = src.length; rootNum < 0; ) {
|
||||
tAt = src.lastIndexOf('trailer', tAt - 1);
|
||||
if (tAt < 0) break;
|
||||
const trailer = src.slice(tAt, tAt + 400);
|
||||
if (rootNum < 0) rootNum = dictRef(trailer, 'Root');
|
||||
}
|
||||
for (const m of src.matchAll(/\/Size\s+(\d+)/g))
|
||||
size = Math.max(size, parseInt(m[1], 10));
|
||||
if (rootNum < 0) {
|
||||
const m = src.match(/\/Root\s+(\d+)\s+\d+\s+R/);
|
||||
if (m) rootNum = parseInt(m[1], 10);
|
||||
}
|
||||
if (rootNum < 0 || size < 0) return null;
|
||||
let pageList = null;
|
||||
const pageNum = (pageIndex) => {
|
||||
if (!pageList) {
|
||||
pageList = [];
|
||||
const root = objBody(rootNum);
|
||||
if (!root) return -1;
|
||||
const seen = new Set();
|
||||
const walk = (num) => {
|
||||
if (seen.has(num) || pageList.length > 8192) return;
|
||||
seen.add(num);
|
||||
const o = objBody(num);
|
||||
if (!o) return;
|
||||
if (/\/Type\s*\/Page\b(?!s)/.test(o.body)) {
|
||||
pageList.push(num);
|
||||
return;
|
||||
}
|
||||
const kids = o.body.match(/\/Kids\s*\[([^\]]*)\]/);
|
||||
if (!kids) return;
|
||||
for (const r of kids[1].matchAll(/(\d+)\s+\d+\s+R/g))
|
||||
walk(parseInt(r[1], 10));
|
||||
};
|
||||
walk(dictRef(root.body, 'Pages'));
|
||||
}
|
||||
return pageIndex < pageList.length ? pageList[pageIndex] : -1;
|
||||
};
|
||||
return { objBody, dictRef, rootNum, size, pageNum };
|
||||
};
|
||||
|
||||
export const streamData = async (bytes, src, obj) => {
|
||||
const si = src.indexOf('stream', obj.start);
|
||||
if (si < 0 || si > obj.end) return null;
|
||||
let ds = si + 6;
|
||||
if (src[ds] === '\r') ds++;
|
||||
if (src[ds] === '\n') ds++;
|
||||
const indirect = obj.body.match(/\/Length\s+(\d+)\s+\d+\s+R/);
|
||||
let len = -1;
|
||||
if (indirect) {
|
||||
const rx = new RegExp(
|
||||
'(?:^|[^0-9])' + indirect[1] + '\\s+0\\s+obj\\s*(\\d+)'
|
||||
);
|
||||
const rm = src.match(rx);
|
||||
if (rm) len = parseInt(rm[1], 10);
|
||||
} else {
|
||||
const lm = obj.body.match(/\/Length\s+(\d+)/);
|
||||
if (lm) len = parseInt(lm[1], 10);
|
||||
}
|
||||
const de = len >= 0 ? ds + len : src.indexOf('endstream', ds);
|
||||
const raw = bytes.subarray(ds, de);
|
||||
if (!/\/Filter\s*\/FlateDecode/.test(obj.body)) {
|
||||
return /\/Filter/.test(obj.body) ? null : latin(raw);
|
||||
}
|
||||
try {
|
||||
const stream = new Blob([raw])
|
||||
.stream()
|
||||
.pipeThrough(new DecompressionStream('deflate'));
|
||||
const buf = new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
return latin(buf);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const objStmIndex = async (bytes, src) => {
|
||||
const out = new Map();
|
||||
for (const m of src.matchAll(/(\d+)\s+0\s+obj\b/g)) {
|
||||
const start = src.indexOf('obj', m.index) + 3;
|
||||
const end = src.indexOf('endobj', start);
|
||||
if (end < 0) continue;
|
||||
const body = src.slice(start, end);
|
||||
if (!/\/Type\s*\/ObjStm/.test(body)) continue;
|
||||
const data = await streamData(bytes, src, { start, end, body });
|
||||
if (!data) continue;
|
||||
const n = parseInt((body.match(/\/N\s+(\d+)/) || [])[1], 10);
|
||||
const first = parseInt((body.match(/\/First\s+(\d+)/) || [])[1], 10);
|
||||
if (!Number.isFinite(n) || !Number.isFinite(first)) continue;
|
||||
const nums = data.slice(0, first).trim().split(/\s+/).map(Number);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const num = nums[i * 2],
|
||||
off = nums[i * 2 + 1];
|
||||
if (!Number.isFinite(num) || !Number.isFinite(off)) continue;
|
||||
const nextOff = i + 1 < n ? nums[i * 2 + 3] : data.length - first;
|
||||
out.set(
|
||||
num,
|
||||
data.slice(
|
||||
first + off,
|
||||
first + (Number.isFinite(nextOff) ? nextOff : data.length - first)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const withObjStm = async (bytes, src, op) => {
|
||||
if (!op) return op;
|
||||
const idx = await objStmIndex(bytes, src);
|
||||
if (!idx.size) return op;
|
||||
const base = op.objBody;
|
||||
const objBody = (num) =>
|
||||
base(num) ||
|
||||
(idx.has(num) ? { start: -1, end: -1, body: idx.get(num) } : null);
|
||||
const dictRef = op.dictRef;
|
||||
let pageList = null;
|
||||
const pageNum = (index) => {
|
||||
if (!pageList) {
|
||||
pageList = [];
|
||||
const root = objBody(op.rootNum);
|
||||
if (!root) return -1;
|
||||
const seen = new Set();
|
||||
const walk = (num, depth) => {
|
||||
if (num < 0 || depth > 32 || seen.has(num) || pageList.length > 8192)
|
||||
return;
|
||||
seen.add(num);
|
||||
const o = objBody(num);
|
||||
if (!o) return;
|
||||
if (/\/Type\s*\/Page(?![sA-Za-z])/.test(o.body)) {
|
||||
pageList.push(num);
|
||||
return;
|
||||
}
|
||||
const kids = o.body.match(/\/Kids\s*\[([^\]]*)\]/);
|
||||
if (!kids) return;
|
||||
for (const km of kids[1].matchAll(/(\d+)\s+\d+\s+R/g))
|
||||
walk(parseInt(km[1], 10), depth + 1);
|
||||
};
|
||||
walk(dictRef(root.body, 'Pages'), 0);
|
||||
}
|
||||
return index < pageList.length ? pageList[index] : -1;
|
||||
};
|
||||
return { ...op, objBody, pageNum };
|
||||
};
|
||||
|
||||
const openQBefore = (content, pos, windowBytes) => {
|
||||
const backStart = Math.max(0, pos - windowBytes);
|
||||
const back = content.slice(backStart, pos);
|
||||
let depth = 0;
|
||||
for (let i = back.length - 1; i >= 0; i--) {
|
||||
const c = back[i];
|
||||
if (c !== 'q' && c !== 'Q') continue;
|
||||
const prev = i > 0 ? back[i - 1] : ' ';
|
||||
const next = i + 1 < back.length ? back[i + 1] : ' ';
|
||||
if (!/[\s>\]]/.test(prev) || !/[\s\/<[(%]/.test(next)) continue;
|
||||
if (c === 'Q') depth++;
|
||||
else if (depth > 0) depth--;
|
||||
else return backStart + i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const STATE_OPS = new Set([
|
||||
'm',
|
||||
'l',
|
||||
'c',
|
||||
'v',
|
||||
'y',
|
||||
're',
|
||||
'h',
|
||||
'W',
|
||||
'W*',
|
||||
'n',
|
||||
'cm',
|
||||
'gs',
|
||||
'ri',
|
||||
'i',
|
||||
'j',
|
||||
'J',
|
||||
'M',
|
||||
'd',
|
||||
'w',
|
||||
'cs',
|
||||
'CS',
|
||||
'sc',
|
||||
'scn',
|
||||
'SC',
|
||||
'SCN',
|
||||
'g',
|
||||
'G',
|
||||
'rg',
|
||||
'RG',
|
||||
'k',
|
||||
'K',
|
||||
'BX',
|
||||
'EX',
|
||||
]);
|
||||
const gapIsStateOnly = (gap) => {
|
||||
const stripped = gap
|
||||
.replace(/%[^\r\n]*/g, ' ')
|
||||
.replace(/\((?:\\.|[^\\)])*\)/g, ' ')
|
||||
.replace(/<<[\s\S]*?>>/g, ' ')
|
||||
.replace(/<[0-9A-Fa-f\s]*>/g, ' ')
|
||||
.replace(/\/[A-Za-z0-9_.#-]*/g, ' ')
|
||||
.replace(/[+-]?\d*\.?\d+/g, ' ')
|
||||
.replace(/[[\]]/g, ' ');
|
||||
for (const tok of stripped.split(/\s+/)) {
|
||||
if (tok && !STATE_OPS.has(tok)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const shBlocks = (content) => {
|
||||
const blocks = [];
|
||||
for (const m of content.matchAll(
|
||||
/\/[A-Za-z0-9_.]+\s+(?:sh|scn)(?![a-zA-Z])/g
|
||||
)) {
|
||||
let qAt = openQBefore(content, m.index, 4096);
|
||||
let endAt = -1;
|
||||
const fwd = content.slice(m.index, m.index + 2048);
|
||||
const qm = fwd.match(/[\s>\]]Q(?=[\s\/<[(%]|$)/);
|
||||
if (qm) endAt = m.index + qm.index + qm[0].length;
|
||||
if (qAt < 0 || endAt <= qAt) continue;
|
||||
const innerStart = qAt,
|
||||
innerEnd = endAt;
|
||||
let ops = content.slice(qAt, endAt);
|
||||
for (let lvl = 0; lvl < 4; lvl++) {
|
||||
const outer = openQBefore(content, qAt, 8192);
|
||||
if (outer < 0) break;
|
||||
const gap = content.slice(outer + 1, qAt);
|
||||
if (!gapIsStateOnly(gap)) break;
|
||||
ops = content.slice(outer, qAt) + ops + '\nQ';
|
||||
qAt = outer;
|
||||
}
|
||||
ops = stripTextObjects(ops);
|
||||
if (!/\b(?:sh|scn)(?![a-zA-Z])/.test(ops)) continue;
|
||||
blocks.push({
|
||||
ops,
|
||||
at: m.index,
|
||||
endAt,
|
||||
innerStart,
|
||||
innerEnd,
|
||||
isPattern: /scn$/.test(m[0].trim()),
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const stripTextObjects = (ops) => {
|
||||
const tok = /(?:^|[\s>\]<)(])(BT|ET)(?=[\s/<[(]|$)/g;
|
||||
let out = '',
|
||||
i = 0,
|
||||
depth = 0;
|
||||
for (const m of ops.matchAll(tok)) {
|
||||
const at = m.index + (m[0].length - 2);
|
||||
if (m[1] === 'BT') {
|
||||
if (depth === 0) out += ops.slice(i, at);
|
||||
depth++;
|
||||
} else if (depth > 0) {
|
||||
depth--;
|
||||
if (depth === 0) i = at + 2;
|
||||
}
|
||||
}
|
||||
if (depth === 0) out += ops.slice(i);
|
||||
return out;
|
||||
};
|
||||
|
||||
const classifyShadings = (content, blocks) => {
|
||||
const btPos = content.search(/(?:^|[^A-Za-z0-9_])BT(?=[^A-Za-z0-9_]|$)/);
|
||||
const doM = content.match(/\/[A-Za-z0-9_.]+\s+Do(?![a-zA-Z])/);
|
||||
const doPos = doM ? doM.index : -1;
|
||||
const firstContent = Math.min(
|
||||
...[btPos, doPos].filter((p) => p >= 0).concat([Infinity])
|
||||
);
|
||||
return blocks.map((b) => ({ isBackground: b.at < firstContent }));
|
||||
};
|
||||
|
||||
const pageContent = async (bytes, src, parsed, pageIndex) => {
|
||||
const pgNum = parsed.pageNum(pageIndex);
|
||||
if (pgNum < 0) return null;
|
||||
const pg = parsed.objBody(pgNum);
|
||||
if (!pg) return null;
|
||||
const one = pg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
|
||||
const arr = pg.body.match(/\/Contents\s*\[([^\]]*)\]/);
|
||||
const refs = one
|
||||
? [parseInt(one[1], 10)]
|
||||
: arr
|
||||
? [...arr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) => parseInt(r[1], 10))
|
||||
: [];
|
||||
let content = '';
|
||||
const spans = [];
|
||||
for (const rn of refs) {
|
||||
const o = parsed.objBody(rn);
|
||||
const d = o && (await streamData(bytes, src, o));
|
||||
if (d == null) return null;
|
||||
spans.push({ start: content.length, end: content.length + d.length });
|
||||
content += d + '\n';
|
||||
}
|
||||
return { content, spans };
|
||||
};
|
||||
|
||||
export async function shadingsForPage(originalBytes, pageIndex) {
|
||||
const orig = latin(originalBytes);
|
||||
const op = await withObjStm(originalBytes, orig, parsePdf(orig));
|
||||
if (!op) return [];
|
||||
const pc = await pageContent(originalBytes, orig, op, pageIndex);
|
||||
if (pc == null) return [];
|
||||
const blocks = shBlocks(pc.content);
|
||||
if (!blocks.length) return [];
|
||||
return classifyShadings(pc.content, blocks);
|
||||
}
|
||||
|
||||
export async function applyShadingSurgery(savedBytes, originalBytes, pages) {
|
||||
if (!Array.isArray(pages)) pages = pages ? [pages] : [];
|
||||
pages = pages.filter(Boolean);
|
||||
if (!pages.length) return null;
|
||||
const saved = latin(savedBytes);
|
||||
const sp = await withObjStm(savedBytes, saved, parsePdf(saved));
|
||||
if (!sp) return null;
|
||||
const sx = saved.lastIndexOf('startxref');
|
||||
const sxNum = parseInt(saved.slice(sx + 9).trim(), 10);
|
||||
if (!Number.isFinite(sxNum) || saved.slice(sxNum, sxNum + 4) !== 'xref')
|
||||
return null;
|
||||
|
||||
const orig = latin(originalBytes);
|
||||
const op = await withObjStm(originalBytes, orig, parsePdf(orig));
|
||||
if (!op) return null;
|
||||
|
||||
let next = sp.size;
|
||||
const pageRewrites = [];
|
||||
const contentObjs = [];
|
||||
|
||||
for (const pg of pages) {
|
||||
const pageIndex = pg.pageIndex ?? 0;
|
||||
const pc = await pageContent(originalBytes, orig, op, pageIndex);
|
||||
if (pc == null) continue;
|
||||
const { content, spans } = pc;
|
||||
const allBlocks = shBlocks(content);
|
||||
const blocks = allBlocks.filter((b) => b.isPattern);
|
||||
if (!blocks.length) continue;
|
||||
let shadings = pg.shadings;
|
||||
if (!shadings || !shadings.length)
|
||||
shadings = classifyShadings(content, blocks);
|
||||
if (shadings.length !== blocks.length)
|
||||
shadings = classifyShadings(content, blocks);
|
||||
if (!shadings.length || blocks.length < shadings.length) continue;
|
||||
|
||||
const spgNum = sp.pageNum(pageIndex);
|
||||
if (spgNum < 0) continue;
|
||||
const spg = sp.objBody(spgNum);
|
||||
let resBody = '';
|
||||
const rref = spg.body.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
|
||||
if (rref) resBody = sp.objBody(parseInt(rref[1], 10))?.body ?? '';
|
||||
else {
|
||||
const k = spg.body.indexOf('/Resources');
|
||||
resBody = k >= 0 ? spg.body.slice(k, k + 4000) : '';
|
||||
}
|
||||
const used = shadings.map((s2, k) => ({ ...s2, ops: blocks[k].ops }));
|
||||
let resOk = true;
|
||||
for (const u of used) {
|
||||
for (const nm of u.ops.matchAll(
|
||||
/\/([A-Za-z0-9_.]+)\s+(?:gs|sh|scn|SCN|Do)\b/g
|
||||
)) {
|
||||
if (!resBody.includes('/' + nm[1])) {
|
||||
console.warn(
|
||||
`[shading] resource /${nm[1]} missing from saved page ${pageIndex} — page skipped`
|
||||
);
|
||||
resOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!resOk) break;
|
||||
}
|
||||
if (!resOk) continue;
|
||||
|
||||
const cOne = spg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
|
||||
const cArr = spg.body.match(/\/Contents\s*\[([^\]]*)\]/);
|
||||
const savedRefs = cOne
|
||||
? [cOne[1] + ' 0 R']
|
||||
: cArr
|
||||
? [...cArr[1].matchAll(/\d+\s+\d+\s+R/g)].map((r) => r[0])
|
||||
: [];
|
||||
if (!savedRefs.length) continue;
|
||||
const PAINT_RE =
|
||||
/(?:^|[^A-Za-z0-9_*'"])(?:BT|Do|sh|EI|f\*?|F|B\*?|b\*?|S|s)(?![A-Za-z0-9_])/g;
|
||||
const paints = (s2) => {
|
||||
PAINT_RE.lastIndex = 0;
|
||||
let n2 = 0;
|
||||
while (PAINT_RE.exec(s2)) n2++;
|
||||
return n2;
|
||||
};
|
||||
const slots = new Map();
|
||||
const patternOps = [];
|
||||
for (let k2 = 0; k2 < used.length; k2++) {
|
||||
const u = used[k2];
|
||||
const at = blocks[k2].at;
|
||||
const si = spans.findIndex((s2) => at >= s2.start && at < s2.end);
|
||||
const matched = si >= 0 && spans.length === savedRefs.length;
|
||||
const before =
|
||||
matched &&
|
||||
paints(content.slice(spans[si].start, at)) <=
|
||||
paints(content.slice(at, spans[si].end));
|
||||
if (blocks[k2].isPattern) {
|
||||
patternOps.push(u.ops);
|
||||
continue;
|
||||
}
|
||||
let slot;
|
||||
if (!matched) {
|
||||
const textBefore = /(?:^|[^A-Za-z0-9_*'"])BT(?![A-Za-z0-9_])/.test(
|
||||
content.slice(0, at)
|
||||
);
|
||||
slot = textBefore ? 2 * savedRefs.length : 0;
|
||||
} else {
|
||||
slot = before ? 2 * si : 2 * si + 2;
|
||||
}
|
||||
if (!slots.has(slot)) slots.set(slot, []);
|
||||
slots.get(slot).push(u.ops);
|
||||
}
|
||||
|
||||
let splitRefs = null;
|
||||
if (patternOps.length && savedRefs.length === 1) {
|
||||
const rnum = parseInt(savedRefs[0], 10);
|
||||
const sobj = sp.objBody(rnum);
|
||||
const sdata = sobj && (await streamData(savedBytes, saved, sobj));
|
||||
let cut = -1;
|
||||
const rectM = patternOps
|
||||
.join('\n')
|
||||
.match(/scn\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+re/);
|
||||
if (sdata != null && rectM) {
|
||||
const want = rectM.slice(1, 5).map(Number);
|
||||
for (const rm of sdata.matchAll(
|
||||
/(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+re\b/g
|
||||
)) {
|
||||
const got = rm.slice(1, 5).map(Number);
|
||||
if (!want.every((v, i) => Math.abs(v - got[i]) < 0.05)) continue;
|
||||
const after = sdata.slice(rm.index);
|
||||
const pm = after.match(
|
||||
/(?:^|[^A-Za-z0-9_*'"])(?:f\*?|F|B\*?|b\*?|S|s|n)(?![A-Za-z0-9_])/
|
||||
);
|
||||
if (pm) cut = rm.index + pm.index + pm[0].length;
|
||||
}
|
||||
}
|
||||
if (sdata != null && cut > 0) {
|
||||
const mk = (body2) => {
|
||||
const num = next++;
|
||||
contentObjs.push({
|
||||
num,
|
||||
body: `<< /Length ${body2.length + 1} >>\nstream\n${body2}\nendstream`,
|
||||
});
|
||||
return `${num} 0 R`;
|
||||
};
|
||||
splitRefs = [
|
||||
mk(sdata.slice(0, cut)),
|
||||
mk(patternOps.join('\n')),
|
||||
mk(sdata.slice(cut)),
|
||||
];
|
||||
}
|
||||
}
|
||||
if (patternOps.length && !splitRefs) {
|
||||
const slot = 0;
|
||||
if (!slots.has(slot)) slots.set(slot, []);
|
||||
slots.get(slot).push(...patternOps);
|
||||
}
|
||||
const refs = [];
|
||||
if (splitRefs) refs.push(...splitRefs);
|
||||
else
|
||||
for (let i2 = 0; i2 <= savedRefs.length; i2++) {
|
||||
const ops = slots.get(2 * i2);
|
||||
if (ops && ops.length) {
|
||||
const body2 = ops.join('\n');
|
||||
const num = next++;
|
||||
contentObjs.push({
|
||||
num,
|
||||
body: `<< /Length ${body2.length + 1} >>\nstream\n${body2}\nendstream`,
|
||||
});
|
||||
refs.push(`${num} 0 R`);
|
||||
}
|
||||
if (i2 < savedRefs.length) refs.push(savedRefs[i2]);
|
||||
}
|
||||
const newPage = spg.body.replace(
|
||||
cOne ? cOne[0] : cArr[0],
|
||||
`/Contents [ ${refs.join(' ')} ]`
|
||||
);
|
||||
pageRewrites.push({ num: spgNum, body: newPage });
|
||||
}
|
||||
if (!pageRewrites.length) return null;
|
||||
|
||||
let out = saved;
|
||||
if (!out.endsWith('\n')) out += '\n';
|
||||
const offsets = new Map();
|
||||
const emit = (num, body) => {
|
||||
offsets.set(num, out.length);
|
||||
out += `${num} 0 obj\n${body.trim()}\nendobj\n`;
|
||||
};
|
||||
for (const o of pageRewrites) emit(o.num, o.body);
|
||||
for (const o of contentObjs) emit(o.num, o.body);
|
||||
const xrefAt = out.length;
|
||||
const nums = [...offsets.keys()].sort((a, b) => a - b);
|
||||
let xref = 'xref\n';
|
||||
for (let i = 0; i < nums.length; ) {
|
||||
let j = i;
|
||||
while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
|
||||
xref += `${nums[i]} ${j - i + 1}\n`;
|
||||
for (let k = i; k <= j; k++)
|
||||
xref += String(offsets.get(nums[k])).padStart(10, '0') + ' 00000 n \n';
|
||||
i = j + 1;
|
||||
}
|
||||
out += xref;
|
||||
const infoM = saved
|
||||
.slice(saved.lastIndexOf('trailer'))
|
||||
.match(/\/Info\s+(\d+)\s+\d+\s+R/);
|
||||
out +=
|
||||
`trailer\n<< /Size ${next} /Root ${sp.rootNum} 0 R` +
|
||||
(infoM ? ` /Info ${infoM[1]} 0 R` : '') +
|
||||
` /Prev ${sxNum} >>\nstartxref\n${xrefAt}\n%%EOF\n`;
|
||||
const u8 = new Uint8Array(out.length);
|
||||
for (let i = 0; i < out.length; i++) u8[i] = out.charCodeAt(i) & 0xff;
|
||||
return u8;
|
||||
}
|
||||
|
||||
export async function pageHasPatternFill(originalBytes, pageIndex) {
|
||||
try {
|
||||
const orig = latin(originalBytes);
|
||||
const op = await withObjStm(originalBytes, orig, parsePdf(orig));
|
||||
if (!op) return false;
|
||||
const pc = await pageContent(originalBytes, orig, op, pageIndex);
|
||||
if (pc == null) return false;
|
||||
return shBlocks(pc.content).some((b) => b.isPattern);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const dictAt = (body, at) => {
|
||||
const open = body.indexOf('<<', at);
|
||||
if (open < 0) return null;
|
||||
let depth = 0;
|
||||
for (let i = open; i < body.length - 1; i++) {
|
||||
if (body[i] === '<' && body[i + 1] === '<') {
|
||||
depth++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (body[i] === '>' && body[i + 1] === '>') {
|
||||
depth--;
|
||||
i++;
|
||||
if (!depth) return body.slice(open, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function protectPatternArtwork(bytes) {
|
||||
const src = latin(bytes);
|
||||
const op = await withObjStm(bytes, src, parsePdf(src));
|
||||
if (!op) return null;
|
||||
let next = op.size;
|
||||
const newObjs = [],
|
||||
pageRewrites = [];
|
||||
for (let pi = 0; pi < 4096; pi++) {
|
||||
const pgNum = op.pageNum(pi);
|
||||
if (pgNum < 0) break;
|
||||
const pg = op.objBody(pgNum);
|
||||
if (!pg) continue;
|
||||
const cOne = pg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
|
||||
if (!cOne) continue;
|
||||
const pc = await pageContent(bytes, src, op, pi);
|
||||
if (pc == null) continue;
|
||||
const pats = shBlocks(pc.content).filter((b) => b.isPattern);
|
||||
if (!pats.length) continue;
|
||||
|
||||
const rref = pg.body.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
|
||||
const resDict = rref
|
||||
? dictAt(op.objBody(parseInt(rref[1], 10))?.body ?? '', 0)
|
||||
: dictAt(pg.body, pg.body.indexOf('/Resources'));
|
||||
if (!resDict) continue;
|
||||
const box = pg.body.match(/\/MediaBox\s*\[([^\]]*)\]/);
|
||||
if (!box) continue;
|
||||
|
||||
let content = pc.content;
|
||||
const added = [];
|
||||
for (const b of [...pats].sort((x, y) => y.innerStart - x.innerStart)) {
|
||||
const ops = content.slice(b.innerStart, b.innerEnd);
|
||||
const num = next++;
|
||||
const name = `ECBG${num}`;
|
||||
newObjs.push({
|
||||
num,
|
||||
body:
|
||||
`<< /Type /XObject /Subtype /Form /FormType 1 /BBox [ ${box[1].trim()} ]` +
|
||||
` /Resources ${resDict} /Length ${ops.length + 1} >>\nstream\n${ops}\nendstream`,
|
||||
});
|
||||
content =
|
||||
content.slice(0, b.innerStart) +
|
||||
`/${name} Do\n` +
|
||||
content.slice(b.innerEnd);
|
||||
added.push({ name, num });
|
||||
}
|
||||
|
||||
const entries = added.map((a) => `/${a.name} ${a.num} 0 R`).join(' ');
|
||||
let newRes;
|
||||
const xo = resDict.match(/\/XObject\s*<<([\s\S]*?)>>/);
|
||||
if (xo)
|
||||
newRes = resDict.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`);
|
||||
else newRes = resDict.replace(/^<</, `<< /XObject << ${entries} >> `);
|
||||
|
||||
const cnum = next++;
|
||||
newObjs.push({
|
||||
num: cnum,
|
||||
body: `<< /Length ${content.length + 1} >>\nstream\n${content}\nendstream`,
|
||||
});
|
||||
let newPage = pg.body.replace(cOne[0], `/Contents ${cnum} 0 R`);
|
||||
newPage = rref
|
||||
? newPage.replace(rref[0], `/Resources ${newRes}`)
|
||||
: newPage.replace(resDict, newRes);
|
||||
pageRewrites.push({ num: pgNum, body: newPage });
|
||||
}
|
||||
if (!pageRewrites.length) return null;
|
||||
|
||||
let out = src;
|
||||
if (!out.endsWith('\n')) out += '\n';
|
||||
const offsets = new Map();
|
||||
const emit = (num, body) => {
|
||||
offsets.set(num, out.length);
|
||||
out += `${num} 0 obj\n${body.trim()}\nendobj\n`;
|
||||
};
|
||||
for (const o of pageRewrites) emit(o.num, o.body);
|
||||
for (const o of newObjs) emit(o.num, o.body);
|
||||
const xrefAt = out.length;
|
||||
const nums = [...offsets.keys()].sort((a, b) => a - b);
|
||||
let xref = 'xref\n';
|
||||
for (let i = 0; i < nums.length; ) {
|
||||
let j = i;
|
||||
while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
|
||||
xref += `${nums[i]} ${j - i + 1}\n`;
|
||||
for (let k = i; k <= j; k++)
|
||||
xref += String(offsets.get(nums[k])).padStart(10, '0') + ' 00000 n \n';
|
||||
i = j + 1;
|
||||
}
|
||||
out += xref;
|
||||
const prev = src.lastIndexOf('startxref');
|
||||
const prevNum = parseInt(src.slice(prev + 9).trim(), 10);
|
||||
const infoM = src
|
||||
.slice(src.lastIndexOf('trailer'))
|
||||
.match(/\/Info\s+(\d+)\s+\d+\s+R/);
|
||||
out +=
|
||||
`trailer\n<< /Size ${next} /Root ${op.rootNum} 0 R` +
|
||||
(infoM ? ` /Info ${infoM[1]} 0 R` : '') +
|
||||
(Number.isFinite(prevNum) ? ` /Prev ${prevNum}` : '') +
|
||||
` >>\nstartxref\n${xrefAt}\n%%EOF\n`;
|
||||
const u8 = new Uint8Array(out.length);
|
||||
for (let i = 0; i < out.length; i++) u8[i] = out.charCodeAt(i) & 0xff;
|
||||
return u8;
|
||||
}
|
||||
@@ -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,774 @@
|
||||
import {
|
||||
latin,
|
||||
parsePdf,
|
||||
streamData,
|
||||
withObjStm,
|
||||
dictAt,
|
||||
} from './shadingsurgery.js';
|
||||
|
||||
const mul = (m, n) => [
|
||||
m[0] * n[0] + m[1] * n[2],
|
||||
m[0] * n[1] + m[1] * n[3],
|
||||
m[2] * n[0] + m[3] * n[2],
|
||||
m[2] * n[1] + m[3] * n[3],
|
||||
m[4] * n[0] + m[5] * n[2] + n[4],
|
||||
m[4] * n[1] + m[5] * n[3] + n[5],
|
||||
];
|
||||
const I = [1, 0, 0, 1, 0, 0];
|
||||
|
||||
const TOKEN_RE =
|
||||
/\((?:\\.|[^\\)])*\)|<<[\s\S]*?>>|<[0-9A-Fa-f\s]*>|\[(?:\((?:\\.|[^\\)])*\)|<[0-9A-Fa-f\s]*>|[^\]])*\]|\/[^\s/<>()[\]{}%]*|[^\s/<>()[\]{}%]+/g;
|
||||
|
||||
const OPERATOR = /^[A-Za-z'"][A-Za-z0-9*'"]*$/;
|
||||
|
||||
function analyzeContent(content, fontIsT3) {
|
||||
const blocks = [];
|
||||
const tjs = [];
|
||||
const stack = [];
|
||||
let ctm = I;
|
||||
let fill = null,
|
||||
strokeC = null,
|
||||
gsOp = null;
|
||||
let tf = null,
|
||||
tfSize = 0,
|
||||
tc = null,
|
||||
tw = null,
|
||||
tz = null,
|
||||
tl = null,
|
||||
ts = null,
|
||||
trm = null;
|
||||
let btEntry = null;
|
||||
let tm = I,
|
||||
tlm = I;
|
||||
let blk = null;
|
||||
let pend = [];
|
||||
const hexCodes = (tok) => {
|
||||
const out = [];
|
||||
if (tok[0] === '<') {
|
||||
const h = tok.slice(1, -1).replace(/\s+/g, '');
|
||||
for (let i = 0; i < h.length; i += 2) {
|
||||
const b = h.slice(i, i + 2);
|
||||
out.push(parseInt(b.length === 2 ? b : b + '0', 16));
|
||||
}
|
||||
} else if (tok[0] === '(') {
|
||||
const s = tok.slice(1, -1);
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
let c = s[i];
|
||||
if (c === '\\') {
|
||||
const n = s[i + 1];
|
||||
if (n >= '0' && n <= '7') {
|
||||
let oct = '';
|
||||
while (oct.length < 3 && s[i + 1] >= '0' && s[i + 1] <= '7')
|
||||
oct += s[++i];
|
||||
out.push(parseInt(oct, 8));
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
c = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f' }[n] ?? n;
|
||||
}
|
||||
out.push(c.charCodeAt(0) & 0xff);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const show = (tok) => {
|
||||
if (tok == null) return;
|
||||
let codes = [];
|
||||
if (tok[0] === '[') {
|
||||
for (const el of tok
|
||||
.slice(1, -1)
|
||||
.matchAll(/\((?:\\.|[^\\)])*\)|<[0-9A-Fa-f\s]*>/g))
|
||||
codes = codes.concat(hexCodes(el[0]));
|
||||
} else codes = hexCodes(tok);
|
||||
const dev = mul(tm, ctm);
|
||||
tjs.push({
|
||||
e: dev[4],
|
||||
f: dev[5],
|
||||
font: tf,
|
||||
size: tfSize,
|
||||
codes,
|
||||
block: blocks.length,
|
||||
});
|
||||
if (blk) {
|
||||
blk.shows.push(tjs.length - 1);
|
||||
if (tf) blk.fonts.add(tf);
|
||||
else blk.selfContained = false;
|
||||
}
|
||||
};
|
||||
for (const m of content.matchAll(TOKEN_RE)) {
|
||||
const t = m[0],
|
||||
at = m.index;
|
||||
if (!OPERATOR.test(t) || /^[\d.]/.test(t)) {
|
||||
pend.push(t);
|
||||
continue;
|
||||
}
|
||||
const nums = () => pend.map(Number);
|
||||
switch (t) {
|
||||
case 'q':
|
||||
stack.push({
|
||||
ctm,
|
||||
fill,
|
||||
strokeC,
|
||||
gsOp,
|
||||
tf,
|
||||
tfSize,
|
||||
tc,
|
||||
tw,
|
||||
tz,
|
||||
tl,
|
||||
ts,
|
||||
trm,
|
||||
});
|
||||
break;
|
||||
case 'Q': {
|
||||
const s = stack.pop();
|
||||
if (s)
|
||||
({ ctm, fill, strokeC, gsOp, tf, tfSize, tc, tw, tz, tl, ts, trm } =
|
||||
s);
|
||||
break;
|
||||
}
|
||||
case 'cm': {
|
||||
const n = nums();
|
||||
if (n.length === 6) ctm = mul(n, ctm);
|
||||
break;
|
||||
}
|
||||
case 'gs':
|
||||
gsOp = pend[0] + ' gs';
|
||||
break;
|
||||
case 'g':
|
||||
case 'rg':
|
||||
case 'k':
|
||||
fill = pend.join(' ') + ' ' + t;
|
||||
break;
|
||||
case 'G':
|
||||
case 'RG':
|
||||
case 'K':
|
||||
strokeC = pend.join(' ') + ' ' + t;
|
||||
break;
|
||||
case 'sc':
|
||||
case 'scn':
|
||||
fill = pend.join(' ') + ' ' + t;
|
||||
break;
|
||||
case 'SC':
|
||||
case 'SCN':
|
||||
strokeC = pend.join(' ') + ' ' + t;
|
||||
break;
|
||||
case 'BT':
|
||||
tm = I;
|
||||
tlm = I;
|
||||
btEntry = { fill, strokeC, gsOp, tf, tfSize, tc, tw, tz, tl, ts, trm };
|
||||
blk = {
|
||||
start: at,
|
||||
end: -1,
|
||||
fonts: new Set(),
|
||||
shows: [],
|
||||
selfContained: true,
|
||||
entry: btEntry,
|
||||
};
|
||||
break;
|
||||
case 'ET':
|
||||
if (blk) {
|
||||
blk.end = at + 2;
|
||||
blocks.push(blk);
|
||||
}
|
||||
blk = null;
|
||||
break;
|
||||
case 'Tf':
|
||||
tf = pend[0] ? pend[0].slice(1) : null;
|
||||
tfSize = Number(pend[1]) || 0;
|
||||
break;
|
||||
case 'Tm': {
|
||||
const n = nums();
|
||||
if (n.length === 6) {
|
||||
tm = n;
|
||||
tlm = n;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Td': {
|
||||
const n = nums();
|
||||
tlm = mul([1, 0, 0, 1, n[0] || 0, n[1] || 0], tlm);
|
||||
tm = tlm;
|
||||
break;
|
||||
}
|
||||
case 'TD': {
|
||||
const n = nums();
|
||||
tl = -(n[1] || 0);
|
||||
tlm = mul([1, 0, 0, 1, n[0] || 0, n[1] || 0], tlm);
|
||||
tm = tlm;
|
||||
break;
|
||||
}
|
||||
case 'T*':
|
||||
tlm = mul([1, 0, 0, 1, 0, -(tl || 0)], tlm);
|
||||
tm = tlm;
|
||||
break;
|
||||
case 'TL':
|
||||
tl = Number(pend[0]) || 0;
|
||||
break;
|
||||
case 'Tc':
|
||||
tc = pend[0] + ' Tc';
|
||||
break;
|
||||
case 'Tw':
|
||||
tw = pend[0] + ' Tw';
|
||||
break;
|
||||
case 'Tz':
|
||||
tz = pend[0] + ' Tz';
|
||||
break;
|
||||
case 'Ts':
|
||||
ts = pend[0] + ' Ts';
|
||||
break;
|
||||
case 'Tr':
|
||||
trm = pend[0] + ' Tr';
|
||||
break;
|
||||
case 'Tj':
|
||||
show(pend[pend.length - 1]);
|
||||
break;
|
||||
case 'TJ':
|
||||
show(pend[pend.length - 1]);
|
||||
break;
|
||||
case "'":
|
||||
tlm = mul([1, 0, 0, 1, 0, -(tl || 0)], tlm);
|
||||
tm = tlm;
|
||||
show(pend[pend.length - 1]);
|
||||
break;
|
||||
case '"':
|
||||
tlm = mul([1, 0, 0, 1, 0, -(tl || 0)], tlm);
|
||||
tm = tlm;
|
||||
show(pend[pend.length - 1]);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
pend = [];
|
||||
}
|
||||
for (const b of blocks) {
|
||||
if (!b.shows.length) b.selfContained = false;
|
||||
for (const si of b.shows) {
|
||||
const tj = tjs[si];
|
||||
if (!tj.font || !fontIsT3(tj.font)) b.selfContained = false;
|
||||
}
|
||||
}
|
||||
return { blocks, tjs };
|
||||
}
|
||||
|
||||
function pageT3Fonts(op, pgBody) {
|
||||
let resBody;
|
||||
const rref = pgBody.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
|
||||
if (rref) resBody = op.objBody(parseInt(rref[1], 10))?.body ?? '';
|
||||
else resBody = dictAt(pgBody, pgBody.indexOf('/Resources')) ?? '';
|
||||
const fontDict = resBody && dictAt(resBody, resBody.indexOf('/Font'));
|
||||
if (!fontDict) return null;
|
||||
const t3 = new Set();
|
||||
const nums = {};
|
||||
for (const m of fontDict.matchAll(
|
||||
/\/([A-Za-z0-9_.#-]+)\s+(\d+)\s+\d+\s+R/g
|
||||
)) {
|
||||
const num = parseInt(m[2], 10);
|
||||
nums[m[1]] = num;
|
||||
const fo = op.objBody(num);
|
||||
if (fo && /\/Subtype\s*\/Type3\b/.test(fo.body)) t3.add(m[1]);
|
||||
}
|
||||
return t3.size
|
||||
? { t3, nums, resBody, resRef: rref ? parseInt(rref[1], 10) : -1 }
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function protectType3Text(bytes) {
|
||||
const src = latin(bytes);
|
||||
if (!/\/Subtype\s*\/Type3\b/.test(src)) return null;
|
||||
const op = await withObjStm(bytes, src, parsePdf(src));
|
||||
if (!op) return null;
|
||||
let next = op.size;
|
||||
const newObjs = [],
|
||||
pageRewrites = [];
|
||||
const seg = {};
|
||||
|
||||
for (let pi = 0; pi < 4096; pi++) {
|
||||
const pgNum = op.pageNum(pi);
|
||||
if (pgNum < 0) break;
|
||||
const pg = op.objBody(pgNum);
|
||||
if (!pg) continue;
|
||||
const fonts = pageT3Fonts(op, pg.body);
|
||||
if (!fonts) continue;
|
||||
|
||||
const cOne = pg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
|
||||
const cArr = pg.body.match(/\/Contents\s*\[([^\]]*)\]/);
|
||||
const refs = cOne
|
||||
? [parseInt(cOne[1], 10)]
|
||||
: cArr
|
||||
? [...cArr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) =>
|
||||
parseInt(r[1], 10)
|
||||
)
|
||||
: [];
|
||||
if (!refs.length) continue;
|
||||
let content = '';
|
||||
let ok = true;
|
||||
for (const rn of refs) {
|
||||
const o = op.objBody(rn);
|
||||
const d = o && (await streamData(bytes, src, o));
|
||||
if (d == null) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
content += d + '\n';
|
||||
}
|
||||
if (!ok) continue;
|
||||
|
||||
const { blocks, tjs } = analyzeContent(content, (n) => fonts.t3.has(n));
|
||||
seg[pi] = {
|
||||
fonts: fonts.nums,
|
||||
t3: [...fonts.t3],
|
||||
tjs: tjs.map((j) => ({
|
||||
e: j.e,
|
||||
f: j.f,
|
||||
font: j.font,
|
||||
size: j.size,
|
||||
codes: j.codes,
|
||||
})),
|
||||
};
|
||||
const liftable = blocks.filter((b) => b.selfContained);
|
||||
if (!liftable.length) continue;
|
||||
|
||||
const resNum = next++;
|
||||
newObjs.push({ num: resNum, body: fonts.resBody.trim() || '<< >>' });
|
||||
|
||||
const added = [];
|
||||
for (const b of [...liftable].sort((x, y) => y.start - x.start)) {
|
||||
const en = b.entry;
|
||||
const pre = [en.fill, en.strokeC, en.gsOp].filter(Boolean).join('\n');
|
||||
const tstate = [
|
||||
en.tf ? `/${en.tf} ${en.tfSize} Tf` : null,
|
||||
en.tc,
|
||||
en.tw,
|
||||
en.tz,
|
||||
en.ts,
|
||||
en.trm,
|
||||
en.tl != null ? `${en.tl} TL` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
let ops = content.slice(b.start, b.end);
|
||||
if (tstate) ops = ops.replace(/^BT\b/, 'BT ' + tstate);
|
||||
if (pre) ops = pre + '\n' + ops;
|
||||
const num = next++;
|
||||
const name = `ECT3${num}`;
|
||||
newObjs.push({
|
||||
num,
|
||||
body:
|
||||
`<< /Type /XObject /Subtype /Form /FormType 1` +
|
||||
` /BBox [ -100000 -100000 100000 100000 ]` +
|
||||
` /Resources ${resNum} 0 R /Length ${ops.length + 1} >>\nstream\n${ops}\nendstream`,
|
||||
});
|
||||
content =
|
||||
content.slice(0, b.start) + `/${name} Do\n` + content.slice(b.end);
|
||||
added.push({ name, num });
|
||||
}
|
||||
|
||||
const cnum = next++;
|
||||
newObjs.push({
|
||||
num: cnum,
|
||||
body: `<< /Length ${content.length + 1} >>\nstream\n${content}\nendstream`,
|
||||
});
|
||||
const entries = added.map((a) => `/${a.name} ${a.num} 0 R`).join(' ');
|
||||
let newPage = pg.body.replace(
|
||||
cOne ? cOne[0] : cArr[0],
|
||||
`/Contents ${cnum} 0 R`
|
||||
);
|
||||
if (fonts.resRef >= 0) {
|
||||
let res = fonts.resBody;
|
||||
const xo = res.match(/\/XObject\s*<<([\s\S]*?)>>/);
|
||||
res = xo
|
||||
? res.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`)
|
||||
: res.replace(/^<</, `<< /XObject << ${entries} >> `);
|
||||
pageRewrites.push({ num: fonts.resRef, body: res });
|
||||
} else {
|
||||
const resDict = dictAt(pg.body, pg.body.indexOf('/Resources'));
|
||||
const xo = resDict.match(/\/XObject\s*<<([\s\S]*?)>>/);
|
||||
const newRes = xo
|
||||
? resDict.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`)
|
||||
: resDict.replace(/^<</, `<< /XObject << ${entries} >> `);
|
||||
newPage = newPage.replace(resDict, newRes);
|
||||
}
|
||||
pageRewrites.push({ num: pgNum, body: newPage });
|
||||
}
|
||||
if (!pageRewrites.length) return null;
|
||||
|
||||
const out = appendRevision(src, op.rootNum, next, pageRewrites, newObjs);
|
||||
return { bytes: out, seg };
|
||||
}
|
||||
|
||||
export async function protectFragileText(bytes) {
|
||||
const src = latin(bytes);
|
||||
if (!/Quartz PDFContext/.test(src)) return null;
|
||||
if (!/\/BaseFont\s*\/[A-Z]{6}\+/.test(src)) return null;
|
||||
const op = await withObjStm(bytes, src, parsePdf(src));
|
||||
if (!op) return null;
|
||||
let next = op.size;
|
||||
const newObjs = [],
|
||||
pageRewrites = [];
|
||||
const pages = [];
|
||||
|
||||
for (let pi = 0; pi < 4096; pi++) {
|
||||
const pgNum = op.pageNum(pi);
|
||||
if (pgNum < 0) break;
|
||||
const pg = op.objBody(pgNum);
|
||||
if (!pg) continue;
|
||||
let resBody;
|
||||
const rref = pg.body.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
|
||||
if (rref) resBody = op.objBody(parseInt(rref[1], 10))?.body ?? '';
|
||||
else resBody = dictAt(pg.body, pg.body.indexOf('/Resources')) ?? '';
|
||||
const fontDict = resBody && dictAt(resBody, resBody.indexOf('/Font'));
|
||||
if (!fontDict || !/\/[A-Za-z0-9_.#-]+\s+\d+\s+\d+\s+R/.test(fontDict))
|
||||
continue;
|
||||
|
||||
const cOne = pg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
|
||||
const cArr = pg.body.match(/\/Contents\s*\[([^\]]*)\]/);
|
||||
const refs = cOne
|
||||
? [parseInt(cOne[1], 10)]
|
||||
: cArr
|
||||
? [...cArr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) =>
|
||||
parseInt(r[1], 10)
|
||||
)
|
||||
: [];
|
||||
if (!refs.length) continue;
|
||||
let content = '';
|
||||
let ok = true;
|
||||
for (const rn of refs) {
|
||||
const o = op.objBody(rn);
|
||||
const d = o && (await streamData(bytes, src, o));
|
||||
if (d == null) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
content += d + '\n';
|
||||
}
|
||||
if (!ok) continue;
|
||||
|
||||
const { blocks } = analyzeContent(content, () => true);
|
||||
const liftable = blocks.filter((b) => b.selfContained);
|
||||
if (!liftable.length) continue;
|
||||
|
||||
const resNum = next++;
|
||||
newObjs.push({ num: resNum, body: resBody.trim() || '<< >>' });
|
||||
const added = [];
|
||||
for (const b of [...liftable].sort((x, y) => y.start - x.start)) {
|
||||
const en = b.entry;
|
||||
const pre = [en.fill, en.strokeC, en.gsOp].filter(Boolean).join('\n');
|
||||
const tstate = [
|
||||
en.tf ? `/${en.tf} ${en.tfSize} Tf` : null,
|
||||
en.tc,
|
||||
en.tw,
|
||||
en.tz,
|
||||
en.ts,
|
||||
en.trm,
|
||||
en.tl != null ? `${en.tl} TL` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
let ops = content.slice(b.start, b.end);
|
||||
if (tstate) ops = ops.replace(/^BT\b/, 'BT ' + tstate);
|
||||
if (pre) ops = pre + '\n' + ops;
|
||||
const num = next++;
|
||||
const name = `ECFT${num}`;
|
||||
newObjs.push({
|
||||
num,
|
||||
body:
|
||||
`<< /Type /XObject /Subtype /Form /FormType 1` +
|
||||
` /BBox [ -100000 -100000 100000 100000 ]` +
|
||||
` /Resources ${resNum} 0 R /Length ${ops.length + 1} >>\nstream\n${ops}\nendstream`,
|
||||
});
|
||||
content =
|
||||
content.slice(0, b.start) + `/${name} Do\n` + content.slice(b.end);
|
||||
added.push({ name, num });
|
||||
}
|
||||
|
||||
const cnum = next++;
|
||||
newObjs.push({
|
||||
num: cnum,
|
||||
body: `<< /Length ${content.length + 1} >>\nstream\n${content}\nendstream`,
|
||||
});
|
||||
const entries = added.map((a) => `/${a.name} ${a.num} 0 R`).join(' ');
|
||||
let newPage = pg.body.replace(
|
||||
cOne ? cOne[0] : cArr[0],
|
||||
`/Contents ${cnum} 0 R`
|
||||
);
|
||||
if (rref) {
|
||||
let res = resBody;
|
||||
const xo = res.match(/\/XObject\s*<<([\s\S]*?)>>/);
|
||||
res = xo
|
||||
? res.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`)
|
||||
: res.replace(/^<</, `<< /XObject << ${entries} >> `);
|
||||
pageRewrites.push({ num: parseInt(rref[1], 10), body: res });
|
||||
} else {
|
||||
const resDict = dictAt(pg.body, pg.body.indexOf('/Resources'));
|
||||
const xo = resDict.match(/\/XObject\s*<<([\s\S]*?)>>/);
|
||||
const newRes = xo
|
||||
? resDict.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`)
|
||||
: resDict.replace(/^<</, `<< /XObject << ${entries} >> `);
|
||||
newPage = newPage.replace(resDict, newRes);
|
||||
}
|
||||
pageRewrites.push({ num: pgNum, body: newPage });
|
||||
pages.push(pi);
|
||||
}
|
||||
if (!pageRewrites.length) return null;
|
||||
return {
|
||||
bytes: appendRevision(src, op.rootNum, next, pageRewrites, newObjs),
|
||||
pages,
|
||||
};
|
||||
}
|
||||
|
||||
export async function consolidateContentArrays(bytes) {
|
||||
const src = latin(bytes);
|
||||
const rawHasArrays = /\/Contents\s*\[/.test(src);
|
||||
if (!rawHasArrays && bytes.length > 4 << 20) return null;
|
||||
const op = await withObjStm(bytes, src, parsePdf(src));
|
||||
if (!op) return null;
|
||||
let next = op.size;
|
||||
const newObjs = [],
|
||||
pageRewrites = [];
|
||||
for (let pi = 0; pi < 4096; pi++) {
|
||||
const pgNum = op.pageNum(pi);
|
||||
if (pgNum < 0) break;
|
||||
const pg = op.objBody(pgNum);
|
||||
if (!pg) continue;
|
||||
const cArr = pg.body.match(/\/Contents\s*\[([^\]]*)\]/);
|
||||
if (!cArr) continue;
|
||||
const refs = [...cArr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) =>
|
||||
parseInt(r[1], 10)
|
||||
);
|
||||
if (refs.length < 2) continue;
|
||||
let content = '';
|
||||
let ok = true;
|
||||
for (const rn of refs) {
|
||||
const o = op.objBody(rn);
|
||||
const d = o && (await streamData(bytes, src, o));
|
||||
if (d == null) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
content += d + '\n';
|
||||
}
|
||||
if (!ok) continue;
|
||||
const cnum = next++;
|
||||
newObjs.push({
|
||||
num: cnum,
|
||||
body: `<< /Length ${content.length} >>\nstream\n${content}endstream`,
|
||||
});
|
||||
pageRewrites.push({
|
||||
num: pgNum,
|
||||
body: pg.body.replace(cArr[0], `/Contents ${cnum} 0 R`),
|
||||
});
|
||||
}
|
||||
if (!pageRewrites.length) return null;
|
||||
return appendRevision(src, op.rootNum, next, pageRewrites, newObjs);
|
||||
}
|
||||
|
||||
export function applyType3LeftoverSurgery(savedBytes, jobs) {
|
||||
jobs = (jobs || []).filter(
|
||||
(j) =>
|
||||
j &&
|
||||
j.seg &&
|
||||
(j.state || []).some((o) => o.t3 && o.glyphs && o.glyphs.length)
|
||||
);
|
||||
if (!jobs.length) return null;
|
||||
const saved = latin(savedBytes);
|
||||
const sp = parsePdf(saved);
|
||||
if (!sp) return null;
|
||||
let next = sp.size;
|
||||
const newObjs = [],
|
||||
pageRewrites = [];
|
||||
const injected = {};
|
||||
|
||||
for (const job of jobs) {
|
||||
const leftovers = job.state.filter(
|
||||
(o) => o.t3 && o.glyphs && o.glyphs.length
|
||||
);
|
||||
leftovers.sort((a, b) => {
|
||||
const band = (o) => Math.round(-o.m[5] / 6);
|
||||
return band(a) - band(b) || a.m[4] - b.m[4];
|
||||
});
|
||||
const tjs = job.seg.tjs || [];
|
||||
const anchors = job.anchors || {};
|
||||
const usedTj = new Set();
|
||||
const lines = [];
|
||||
const injectedTjs = [];
|
||||
for (const o of leftovers) {
|
||||
let name = anchors[o.font] ?? null;
|
||||
const oCodes = o.glyphs.map((g) => g[0]);
|
||||
if (!name) {
|
||||
const sameCodes = (tj) =>
|
||||
tj.codes.length === oCodes.length &&
|
||||
tj.codes.every((c, k) => c === oCodes[k]);
|
||||
let hit = tjs.findIndex(
|
||||
(tj, i) =>
|
||||
!usedTj.has(i) &&
|
||||
Math.abs(tj.e - o.m[4]) < 0.05 &&
|
||||
Math.abs(tj.f - o.m[5]) < 0.05 &&
|
||||
sameCodes(tj)
|
||||
);
|
||||
if (hit < 0)
|
||||
hit = tjs.findIndex((tj, i) => !usedTj.has(i) && sameCodes(tj));
|
||||
if (hit < 0) {
|
||||
hit = tjs.findIndex(
|
||||
(tj, i) =>
|
||||
!usedTj.has(i) &&
|
||||
tj.codes.length >= oCodes.length &&
|
||||
oCodes.every((c, k) => c === tj.codes[k])
|
||||
);
|
||||
}
|
||||
if (hit < 0) {
|
||||
console.warn('[type3] leftover glyph unmatched — skipped');
|
||||
continue;
|
||||
}
|
||||
usedTj.add(hit);
|
||||
name = tjs[hit].font;
|
||||
}
|
||||
const shownCodes = [];
|
||||
const num6 = (v) => (Math.round(v * 10000) / 10000).toString();
|
||||
const hex = (c) =>
|
||||
'<' +
|
||||
(c > 255
|
||||
? c.toString(16).padStart(4, '0')
|
||||
: c.toString(16).padStart(2, '0')) +
|
||||
'>';
|
||||
let ops = 'q ';
|
||||
ops += `${num6(o.fill[0] / 255)} ${num6(o.fill[1] / 255)} ${num6(o.fill[2] / 255)} rg `;
|
||||
ops += `${num6(o.stroke[0] / 255)} ${num6(o.stroke[1] / 255)} ${num6(o.stroke[2] / 255)} RG `;
|
||||
if (o.sw > 0) ops += `${num6(o.sw)} w `;
|
||||
ops += `BT ${o.m.map(num6).join(' ')} Tm /${name} ${num6(o.size)} Tf `;
|
||||
if (o.tr) ops += `${o.tr} Tr `;
|
||||
const known = new Set();
|
||||
for (const tj of tjs)
|
||||
if (tj.font === name) tj.codes.forEach((c) => known.add(c));
|
||||
let px = 0,
|
||||
py = 0;
|
||||
o.glyphs.forEach(([c, gx, gy], gi) => {
|
||||
if (gi === 0) {
|
||||
if (gx || gy) ops += `${num6(gx)} ${num6(gy)} Td `;
|
||||
} else ops += `${num6(gx - px)} ${num6(gy - py)} Td `;
|
||||
px = gx;
|
||||
py = gy;
|
||||
if (known.has(c)) {
|
||||
ops += hex(c) + ' Tj ';
|
||||
shownCodes.push(c);
|
||||
}
|
||||
});
|
||||
ops += 'ET Q';
|
||||
injectedTjs.push({
|
||||
e: o.m[4],
|
||||
f: o.m[5],
|
||||
font: name,
|
||||
size: o.size,
|
||||
codes: shownCodes,
|
||||
});
|
||||
lines.push(ops);
|
||||
}
|
||||
if (!lines.length) continue;
|
||||
|
||||
const prefix = 'ET Q\n'.repeat(leftovers.length);
|
||||
const body = prefix + lines.join('\n') + '\n';
|
||||
|
||||
const pgNum = sp.pageNum(job.pageIndex);
|
||||
if (pgNum < 0) continue;
|
||||
const spg = sp.objBody(pgNum);
|
||||
if (!spg) continue;
|
||||
const cOne = spg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
|
||||
const cArr = spg.body.match(/\/Contents\s*\[([^\]]*)\]/);
|
||||
if (!cOne && !cArr) continue;
|
||||
const snum = next++;
|
||||
newObjs.push({
|
||||
num: snum,
|
||||
body: `<< /Length ${body.length} >>\nstream\n${body}endstream`,
|
||||
});
|
||||
const refList = cOne ? `${cOne[1]} 0 R` : cArr[1].trim();
|
||||
let newPage = spg.body.replace(
|
||||
cOne ? cOne[0] : cArr[0],
|
||||
`/Contents [ ${refList} ${snum} 0 R ]`
|
||||
);
|
||||
|
||||
const usedNames = new Set(
|
||||
(lines.join(' ').match(/\/([A-Za-z0-9_.#-]+)\s+[\d.]+\s+Tf/g) ?? []).map(
|
||||
(s) => s.match(/\/([A-Za-z0-9_.#-]+)/)[1]
|
||||
)
|
||||
);
|
||||
const rref = newPage.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
|
||||
if (rref) {
|
||||
const rnum = parseInt(rref[1], 10);
|
||||
const ro = sp.objBody(rnum);
|
||||
if (ro) {
|
||||
const res = mergeFonts(ro.body, usedNames, job.seg.fonts);
|
||||
if (res !== ro.body) pageRewrites.push({ num: rnum, body: res });
|
||||
}
|
||||
} else {
|
||||
newPage = mergeFonts(newPage, usedNames, job.seg.fonts);
|
||||
}
|
||||
pageRewrites.push({ num: pgNum, body: newPage });
|
||||
injected[job.pageIndex] = injectedTjs;
|
||||
}
|
||||
if (!newObjs.length) return null;
|
||||
return {
|
||||
bytes: appendRevision(saved, sp.rootNum, next, pageRewrites, newObjs),
|
||||
injected,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFonts(body, usedNames, fontNums) {
|
||||
const resAt = body.indexOf('/Resources');
|
||||
const resDict = resAt >= 0 ? dictAt(body, resAt) : dictAt(body, 0);
|
||||
if (!resDict) return body;
|
||||
const fontDict = dictAt(resDict, resDict.indexOf('/Font'));
|
||||
const missing = [...usedNames].filter(
|
||||
(n) =>
|
||||
fontNums[n] != null &&
|
||||
!(fontDict || '').match(
|
||||
new RegExp('/' + n.replace(/[^A-Za-z0-9_]/g, '\\$&') + '[\\s/]')
|
||||
)
|
||||
);
|
||||
if (!missing.length) return body;
|
||||
const entries = missing.map((n) => `/${n} ${fontNums[n]} 0 R`).join(' ');
|
||||
let newRes;
|
||||
if (fontDict)
|
||||
newRes = resDict.replace(
|
||||
fontDict,
|
||||
fontDict.replace(/^<</, `<< ${entries} `)
|
||||
);
|
||||
else newRes = resDict.replace(/^<</, `<< /Font << ${entries} >> `);
|
||||
return body.replace(resDict, newRes);
|
||||
}
|
||||
|
||||
function appendRevision(src, rootNum, size, pageRewrites, newObjs) {
|
||||
let out = src;
|
||||
if (!out.endsWith('\n')) out += '\n';
|
||||
const offsets = new Map();
|
||||
const emit = (num, body) => {
|
||||
offsets.set(num, out.length);
|
||||
out += `${num} 0 obj\n${body.trim()}\nendobj\n`;
|
||||
};
|
||||
for (const o of pageRewrites) emit(o.num, o.body);
|
||||
for (const o of newObjs) emit(o.num, o.body);
|
||||
const xrefAt = out.length;
|
||||
const nums = [...offsets.keys()].sort((a, b) => a - b);
|
||||
let xref = 'xref\n';
|
||||
for (let i = 0; i < nums.length; ) {
|
||||
let j = i;
|
||||
while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
|
||||
xref += `${nums[i]} ${j - i + 1}\n`;
|
||||
for (let k = i; k <= j; k++)
|
||||
xref += String(offsets.get(nums[k])).padStart(10, '0') + ' 00000 n \n';
|
||||
i = j + 1;
|
||||
}
|
||||
out += xref;
|
||||
const prevAt = src.lastIndexOf('startxref');
|
||||
const prevNum = parseInt(src.slice(prevAt + 9).trim(), 10);
|
||||
const infoM = src
|
||||
.slice(src.lastIndexOf('trailer'))
|
||||
.match(/\/Info\s+(\d+)\s+\d+\s+R/);
|
||||
out +=
|
||||
`trailer\n<< /Size ${size} /Root ${rootNum} 0 R` +
|
||||
(infoM ? ` /Info ${infoM[1]} 0 R` : '') +
|
||||
(Number.isFinite(prevNum) ? ` /Prev ${prevNum}` : '') +
|
||||
` >>\nstartxref\n${xrefAt}\n%%EOF\n`;
|
||||
const u8 = new Uint8Array(out.length);
|
||||
for (let i = 0; i < out.length; i++) u8[i] = out.charCodeAt(i) & 0xff;
|
||||
return u8;
|
||||
}
|
||||
Reference in new issue
Block a user