mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 08:46:55 +08:00
This commit is contained in:
1 parent
0ad34ee2b1
commit
0ad2bda1f7
47 files changed
+2786
-459
No files matched your search
@@ -2496,6 +2496,40 @@ audio.page-media {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.page-surface video.page-media {
|
||||
transform: scale(calc(1 / var(--surface-scale, 1)));
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.media-dialog {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
width: 640px;
|
||||
}
|
||||
|
||||
.media-dialog .form-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.media-dialog .page-media {
|
||||
position: static;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: calc(100dvh - 100px);
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.media-dialog audio.page-media {
|
||||
height: 54px;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.editing .page-media {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+225
-49
@@ -68,6 +68,12 @@ const state = {
|
||||
pageMultiSelect: false,
|
||||
pageTouchPointer: null,
|
||||
outlineSelection: null,
|
||||
outlineSeq: 0,
|
||||
openActionEnabled: false,
|
||||
openActionDocument: false,
|
||||
openActionPage: null,
|
||||
openActionTask: null,
|
||||
openActionFrame: 0,
|
||||
outlineAction: "add",
|
||||
styleOriginal: null,
|
||||
objectClipboard: null,
|
||||
@@ -2099,7 +2105,7 @@ async function switchDocument(index) {
|
||||
updateTextFonts(null, true);
|
||||
state.ofdBytes = null;
|
||||
setEditorInfo(doc);
|
||||
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true, clearSelection: true, fitMode: state.fitMode, scale: state.scale });
|
||||
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true, clearSelection: true, fitMode: state.fitMode, scale: state.scale, activateActions: true });
|
||||
} catch (error) {
|
||||
if (openSeq === state.openSeq) {
|
||||
el.documentSelect.value = String(previous);
|
||||
@@ -3339,12 +3345,13 @@ function resizeViewer() {
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const openSeq = state.openSeq;
|
||||
setBusy(true, "正在准备引擎", 8, STATUS.engine);
|
||||
try {
|
||||
if (!await prepareOffline()) {
|
||||
return;
|
||||
}
|
||||
setProgress("正在准备引擎", 8, STATUS.engine);
|
||||
if (openSeq === state.openSeq) setProgress("正在准备引擎", 8, STATUS.engine);
|
||||
await ensureWASM();
|
||||
let fontsRestored = true;
|
||||
try {
|
||||
@@ -3352,16 +3359,18 @@ async function boot() {
|
||||
} catch {
|
||||
fontsRestored = false;
|
||||
}
|
||||
setProgress("引擎准备完成", 70);
|
||||
if (openSeq === state.openSeq) setProgress("引擎准备完成", 70);
|
||||
await loadExportFormats();
|
||||
setEmpty("选择 OFD 文件");
|
||||
if (openSeq === state.openSeq) setEmpty("选择 OFD 文件");
|
||||
updateFontSummary();
|
||||
renderFontList();
|
||||
updateControls();
|
||||
updateLocalFontButton();
|
||||
await fontManager.refreshPermission();
|
||||
if (openSeq === state.openSeq) {
|
||||
setStatus(fontsRestored ? STATUS.ready : "字体读取失败");
|
||||
setBusy(false);
|
||||
}
|
||||
if ("launchQueue" in window) {
|
||||
let opening = Promise.resolve();
|
||||
window.launchQueue.setConsumer(({ files }) => {
|
||||
@@ -3375,14 +3384,16 @@ async function boot() {
|
||||
}
|
||||
try {
|
||||
const remote = remoteDocumentOptions(window.location.hash);
|
||||
if (remote) await openOFD({ name: remote.name }, remote);
|
||||
if (remote && openSeq === state.openSeq) await openOFD({ name: remote.name }, remote);
|
||||
} catch (err) {
|
||||
showError(err, !state.doc);
|
||||
if (openSeq === state.openSeq) showError(err, !state.doc);
|
||||
}
|
||||
} catch (err) {
|
||||
if (openSeq === state.openSeq) {
|
||||
setStatus("引擎加载失败");
|
||||
setEmpty(String(err.message || err));
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3754,19 +3765,14 @@ async function downloadRemoteDocument(options) {
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
controller.signal.throwIfAborted();
|
||||
const bytes = new Uint8Array(received);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
||||
const pdf = new TextDecoder("latin1").decode(bytes.subarray(0, 1024)).includes("%PDF-");
|
||||
if (!pdf && !(bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 3 && bytes[3] === 4)) throw new Error("下载内容不是 OFD 或 PDF 文档");
|
||||
const disposition = response.headers.get("Content-Disposition") || "";
|
||||
let supplied = /(?:^|;)\s*filename\*=UTF-8'[^']*'([^;]+)/i.exec(disposition)?.[1];
|
||||
if (supplied) { try { supplied = decodeURIComponent(supplied.trim()); } catch { supplied = ""; } }
|
||||
if (!supplied) supplied = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;]*))/i.exec(disposition)?.slice(1).find(value => value !== undefined)?.replace(/\\(.)/g, "$1");
|
||||
let pathName = new URL(response.url || options.url).pathname.split("/").pop();
|
||||
try { pathName = decodeURIComponent(pathName); } catch { pathName = ""; }
|
||||
const name = remoteDocumentName(options.name) || remoteDocumentName(supplied) || remoteDocumentName(pathName) || `ofdgo.${pdf ? "pdf" : "ofd"}`;
|
||||
return { bytes, pdf, name };
|
||||
const name = remoteDocumentName(options.name) || remoteDocumentName(supplied) || remoteDocumentName(pathName);
|
||||
return { file: new Blob(chunks), name };
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) throw new Error("下载失败,请检查网络、链接及跨域配置");
|
||||
throw err;
|
||||
@@ -3797,14 +3803,20 @@ async function openOFD(file, remote = null) {
|
||||
setBusy(true, "正在读取文档", 10, STATUS.opening);
|
||||
try {
|
||||
const downloaded = remote ? await downloadRemoteDocument(remote) : null;
|
||||
let bytes = downloaded ? downloaded.bytes : new Uint8Array(await file.arrayBuffer());
|
||||
const name = downloaded ? downloaded.name : file.name;
|
||||
if (openSeq !== state.openSeq) return;
|
||||
await ensureWASM();
|
||||
if (openSeq !== state.openSeq) return;
|
||||
const source = downloaded ? downloaded.file : file;
|
||||
const format = await callWASM("ofdgoDetectFormat", source);
|
||||
if (openSeq !== state.openSeq) return;
|
||||
let bytes = new Uint8Array(await source.arrayBuffer());
|
||||
const name = (downloaded ? downloaded.name : file.name) || `ofdgo.${format}`;
|
||||
let warnings = [];
|
||||
let convertedDoc;
|
||||
if (openSeq !== state.openSeq) {
|
||||
return;
|
||||
}
|
||||
if (downloaded ? downloaded.pdf : /\.pdf$/i.test(file.name || "")) {
|
||||
if (format === "pdf") {
|
||||
setProgress("正在转换文档", 25);
|
||||
await ensureWASM();
|
||||
if (openSeq !== state.openSeq) return;
|
||||
@@ -3864,7 +3876,7 @@ async function openOFD(file, remote = null) {
|
||||
el.objectBoundsPanel.close();
|
||||
el.outlinePanel.close();
|
||||
updateControls();
|
||||
await openDocument({ doc, pageIndex: remote?.pageIndex || 0, resetScroll: true, openSeq });
|
||||
await openDocument({ doc, pageIndex: remote?.pageIndex || 0, resetScroll: true, openSeq, activateActions: true });
|
||||
} catch (err) {
|
||||
if (openSeq === state.openSeq) {
|
||||
if (err.name === "AbortError") setStatus("打开已取消");
|
||||
@@ -4445,6 +4457,7 @@ async function openDocument(options = {}) {
|
||||
openSeq,
|
||||
reuseSession: true,
|
||||
fontsChanged: true,
|
||||
activateActions: options.activateActions,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -4515,6 +4528,13 @@ async function openDocument(options = {}) {
|
||||
}
|
||||
queueNearbyPages(pageIndex, openSeq);
|
||||
loadDocumentDetails(openSeq);
|
||||
if (page && options.activateActions) {
|
||||
state.openActionEnabled = true;
|
||||
state.openActionDocument = !state.editing;
|
||||
state.openActionPage = state.editing ? state.doc.pages[state.pageIndex]?.id : null;
|
||||
state.openActionTask = null;
|
||||
scheduleOpenActions();
|
||||
}
|
||||
} catch (err) {
|
||||
if (openSeq !== state.openSeq) {
|
||||
return;
|
||||
@@ -4618,6 +4638,7 @@ async function renderPage(index, options = {}) {
|
||||
} finally {
|
||||
if (!options.keepBusy && !state.exporting && openSeq === state.openSeq && pageSeq === state.pageSeq) {
|
||||
setBusy(false);
|
||||
if (!options.openAction) scheduleOpenActions();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6022,11 +6043,19 @@ function mountPageSVG(index, page, openSeq = state.openSeq) {
|
||||
|
||||
function releaseMediaPlayers(surface) {
|
||||
for (const [id, entry] of state.mediaPlayers || []) {
|
||||
if (surface && entry.surface !== surface) continue;
|
||||
if (surface && (entry.surface !== surface || entry.floating)) continue;
|
||||
releaseMediaPlayer(id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
function releaseMediaPlayer(id, entry) {
|
||||
if (state.mediaPlayers?.get(id) !== entry) return;
|
||||
state.mediaPlayers.delete(id);
|
||||
for (const [requestID, request] of wasmRequests) {
|
||||
if (request.mediaID === id) wasmWorker?.postMessage({type: "cancel", id: requestID});
|
||||
}
|
||||
entry.finish?.();
|
||||
closeMediaWindow(entry);
|
||||
if (entry.element) {
|
||||
entry.element.pause();
|
||||
entry.element.removeAttribute("src");
|
||||
@@ -6034,8 +6063,73 @@ function releaseMediaPlayers(surface) {
|
||||
entry.element.remove();
|
||||
}
|
||||
if (entry.url) URL.revokeObjectURL(entry.url);
|
||||
state.mediaPlayers.delete(id);
|
||||
}
|
||||
|
||||
function closeMediaWindow(entry) {
|
||||
const dialog = entry.dialog;
|
||||
if (!dialog) return;
|
||||
const restoreFocus = dialog.contains(document.activeElement);
|
||||
entry.dialog = null;
|
||||
dialog.close();
|
||||
dialog.remove();
|
||||
if (restoreFocus && entry.opener?.isConnected) entry.opener.focus({ preventScroll: true });
|
||||
entry.opener = null;
|
||||
}
|
||||
|
||||
function placeMediaPlayer(id, entry) {
|
||||
const box = entry.placement;
|
||||
if (!box) return;
|
||||
const element = entry.element;
|
||||
entry.finish?.();
|
||||
if (box.floating) {
|
||||
element.removeAttribute("style");
|
||||
if (!entry.dialog) {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "form-dialog media-dialog";
|
||||
const label = entry.kind === "Video" ? "视频" : "音频";
|
||||
dialog.setAttribute("aria-label", `${label}播放`);
|
||||
const header = document.createElement("div");
|
||||
header.className = "form-title";
|
||||
const title = document.createElement("span");
|
||||
title.textContent = `${label}播放`;
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "small-button";
|
||||
close.textContent = "关闭";
|
||||
close.title = `关闭${label}`;
|
||||
close.setAttribute("aria-label", `关闭${label}`);
|
||||
close.addEventListener("click", () => releaseMediaPlayer(id, entry));
|
||||
dialog.addEventListener("keydown", event => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
releaseMediaPlayer(id, entry);
|
||||
});
|
||||
dialog.addEventListener("close", () => {
|
||||
if (entry.dialog === dialog) releaseMediaPlayer(id, entry);
|
||||
});
|
||||
header.append(title, close);
|
||||
dialog.append(header, element);
|
||||
entry.opener = document.activeElement;
|
||||
entry.dialog = dialog;
|
||||
document.body.append(dialog);
|
||||
dialog.show();
|
||||
}
|
||||
} else {
|
||||
entry.surface.append(element);
|
||||
closeMediaWindow(entry);
|
||||
element.style.left = `${box.x / box.pageWidth * 100}%`;
|
||||
element.style.width = `${box.width / box.pageWidth * 100}%`;
|
||||
if (entry.kind === "Video") {
|
||||
element.style.width = `calc(${box.width / box.pageWidth * 100}% * var(--surface-scale, 1))`;
|
||||
element.style.top = `${box.y / box.pageHeight * 100}%`;
|
||||
element.style.height = `calc(${box.height / box.pageHeight * 100}% * var(--surface-scale, 1))`;
|
||||
} else {
|
||||
element.style.bottom = `${Math.max(0, 100 - (box.y + box.height) / box.pageHeight * 100)}%`;
|
||||
}
|
||||
}
|
||||
entry.placement = null;
|
||||
element.currentTime = 0;
|
||||
}
|
||||
|
||||
async function playMediaAction(link, page, surface) {
|
||||
@@ -6048,23 +6142,26 @@ async function playMediaAction(link, page, surface) {
|
||||
let entry = players.get(id);
|
||||
if (operator === "Stop" || operator === "Pause") {
|
||||
if (entry) entry.operation = (entry.operation || 0) + 1;
|
||||
if (entry?.element) {
|
||||
entry.element.pause();
|
||||
if (operator === "Stop") {
|
||||
entry.element.currentTime = 0;
|
||||
entry.finish?.();
|
||||
}
|
||||
if (entry) releaseMediaPlayer(id, entry);
|
||||
} else if (entry) {
|
||||
entry.paused = true;
|
||||
entry.element?.pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (operator === "Resume" && !entry) return;
|
||||
if (operator === "Resume" && (!entry || entry.element?.ended || !entry.paused && !entry.element?.paused)) return;
|
||||
const seq = state.openSeq;
|
||||
try {
|
||||
if (!entry) {
|
||||
entry = { surface };
|
||||
players.set(id, entry);
|
||||
entry.pending = callWASM("ofdgoExportMedia", id, null).then(data => {
|
||||
if (seq !== state.openSeq || players.get(id) !== entry || !entry.surface.isConnected) return;
|
||||
if (seq !== state.openSeq || players.get(id) !== entry) return;
|
||||
if (!entry.floating && !entry.surface.isConnected) {
|
||||
releaseMediaPlayer(id, entry);
|
||||
return;
|
||||
}
|
||||
const element = document.createElement(data.kind === "Video" ? "video" : "audio");
|
||||
entry.kind = data.kind;
|
||||
entry.element = element;
|
||||
@@ -6074,12 +6171,18 @@ async function playMediaAction(link, page, surface) {
|
||||
element.className = "page-media";
|
||||
element.addEventListener("error", () => {
|
||||
entry.finish?.();
|
||||
if (seq === state.openSeq) setStatus("媒体无法播放");
|
||||
if (seq === state.openSeq && players.get(id) === entry) setStatus("媒体无法播放");
|
||||
});
|
||||
entry.surface.append(element);
|
||||
placeMediaPlayer(id, entry);
|
||||
});
|
||||
}
|
||||
if (operator === "Play") entry.surface = surface;
|
||||
if (operator === "Play") {
|
||||
entry.surface = surface;
|
||||
const box = link.movie && link.playbackBox ? link.playbackBox : link;
|
||||
entry.floating = !!link.movie && !link.playbackBox || !!link.sound && !(link.width > 0 && link.height > 0);
|
||||
entry.placement = { x: box.x, y: box.y, width: box.width, height: box.height, pageWidth: page.width, pageHeight: page.height, floating: entry.floating };
|
||||
}
|
||||
entry.paused = false;
|
||||
const operation = entry.operation = (entry.operation || 0) + 1;
|
||||
await entry.pending;
|
||||
if (seq !== state.openSeq || players.get(id) !== entry || operation !== entry.operation || !entry.element?.isConnected) return false;
|
||||
@@ -6088,19 +6191,7 @@ async function playMediaAction(link, page, surface) {
|
||||
element.volume = Math.max(0, Math.min(100, action.volume ?? 100)) / 100;
|
||||
element.loop = !!action.repeat;
|
||||
}
|
||||
if (operator === "Play") {
|
||||
entry.finish?.();
|
||||
element.style.left = `${link.x / page.width * 100}%`;
|
||||
element.style.width = `${link.width / page.width * 100}%`;
|
||||
if (entry.kind === "Video") {
|
||||
element.style.top = `${link.y / page.height * 100}%`;
|
||||
element.style.height = `${link.height / page.height * 100}%`;
|
||||
} else {
|
||||
element.style.bottom = `${Math.max(0, 100 - (link.y + link.height) / page.height * 100)}%`;
|
||||
}
|
||||
surface.append(element);
|
||||
element.currentTime = 0;
|
||||
}
|
||||
placeMediaPlayer(id, entry);
|
||||
let ended;
|
||||
if (link.sound && action.synchronous && !action.repeat) {
|
||||
ended = new Promise(resolve => {
|
||||
@@ -6123,13 +6214,13 @@ async function playMediaAction(link, page, surface) {
|
||||
}
|
||||
}
|
||||
|
||||
function pageLinkTarget(link, page, surface) {
|
||||
function pageLinkTarget(link, page, surface, openAction) {
|
||||
if (link.sound || link.movie) {
|
||||
return { href: "#", title: link.sound ? "音频播放" : "视频播放", activate: () => playMediaAction(link, page, surface) };
|
||||
}
|
||||
if (link.dest) {
|
||||
const target = pageIndexByID(state.doc.pages, link.dest.pageID);
|
||||
return { href: target < 0 ? "#" : `#page-${target + 1}`, title: target < 0 ? "文档链接" : `第${target + 1}页`, activate: () => navigateDestination(link.dest) };
|
||||
return { href: target < 0 ? "#" : `#page-${target + 1}`, title: target < 0 ? "文档链接" : `第${target + 1}页`, activate: () => navigateDestination(link.dest, openAction) };
|
||||
}
|
||||
if (link.attachment) {
|
||||
return { href: "#", title: link.fileName ? `下载附件:${link.fileName}` : "下载附件", activate: () => {
|
||||
@@ -6167,6 +6258,7 @@ function mountPageLinks(page, surface) {
|
||||
anchor.addEventListener("click", async event => {
|
||||
event.preventDefault();
|
||||
if (document.body.hasAttribute("aria-busy")) return;
|
||||
state.openActionTask = null;
|
||||
const openSeq = state.openSeq;
|
||||
let actions = targets;
|
||||
if (event.detail && link.group) {
|
||||
@@ -6209,8 +6301,9 @@ function destinationPoint(page, x, y) {
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
async function navigateDestination(dest) {
|
||||
async function navigateDestination(dest, openAction) {
|
||||
if (document.body.hasAttribute("aria-busy")) return;
|
||||
if (openAction && state.openActionTask !== openAction) return false;
|
||||
const index = pageIndexByID(state.doc.pages, dest.pageID);
|
||||
if (index < 0) {
|
||||
setStatus("目标页面不存在");
|
||||
@@ -6221,7 +6314,8 @@ async function navigateDestination(dest) {
|
||||
return;
|
||||
}
|
||||
const seq = state.openSeq, scale = state.scale;
|
||||
await renderPage(index, { fit: false, scroll: false });
|
||||
await renderPage(index, { fit: false, scroll: false, openAction });
|
||||
if (openAction && state.openActionTask !== openAction) return false;
|
||||
if (seq !== state.openSeq || state.pageIndex !== index) return;
|
||||
const page = state.doc.pages[index], size = pageViewSize(page), space = pageSpace();
|
||||
const width = el.viewerPanel.clientWidth - space * 2, height = el.viewerPanel.clientHeight - space * 2;
|
||||
@@ -6581,6 +6675,7 @@ function schedulePageSync() {
|
||||
}
|
||||
|
||||
function syncCurrentPageFromScroll() {
|
||||
if (state.openActionTask?.navigating) return;
|
||||
syncPageWindow(state.pageWindow);
|
||||
const shell = pageShellFromView();
|
||||
if (!shell) {
|
||||
@@ -6590,6 +6685,7 @@ function syncCurrentPageFromScroll() {
|
||||
if (Number.isFinite(nextIndex) && nextIndex !== state.pageIndex) {
|
||||
setCurrentPage(nextIndex);
|
||||
queueNearbyPages(nextIndex);
|
||||
scheduleOpenActions();
|
||||
}
|
||||
prunePageRenderQueue();
|
||||
}
|
||||
@@ -6626,8 +6722,10 @@ function pageShellAtPoint(x, y) {
|
||||
}
|
||||
|
||||
function setCurrentPage(index) {
|
||||
if (index !== state.pageIndex && !state.openActionTask?.navigating) state.openActionTask = null;
|
||||
if (state.composite && state.composite.index !== index) resetCompositeScope();
|
||||
state.pageIndex = index;
|
||||
if (state.editing) state.openActionPage = state.doc?.pages[index]?.id;
|
||||
if (state.fitMode === "width") {
|
||||
state.scale = fitWidthScale(currentPageInfo());
|
||||
el.zoomLabel.textContent = `${Math.round(state.scale * 100)}%`;
|
||||
@@ -7011,6 +7109,82 @@ function renderSearchHighlights(index) {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleOpenActions() {
|
||||
if (!state.openActionEnabled || state.openActionFrame) return;
|
||||
state.openActionFrame = requestAnimationFrame(() => {
|
||||
state.openActionFrame = 0;
|
||||
activateOpenActions();
|
||||
});
|
||||
}
|
||||
|
||||
async function activateOpenActions() {
|
||||
if (!state.openActionEnabled || !state.doc || state.editing || state.exporting || document.body.hasAttribute("aria-busy")) return;
|
||||
const pageID = state.doc.pages[state.pageIndex]?.id;
|
||||
if (!pageID || !state.openActionDocument && state.openActionPage === pageID) return;
|
||||
const task = { sequence: state.openSeq, seen: new Set(), navigating: false };
|
||||
state.openActionTask = task;
|
||||
const current = () => task === state.openActionTask && task.sequence === state.openSeq && !state.editing;
|
||||
let documentOpened = state.openActionDocument;
|
||||
state.openActionDocument = false;
|
||||
state.openActionPage = pageID;
|
||||
try {
|
||||
while (current()) {
|
||||
const index = state.pageIndex;
|
||||
const id = state.doc.pages[index]?.id;
|
||||
if (!id || !documentOpened && task.seen.has(id)) break;
|
||||
if (!documentOpened) {
|
||||
task.seen.add(id);
|
||||
state.openActionPage = id;
|
||||
}
|
||||
const page = await renderFlowPage(index, { openSeq: task.sequence, priority: 0, throwError: true });
|
||||
if (!page || !current()) break;
|
||||
const links = documentOpened ? await callWASM("ofdgoOpenLinks") : await callWASM("ofdgoOpenLinks", index);
|
||||
if (!current()) break;
|
||||
for (const link of links) {
|
||||
if (!current()) break;
|
||||
const info = state.doc.pages[state.pageIndex];
|
||||
const surface = pageShell(state.pageIndex)?.querySelector(".page-surface");
|
||||
const target = pageLinkTarget(link, info, surface, task);
|
||||
if (!target) continue;
|
||||
if ((target.external || link.attachment) && !window.confirm(link.attachment
|
||||
? `文档请求下载附件,是否继续?\n${link.fileName || ""}` : `文档请求打开链接,是否继续?\n${target.href}`)) return;
|
||||
task.navigating = !!link.dest;
|
||||
if (await target.activate() === false) return;
|
||||
if (task.navigating) await nextFrame();
|
||||
task.navigating = false;
|
||||
}
|
||||
if (documentOpened) documentOpened = false;
|
||||
else if (state.doc.pages[state.pageIndex]?.id === id) break;
|
||||
}
|
||||
} catch (err) {
|
||||
if (current()) setStatus(err.message || "动作无法执行");
|
||||
} finally {
|
||||
if (task === state.openActionTask) {
|
||||
state.openActionPage = state.doc?.pages[state.pageIndex]?.id;
|
||||
state.openActionTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function activateOutline(path) {
|
||||
if (document.body.hasAttribute("aria-busy") || state.editing) return;
|
||||
state.openActionTask = null;
|
||||
const sequence = state.openSeq;
|
||||
const request = ++state.outlineSeq;
|
||||
try {
|
||||
const links = await callWASM("ofdgoOutlineLinks", path);
|
||||
for (const link of links) {
|
||||
if (sequence !== state.openSeq || request !== state.outlineSeq || state.editing) break;
|
||||
const page = state.doc?.pages[state.pageIndex];
|
||||
const surface = pageShell(state.pageIndex)?.querySelector(".page-surface");
|
||||
const target = pageLinkTarget(link, page, surface);
|
||||
if (target && await target.activate() === false) break;
|
||||
}
|
||||
} catch (err) {
|
||||
if (sequence === state.openSeq && request === state.outlineSeq && !state.editing) setStatus(err.message || "目录无法打开");
|
||||
}
|
||||
}
|
||||
|
||||
function createOutlineList(outlines, path = []) {
|
||||
const list = document.createElement("ul");
|
||||
list.className = "outline-list";
|
||||
@@ -7022,7 +7196,7 @@ function createOutlineList(outlines, path = []) {
|
||||
if (!hasChildren) {
|
||||
row.className = "outline-leaf";
|
||||
}
|
||||
const link = document.createElement(outline.page || state.editing ? "button" : "span");
|
||||
const link = document.createElement(outline.actionCount || outline.page || state.editing ? "button" : "span");
|
||||
link.className = "outline-link";
|
||||
link.classList.toggle("selected", state.editing && JSON.stringify(current) === JSON.stringify(state.outlineSelection));
|
||||
const title = document.createElement("span");
|
||||
@@ -7036,7 +7210,7 @@ function createOutlineList(outlines, path = []) {
|
||||
page.setAttribute("aria-label", `第 ${outline.page} 页`);
|
||||
link.append(page);
|
||||
}
|
||||
if (outline.page || state.editing) {
|
||||
if (outline.actionCount || outline.page || state.editing) {
|
||||
link.type = "button";
|
||||
link.addEventListener("keydown", async event => {
|
||||
if (!state.editing || event.defaultPrevented || event.isComposing || !event.altKey || event.ctrlKey || event.metaKey || event.shiftKey
|
||||
@@ -7056,8 +7230,10 @@ function createOutlineList(outlines, path = []) {
|
||||
state.outlineSelection = current;
|
||||
for (const node of el.outlineList.querySelectorAll(".outline-link")) node.classList.toggle("selected", node === link);
|
||||
for (const button of el.outlineList.querySelectorAll("[data-outline-action]")) button.disabled = false;
|
||||
}
|
||||
if (outline.page) renderPage(outline.page - 1);
|
||||
return;
|
||||
}
|
||||
activateOutline(current);
|
||||
});
|
||||
}
|
||||
row.append(link);
|
||||
|
||||
@@ -173,6 +173,14 @@ async function handleMessage({ id, name, args }) {
|
||||
const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
if (name === "ofdgoDetectFormat") {
|
||||
const file = args[0], reader = new FileReaderSync();
|
||||
channel = new MessageChannel();
|
||||
args = [(offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size))), file.size, done => {
|
||||
channel.port1.onmessage = () => done("", false);
|
||||
channel.port2.postMessage(null);
|
||||
}];
|
||||
}
|
||||
if (name === "ofdgoChangeAttachment" && (args[0] === "add" || args[0] === "replace")) {
|
||||
const files = args[3], reader = new FileReaderSync();
|
||||
args[3] = index => {
|
||||
|
||||
@@ -9,11 +9,11 @@ require (
|
||||
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
|
||||
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
|
||||
github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893
|
||||
github.com/xiaoqidun/jbig2 v1.1.3
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261009130638-37c73506b373
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/image v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
github.com/xiaoqidun/jbig2 v1.1.4
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261010014421-57af90cea2c6
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/image v0.47.0
|
||||
golang.org/x/text v0.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -32,8 +32,8 @@ require (
|
||||
github.com/tdewolff/minify/v2 v2.24.17 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.16 // indirect
|
||||
github.com/yuin/goldmark v1.8.5 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/net v0.60.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
modernc.org/knuth v0.6.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
star-tex.org/x/tex v0.7.1 // indirect
|
||||
|
||||
@@ -60,32 +60,32 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893 h1:jmQlBO7R+bcimOMs9jjyDWR1FqiPNGtZqA/V1WiYfPA=
|
||||
github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
|
||||
github.com/xiaoqidun/jbig2 v1.1.3 h1:p0d8FMLiLfzQuX278T4VTfTz8tIJEw87u4v63lH4CBc=
|
||||
github.com/xiaoqidun/jbig2 v1.1.3/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261009130638-37c73506b373 h1:jw9W5q/sUnMOSEbYQD5U6Ppd0cDHXTuzREOwXm6ABtk=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261009130638-37c73506b373/go.mod h1:swzoqA5+wSHsRPesJS1VOMLhSyWkLCSQ+IgHNYXbcKo=
|
||||
github.com/xiaoqidun/jbig2 v1.1.4 h1:LF6J1si4JY+TszTh9+m4KAg9/ba0JjfXoGtQTQC2S8c=
|
||||
github.com/xiaoqidun/jbig2 v1.1.4/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261010014421-57af90cea2c6 h1:APuDHlsRUfDMkV7+ONj2FwRFRGkO+KzbHqBoWh2aG54=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261010014421-57af90cea2c6/go.mod h1:fXz1emtF1YCpcIv+fglDZstjkHxjS2l3G4DVMgUzVnk=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
|
||||
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
|
||||
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gonum.org/v1/plot v0.17.0 h1:d0DwPVBe9jnEGqQBoZGl/P2M9WciJbG2CnV59C9QBT4=
|
||||
gonum.org/v1/plot v0.17.0/go.mod h1:ipt2GUN1oqzr2O7wCjLDtw1ShfIYYNBp4o0O1Ez5B3Y=
|
||||
|
||||
+115
-44
@@ -183,6 +183,7 @@ func (r *browserReaderAt) ReadAt(data []byte, offset int64) (int, error) {
|
||||
|
||||
// RunWASM 注册浏览器WASM接口并阻塞运行
|
||||
func RunWASM() {
|
||||
registerAsyncCallback("ofdgoDetectFormat", detectDocumentFormat)
|
||||
registerCallback("ofdgoOpen", openDocument)
|
||||
registerCallback("ofdgoSelectDocument", selectDocument)
|
||||
registerCallback("ofdgoChangeDocument", changePackageDocument)
|
||||
@@ -195,6 +196,8 @@ func RunWASM() {
|
||||
registerCallback("ofdgoDocumentInfo", documentInfo)
|
||||
registerCallback("ofdgoVerifySignatures", verifySignatures)
|
||||
registerCallback("ofdgoRenderPage", renderPage)
|
||||
registerCallback("ofdgoOutlineLinks", outlineLinks)
|
||||
registerCallback("ofdgoOpenLinks", openLinks)
|
||||
registerCallback("ofdgoRenderBackends", renderBackends)
|
||||
registerCallback("ofdgoSVGFontData", svgFontData)
|
||||
registerCallback("ofdgoSearchPage", searchPage)
|
||||
@@ -375,6 +378,17 @@ func safeCall(fn func([]js.Value) (any, error), args []js.Value) (data any, err
|
||||
return fn(args)
|
||||
}
|
||||
|
||||
// detectDocumentFormat 通过库按需识别输入,不加载或替换文档会话
|
||||
// 入参: args 随机读取回调、文件大小及检查点
|
||||
// 返回: any 格式标识, error 读取或识别错误
|
||||
func detectDocumentFormat(args []js.Value) (any, error) {
|
||||
if len(args) != 3 {
|
||||
return nil, fmt.Errorf("missing detection arguments")
|
||||
}
|
||||
source := &browserReaderAt{read: args[0], size: int64(args[1].Float()), checkpoint: args[2]}
|
||||
return ofdgo.DetectFormat(source, source.size)
|
||||
}
|
||||
|
||||
// convertPDFDocument 通过库转换PDF,不替换当前文档会话
|
||||
// 入参: args PDF数据、外部字体、UTF-8密码、字体目录、进度及字体读取回调
|
||||
// 返回: any OFD数据及转换警告, error 错误信息
|
||||
@@ -819,53 +833,10 @@ func renderPage(args []js.Value) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
links := make([]any, len(page.Links))
|
||||
attachmentNames := make(map[string]string)
|
||||
for _, link := range page.Links {
|
||||
if link.Attachment == "" {
|
||||
continue
|
||||
}
|
||||
attachments, err := currentSession.Reader.Attachments()
|
||||
links, err := linkResults(page.Links)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, attachment := range attachments {
|
||||
attachmentNames[attachment.ID] = attachment.FileName()
|
||||
}
|
||||
break
|
||||
}
|
||||
for i, link := range page.Links {
|
||||
item := map[string]any{
|
||||
"uri": link.URI,
|
||||
"target": link.Target,
|
||||
"path": link.Path,
|
||||
"group": link.Group,
|
||||
"x": link.Box.X,
|
||||
"y": link.Box.Y,
|
||||
"width": link.Box.W,
|
||||
"height": link.Box.H,
|
||||
}
|
||||
if link.Attachment != "" {
|
||||
item["attachment"] = link.Attachment
|
||||
item["fileName"] = attachmentNames[link.Attachment]
|
||||
}
|
||||
if sound := link.Sound; sound != nil {
|
||||
volume := 100
|
||||
if sound.Volume != nil {
|
||||
volume = *sound.Volume
|
||||
}
|
||||
item["sound"] = map[string]any{"resourceID": sound.ResourceID, "volume": volume, "repeat": sound.Repeat, "synchronous": sound.Synchronous}
|
||||
}
|
||||
if movie := link.Movie; movie != nil {
|
||||
item["movie"] = map[string]any{"resourceID": movie.ResourceID, "operator": movie.Operator}
|
||||
}
|
||||
if dest := link.Dest; dest != nil {
|
||||
item["dest"] = map[string]any{"type": dest.Type, "pageID": dest.PageID, "left": dest.Left, "top": dest.Top,
|
||||
"right": dest.Right, "bottom": dest.Bottom, "zoom": dest.Zoom,
|
||||
"omitLeft": dest.OmitLeft, "omitTop": dest.OmitTop, "omitZoom": dest.OmitZoom}
|
||||
}
|
||||
links[i] = item
|
||||
}
|
||||
fonts := make([]any, len(page.Fonts))
|
||||
for i, font := range page.Fonts {
|
||||
fonts[i] = map[string]any{"name": font.Name, "weight": font.Weight, "style": font.Style}
|
||||
@@ -890,6 +861,106 @@ func renderPage(args []js.Value) (any, error) {
|
||||
}), nil
|
||||
}
|
||||
|
||||
// openLinks 获取文档或页面打开动作,不执行动作
|
||||
// 入参: args 可选页面索引,省略时读取文档打开动作
|
||||
// 返回: any 动作目标, error 文档或页码错误
|
||||
func openLinks(args []js.Value) (any, error) {
|
||||
if currentSession == nil {
|
||||
return nil, fmt.Errorf("ofd document is not opened")
|
||||
}
|
||||
var links []ofdgo.PageLink
|
||||
var err error
|
||||
if len(args) == 0 {
|
||||
links, err = currentSession.Renderer.DocumentOpenLinks()
|
||||
} else if len(args) == 1 {
|
||||
page, readErr := currentSession.Reader.PageContentByIndex(args[0].Int())
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
links, err = currentSession.Renderer.PageOpenLinks(page)
|
||||
} else {
|
||||
return nil, fmt.Errorf("unexpected open action arguments")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return linkResults(links)
|
||||
}
|
||||
|
||||
// outlineLinks 获取目录节点的有序动作目标
|
||||
// 入参: args 各级目录索引
|
||||
// 返回: any 动作目标, error 目录或文档错误
|
||||
func outlineLinks(args []js.Value) (any, error) {
|
||||
if currentSession == nil {
|
||||
return nil, fmt.Errorf("ofd document is not opened")
|
||||
}
|
||||
if len(args) != 1 {
|
||||
return nil, fmt.Errorf("missing outline path")
|
||||
}
|
||||
links, err := currentSession.Renderer.OutlineLinks(indexesFromJS(args[0]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return linkResults(links)
|
||||
}
|
||||
|
||||
// linkResults 将库层动作目标转换为浏览器数据
|
||||
// 入参: source 动作目标
|
||||
// 返回: []any 浏览器动作数据, error 附件读取错误
|
||||
func linkResults(source []ofdgo.PageLink) ([]any, error) {
|
||||
links := make([]any, len(source))
|
||||
attachmentNames := make(map[string]string)
|
||||
for _, link := range source {
|
||||
if link.Attachment == "" {
|
||||
continue
|
||||
}
|
||||
attachments, err := currentSession.Reader.Attachments()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, attachment := range attachments {
|
||||
attachmentNames[attachment.ID] = attachment.FileName()
|
||||
}
|
||||
break
|
||||
}
|
||||
for i, link := range source {
|
||||
item := map[string]any{
|
||||
"uri": link.URI,
|
||||
"target": link.Target,
|
||||
"path": link.Path,
|
||||
"group": link.Group,
|
||||
"x": link.Box.X,
|
||||
"y": link.Box.Y,
|
||||
"width": link.Box.W,
|
||||
"height": link.Box.H,
|
||||
}
|
||||
if link.Attachment != "" {
|
||||
item["attachment"] = link.Attachment
|
||||
item["fileName"] = attachmentNames[link.Attachment]
|
||||
}
|
||||
if sound := link.Sound; sound != nil {
|
||||
volume := 100
|
||||
if sound.Volume != nil {
|
||||
volume = *sound.Volume
|
||||
}
|
||||
item["sound"] = map[string]any{"resourceID": sound.ResourceID, "volume": volume, "repeat": sound.Repeat, "synchronous": sound.Synchronous}
|
||||
}
|
||||
if movie := link.Movie; movie != nil {
|
||||
item["movie"] = map[string]any{"resourceID": movie.ResourceID, "operator": movie.Operator}
|
||||
if box := link.PlaybackBox; box != nil {
|
||||
item["playbackBox"] = map[string]any{"x": box.X, "y": box.Y, "width": box.W, "height": box.H}
|
||||
}
|
||||
}
|
||||
if dest := link.Dest; dest != nil {
|
||||
item["dest"] = map[string]any{"type": dest.Type, "pageID": dest.PageID, "left": dest.Left, "top": dest.Top,
|
||||
"right": dest.Right, "bottom": dest.Bottom, "zoom": dest.Zoom,
|
||||
"omitLeft": dest.OmitLeft, "omitTop": dest.OmitTop, "omitZoom": dest.OmitZoom}
|
||||
}
|
||||
links[i] = item
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// pageTextString 提取OFD页面原文
|
||||
// 入参: args 页面索引
|
||||
// 返回: any 页面原文, error 错误信息
|
||||
|
||||
+36
-12
@@ -66,14 +66,17 @@ type URI struct {
|
||||
Target string `xml:"Target,attr,omitempty"`
|
||||
}
|
||||
|
||||
// PageLink 页面点击动作,包含外链、跳转、附件和音视频播放
|
||||
// PageLink 动作目标,包含外链、跳转、附件和音视频播放
|
||||
// Box使用页面毫米坐标,Path为可选的页面坐标SVG路径,存在时作为精确点击区域
|
||||
// 目录和打开动作的Box和Path为空,不限定启动区域
|
||||
// PlaybackBox为图元视频的播放区域,使用页面毫米坐标;非图元动作为空
|
||||
// 同一来源的多个动作共享非零Group,组内按返回顺序执行
|
||||
// NewWindow用于附件动作,为空时采用OFD默认的新窗口
|
||||
type PageLink struct {
|
||||
URI string
|
||||
Target string
|
||||
Box Box
|
||||
PlaybackBox *Box
|
||||
Dest *Dest
|
||||
Attachment string
|
||||
NewWindow *bool
|
||||
@@ -133,6 +136,7 @@ type actionSource struct {
|
||||
Actions []Action
|
||||
Matrix Matrix
|
||||
Path string
|
||||
Graphic bool
|
||||
}
|
||||
|
||||
// UnmarshalXML 解析跳转目标
|
||||
@@ -269,18 +273,39 @@ func gotoDest(action *Goto, bookmarks map[string]Dest) *Dest {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pageActionSources 获取页面动作来源
|
||||
// 入参: page 页面内容, box 页面区域
|
||||
// pageOpenActionSources 获取页面及模板的打开动作,保留模板层次和引用顺序
|
||||
// 入参: page 页面内容
|
||||
// 返回: []actionSource 动作来源
|
||||
func (r *Renderer) pageActionSources(page *PageContent, box Box) []actionSource {
|
||||
sources := make([]actionSource, 0)
|
||||
func (r *Renderer) pageOpenActionSources(page *PageContent) []actionSource {
|
||||
var sources []actionSource
|
||||
if len(page.Actions) > 0 {
|
||||
sources = append(sources, actionSource{
|
||||
Box: Box{W: box.W, H: box.H},
|
||||
Actions: page.Actions,
|
||||
Matrix: IdentityMatrix,
|
||||
})
|
||||
sources = append(sources, actionSource{Actions: page.Actions})
|
||||
}
|
||||
if r.Reader.doc == nil {
|
||||
return sources
|
||||
}
|
||||
for order := range 3 {
|
||||
for _, ref := range page.Template {
|
||||
kind := ref.ZOrder
|
||||
if kind == "" {
|
||||
kind = "Background"
|
||||
}
|
||||
if layerOrder(kind) != order {
|
||||
continue
|
||||
}
|
||||
if template := r.loadTemplate(ref.TemplateID); template != nil && len(template.Actions) > 0 {
|
||||
sources = append(sources, actionSource{Actions: template.Actions})
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
// pageActionSources 获取页面及模板内的图元动作来源
|
||||
// 入参: page 页面内容
|
||||
// 返回: []actionSource 动作来源
|
||||
func (r *Renderer) pageActionSources(page *PageContent) []actionSource {
|
||||
sources := make([]actionSource, 0)
|
||||
for order := range 3 {
|
||||
if r.Reader.doc != nil {
|
||||
for _, ref := range page.Template {
|
||||
@@ -292,7 +317,6 @@ func (r *Renderer) pageActionSources(page *PageContent, box Box) []actionSource
|
||||
continue
|
||||
}
|
||||
if template := r.loadTemplate(ref.TemplateID); template != nil {
|
||||
sources = append(sources, actionSource{Box: Box{W: box.W, H: box.H}, Actions: template.Actions, Matrix: IdentityMatrix})
|
||||
for templateOrder := range 3 {
|
||||
sources = r.appendLayerActionSources(sources, template.Content.Layer, templateOrder)
|
||||
}
|
||||
@@ -375,7 +399,7 @@ func (r *Renderer) appendGraphicActionSources(sources []actionSource, object Gra
|
||||
}
|
||||
matrix := placement.Multiply(NewMatrix(ctm))
|
||||
if len(actions) > 0 {
|
||||
source := actionSource{Box: placement.TransformBox(Box{W: box.W, H: box.H}), Actions: actions, Matrix: matrix}
|
||||
source := actionSource{Box: placement.TransformBox(Box{W: box.W, H: box.H}), Actions: actions, Matrix: matrix, Graphic: true}
|
||||
if placement.a == 1 && placement.b == 0 && placement.c == 0 && placement.d == 1 {
|
||||
source.Box.W, source.Box.H = box.W, box.H
|
||||
}
|
||||
|
||||
+6
-11
@@ -15,7 +15,6 @@
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -159,19 +158,15 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
|
||||
if err = progress("open", 0, 0); err != nil {
|
||||
return report, err
|
||||
}
|
||||
if source == nil || size <= 0 {
|
||||
return report, fmt.Errorf("empty document source")
|
||||
}
|
||||
source = convertReader{context: ctx, source: source}
|
||||
report.Input = strings.ToLower(strings.TrimSpace(options.Input))
|
||||
if report.Input == "" {
|
||||
var header [1024]byte
|
||||
n, readErr := source.ReadAt(header[:], 0)
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
return report, readErr
|
||||
}
|
||||
switch {
|
||||
case bytes.HasPrefix(header[:n], []byte("PK\x03\x04")):
|
||||
report.Input = "ofd"
|
||||
case bytes.Contains(header[:n], []byte("%PDF-")):
|
||||
report.Input = "pdf"
|
||||
report.Input, err = DetectFormat(source, size)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
}
|
||||
var reader *Reader
|
||||
|
||||
+2
-1
@@ -170,6 +170,7 @@ type OutlineInfo struct {
|
||||
Title string `json:"title"`
|
||||
Expanded bool `json:"expanded"`
|
||||
Page int `json:"page,omitempty"`
|
||||
ActionCount int `json:"actionCount,omitempty"`
|
||||
Children []OutlineInfo `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
@@ -337,7 +338,7 @@ func (p *Permissions) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
|
||||
func outlineInfos(outlines []OutlineElem, bookmarks map[string]Dest, pages map[string]int) []OutlineInfo {
|
||||
infos := make([]OutlineInfo, 0, len(outlines))
|
||||
for _, outline := range outlines {
|
||||
info := OutlineInfo{Title: outline.Title, Expanded: outline.Expanded}
|
||||
info := OutlineInfo{Title: outline.Title, Expanded: outline.Expanded, ActionCount: len(outline.Actions)}
|
||||
for _, action := range outline.Actions {
|
||||
if action.Goto != nil {
|
||||
if dest := gotoDest(action.Goto, bookmarks); dest != nil {
|
||||
|
||||
+1
-1
@@ -902,7 +902,7 @@ func (v *editorValidation) prepareObject(id string, object GraphicObject) (Graph
|
||||
stroke = (*FillColor)(defaults.StrokeColor)
|
||||
}
|
||||
}
|
||||
if err := validateObjectActions(actions); err != nil {
|
||||
if err := validateActionEvents(actions, "CLICK"); err != nil {
|
||||
return GraphicObject{}, err
|
||||
}
|
||||
if err := v.validateObjectClips(clips); err != nil {
|
||||
|
||||
+125
-5
@@ -18,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DocumentActions 获取文档打开动作的独立副本,不执行动作
|
||||
@@ -33,7 +34,7 @@ func (e *Editor) DocumentActions() []Action {
|
||||
// 入参: actions 事件为DO的动作及已注册的资源引用
|
||||
// 返回: error 结构、编码或提交错误
|
||||
func (e *Editor) SetDocumentActions(actions []Action) error {
|
||||
if err := validateLifecycleActions(actions, "DO"); err != nil {
|
||||
if err := validateActionEvents(actions, "DO"); err != nil {
|
||||
return err
|
||||
}
|
||||
current := e.DocumentActions()
|
||||
@@ -85,7 +86,7 @@ func (e *Editor) SetDocumentActions(actions []Action) error {
|
||||
// 入参: index 页面索引, actions 事件为PO的动作及已注册的资源引用
|
||||
// 返回: error 页码或动作错误
|
||||
func (e *Editor) SetPageActions(index int, actions []Action) error {
|
||||
if err := validateLifecycleActions(actions, "PO"); err != nil {
|
||||
if err := validateActionEvents(actions, "PO"); err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := e.page(index)
|
||||
@@ -104,16 +105,135 @@ func (e *Editor) SetPageActions(index int, actions []Action) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLifecycleActions 校验文档或页面动作的事件类型和动作结构
|
||||
// validateActionEvents 按动作归属校验事件类型和动作结构
|
||||
// 入参: actions 动作列表, event 允许的事件
|
||||
// 返回: error 事件或动作错误
|
||||
func validateLifecycleActions(actions []Action, event string) error {
|
||||
func validateActionEvents(actions []Action, event string) error {
|
||||
for _, action := range actions {
|
||||
if action.Event != event {
|
||||
return fmt.Errorf("expected %s action event", event)
|
||||
}
|
||||
}
|
||||
return validateObjectActions(actions)
|
||||
return validateActions(actions)
|
||||
}
|
||||
|
||||
// validateActions 校验动作结构,目标不必可达
|
||||
// 入参: actions 动作列表
|
||||
// 返回: error 错误信息
|
||||
func validateActions(actions []Action) error {
|
||||
for _, action := range actions {
|
||||
if !slices.Contains([]string{"CLICK", "DO", "PO"}, action.Event) {
|
||||
return fmt.Errorf("unsupported action event %q", action.Event)
|
||||
}
|
||||
count := 0
|
||||
for _, present := range []bool{action.URI != nil, action.Goto != nil, action.GotoA != nil, action.Sound != nil, action.Movie != nil} {
|
||||
if present {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("specify one action target")
|
||||
}
|
||||
if action.GotoA != nil && action.GotoA.AttachID == "" {
|
||||
return fmt.Errorf("attachment action requires an attachment ID")
|
||||
}
|
||||
if sound := action.Sound; sound != nil {
|
||||
if sound.ResourceID == "" || sound.Volume != nil && (*sound.Volume < 0 || *sound.Volume > 100) {
|
||||
return fmt.Errorf("invalid sound action resource or volume")
|
||||
}
|
||||
}
|
||||
if movie := action.Movie; movie != nil {
|
||||
if movie.ResourceID == "" || !slices.Contains([]string{"", "Play", "Stop", "Pause", "Resume"}, movie.Operator) {
|
||||
return fmt.Errorf("invalid movie action resource or operator")
|
||||
}
|
||||
}
|
||||
if action.Goto != nil {
|
||||
if (action.Goto.Dest == nil) == (action.Goto.Bookmark == nil) {
|
||||
return fmt.Errorf("specify one destination or bookmark")
|
||||
}
|
||||
if action.Goto.Dest != nil {
|
||||
if _, err := annotationLinkXML(AnnotationLink{Dest: action.Goto.Dest}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if action.Region != nil {
|
||||
if err := validateActionRegion(action.Region); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateActionRegion 校验动作区域,保留标准允许的曲线缺省值和圆弧异常处理
|
||||
// 入参: region 区域
|
||||
// 返回: error 路径或参数错误
|
||||
func validateActionRegion(region *Region) error {
|
||||
if len(region.Area) == 0 {
|
||||
return fmt.Errorf("action region requires an area")
|
||||
}
|
||||
for _, area := range region.Area {
|
||||
values, err := creationNumbers(area.Start, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current := Point{values[0], values[1]}
|
||||
start := current
|
||||
if len(area.Command) == 0 {
|
||||
return fmt.Errorf("action area requires a path command")
|
||||
}
|
||||
for _, command := range area.Command {
|
||||
var points []string
|
||||
switch command.Type {
|
||||
case "Move", "Line":
|
||||
points = []string{command.Point1}
|
||||
case "QuadraticBezier":
|
||||
points = []string{command.Point1, command.Point2}
|
||||
case "CubicBezier":
|
||||
for _, control := range []string{command.Point1, command.Point2} {
|
||||
if control != "" {
|
||||
points = append(points, control)
|
||||
}
|
||||
}
|
||||
points = append(points, command.Point3)
|
||||
case "Arc":
|
||||
if _, err := creationNumbers(command.EllipseSize, len(strings.Fields(command.EllipseSize))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := creationNumbers(command.RotationAngle, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, flag := range []string{command.LargeArc, command.SweepDirection} {
|
||||
if !slices.Contains([]string{"true", "false", "1", "0"}, flag) {
|
||||
return fmt.Errorf("invalid arc boolean %q", flag)
|
||||
}
|
||||
}
|
||||
points = []string{command.EndPoint}
|
||||
case "Close":
|
||||
current = start
|
||||
continue
|
||||
default:
|
||||
return fmt.Errorf("unsupported action region command %q", command.Type)
|
||||
}
|
||||
var end Point
|
||||
for _, point := range points {
|
||||
values, err := creationNumbers(point, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
end = Point{values[0], values[1]}
|
||||
}
|
||||
if command.Type == "Arc" && end == current {
|
||||
return fmt.Errorf("arc endpoint equals current point")
|
||||
}
|
||||
current = end
|
||||
if command.Type == "Move" {
|
||||
start = current
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// editorActionsXML 复用动作编码并补齐独立片段的命名空间,空列表不输出节点
|
||||
|
||||
+1
-71
@@ -100,7 +100,7 @@ func collectActionReferences(actions []Action, used map[string]bool) {
|
||||
// 入参: page 页面索引, ids 对象标识, actions 动作及已注册的资源引用
|
||||
// 返回: error 错误信息
|
||||
func (e *Editor) SetObjectActions(page int, ids []string, actions []Action) error {
|
||||
if err := validateObjectActions(actions); err != nil {
|
||||
if err := validateActionEvents(actions, "CLICK"); err != nil {
|
||||
return err
|
||||
}
|
||||
objects, _, err := e.selectedObjects(page, ids)
|
||||
@@ -286,76 +286,6 @@ func (e *Editor) UpdateAnnotationLink(page int, id string, target AnnotationLink
|
||||
})
|
||||
}
|
||||
|
||||
// validateObjectActions 校验新建对象的动作结构,目标不必可达
|
||||
// 入参: actions 动作列表
|
||||
// 返回: error 错误信息
|
||||
func validateObjectActions(actions []Action) error {
|
||||
for _, action := range actions {
|
||||
if !slices.Contains([]string{"CLICK", "DO", "PO"}, action.Event) {
|
||||
return fmt.Errorf("unsupported action event %q", action.Event)
|
||||
}
|
||||
count := 0
|
||||
for _, present := range []bool{action.URI != nil, action.Goto != nil, action.GotoA != nil, action.Sound != nil, action.Movie != nil} {
|
||||
if present {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("specify one action target")
|
||||
}
|
||||
if action.GotoA != nil && action.GotoA.AttachID == "" {
|
||||
return fmt.Errorf("attachment action requires an attachment ID")
|
||||
}
|
||||
if sound := action.Sound; sound != nil {
|
||||
if sound.ResourceID == "" || sound.Volume != nil && (*sound.Volume < 0 || *sound.Volume > 100) {
|
||||
return fmt.Errorf("invalid sound action resource or volume")
|
||||
}
|
||||
}
|
||||
if movie := action.Movie; movie != nil {
|
||||
if movie.ResourceID == "" || !slices.Contains([]string{"", "Play", "Stop", "Pause", "Resume"}, movie.Operator) {
|
||||
return fmt.Errorf("invalid movie action resource or operator")
|
||||
}
|
||||
}
|
||||
if action.Goto != nil {
|
||||
if (action.Goto.Dest == nil) == (action.Goto.Bookmark == nil) {
|
||||
return fmt.Errorf("specify one destination or bookmark")
|
||||
}
|
||||
if action.Goto.Dest != nil {
|
||||
if _, err := annotationLinkXML(AnnotationLink{Dest: action.Goto.Dest}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if action.Region != nil {
|
||||
for _, area := range action.Region.Area {
|
||||
if _, err := creationNumbers(area.Start, 2); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, command := range area.Command {
|
||||
var points []string
|
||||
switch command.Type {
|
||||
case "Line":
|
||||
points = []string{command.Point1}
|
||||
case "QuadraticBezier":
|
||||
points = []string{command.Point1, command.Point2}
|
||||
case "CubicBezier":
|
||||
points = []string{command.Point1, command.Point2, command.Point3}
|
||||
case "Close":
|
||||
default:
|
||||
return fmt.Errorf("unsupported new action region command %q", command.Type)
|
||||
}
|
||||
for _, point := range points {
|
||||
if _, err := creationNumbers(point, 2); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// annotationLinkNodes 获取外观内的点击跳转和基本对象,不进入私有扩展
|
||||
// 入参: root 注解根节点
|
||||
// 返回: []*editorXML 跳转目标, []*editorXML 基本对象
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
// AddMedia 注册音频或视频资源,保持原始编码,资源被动作引用后写入文档
|
||||
// 本方法不转码或验证媒体编解码器,播放能力由调用方提供
|
||||
// 相同类型、格式及内容复用标识;需要独立控制时可通过CopyMedia复制资源
|
||||
// 入参: kind 为Audio或Video, format 媒体格式,可为空, data 完整媒体文件
|
||||
// 返回: string 资源标识, error 无效类型或数据
|
||||
func (e *Editor) AddMedia(kind, format string, data []byte) (string, error) {
|
||||
@@ -46,3 +47,36 @@ func (e *Editor) AddMedia(kind, format string, data []byte) (string, error) {
|
||||
e.resourceID[key] = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// CopyMedia 复制音视频资源定义,共用原始文件,以新标识区分播放控制
|
||||
// 原页面资源须先通过Page读取;未被动作引用的副本不写入文档
|
||||
// 入参: id 音视频资源标识
|
||||
// 返回: string 新资源标识, error 资源缺失、类型或包结构错误
|
||||
func (e *Editor) CopyMedia(id string) (string, error) {
|
||||
var copy editorResource
|
||||
for _, resource := range e.resources {
|
||||
if resource.image != nil && resource.image.ID == id {
|
||||
copy = resource
|
||||
break
|
||||
}
|
||||
}
|
||||
if copy.image == nil && e.source != nil {
|
||||
media, err := e.source.reader.Media(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
media.MediaFile = "/" + cleanPackagePath(e.source.reader.ResPath(media.MediaFile))
|
||||
copy.image = &media
|
||||
}
|
||||
if copy.image == nil || copy.image.Type != "Audio" && copy.image.Type != "Video" {
|
||||
return "", fmt.Errorf("audio or video resource %q is unavailable", id)
|
||||
}
|
||||
if err := e.prepareSourceIDs(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
media := *copy.image
|
||||
media.ID = e.nextID()
|
||||
copy.image = &media
|
||||
e.resources = append(e.resources, copy)
|
||||
return media.ID, nil
|
||||
}
|
||||
@@ -174,6 +174,52 @@ func (e *Editor) UpdateOutline(path []int, title string, page int) error {
|
||||
return e.setOutlineXML(editorPatchXML(updated, []editorXMLPatch{{node.start, node.end, value}}))
|
||||
}
|
||||
|
||||
// SetOutlineActions 替换目录节点的动作序列,空列表清除动作,不改变子目录
|
||||
// 入参: path 从0开始的层级索引, actions 按激活顺序执行的动作
|
||||
// 返回: error 路径、动作或提交错误
|
||||
func (e *Editor) SetOutlineActions(path []int, actions []Action) error {
|
||||
if len(path) == 0 {
|
||||
return fmt.Errorf("outline path must not be empty")
|
||||
}
|
||||
if err := validateActions(actions); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := e.outlineXML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node, err := editorOutlineNode(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := editorActionsXML(actions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
context := node
|
||||
if existing := node.child("Actions"); existing != nil {
|
||||
context = existing
|
||||
}
|
||||
encoded, err = editorXMLGenerated(encoded, context.name.Space)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var patch editorXMLPatch
|
||||
if context != node {
|
||||
patch = editorXMLPatch{context.start, context.end, encoded}
|
||||
} else {
|
||||
if node.open != node.end {
|
||||
encoded = append(encoded, data[node.open:node.close]...)
|
||||
}
|
||||
patch = editorXMLContent(data, node, encoded)
|
||||
}
|
||||
return e.setOutlineXML(editorPatchXML(data, []editorXMLPatch{patch}))
|
||||
}
|
||||
|
||||
// DeleteOutline 删除目录节点及其子节点
|
||||
// 入参: path 从0开始的层级索引
|
||||
// 返回: error 错误信息
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// DetectFormat 按文件内容识别PDF或OFD,不读取压缩内容或关闭输入
|
||||
// 识别结果不代表文档完整有效,仍需通过对应阅读器解析
|
||||
// 入参: source 随机读取源, size 文件字节数
|
||||
// 返回: string 格式标识pdf或ofd, error 读取错误或不支持的格式
|
||||
func DetectFormat(source io.ReaderAt, size int64) (string, error) {
|
||||
if source == nil || size <= 0 {
|
||||
return "", fmt.Errorf("empty document source")
|
||||
}
|
||||
source = io.NewSectionReader(source, 0, size)
|
||||
var header [1024]byte
|
||||
data := header[:min(size, int64(len(header)))]
|
||||
n, err := source.ReadAt(data, 0)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
if n != len(data) {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
if bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||
return "pdf", nil
|
||||
}
|
||||
archive, err := zip.NewReader(source, size)
|
||||
if err == nil {
|
||||
reader := &Reader{Zip: archive}
|
||||
if err := reader.indexPackage(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reader.fileIndexFold["ofd.xml"] != nil || reader.fileIndexFold["encryptions.xml"] != nil {
|
||||
return "ofd", nil
|
||||
}
|
||||
} else {
|
||||
if !errors.Is(err, zip.ErrFormat) {
|
||||
return "", err
|
||||
}
|
||||
if bytes.Contains(data, []byte("%PDF-")) {
|
||||
return "pdf", nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unsupported input format")
|
||||
}
|
||||
@@ -91,6 +91,7 @@ type pdfImporter struct {
|
||||
formResources *pdfFormCache
|
||||
validation *editorValidation
|
||||
pages map[pdfgo.Reference]*pdfgo.Page
|
||||
pageOrder []*pdfgo.Page
|
||||
pageIDs map[pdfgo.Reference]string
|
||||
pageIndexes map[pdfgo.Reference]int
|
||||
destinationBoxes map[pdfgo.Reference]Box
|
||||
@@ -98,6 +99,10 @@ type pdfImporter struct {
|
||||
imageContents *renderCache[pdfImageContentKey, pdfImageContent]
|
||||
attachmentIDs map[pdfAttachmentKey]string
|
||||
modelAttachments map[*pdfgo.Stream]string
|
||||
mediaResources map[pdfMediaFileKey]pdfMediaResource
|
||||
mediaInstances map[pdfMediaInstanceKey]pdfMediaResource
|
||||
movieBounds map[*Movie]Box
|
||||
renditions *pdfRenditionImport
|
||||
maskClips map[*pdfgo.SoftMask]pdfgo.Path
|
||||
pageBox pdfgo.Rectangle
|
||||
pageWidth float64
|
||||
@@ -203,6 +208,7 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI
|
||||
return err
|
||||
}
|
||||
importer.pages[page.Reference] = page
|
||||
importer.pageOrder = append(importer.pageOrder, page)
|
||||
importer.pageIDs[page.Reference] = editor.pages[index].ID
|
||||
importer.pageIndexes[page.Reference] = index
|
||||
if options.OnProgress != nil {
|
||||
@@ -215,6 +221,9 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI
|
||||
}
|
||||
if len(editor.pages) != 0 {
|
||||
importer.page = -1
|
||||
if err := importer.documentAttachments(ctx); err != nil {
|
||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF attachments: %w", err)
|
||||
}
|
||||
if err := importer.outlines(ctx); err != nil {
|
||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF outlines: %w", err)
|
||||
}
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ func (p *pdfImporter) threeDPainter(ctx context.Context, page *pdfgo.Page, sourc
|
||||
var err error
|
||||
overlay, err = p.collectCompositeNodes(func(visitor pdfgo.Visitor) error {
|
||||
return p.reader.WalkThreeDOverlay(ctx, page, source, visitor)
|
||||
})
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func (p *pdfImporter) documentOpenActions(ctx context.Context) error {
|
||||
object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": array}
|
||||
}
|
||||
currentPage := 0
|
||||
actions, err := p.eventActions(ctx, object, "DO", ¤tPage)
|
||||
actions, err := p.eventActions(ctx, object, "DO", pdfgo.Annotation{}, ¤tPage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func (p *pdfImporter) pageOpenActions(ctx context.Context, page *pdfgo.Page) err
|
||||
}
|
||||
continue
|
||||
}
|
||||
actions, err := p.eventActions(ctx, trigger.Action, "PO", &p.pageActionPage)
|
||||
actions, err := p.eventActions(ctx, trigger.Action, "PO", pdfgo.Annotation{}, &p.pageActionPage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -92,14 +92,18 @@ func (p *pdfImporter) pageOpenActions(ctx context.Context, page *pdfgo.Page) err
|
||||
}
|
||||
|
||||
// eventActions 按原顺序转换动作链,保留事件类型与链内跳转页状态
|
||||
// 入参: ctx 取消上下文, object 动作对象, event OFD事件, currentPage 当前页面
|
||||
// 入参: ctx 取消上下文, object 动作对象, event OFD事件, source 触发注解,零值表示非图元动作, currentPage 当前页面
|
||||
// 返回: []Action 转换动作, error 动作或取消错误
|
||||
func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, event string, currentPage *int) ([]Action, error) {
|
||||
func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, event string, source pdfgo.Annotation, currentPage *int) ([]Action, error) {
|
||||
var actions []Action
|
||||
err := p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error {
|
||||
annotation := pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}
|
||||
action, err := p.linkAction(annotation, p.warning == nil, currentPage)
|
||||
annotation := source
|
||||
annotation.Dictionary = pdfgo.Dictionary{"A": info.Dictionary}
|
||||
converted, err := p.linkActions(annotation, p.warning == nil, currentPage)
|
||||
if err != nil {
|
||||
if errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
var unsupported *pdfgo.UnsupportedError
|
||||
if event != "CLICK" && p.warning != nil && errors.Is(err, pdfgo.ErrInvalidAction) {
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF %s action ignored: %v", event, err)})
|
||||
@@ -115,9 +119,9 @@ func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, eve
|
||||
}
|
||||
return err
|
||||
}
|
||||
if action != nil {
|
||||
for _, action := range converted {
|
||||
action.Event = event
|
||||
actions = append(actions, *action)
|
||||
actions = append(actions, action)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
+32
-41
@@ -21,6 +21,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
@@ -37,7 +38,7 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page
|
||||
// 入参: ctx 取消上下文, page PDF页面, annotation PDF注解, paint 绘制回调,空值使用原外观, actions 外观区域的动作
|
||||
// 返回: error 外观或转换错误
|
||||
func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, paint func(*pdfImporter) error, actions ...Action) error {
|
||||
clicks, opened, err := p.annotationTriggerActions(ctx, annotation)
|
||||
clicks, err := p.annotationTriggerActions(ctx, annotation, "CLICK")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,7 +57,7 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
|
||||
return &pdfgo.UnsupportedError{Feature: fmt.Sprintf("annotation field %q", key)}
|
||||
}
|
||||
}
|
||||
remark, err := p.reader.ReadAnnotationText(annotation, "Contents", p.warning)
|
||||
remark, err := p.reader.ReadAnnotationTextContext(ctx, annotation, "Contents", p.warning)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -91,6 +92,15 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
|
||||
}
|
||||
}
|
||||
actions = append(actions, clicks...)
|
||||
playback, err := p.annotationMovieBounds(actions, box, int64(flags))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source := box
|
||||
if playback != source {
|
||||
x, y := math.Min(source.X, playback.X), math.Min(source.Y, playback.Y)
|
||||
box = Box{X: x, Y: y, W: math.Max(source.X+source.W, playback.X+playback.W) - x, H: math.Max(source.Y+source.H, playback.Y+playback.H) - y}
|
||||
}
|
||||
stamp := *p
|
||||
stamp.matrix = (pdfgo.Matrix{1, 0, 0, 1, -box.X, -box.Y}).Mul(p.matrix)
|
||||
stamp.objects = nil
|
||||
@@ -105,23 +115,8 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
|
||||
}
|
||||
} else {
|
||||
nodes, err := p.collectCompositeNodes(func(visitor pdfgo.Visitor) error {
|
||||
if annotation.Subtype == "Widget" {
|
||||
visitor.MarkedContent = func(mark pdfgo.MarkedContentMark) error {
|
||||
if mark.Tag == "Tx" && len(mark.Properties) == 0 {
|
||||
return nil
|
||||
}
|
||||
if mark.Operator == "EMC" {
|
||||
return nil
|
||||
}
|
||||
if p.warning == nil {
|
||||
return &pdfgo.UnsupportedError{Feature: "widget marked content " + string(mark.Tag)}
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF widget marked content " + string(mark.Tag) + " not preserved"})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return p.reader.WalkAnnotationAppearance(ctx, page, annotation, visitor)
|
||||
})
|
||||
}, annotation.Subtype == "Widget")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -184,11 +179,23 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
|
||||
}...)
|
||||
}
|
||||
if len(actions) != 0 {
|
||||
region, err := NewShape(ShapeRectangle, Box{W: box.W, H: box.H})
|
||||
region, err := NewShape(ShapeRectangle, Box{X: playback.X - box.X, Y: playback.Y - box.Y, W: playback.W, H: playback.H})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
no := false
|
||||
if playback != source {
|
||||
region.CTM = pdfNumbers(1, 0, 0, 1, source.X-playback.X, source.Y-playback.Y)
|
||||
for i := range actions {
|
||||
if actions[i].Region == nil {
|
||||
actions[i].Region = &Region{Area: []RegionArea{{Start: "0 0", Command: []RegionCommand{
|
||||
{Type: "Line", Point1: pdfNumbers(source.W, 0)},
|
||||
{Type: "Line", Point1: pdfNumbers(source.W, source.H)},
|
||||
{Type: "Line", Point1: pdfNumbers(0, source.H)}, {Type: "Close"},
|
||||
}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
region.Fill, region.Stroke, region.Actions = &no, &no, actions
|
||||
converted.Appearance.Objects = append(converted.Appearance.Objects, GraphicObject{Type: "PathObject", PathObject: region})
|
||||
}
|
||||
@@ -198,11 +205,11 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
|
||||
}
|
||||
converted.NoZoom = flags&8 != 0 || annotation.Subtype == "Text"
|
||||
converted.NoRotate = flags&16 != 0 || annotation.Subtype == "Text"
|
||||
converted.Creator, err = p.reader.ReadAnnotationText(annotation, "T", p.warning)
|
||||
converted.Creator, err = p.reader.ReadAnnotationTextContext(ctx, annotation, "T", p.warning)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modified, err := p.reader.ReadAnnotationText(annotation, "M", p.warning)
|
||||
modified, err := p.reader.ReadAnnotationTextContext(ctx, annotation, "M", p.warning)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -220,9 +227,6 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
|
||||
return fmt.Errorf("PDF annotation: %w", err)
|
||||
}
|
||||
p.annotationData = append(p.annotationData, bytes.TrimPrefix(data, []byte(xml.Header))...)
|
||||
if len(opened) != 0 {
|
||||
p.editor.pages[p.page].Actions = append(p.editor.pages[p.page].Actions, opened...)
|
||||
}
|
||||
if len(actions) != 0 && annotation.Subtype == "Link" || len(clicks) != 0 {
|
||||
p.report.Links++
|
||||
}
|
||||
@@ -260,15 +264,11 @@ func (p *pdfImporter) formWidget(ctx context.Context, page *pdfgo.Page, annotati
|
||||
if strict {
|
||||
return &pdfgo.UnsupportedError{Feature: "interactive PDF form conversion"}
|
||||
}
|
||||
value, err := p.reader.Resolve(annotation.Dictionary["F"])
|
||||
flags, err := p.reader.ReadAnnotationFlags(annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flags, ok := value.(pdfgo.Integer)
|
||||
if value != nil && !ok {
|
||||
return fmt.Errorf("invalid PDF widget flags")
|
||||
}
|
||||
if flags&(1|2|32) != 0 {
|
||||
if flags&(2|32) != 0 {
|
||||
p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "hidden PDF form field not transferred"})
|
||||
return nil
|
||||
}
|
||||
@@ -289,20 +289,11 @@ func (p *pdfImporter) signatureWidget(ctx context.Context, page *pdfgo.Page, ann
|
||||
if annotation.Dictionary["FT"] != pdfgo.Name("Sig") || annotation.Dictionary["V"] == nil {
|
||||
return &pdfgo.UnsupportedError{Feature: "non-signature widget"}
|
||||
}
|
||||
flags := int64(0)
|
||||
if value := annotation.Dictionary["F"]; value != nil {
|
||||
resolved, err := p.reader.Resolve(value)
|
||||
flags, err := p.reader.ReadAnnotationFlags(annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
number, ok := resolved.(pdfgo.Integer)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid PDF annotation flags")
|
||||
}
|
||||
flags = int64(number)
|
||||
}
|
||||
const invisibleFlags = 1 | 2 | 32
|
||||
if flags&invisibleFlags != 0 {
|
||||
if flags&(2|32) != 0 {
|
||||
p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "hidden PDF signature not transferred"})
|
||||
return nil
|
||||
}
|
||||
|
||||
+35
-26
@@ -21,55 +21,64 @@ import (
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// annotationTriggerActions 将释放点击和页面打开事件转换为OFD,不改变其他事件的触发条件
|
||||
// 入参: ctx 取消上下文, annotation PDF注解
|
||||
// 返回: []Action 点击动作, []Action 页面打开动作, error 事件、动作或取消错误
|
||||
func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation pdfgo.Annotation) ([]Action, []Action, error) {
|
||||
if annotation.Subtype == "Widget" {
|
||||
return nil, nil, nil
|
||||
// annotationTriggerActions 按事件转换注解动作,页面打开动作独立于注解外观
|
||||
// 入参: ctx 取消上下文, annotation PDF注解, event OFD事件
|
||||
// 返回: []Action 动作序列, error 事件、动作或取消错误
|
||||
func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation pdfgo.Annotation, event string) ([]Action, error) {
|
||||
if annotation.Subtype == "Widget" && event == "CLICK" {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
triggers, err := p.reader.ReadAnnotationTriggers(ctx, annotation)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
if len(triggers) == 0 {
|
||||
return nil, nil, nil
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
flags, err := p.reader.ReadAnnotationFlags(annotation)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
var clicks, opened []Action
|
||||
var converted []Action
|
||||
for _, trigger := range triggers {
|
||||
if trigger.Event == "PC" || trigger.Event == "PV" || trigger.Event == "PI" {
|
||||
if event == "PO" {
|
||||
if err := p.unmappedTrigger(ctx, "annotation", trigger); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (trigger.Event == "PO") != (event == "PO") {
|
||||
continue
|
||||
}
|
||||
if flags&64 != 0 && (trigger.Event == "E" || trigger.Event == "X" || trigger.Event == "D" || trigger.Event == "U") {
|
||||
continue
|
||||
}
|
||||
event := ""
|
||||
switch trigger.Event {
|
||||
case "U":
|
||||
event = "CLICK"
|
||||
case "PO":
|
||||
event = "PO"
|
||||
case "U", "PO":
|
||||
default:
|
||||
return nil, nil, &pdfgo.UnsupportedError{Feature: "annotation trigger " + string(trigger.Event)}
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "annotation trigger " + string(trigger.Event)}
|
||||
}
|
||||
currentPage := p.page
|
||||
if event == "PO" {
|
||||
currentPage = p.pageActionPage
|
||||
}
|
||||
actions, err := p.eventActions(ctx, trigger.Action, event, ¤tPage)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var source pdfgo.Annotation
|
||||
if event == "CLICK" {
|
||||
clicks = append(clicks, actions...)
|
||||
} else {
|
||||
source = annotation
|
||||
}
|
||||
actions, err := p.eventActions(ctx, trigger.Action, event, source, ¤tPage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
converted = append(converted, actions...)
|
||||
if event == "PO" {
|
||||
p.pageActionPage = currentPage
|
||||
opened = append(opened, actions...)
|
||||
}
|
||||
}
|
||||
if len(opened) != 0 && (p.page < 0 || p.page >= len(p.editor.pages)) {
|
||||
return nil, nil, fmt.Errorf("invalid annotation trigger page")
|
||||
if event == "PO" && len(converted) != 0 && (p.page < 0 || p.page >= len(p.editor.pages)) {
|
||||
return nil, fmt.Errorf("invalid annotation trigger page")
|
||||
}
|
||||
return clicks, opened, ctx.Err()
|
||||
return converted, ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// documentAttachments 导入文档名称树中的附件,与注解及动作引用共用附件资源
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 名称树、附件读取或写入错误
|
||||
func (p *pdfImporter) documentAttachments(ctx context.Context) error {
|
||||
return p.reader.WalkEmbeddedFiles(ctx, func(_ string, file pdfgo.FileSpecification) error {
|
||||
if file.Embedded == nil {
|
||||
if p.warning == nil {
|
||||
return fmt.Errorf("PDF embedded file has no embedded data")
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF embedded file without embedded data ignored"})
|
||||
return nil
|
||||
}
|
||||
data, err := file.Embedded.DecodeContext(ctx)
|
||||
if err != nil {
|
||||
var unsupported *pdfgo.UnsupportedError
|
||||
if p.warning != nil && errors.As(err, &unsupported) {
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF document attachment not transferred to OFD: " + err.Error()})
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err = p.attachmentFile(file.Name, data)
|
||||
return err
|
||||
})
|
||||
}
|
||||
+34
-9
@@ -367,14 +367,15 @@ func (p *pdfImporter) compositeTextBounds(mark pdfgo.TextMark, stroke bool) Box
|
||||
}
|
||||
|
||||
// collectCompositeNodes 收集原始绘制顺序并展开Type3字形,保留透明组层级
|
||||
// 入参: walk 内容访问函数
|
||||
// 入参: walk 内容访问函数, widget 是否为表单外观
|
||||
// 返回: []pdfCompositeNode 原始图元, error 解析错误
|
||||
func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]pdfCompositeNode, error) {
|
||||
func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error, widget bool) ([]pdfCompositeNode, error) {
|
||||
active := make(map[*pdfgo.Font]map[string]bool)
|
||||
var seenFonts map[*pdfgo.Font]bool
|
||||
var externalNames map[string]bool
|
||||
var embeddedFonts map[*pdfgo.Font]bool
|
||||
var textMarks []pdfgo.TextMark
|
||||
replacements := pdfTextReplacements{importer: p, widget: widget}
|
||||
storeText := func(mark pdfgo.TextMark) *pdfgo.TextMark {
|
||||
if len(textMarks) == cap(textMarks) {
|
||||
textMarks = make([]pdfgo.TextMark, 0, min(64, max(8, cap(textMarks)*2)))
|
||||
@@ -382,11 +383,28 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p
|
||||
textMarks = append(textMarks, mark)
|
||||
return &textMarks[len(textMarks)-1]
|
||||
}
|
||||
var collect func(func(pdfgo.Visitor) error) ([]pdfCompositeNode, error)
|
||||
collect = func(walk func(pdfgo.Visitor) error) ([]pdfCompositeNode, error) {
|
||||
var collect func(func(pdfgo.Visitor) error, *pdfgo.ReplacementText) ([]pdfCompositeNode, error)
|
||||
collect = func(walk func(pdfgo.Visitor) error, replacement *pdfgo.ReplacementText) ([]pdfCompositeNode, error) {
|
||||
var nodes []pdfCompositeNode
|
||||
v := pdfgo.Visitor{Warning: p.warning, Reference: p.referencePage, Halftones: p.halftones}
|
||||
v.MarkedContent = func(mark pdfgo.MarkedContentMark) error {
|
||||
if mark.Replacement != nil {
|
||||
if mark.Operator == "EMC" {
|
||||
replacement = mark.Replacement.Parent
|
||||
} else {
|
||||
replacement = mark.Replacement
|
||||
if !mark.Hidden {
|
||||
span := replacements.span(replacement)
|
||||
if replacement.Parent == nil {
|
||||
span.offset = mark.Offset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return replacements.markedContent(mark)
|
||||
}
|
||||
v.Path = func(mark pdfgo.PathMark) error {
|
||||
replacements.block(replacement)
|
||||
if err := p.halftone(mark.Style); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -394,6 +412,7 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p
|
||||
return nil
|
||||
}
|
||||
v.Text = func(mark pdfgo.TextMark) error {
|
||||
replacements.span(mark.Replacement)
|
||||
if mark.Font.Subtype != "Type3" && !seenFonts[mark.Font] {
|
||||
if seenFonts == nil {
|
||||
seenFonts = make(map[*pdfgo.Font]bool)
|
||||
@@ -448,7 +467,8 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p
|
||||
}
|
||||
glyphs[glyph.Name] = true
|
||||
source := mark
|
||||
children, err := collect(func(visitor pdfgo.Visitor) error { return p.reader.WalkType3Glyph(p.ctx, source, index, visitor) })
|
||||
replacements.block(mark.Replacement)
|
||||
children, err := collect(func(visitor pdfgo.Visitor) error { return p.reader.WalkType3Glyph(p.ctx, source, index, visitor) }, replacement)
|
||||
delete(glyphs, glyph.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -461,6 +481,7 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p
|
||||
return nil
|
||||
}
|
||||
v.Image = func(mark pdfgo.ImageMark) error {
|
||||
replacements.block(replacement)
|
||||
if err := p.halftone(mark.Style); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -468,7 +489,7 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p
|
||||
return nil
|
||||
}
|
||||
v.Group = func(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error {
|
||||
children, err := collect(walk)
|
||||
children, err := collect(walk, replacement)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -476,7 +497,7 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p
|
||||
return nil
|
||||
}
|
||||
v.Form = func(_ pdfgo.FormMark, walk func(pdfgo.Visitor) error) error {
|
||||
children, err := collect(walk)
|
||||
children, err := collect(walk, replacement)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -489,7 +510,11 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p
|
||||
err := walk(v)
|
||||
return nodes, err
|
||||
}
|
||||
nodes, err := collect(walk)
|
||||
nodes, err := collect(walk, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err = replacements.apply(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -511,7 +536,7 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p
|
||||
// 入参: space 页面混合空间, walk 页面内容
|
||||
// 返回: error 转换错误
|
||||
func (p *pdfImporter) compositePage(space *pdfgo.ColorSpace, walk func(pdfgo.Visitor) error) error {
|
||||
nodes, err := p.collectCompositeNodes(walk)
|
||||
nodes, err := p.collectCompositeNodes(walk, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -701,7 +701,7 @@ func (c *pdfCompositor) mask(mask *pdfgo.SoftMask, inherited *pdfgo.ColorSpace)
|
||||
nodes, ok := c.cache.masks[mask]
|
||||
var err error
|
||||
if !ok {
|
||||
nodes, err = c.importer.collectCompositeNodes(func(v pdfgo.Visitor) error { return mask.WalkContext(c.importer.ctx, v) })
|
||||
nodes, err = c.importer.collectCompositeNodes(func(v pdfgo.Visitor) error { return mask.WalkContext(c.importer.ctx, v) }, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ func (p *pdfImporter) compositePattern(paint pdfgo.Paint) (*pdfCompositePattern,
|
||||
return pattern, nil
|
||||
}
|
||||
source := paint.Tiling
|
||||
nodes, err := p.collectCompositeNodes(func(v pdfgo.Visitor) error { return source.Walk(p.ctx, paint, v) })
|
||||
nodes, err := p.collectCompositeNodes(func(v pdfgo.Visitor) error { return source.Walk(p.ctx, paint, v) }, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (p *pdfImporter) compositeShadingPattern(pattern *pdfgo.ShadingPattern) ([]
|
||||
})
|
||||
nodes, err := p.collectCompositeNodes(func(visitor pdfgo.Visitor) error {
|
||||
return pattern.Walk(p.ctx, pdfgo.Rectangle{XMin: box.X, YMin: box.Y, XMax: box.X + box.W, YMax: box.Y + box.H}, visitor)
|
||||
})
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+39
-9
@@ -27,6 +27,7 @@ import (
|
||||
// 入参: ctx 取消上下文, page PDF页面, strict 严格检查开关
|
||||
// 返回: error 错误信息
|
||||
func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict bool) error {
|
||||
p.movieBounds = nil
|
||||
annotations, err := page.AnnotationsContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -41,6 +42,13 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
opened, err := p.annotationTriggerActions(ctx, annotation, "PO")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(opened) != 0 {
|
||||
p.editor.pages[p.page].Actions = append(p.editor.pages[p.page].Actions, opened...)
|
||||
}
|
||||
if annotation.Reference != (pdfgo.Reference{}) && annotation.Subtype != "Popup" {
|
||||
parents[annotation.Reference] = i
|
||||
}
|
||||
@@ -167,7 +175,7 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
|
||||
continue
|
||||
}
|
||||
if annotation.Subtype == "Movie" {
|
||||
if err := p.movieAnnotation(ctx, page, annotation); err != nil {
|
||||
if err := p.movieAnnotation(ctx, page, annotation, strict); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
@@ -265,13 +273,11 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
|
||||
appendAction := func(dict pdfgo.Dictionary) error {
|
||||
current := annotation
|
||||
current.Dictionary = dict
|
||||
action, err := p.linkAction(current, strict, ¤tPage)
|
||||
converted, err := p.linkActions(current, strict, ¤tPage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if action != nil {
|
||||
actions = append(actions, *action)
|
||||
}
|
||||
actions = append(actions, converted...)
|
||||
return nil
|
||||
}
|
||||
if primary == nil {
|
||||
@@ -348,7 +354,31 @@ func (p *pdfImporter) linkRegion(annotation pdfgo.Annotation, box Box) (*Region,
|
||||
return region, nil
|
||||
}
|
||||
|
||||
// linkAction 将单个链接动作转换为OFD动作,失效目标按导入策略报告
|
||||
// linkActions 将单个PDF动作转换为有序OFD动作,保留动态媒体控制的目标范围
|
||||
// 入参: annotation 触发注解, strict 严格检查开关, currentPage 动作链当前页
|
||||
// 返回: []Action 动作序列,空切片表示无操作,nil表示未转换, error 转换错误
|
||||
func (p *pdfImporter) linkActions(annotation pdfgo.Annotation, strict bool, currentPage *int) ([]Action, error) {
|
||||
value, err := p.reader.Resolve(annotation.Dictionary["A"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dict, ok := value.(pdfgo.Dictionary); ok {
|
||||
kind, err := p.reader.Resolve(dict["S"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind == pdfgo.Name("Rendition") {
|
||||
return p.renditionLinkActions(dict, annotation, strict, *currentPage)
|
||||
}
|
||||
}
|
||||
action, err := p.linkAction(annotation, strict, currentPage)
|
||||
if err != nil || action == nil {
|
||||
return nil, err
|
||||
}
|
||||
return []Action{*action}, nil
|
||||
}
|
||||
|
||||
// linkAction 将单个非呈现动作转换为OFD动作,失效目标按导入策略报告
|
||||
// 入参: annotation 链接注解, strict 严格检查开关, currentPage 动作链当前页
|
||||
// 返回: *Action 动作,失效目标为空, error 转换错误
|
||||
func (p *pdfImporter) linkAction(annotation pdfgo.Annotation, strict bool, currentPage *int) (*Action, error) {
|
||||
@@ -383,7 +413,9 @@ func (p *pdfImporter) linkAction(annotation pdfgo.Annotation, strict bool, curre
|
||||
return p.launchLinkAction(a, strict)
|
||||
case pdfgo.Name("Sound"):
|
||||
return p.soundLinkAction(a, strict)
|
||||
case pdfgo.Name("JavaScript"), pdfgo.Name("ResetForm"), pdfgo.Name("ImportData"), pdfgo.Name("Hide"), pdfgo.Name("Movie"):
|
||||
case pdfgo.Name("Movie"):
|
||||
return p.movieLinkAction(a, annotation, strict, *currentPage)
|
||||
case pdfgo.Name("JavaScript"), pdfgo.Name("ResetForm"), pdfgo.Name("ImportData"), pdfgo.Name("Hide"):
|
||||
return p.staticLinkAction(a, kind.(pdfgo.Name), strict)
|
||||
case pdfgo.Name("GoToE"):
|
||||
converted, local, err := p.embeddedLinkAction(a, strict)
|
||||
@@ -528,8 +560,6 @@ func (p *pdfImporter) staticLinkAction(object pdfgo.Object, kind pdfgo.Name, str
|
||||
_, err = p.reader.ReadImportDataAction(p.ctx, object)
|
||||
case "Hide":
|
||||
_, err = p.reader.ReadHideAction(p.ctx, object)
|
||||
case "Movie":
|
||||
_, err = p.reader.ReadMovieAction(p.ctx, object)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+110
-48
@@ -23,11 +23,101 @@ import (
|
||||
"math"
|
||||
"net/url"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// errPDFMediaRead 标识媒体读取失败,避免作为可恢复的导航问题忽略
|
||||
var errPDFMediaRead = errors.New("PDF media read failed")
|
||||
|
||||
// pdfMediaFileKey 区分媒体文件及注册用途,避免重复解码或调用文件解析器
|
||||
type pdfMediaFileKey struct {
|
||||
Stream *pdfgo.Stream
|
||||
Name string
|
||||
System pdfgo.Name
|
||||
Specification uintptr
|
||||
Attachment bool
|
||||
Kind string
|
||||
Format string
|
||||
}
|
||||
|
||||
// pdfMediaResource 保留资源标识和外部文件说明,避免缓存期间字典地址被复用
|
||||
type pdfMediaResource struct {
|
||||
ID string
|
||||
Dictionary pdfgo.Dictionary
|
||||
}
|
||||
|
||||
// pdfMediaInstanceKey 区分播放目标与资源文件,避免不同注解共用控制状态
|
||||
type pdfMediaInstanceKey struct {
|
||||
Page int
|
||||
Reference pdfgo.Reference
|
||||
Dictionary uintptr
|
||||
Resource string
|
||||
}
|
||||
|
||||
// mediaInstance 为播放目标分配独立标识,同一目标的动作继续共用控制状态
|
||||
// 入参: id 已注册的媒体资源, target 目标注解, page 所属页面索引
|
||||
// 返回: string 独立资源标识, error 资源复制或取消错误
|
||||
func (p *pdfImporter) mediaInstance(id string, target pdfgo.Annotation, page int) (string, error) {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := pdfMediaInstanceKey{Page: page, Reference: target.Reference, Resource: id}
|
||||
if key.Reference == (pdfgo.Reference{}) {
|
||||
key.Dictionary = reflect.ValueOf(target.Dictionary).Pointer()
|
||||
}
|
||||
if resource, exists := p.mediaInstances[key]; exists {
|
||||
return resource.ID, nil
|
||||
}
|
||||
copy, err := p.editor.CopyMedia(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if p.mediaInstances == nil {
|
||||
p.mediaInstances = make(map[pdfMediaInstanceKey]pdfMediaResource)
|
||||
}
|
||||
p.mediaInstances[key] = pdfMediaResource{ID: copy, Dictionary: target.Dictionary}
|
||||
return copy, nil
|
||||
}
|
||||
|
||||
// mediaResource 复用媒体文件的注册结果,区分格式、类别及附件用途
|
||||
// 入参: ctx 取消上下文, file 文件说明, kind 媒体类别, format 文件格式, attachment 是否作为附件保存
|
||||
// 返回: string 资源标识,外部资源不可用时为空, error 读取、注册或取消错误
|
||||
func (p *pdfImporter) mediaResource(ctx context.Context, file pdfgo.FileSpecification, kind, format string, attachment bool) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := pdfMediaFileKey{Stream: file.Embedded, Name: file.Name, System: file.FileSystem, Attachment: attachment, Kind: kind, Format: format}
|
||||
if file.Embedded == nil && file.Dictionary != nil {
|
||||
key.Specification = reflect.ValueOf(file.Dictionary).Pointer()
|
||||
}
|
||||
if resource, exists := p.mediaResources[key]; exists {
|
||||
return resource.ID, nil
|
||||
}
|
||||
data, available, err := p.mediaData(ctx, file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var id string
|
||||
if available {
|
||||
if attachment {
|
||||
id, err = p.attachmentFile(file.Name, data)
|
||||
} else {
|
||||
id, err = p.editor.AddMedia(kind, format, data)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if p.mediaResources == nil {
|
||||
p.mediaResources = make(map[pdfMediaFileKey]pdfMediaResource)
|
||||
}
|
||||
p.mediaResources[key] = pdfMediaResource{ID: id, Dictionary: file.Dictionary}
|
||||
return id, ctx.Err()
|
||||
}
|
||||
|
||||
// soundResource 注册采样音频或调用方提供的外部音频,不自动访问文件
|
||||
// 入参: ctx 取消上下文, sound 音频对象
|
||||
// 返回: string 资源标识,不可用时为空, error 读取或封装错误
|
||||
@@ -46,7 +136,7 @@ func (p *pdfImporter) soundResource(ctx context.Context, sound pdfgo.Sound) (str
|
||||
}
|
||||
format = pdfMediaFormat(*sound.File)
|
||||
} else {
|
||||
data, err = pdfSoundWAV(sound)
|
||||
data, err = pdfSoundWAV(ctx, sound)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -90,47 +180,6 @@ func (p *pdfImporter) soundLinkAction(object pdfgo.Object, strict bool) (*Action
|
||||
return &Action{Event: "CLICK", Sound: &Sound{ResourceID: id, Volume: &volume, Repeat: source.Repeat, Synchronous: source.Synchronous}}, nil
|
||||
}
|
||||
|
||||
// movieAnnotation 转换内嵌视频及其点击播放动作,保留独立海报外观
|
||||
// 入参: ctx 取消上下文, page PDF页面, annotation 视频注解
|
||||
// 返回: error 媒体资源或播放参数无法表示时返回错误
|
||||
func (p *pdfImporter) movieAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation) error {
|
||||
movie, err := p.reader.ReadMovie(annotation.Dictionary["Movie"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activation, err := p.reader.Resolve(annotation.Dictionary["A"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if activation == pdfgo.Boolean(false) {
|
||||
return p.appearanceAnnotation(ctx, page, annotation)
|
||||
}
|
||||
if activation != nil && activation != pdfgo.Boolean(true) {
|
||||
parameters, err := p.reader.ReadMovieActivation(ctx, activation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parameters.Start != nil && parameters.Start.Value != 0 || parameters.Duration != nil || parameters.Rate != 1 || parameters.Volume != 1 || parameters.ShowControls || parameters.Synchronous || parameters.Mode != "Once" || parameters.FloatingScale != nil {
|
||||
return &pdfgo.UnsupportedError{Feature: "movie activation playback parameters conversion"}
|
||||
}
|
||||
}
|
||||
if movie.Rotate != 0 {
|
||||
return &pdfgo.UnsupportedError{Feature: "rotated movie playback"}
|
||||
}
|
||||
data, available, err := p.mediaData(ctx, movie.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !available {
|
||||
return p.appearanceAnnotation(ctx, page, annotation)
|
||||
}
|
||||
id, err := p.editor.AddMedia("Video", pdfMediaFormat(movie.File), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.appearanceAnnotation(ctx, page, annotation, Action{Event: "CLICK", Movie: &Movie{ResourceID: id, Operator: "Play"}})
|
||||
}
|
||||
|
||||
// interactiveAnnotation 保留3D或富媒体的静态外观和原始资源,不执行脚本
|
||||
// 入参: ctx 取消上下文, page PDF页面, annotation 交互注解, strict 严格检查开关
|
||||
// 返回: error 外观、资源或不可等价转换错误
|
||||
@@ -261,7 +310,10 @@ func (p *pdfImporter) mediaData(ctx context.Context, file pdfgo.FileSpecificatio
|
||||
return nil, false, nil
|
||||
}
|
||||
data, err := p.reader.ReadFileData(ctx, file, p.resolveFile)
|
||||
return data, err == nil, err
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("%w: %w", errPDFMediaRead, err)
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
// pdfMediaFormat 获取媒体文件扩展名,URL参数和片段不参与格式判断
|
||||
@@ -278,12 +330,18 @@ func pdfMediaFormat(file pdfgo.FileSpecification) string {
|
||||
}
|
||||
|
||||
// pdfSoundWAV 封装PDF采样音频,保持采样率、声道及数值,超过RIFF容量时使用RF64
|
||||
// 入参: sound 音频流及参数
|
||||
// 入参: ctx 取消上下文, sound 音频流及参数
|
||||
// 返回: []byte WAVE文件, error 不可表示的参数或损坏的采样数据
|
||||
func pdfSoundWAV(sound pdfgo.Sound) ([]byte, error) {
|
||||
func pdfSoundWAV(ctx context.Context, sound pdfgo.Sound) ([]byte, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sound.File != nil || sound.Compression != "" {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "external or compressed sound samples"}
|
||||
}
|
||||
if sound.Stream == nil || sound.Channels <= 0 || math.IsNaN(sound.Rate) || math.IsInf(sound.Rate, 0) || sound.Rate <= 0 {
|
||||
return nil, fmt.Errorf("invalid sound sample parameters")
|
||||
}
|
||||
if sound.Rate != math.Trunc(sound.Rate) || sound.Rate > math.MaxUint32 || sound.Channels > math.MaxUint16 || sound.Bits != 8 && sound.Bits != 16 && sound.Bits != 24 && sound.Bits != 32 {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "sound parameters in WAVE"}
|
||||
}
|
||||
@@ -302,16 +360,20 @@ func pdfSoundWAV(sound pdfgo.Sound) ([]byte, error) {
|
||||
if align > math.MaxUint16 || rate*align > math.MaxUint32 {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "sound block alignment in WAVE"}
|
||||
}
|
||||
data, err := sound.Stream.Decode()
|
||||
data, err := sound.Stream.DecodeContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if uint64(len(data))%align != 0 {
|
||||
return nil, fmt.Errorf("incomplete sound sample frame")
|
||||
}
|
||||
data = bytes.Clone(data)
|
||||
if format == 1 {
|
||||
for offset := 0; offset < len(data); offset += width {
|
||||
if offset%(1<<16) == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if sound.Encoding == "Raw" && width > 1 || sound.Encoding == "Signed" && width == 1 {
|
||||
data[offset] ^= 0x80
|
||||
}
|
||||
@@ -385,5 +447,5 @@ func pdfSoundWAV(sound pdfgo.Sound) ([]byte, error) {
|
||||
if len(data)%2 != 0 {
|
||||
out.WriteByte(0)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
return out.Bytes(), ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// movieResource 保存视频原始内容,按用途复用媒体或附件资源
|
||||
// 入参: ctx 取消上下文, file 文件说明, attachment 是否作为附件保存
|
||||
// 返回: string 媒体或附件标识,缺少外部资源时为空, error 读取、注册或取消错误
|
||||
func (p *pdfImporter) movieResource(ctx context.Context, file pdfgo.FileSpecification, attachment bool) (string, error) {
|
||||
return p.mediaResource(ctx, file, "Video", pdfMediaFormat(file), attachment)
|
||||
}
|
||||
|
||||
// movieAnnotation 保留视频外观及可表示的播放动作,无法等价转换时按策略处理
|
||||
// 入参: ctx 取消上下文, page PDF页面, annotation 视频注解, strict 是否禁止语义损失
|
||||
// 返回: error 媒体、外观、播放参数或取消错误
|
||||
func (p *pdfImporter) movieAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, strict bool) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
movie, err := p.reader.ReadMovie(annotation.Dictionary["Movie"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activation, err := p.reader.Resolve(annotation.Dictionary["A"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if activation == pdfgo.Boolean(false) {
|
||||
return p.appearanceAnnotation(ctx, page, annotation)
|
||||
}
|
||||
if activation == pdfgo.Boolean(true) {
|
||||
activation = nil
|
||||
}
|
||||
parameters, err := p.reader.ReadMovieActivation(ctx, activation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !pdfMoviePlaybackSupported(movie, parameters) {
|
||||
if err := p.moviePlaybackLoss(ctx, movie.File, strict); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.appearanceAnnotation(ctx, page, annotation)
|
||||
}
|
||||
if parameters.FloatingScale != nil {
|
||||
if err := p.movieActionLoss("movie playback window", strict, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
id, err := p.movieResource(ctx, movie.File, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id == "" {
|
||||
return p.appearanceAnnotation(ctx, page, annotation)
|
||||
}
|
||||
id, err = p.mediaInstance(id, annotation, p.page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.appearanceAnnotation(ctx, page, annotation, Action{Event: "CLICK", Movie: &Movie{ResourceID: id, Operator: "Play"}})
|
||||
}
|
||||
|
||||
// pdfMoviePlaybackSupported 判断OFD视频动作能否保持播放内容、声音及控制方式
|
||||
// 入参: movie 视频信息, parameters 播放参数
|
||||
// 返回: bool 是否可直接转换,不含播放窗口差异
|
||||
func pdfMoviePlaybackSupported(movie pdfgo.Movie, parameters pdfgo.MovieActivation) bool {
|
||||
return (parameters.Start == nil || parameters.Start.Value == 0) && parameters.Duration == nil && parameters.Rate == 1 && parameters.Volume == 1 && !parameters.ShowControls && !parameters.Synchronous && parameters.Mode == "Once" && movie.Rotate == 0
|
||||
}
|
||||
|
||||
// movieLinkAction 转换当前页视频操作,不将指定播放区间或速度改为完整默认播放
|
||||
// 入参: object PDF视频动作, source 触发注解, strict 是否禁止语义损失, currentPage 动作链当前页
|
||||
// 返回: *Action OFD视频动作,不能安全转换时为空, error 参数、资源或严格模式错误
|
||||
func (p *pdfImporter) movieLinkAction(object pdfgo.Object, source pdfgo.Annotation, strict bool, currentPage int) (*Action, error) {
|
||||
if currentPage < 0 || currentPage >= len(p.pageOrder) {
|
||||
if _, err := p.reader.ReadMovieAction(p.ctx, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, p.movieActionLoss("movie target without a fixed current page", strict, false)
|
||||
}
|
||||
playback, err := p.reader.ReadMoviePlayback(p.ctx, p.pageOrder[currentPage], object)
|
||||
if err != nil {
|
||||
if !strict && errors.Is(err, pdfgo.ErrInvalidAction) {
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF movie action ignored: %v", err)})
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if playback.Operation == "Play" {
|
||||
parameters := playback.Activation
|
||||
if !pdfMoviePlaybackSupported(playback.Movie, parameters) {
|
||||
return nil, p.moviePlaybackLoss(p.ctx, playback.Movie.File, strict)
|
||||
}
|
||||
if parameters.FloatingScale != nil {
|
||||
if err := p.movieActionLoss("movie playback window", strict, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
id, err := p.movieResource(p.ctx, playback.Movie.File, false)
|
||||
if err != nil || id == "" {
|
||||
return nil, err
|
||||
}
|
||||
id, err = p.mediaInstance(id, playback.Annotation, currentPage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
action := &Action{Event: "CLICK", Movie: &Movie{ResourceID: id, Operator: string(playback.Operation)}}
|
||||
if playback.Operation == "Play" && playback.Activation.FloatingScale == nil {
|
||||
if err := p.moviePlaybackBounds(action.Movie, source, playback.Annotation, currentPage, strict); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return action, nil
|
||||
}
|
||||
|
||||
// moviePlaybackBounds 保存同页嵌入视频的播放区域,不改变触发注解的点击区域
|
||||
// 入参: movie OFD视频动作, source 触发注解, target 播放注解, currentPage 当前页, strict 是否禁止语义损失
|
||||
// 返回: error 播放区域、标志或严格模式错误
|
||||
func (p *pdfImporter) moviePlaybackBounds(movie *Movie, source, target pdfgo.Annotation, currentPage int, strict bool) error {
|
||||
if source.Subtype == "" || currentPage != p.page {
|
||||
return p.movieActionLoss("movie playback window", strict, true)
|
||||
}
|
||||
if source.Rect == target.Rect {
|
||||
return nil
|
||||
}
|
||||
flags, err := p.reader.ReadAnnotationFlags(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags&(8|16) != 0 {
|
||||
return p.movieActionLoss("movie playback window scaling", strict, true)
|
||||
}
|
||||
box := pdfBounds([]pdfgo.Point{
|
||||
p.matrix.Apply(pdfgo.Point{X: target.Rect.XMin, Y: target.Rect.YMin}),
|
||||
p.matrix.Apply(pdfgo.Point{X: target.Rect.XMax, Y: target.Rect.YMin}),
|
||||
p.matrix.Apply(pdfgo.Point{X: target.Rect.XMax, Y: target.Rect.YMax}),
|
||||
p.matrix.Apply(pdfgo.Point{X: target.Rect.XMin, Y: target.Rect.YMax}),
|
||||
})
|
||||
if box.W <= 0 || box.H <= 0 {
|
||||
return p.movieActionLoss("empty movie playback window", strict, true)
|
||||
}
|
||||
if p.movieBounds == nil {
|
||||
p.movieBounds = make(map[*Movie]Box)
|
||||
}
|
||||
p.movieBounds[movie] = box
|
||||
return nil
|
||||
}
|
||||
|
||||
// annotationMovieBounds 校验同一动作链的播放区域,保持单一图元上的动作顺序
|
||||
// 入参: actions 注解动作, source 触发区域, flags 触发注解标志
|
||||
// 返回: Box 播放区域, error 无法等价转换的窗口要求
|
||||
func (p *pdfImporter) annotationMovieBounds(actions []Action, source Box, flags int64) (Box, error) {
|
||||
playback, found := source, false
|
||||
for _, action := range actions {
|
||||
if action.Movie == nil || action.Movie.Operator != "" && action.Movie.Operator != "Play" {
|
||||
continue
|
||||
}
|
||||
box, ok := p.movieBounds[action.Movie]
|
||||
if !ok {
|
||||
box = source
|
||||
}
|
||||
if found && playback != box || box != source && flags&(8|16) != 0 {
|
||||
return source, p.movieActionLoss("multiple or fixed-scale movie playback windows", p.warning == nil, true)
|
||||
}
|
||||
playback, found = box, true
|
||||
}
|
||||
return playback, nil
|
||||
}
|
||||
|
||||
// moviePlaybackLoss 保留不可等价转换的视频文件,不生成改变播放内容的动作
|
||||
// 入参: ctx 取消上下文, file 视频文件说明, strict 是否禁止语义损失
|
||||
// 返回: error 严格模式、资源读取或取消错误
|
||||
func (p *pdfImporter) moviePlaybackLoss(ctx context.Context, file pdfgo.FileSpecification, strict bool) error {
|
||||
if strict {
|
||||
return &pdfgo.UnsupportedError{Feature: "movie playback parameters conversion"}
|
||||
}
|
||||
id, err := p.movieResource(ctx, file, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message := "PDF movie playback parameters not transferred to OFD; action omitted"
|
||||
if id != "" {
|
||||
message += "; original media retained as attachment"
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: message})
|
||||
return nil
|
||||
}
|
||||
|
||||
// movieActionLoss 按策略报告OFD无法表达的视频参数,不自行添加扩展属性
|
||||
// 入参: feature 差异内容, strict 严格模式, retained 是否保留视频操作
|
||||
// 返回: error 严格模式下的不可等价转换错误
|
||||
func (p *pdfImporter) movieActionLoss(feature string, strict, retained bool) error {
|
||||
if strict {
|
||||
return &pdfgo.UnsupportedError{Feature: feature + " conversion"}
|
||||
}
|
||||
suffix := "; action omitted"
|
||||
if retained {
|
||||
suffix = "; video operation retained"
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF " + feature + " not transferred to OFD" + suffix})
|
||||
return nil
|
||||
}
|
||||
@@ -522,6 +522,9 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if mark.Replacement != nil {
|
||||
pdfApplyReplacement(&object, mark.Replacement.Text, mark.Positions)
|
||||
}
|
||||
box := extent.box()
|
||||
if stroke && !outlineStroke {
|
||||
strokeMatrix := mark.StrokeMatrix
|
||||
|
||||
@@ -36,7 +36,7 @@ func (p *pdfImporter) outlines(ctx context.Context) error {
|
||||
err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error {
|
||||
actions, err := p.outlineActions(ctx, item)
|
||||
if err != nil {
|
||||
if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline %q actions not transferred to OFD: %v", item.Title, err)})
|
||||
@@ -68,7 +68,7 @@ func (p *pdfImporter) outlines(ctx context.Context) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outlines not transferred to OFD: %v", err)})
|
||||
@@ -101,8 +101,11 @@ func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem
|
||||
currentPage := -1
|
||||
var actions []Action
|
||||
err = p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error {
|
||||
action, err := p.linkAction(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage)
|
||||
converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage)
|
||||
if err != nil {
|
||||
if errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
var unsupported *pdfgo.UnsupportedError
|
||||
if p.warning != nil && (errors.As(err, &unsupported) || errors.Is(err, pdfgo.ErrInvalidDestination) || errors.Is(err, pdfgo.ErrInvalidAction)) {
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline action not transferred to OFD: %v", err)})
|
||||
@@ -111,8 +114,8 @@ func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem
|
||||
}
|
||||
return err
|
||||
}
|
||||
if action != nil {
|
||||
actions = append(actions, *action)
|
||||
if converted != nil {
|
||||
actions = append(actions, converted...)
|
||||
} else {
|
||||
currentPage = -1
|
||||
}
|
||||
|
||||
+149
-44
@@ -16,6 +16,7 @@ package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"mime"
|
||||
@@ -34,77 +35,110 @@ func (p *pdfImporter) screenAnnotation(ctx context.Context, page *pdfgo.Page, an
|
||||
appearance := annotation
|
||||
appearance.Dictionary = maps.Clone(annotation.Dictionary)
|
||||
delete(appearance.Dictionary, "A")
|
||||
additional, err := p.reader.Resolve(annotation.Dictionary["AA"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dict, ok := additional.(pdfgo.Dictionary); ok {
|
||||
dict = maps.Clone(dict)
|
||||
delete(dict, "U")
|
||||
appearance.Dictionary["AA"] = dict
|
||||
}
|
||||
if appearance.Dictionary["AP"] == nil {
|
||||
appearance.Dictionary["AP"] = pdfgo.Dictionary{"N": &pdfgo.Stream{Dictionary: pdfgo.Dictionary{
|
||||
"Type": pdfgo.Name("XObject"), "Subtype": pdfgo.Name("Form"), "BBox": pdfgo.Array{pdfgo.Integer(0), pdfgo.Integer(0), pdfgo.Integer(1), pdfgo.Integer(1)},
|
||||
}}}
|
||||
}
|
||||
fallback := func(reason string) error {
|
||||
if strict {
|
||||
return &pdfgo.UnsupportedError{Feature: reason}
|
||||
flags, err := p.reader.ReadAnnotationFlags(annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF screen appearance retained; " + reason})
|
||||
delete(appearance.Dictionary, "AA")
|
||||
if flags&64 != 0 {
|
||||
return p.appearanceAnnotation(ctx, page, appearance)
|
||||
}
|
||||
value, err := p.reader.Resolve(annotation.Dictionary["A"])
|
||||
if err != nil {
|
||||
var actions []Action
|
||||
currentPage := p.page
|
||||
if err := p.reader.WalkActions(ctx, annotation.Dictionary["A"], func(info pdfgo.ActionInfo) error {
|
||||
current := annotation
|
||||
current.Dictionary = pdfgo.Dictionary{"A": info.Dictionary}
|
||||
converted, err := p.linkActions(current, strict, ¤tPage)
|
||||
actions = append(actions, converted...)
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
action, ok := value.(pdfgo.Dictionary)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid screen action")
|
||||
}
|
||||
subtype, err := p.reader.Resolve(action["S"])
|
||||
return p.appearanceAnnotation(ctx, page, appearance, actions...)
|
||||
}
|
||||
|
||||
// renditionLinkActions 按屏幕关联转换播放及控制,切换视频时先停止其他候选
|
||||
// 入参: object PDF媒体动作, source 触发注解,零值表示非图元动作, strict 是否禁止语义损失, currentPage 动作链当前页
|
||||
// 返回: []Action 有序OFD动作,无法转换时为空, error 参数、资源或严格模式错误
|
||||
func (p *pdfImporter) renditionLinkActions(object pdfgo.Object, source pdfgo.Annotation, strict bool, currentPage int) ([]Action, error) {
|
||||
playback, err := p.reader.ReadRenditionPlayback(p.ctx, object)
|
||||
if err != nil {
|
||||
return err
|
||||
if !strict && errors.Is(err, pdfgo.ErrInvalidAction) {
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF rendition action ignored: %v", err)})
|
||||
return nil, nil
|
||||
}
|
||||
operation, err := p.reader.Resolve(action["OP"])
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if subtype != pdfgo.Name("Rendition") || operation != pdfgo.Integer(0) || action["Next"] != nil || annotation.Dictionary["AA"] != nil {
|
||||
return fallback("screen action cannot be represented in OFD")
|
||||
if playback.Operation == nil || *playback.Operation < 0 || *playback.Operation >= 4 {
|
||||
return nil, p.renditionActionLoss("conditional rendition playback or script", strict)
|
||||
}
|
||||
if target, ok := action["AN"].(pdfgo.Reference); !ok || target != annotation.Reference || annotation.Dictionary["P"] != page.Reference {
|
||||
return fallback("screen action target is unavailable")
|
||||
if _, ok := p.pages[playback.Page]; !ok {
|
||||
return nil, p.renditionActionLoss("screen page is outside the document", strict)
|
||||
}
|
||||
rendition, err := p.reader.ReadRendition(ctx, action["R"])
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
if *playback.Operation != 0 {
|
||||
return p.renditionControls(playback.Annotation.Reference, *playback.Operation, "", strict)
|
||||
}
|
||||
return fallback("media rendition unavailable: " + err.Error())
|
||||
}
|
||||
selected, err := rendition.Select(ctx, func(candidate *pdfgo.Rendition) (bool, error) {
|
||||
selected, err := playback.Rendition.Select(p.ctx, func(candidate *pdfgo.Rendition) (bool, error) {
|
||||
_, _, viable, err := p.nativeRendition(candidate)
|
||||
return viable, err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if selected == nil {
|
||||
return fallback("no equivalent media rendition")
|
||||
return nil, p.renditionActionLoss("no equivalent media rendition", strict)
|
||||
}
|
||||
converted, kind, _, err := p.nativeRendition(selected)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
data, available, err := p.mediaData(ctx, *selected.Clip.File)
|
||||
if kind == "Video" {
|
||||
parameters, _, err := p.renditionParameters(selected.Screen, map[pdfgo.Name]func(pdfgo.Object) bool{
|
||||
"W": func(value pdfgo.Object) bool { return value == pdfgo.Integer(0) || value == pdfgo.Integer(3) },
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
return p.appearanceAnnotation(ctx, page, appearance)
|
||||
window := pdfgo.Integer(3)
|
||||
if value, ok := parameters["W"].(pdfgo.Integer); ok {
|
||||
window = value
|
||||
}
|
||||
format := pdfMediaFormat(*selected.Clip.File)
|
||||
if format == "" {
|
||||
mediaType, _, _ := mime.ParseMediaType(selected.Clip.ContentType)
|
||||
format = strings.TrimPrefix(strings.SplitN(mediaType, "/", 2)[1], "x-")
|
||||
matches := window == 0 && source.Subtype == ""
|
||||
if window == 3 && source.Subtype != "" {
|
||||
index := p.pageIndexes[playback.Page]
|
||||
matches = currentPage == index && p.page == index
|
||||
if matches {
|
||||
if err := p.moviePlaybackBounds(converted.Movie, source, playback.Annotation, currentPage, strict); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := p.editor.AddMedia(kind, format, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !matches {
|
||||
if err := p.renditionActionLoss("media playback window differs in OFD", strict); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
id, err := p.renditionResource(selected, kind, playback)
|
||||
if err != nil || id == "" {
|
||||
return nil, err
|
||||
}
|
||||
controls, err := p.renditionControls(playback.Annotation.Reference, 1, id, strict)
|
||||
if err != nil || controls == nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind == "Audio" {
|
||||
converted.Sound.ResourceID = id
|
||||
@@ -114,7 +148,34 @@ func (p *pdfImporter) screenAnnotation(ctx context.Context, page *pdfgo.Page, an
|
||||
if len(selected.Play) != 0 || len(selected.Screen) != 0 || len(selected.Clip.BestEffort) != 0 {
|
||||
p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF media rendition converted; best-effort presentation preferences may differ in OFD"})
|
||||
}
|
||||
return p.appearanceAnnotation(ctx, page, appearance, converted)
|
||||
return append(controls, converted), nil
|
||||
}
|
||||
|
||||
// renditionResource 注册所选媒体,按屏幕区分播放实例并共用原始文件
|
||||
// 入参: selected 已选择的原生呈现, kind 媒体类型, playback 播放目标
|
||||
// 返回: string 资源标识,不可用时为空, error 文件或资源错误
|
||||
func (p *pdfImporter) renditionResource(selected *pdfgo.Rendition, kind string, playback pdfgo.RenditionPlayback) (string, error) {
|
||||
format := pdfMediaFormat(*selected.Clip.File)
|
||||
if format == "" {
|
||||
mediaType, _, _ := mime.ParseMediaType(selected.Clip.ContentType)
|
||||
format = strings.TrimPrefix(strings.SplitN(mediaType, "/", 2)[1], "x-")
|
||||
}
|
||||
id, err := p.mediaResource(p.ctx, *selected.Clip.File, kind, format, false)
|
||||
if err != nil || id == "" {
|
||||
return "", err
|
||||
}
|
||||
return p.mediaInstance(id, playback.Annotation, p.pageIndexes[playback.Page])
|
||||
}
|
||||
|
||||
// renditionActionLoss 按导入策略报告媒体控制或呈现差异,不执行脚本
|
||||
// 入参: reason 无法等价转换的原因, strict 是否禁止语义损失
|
||||
// 返回: error 严格模式错误
|
||||
func (p *pdfImporter) renditionActionLoss(reason string, strict bool) error {
|
||||
if strict {
|
||||
return &pdfgo.UnsupportedError{Feature: reason}
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF rendition action: " + reason})
|
||||
return nil
|
||||
}
|
||||
|
||||
// nativeRendition 检查可等价转换的媒体分支,视频不接受无法表达的音量及循环
|
||||
@@ -170,12 +231,18 @@ func (p *pdfImporter) nativeRendition(rendition *pdfgo.Rendition) (Action, strin
|
||||
if err != nil || !viable {
|
||||
return result, "", false, err
|
||||
}
|
||||
_, viable, err = p.renditionParameters(rendition.Screen, map[pdfgo.Name]func(pdfgo.Object) bool{
|
||||
"W": func(value pdfgo.Object) bool { return value == pdfgo.Integer(3) },
|
||||
screen, viable, err := p.renditionParameters(rendition.Screen, map[pdfgo.Name]func(pdfgo.Object) bool{
|
||||
"W": func(value pdfgo.Object) bool { return !audio && value == pdfgo.Integer(0) || value == pdfgo.Integer(3) },
|
||||
})
|
||||
if err != nil || !viable {
|
||||
return result, "", false, err
|
||||
}
|
||||
if screen["W"] == pdfgo.Integer(0) {
|
||||
valid, err := p.renditionFloatingWindow(rendition.Screen)
|
||||
if err != nil || !valid {
|
||||
return result, "", false, err
|
||||
}
|
||||
}
|
||||
volume := 100
|
||||
if value := play["V"]; value != nil {
|
||||
volume = int(value.(pdfgo.Integer))
|
||||
@@ -192,6 +259,44 @@ func (p *pdfImporter) nativeRendition(rendition *pdfgo.Rendition) (Action, strin
|
||||
return result, "Video", true, nil
|
||||
}
|
||||
|
||||
// renditionFloatingWindow 校验浮窗必需的像素尺寸,呈现偏好不写成视频固有尺寸
|
||||
// 入参: screen 媒体屏幕参数
|
||||
// 返回: bool 是否具备合法尺寸, error 引用错误
|
||||
func (p *pdfImporter) renditionFloatingWindow(screen pdfgo.Dictionary) (bool, error) {
|
||||
value, err := p.reader.Resolve(screen["BE"])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
preferences, _ := value.(pdfgo.Dictionary)
|
||||
value, err = p.reader.Resolve(preferences["F"])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
window, ok := value.(pdfgo.Dictionary)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
value, err = p.reader.Resolve(window["D"])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
size, ok := value.(pdfgo.Array)
|
||||
if !ok || len(size) != 2 {
|
||||
return false, nil
|
||||
}
|
||||
for _, part := range size {
|
||||
value, err = p.reader.Resolve(part)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
number, ok := value.(pdfgo.Integer)
|
||||
if !ok || number < 0 {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// renditionCriteria 检查媒体环境条件,未知尽力处理字段不影响候选可用性
|
||||
// 入参: rendition 媒体呈现或选择器
|
||||
// 返回: bool 是否可用, error 条件字典或引用错误
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfRenditionImport 保存可转换事件中的屏幕关联,首次遇到呈现动作时建立
|
||||
type pdfRenditionImport struct {
|
||||
actions map[pdfgo.Reference][]pdfgo.Dictionary
|
||||
media map[pdfgo.Reference][]pdfRenditionResource
|
||||
err error
|
||||
}
|
||||
|
||||
// pdfRenditionResource 保存屏幕候选媒体及其独立控制标识
|
||||
type pdfRenditionResource struct {
|
||||
ID string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// pdfRenditionAssignment 区分同一屏幕的呈现定义,重复触发不重复登记
|
||||
type pdfRenditionAssignment struct {
|
||||
Screen pdfgo.Reference
|
||||
Rendition uintptr
|
||||
}
|
||||
|
||||
// renditionControls 生成屏幕关联的控制序列,切换时不停止即将重新播放的资源
|
||||
// 入参: screen 屏幕引用, operation 停止、暂停或恢复操作, playing 即将播放的资源,控制动作为空, strict 是否禁止语义损失
|
||||
// 返回: []Action 控制序列,nil表示无法转换, error 资源或严格模式错误
|
||||
func (p *pdfImporter) renditionControls(screen pdfgo.Reference, operation int64, playing string, strict bool) ([]Action, error) {
|
||||
if p.renditions == nil {
|
||||
p.renditions = &pdfRenditionImport{actions: make(map[pdfgo.Reference][]pdfgo.Dictionary), media: make(map[pdfgo.Reference][]pdfRenditionResource)}
|
||||
p.renditions.err = p.collectRenditions()
|
||||
}
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.renditions.err != nil {
|
||||
return nil, p.renditionActionLoss("screen associations cannot be determined: "+p.renditions.err.Error(), strict)
|
||||
}
|
||||
resources, err := p.renditionResources(screen, strict)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if resource.Kind != "Video" && (playing == "" || len(resources) > 1) {
|
||||
return nil, p.renditionActionLoss("audio rendition control cannot be represented by OFD Sound", strict)
|
||||
}
|
||||
}
|
||||
operator := map[int64]string{1: "Stop", 2: "Pause", 3: "Resume"}[operation]
|
||||
actions := make([]Action, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
if resource.ID != playing {
|
||||
actions = append(actions, Action{Event: "CLICK", Movie: &Movie{ResourceID: resource.ID, Operator: operator}})
|
||||
}
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
// renditionResources 解析一个屏幕的可用媒体,文件数据仅在该屏幕参与转换时读取
|
||||
// 入参: screen 屏幕引用, strict 是否禁止忽略无效动作
|
||||
// 返回: []pdfRenditionResource 候选媒体, error 解析、资源或取消错误
|
||||
func (p *pdfImporter) renditionResources(screen pdfgo.Reference, strict bool) ([]pdfRenditionResource, error) {
|
||||
if resources, exists := p.renditions.media[screen]; exists {
|
||||
return resources, nil
|
||||
}
|
||||
resources := make([]pdfRenditionResource, 0)
|
||||
seen := make(map[string]bool)
|
||||
for _, object := range p.renditions.actions[screen] {
|
||||
playback, err := p.reader.ReadRenditionPlayback(p.ctx, object)
|
||||
if err != nil {
|
||||
if !strict && errors.Is(err, pdfgo.ErrInvalidAction) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := p.pages[playback.Page]; !exists {
|
||||
continue
|
||||
}
|
||||
selected, err := playback.Rendition.Select(p.ctx, func(candidate *pdfgo.Rendition) (bool, error) {
|
||||
_, _, viable, err := p.nativeRendition(candidate)
|
||||
return viable, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selected == nil {
|
||||
continue
|
||||
}
|
||||
_, kind, _, err := p.nativeRendition(selected)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := p.renditionResource(selected, kind, playback)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
resources = append(resources, pdfRenditionResource{ID: id, Kind: kind})
|
||||
}
|
||||
}
|
||||
p.renditions.media[screen] = resources
|
||||
return resources, p.ctx.Err()
|
||||
}
|
||||
|
||||
// collectRenditions 索引可转换事件中的媒体关联,不解码媒体或执行脚本
|
||||
// 返回: error 动作结构、引用或取消错误
|
||||
func (p *pdfImporter) collectRenditions() error {
|
||||
seen := make(map[pdfRenditionAssignment]bool)
|
||||
collect := func(object pdfgo.Object) error {
|
||||
return p.reader.WalkActions(p.ctx, object, func(info pdfgo.ActionInfo) error {
|
||||
if info.Type != "Rendition" {
|
||||
return nil
|
||||
}
|
||||
value, err := p.reader.Resolve(info.Dictionary["OP"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value != pdfgo.Integer(0) && value != pdfgo.Integer(4) {
|
||||
return nil
|
||||
}
|
||||
screen, ok := info.Dictionary["AN"].(pdfgo.Reference)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
value, err = p.reader.Resolve(info.Dictionary["R"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, ok := value.(pdfgo.Dictionary)
|
||||
if !ok || r == nil {
|
||||
return nil
|
||||
}
|
||||
key := pdfRenditionAssignment{Screen: screen, Rendition: reflect.ValueOf(r).Pointer()}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
p.renditions.actions[screen] = append(p.renditions.actions[screen], info.Dictionary)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
opened, err := p.reader.ReadOpenAction(p.ctx)
|
||||
if err != nil && !errors.Is(err, pdfgo.ErrInvalidDestination) {
|
||||
return err
|
||||
}
|
||||
if opened.Action != nil {
|
||||
if err := collect(opened.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := p.reader.WalkOutlines(p.ctx, func(_ int, item pdfgo.OutlineItem) error {
|
||||
return collect(item.Action)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, page := range p.pageOrder {
|
||||
triggers, err := p.reader.ReadPageTriggers(p.ctx, page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, trigger := range triggers {
|
||||
if trigger.Event == "O" {
|
||||
if err := collect(trigger.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
annotations, err := page.AnnotationsContext(p.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, annotation := range annotations {
|
||||
flags, err := p.reader.ReadAnnotationFlags(annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags&64 == 0 && (annotation.Subtype == "Screen" || annotation.Subtype == "Link") {
|
||||
if err := collect(annotation.Dictionary["A"]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
triggers, err := p.reader.ReadAnnotationTriggers(p.ctx, annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, trigger := range triggers {
|
||||
if trigger.Event == "PO" || trigger.Event == "U" && flags&64 == 0 && annotation.Subtype != "Widget" {
|
||||
if err := collect(trigger.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return p.ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfTextReplacement 记录一个完整替换区段的文字数量与转换状态
|
||||
type pdfTextReplacement struct {
|
||||
text *pdfgo.ReplacementText
|
||||
count int
|
||||
offset int64
|
||||
blocked bool
|
||||
applied bool
|
||||
}
|
||||
|
||||
// pdfTextReplacements 按绘制顺序收集替换区段,避免拆分或重复写入原文
|
||||
type pdfTextReplacements struct {
|
||||
importer *pdfImporter
|
||||
spans map[*pdfgo.ReplacementText]*pdfTextReplacement
|
||||
order []*pdfTextReplacement
|
||||
widget bool
|
||||
}
|
||||
|
||||
// span 查找最外层替换区段,相邻区段即使原文相同也分别保留
|
||||
// 入参: text 替换文本
|
||||
// 返回: *pdfTextReplacement 区段状态,未声明时为空
|
||||
func (r *pdfTextReplacements) span(text *pdfgo.ReplacementText) *pdfTextReplacement {
|
||||
if text == nil {
|
||||
return nil
|
||||
}
|
||||
if r.spans == nil {
|
||||
r.spans = make(map[*pdfgo.ReplacementText]*pdfTextReplacement)
|
||||
}
|
||||
first := text
|
||||
for text.Parent != nil && r.spans[text] == nil {
|
||||
text = text.Parent
|
||||
}
|
||||
span := r.spans[text]
|
||||
if span == nil {
|
||||
span = &pdfTextReplacement{text: text}
|
||||
r.spans[text] = span
|
||||
r.order = append(r.order, span)
|
||||
}
|
||||
for first != text {
|
||||
r.spans[first] = span
|
||||
first = first.Parent
|
||||
}
|
||||
return span
|
||||
}
|
||||
|
||||
// block 标记无法仅用字符与字形变换表达的区段
|
||||
// 入参: text 替换文本
|
||||
func (r *pdfTextReplacements) block(text *pdfgo.ReplacementText) {
|
||||
if span := r.span(text); span != nil {
|
||||
span.blocked = true
|
||||
}
|
||||
}
|
||||
|
||||
// markedContent 核对已接入的替换文本属性,不静默丢弃其他内容语义
|
||||
// 入参: mark 内容标记
|
||||
// 返回: error 未支持的语义错误
|
||||
func (r *pdfTextReplacements) markedContent(mark pdfgo.MarkedContentMark) error {
|
||||
if mark.Operator == "EMC" {
|
||||
return nil
|
||||
}
|
||||
if mark.Tag == "OC" {
|
||||
kind, err := r.importer.reader.Resolve(mark.Properties["Type"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind == pdfgo.Name("OCG") || kind == pdfgo.Name("OCMD") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if r.widget && mark.Tag == "Tx" && len(mark.Properties) == 0 {
|
||||
return nil
|
||||
}
|
||||
if mark.Replacement != nil && len(mark.Properties) == 1 {
|
||||
return nil
|
||||
}
|
||||
if r.importer.warning == nil {
|
||||
return &pdfgo.UnsupportedError{Feature: "marked content requiring semantic preservation"}
|
||||
}
|
||||
r.importer.warning(pdfgo.Diagnostic{Offset: mark.Offset, Message: "marked content " + string(mark.Tag) + " not preserved"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// apply 将完整且样式一致的区段合并为标准OFD文字对象
|
||||
// 入参: nodes 原始图元
|
||||
// 返回: []pdfCompositeNode 转换图元, error 取消或语义转换错误
|
||||
func (r *pdfTextReplacements) apply(nodes []pdfCompositeNode) ([]pdfCompositeNode, error) {
|
||||
if len(r.order) == 0 {
|
||||
return nodes, nil
|
||||
}
|
||||
r.count(nodes, false)
|
||||
var err error
|
||||
nodes, err = r.merge(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, span := range r.order {
|
||||
if span.applied || !span.blocked && span.count == 0 && span.text.Text == "" {
|
||||
continue
|
||||
}
|
||||
if r.importer.warning == nil {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "ActualText span without equivalent OFD glyph mapping"}
|
||||
}
|
||||
r.importer.warning(pdfgo.Diagnostic{Offset: span.offset, Message: "PDF ActualText span not preserved; original appearance retained"})
|
||||
}
|
||||
r.clear(nodes)
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// count 统计完整区段中的文字图元,排除需要组级合成的内容
|
||||
// 入参: nodes 图元, blocked 上层组是否阻止直接转换
|
||||
func (r *pdfTextReplacements) count(nodes []pdfCompositeNode, blocked bool) {
|
||||
for _, node := range nodes {
|
||||
if node.text != nil {
|
||||
if span := r.span(node.text.Replacement); span != nil {
|
||||
span.count++
|
||||
span.blocked = span.blocked || blocked
|
||||
}
|
||||
}
|
||||
childBlocked := blocked
|
||||
if group := node.group; group != nil && node.textObject == nil {
|
||||
opaque, err := node.opaque(group.ColorSpace)
|
||||
childBlocked = childBlocked || !opaque || err != nil || group.ColorSpace != nil && !group.ColorSpace.SRGBEquivalent()
|
||||
}
|
||||
r.count(node.children, childBlocked)
|
||||
}
|
||||
}
|
||||
|
||||
// merge 合并同层连续图元,保留原始绘制顺序与字形坐标
|
||||
// 入参: nodes 图元
|
||||
// 返回: []pdfCompositeNode 合并结果, error 取消错误
|
||||
func (r *pdfTextReplacements) merge(nodes []pdfCompositeNode) ([]pdfCompositeNode, error) {
|
||||
result := nodes[:0]
|
||||
for i := 0; i < len(nodes); {
|
||||
if err := r.importer.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node := nodes[i]
|
||||
var err error
|
||||
node.children, err = r.merge(node.children)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := i + 1
|
||||
combined := false
|
||||
first := node.text
|
||||
if first == nil && node.textObject != nil && len(node.children) != 0 {
|
||||
first = node.children[0].text
|
||||
}
|
||||
if first != nil {
|
||||
if span := r.span(first.Replacement); span != nil && !span.applied && !span.blocked && span.text.Text != "" {
|
||||
var marks []pdfCompositeNode
|
||||
for end = i; end < len(nodes); end++ {
|
||||
current := nodes[end]
|
||||
children := current.children
|
||||
if current.text != nil {
|
||||
children = nodes[end : end+1]
|
||||
} else if current.textObject == nil {
|
||||
break
|
||||
}
|
||||
valid := len(children) != 0
|
||||
for _, child := range children {
|
||||
if child.text == nil || r.span(child.text.Replacement) != span {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
break
|
||||
}
|
||||
marks = append(marks, children...)
|
||||
}
|
||||
end = max(end, i+1)
|
||||
if len(marks) == span.count {
|
||||
if mark, ok := pdfMergeReplacement(marks, span.text); ok {
|
||||
if node.text != nil {
|
||||
node.text = mark
|
||||
} else {
|
||||
node.children = []pdfCompositeNode{{text: mark}}
|
||||
}
|
||||
span.applied = true
|
||||
combined = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
node.formCount = 0
|
||||
result = append(result, node)
|
||||
if !combined {
|
||||
for _, remaining := range nodes[i+1 : end] {
|
||||
remaining.formCount = 0
|
||||
result = append(result, remaining)
|
||||
}
|
||||
}
|
||||
i = end
|
||||
}
|
||||
clear(nodes[len(result):])
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// clear 清除未转换的区段引用,防止局部绘制重复使用整段原文
|
||||
// 入参: nodes 图元
|
||||
func (r *pdfTextReplacements) clear(nodes []pdfCompositeNode) {
|
||||
for _, node := range nodes {
|
||||
if node.text != nil {
|
||||
if span := r.span(node.text.Replacement); span != nil && !span.applied {
|
||||
node.text.Replacement = nil
|
||||
}
|
||||
}
|
||||
r.clear(node.children)
|
||||
}
|
||||
}
|
||||
|
||||
// pdfMergeReplacement 统一同一替换区段内的坐标原点,不重新塑形或调整字距
|
||||
// 入参: nodes 连续文字图元, replacement 整段原文
|
||||
// 返回: *pdfgo.TextMark 合并文字, bool 是否可等价合并
|
||||
func pdfMergeReplacement(nodes []pdfCompositeNode, replacement *pdfgo.ReplacementText) (*pdfgo.TextMark, bool) {
|
||||
first := nodes[0].text
|
||||
style := first.Style
|
||||
paint := style.Fill
|
||||
if first.Font == nil || len(first.Font.Program) == 0 || first.Mode != 0 || first.Size <= 0 || first.HorizontalScale <= 0 || first.Clip != nil || style.SoftMask != nil || style.Transfer != nil || style.AlphaIsShape || style.FillOverprint || paint.Alpha != 1 || !pdfNormalBlend(style.BlendMode) || paint.Tiling != nil || paint.Axial != nil || paint.Radial != nil || paint.Mesh != nil || paint.Function != nil {
|
||||
return nil, false
|
||||
}
|
||||
inverse, ok := first.Matrix.Inverse()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
count := 0
|
||||
for _, node := range nodes {
|
||||
mark := node.text
|
||||
if mark.Font != first.Font || mark.Size != first.Size || mark.HorizontalScale != first.HorizontalScale || mark.Mode != first.Mode || mark.Clip != nil || len(mark.Glyphs) != len(mark.Positions) || !reflect.DeepEqual(mark.Style, style) {
|
||||
return nil, false
|
||||
}
|
||||
if mark.Matrix[0] != first.Matrix[0] || mark.Matrix[1] != first.Matrix[1] || mark.Matrix[2] != first.Matrix[2] || mark.Matrix[3] != first.Matrix[3] {
|
||||
return nil, false
|
||||
}
|
||||
count += len(mark.Glyphs)
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, false
|
||||
}
|
||||
merged := *first
|
||||
merged.Replacement = replacement
|
||||
merged.Glyphs = make([]pdfgo.Glyph, 0, count)
|
||||
merged.Positions = make([]pdfgo.Point, 0, count)
|
||||
for _, node := range nodes {
|
||||
mark := node.text
|
||||
dx, dy := mark.Matrix[4]-first.Matrix[4], mark.Matrix[5]-first.Matrix[5]
|
||||
offset := pdfgo.Point{X: inverse[0]*dx + inverse[2]*dy, Y: inverse[1]*dx + inverse[3]*dy}
|
||||
for _, point := range mark.Positions {
|
||||
point.X += offset.X
|
||||
point.Y += offset.Y
|
||||
if math.IsNaN(point.X) || math.IsNaN(point.Y) || math.IsInf(point.X, 0) || math.IsInf(point.Y, 0) {
|
||||
return nil, false
|
||||
}
|
||||
merged.Positions = append(merged.Positions, point)
|
||||
}
|
||||
merged.Glyphs = append(merged.Glyphs, mark.Glyphs...)
|
||||
}
|
||||
return &merged, true
|
||||
}
|
||||
|
||||
// pdfApplyReplacement 将整段原文映射到全部字形,字形位移遵循GB/T 33190的文字定位规则
|
||||
// 入参: object 文字对象, text 整段原文, positions 原始字形位置
|
||||
func pdfApplyReplacement(object *TextObject, text string, positions []pdfgo.Point) {
|
||||
const unit = 25.4 / 72
|
||||
glyphs := make([]string, len(object.CGTransform))
|
||||
for i, transform := range object.CGTransform {
|
||||
glyphs[i] = transform.Glyphs
|
||||
}
|
||||
dx, dy := make([]float64, len(positions)-1), make([]float64, len(positions)-1)
|
||||
for i := 1; i < len(positions); i++ {
|
||||
dx[i-1] = (positions[i].X - positions[i-1].X) * unit
|
||||
dy[i-1] = -(positions[i].Y - positions[i-1].Y) * unit
|
||||
}
|
||||
object.TextCode = []TextCode{{X: pdfNumbers(positions[0].X * unit), Y: pdfNumbers(-positions[0].Y * unit), DeltaX: pdfNumbers(dx...), DeltaY: pdfNumbers(dy...), Value: escapeOFDText(text)}}
|
||||
object.CGTransform = []CGTransform{{CodeCount: utf8.RuneCountInString(text), GlyphCount: len(glyphs), Glyphs: strings.Join(glyphs, " ")}}
|
||||
}
|
||||
+60
-8
@@ -14,6 +14,8 @@
|
||||
|
||||
package ofdgo
|
||||
|
||||
import "fmt"
|
||||
|
||||
// RenderPage 编译页面为与绘图库无关的只读绘制数据
|
||||
// 入参: page 页面内容
|
||||
// 返回: *RasterPage 绘制页面, error 错误信息
|
||||
@@ -42,26 +44,71 @@ func (r *Renderer) GetPageBox(page *PageContent) (Box, error) {
|
||||
return ParseBox(boxStr)
|
||||
}
|
||||
|
||||
// PageLinks 获取页面、模板和可见注释的点击动作,保留复杂区域和组合图元
|
||||
// PageLinks 获取页面、模板和可见注释中图元的点击动作,保留复杂区域和组合图元
|
||||
// 入参: page 页面内容
|
||||
// 返回: []PageLink 页面链接, error 错误信息
|
||||
func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) {
|
||||
box, err := r.GetPageBox(page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources := r.pageActionSources(page, box)
|
||||
sources := r.pageActionSources(page)
|
||||
if r.RenderAnnotations {
|
||||
sources = append(sources, r.annotationActionSources(r.Reader.Annots[page.ID])...)
|
||||
}
|
||||
return r.sourceLinks(sources, "CLICK")
|
||||
}
|
||||
|
||||
// DocumentOpenLinks 获取文档打开动作的有序目标,不执行动作
|
||||
// 返回: []PageLink 动作目标, error 文档解析错误
|
||||
func (r *Renderer) DocumentOpenLinks() ([]PageLink, error) {
|
||||
doc, err := r.Reader.Doc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.sourceLinks([]actionSource{{Actions: doc.Actions}}, "DO")
|
||||
}
|
||||
|
||||
// PageOpenLinks 获取页面及模板打开动作的有序目标,不遍历图元或执行动作
|
||||
// 入参: page 页面内容
|
||||
// 返回: []PageLink 动作目标, error 目标解析错误
|
||||
func (r *Renderer) PageOpenLinks(page *PageContent) ([]PageLink, error) {
|
||||
return r.sourceLinks(r.pageOpenActionSources(page), "PO")
|
||||
}
|
||||
|
||||
// OutlineLinks 获取目录节点的有序动作目标,不执行动作
|
||||
// 入参: path 从根节点开始的各级索引
|
||||
// 返回: []PageLink 动作目标,不包含页面点击区域, error 目录路径或解析错误
|
||||
func (r *Renderer) OutlineLinks(path []int) ([]PageLink, error) {
|
||||
doc, err := r.Reader.Doc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(path) == 0 {
|
||||
return nil, fmt.Errorf("empty outline path")
|
||||
}
|
||||
nodes := doc.Outlines.OutlineElem
|
||||
var node OutlineElem
|
||||
for _, index := range path {
|
||||
if index < 0 || index >= len(nodes) {
|
||||
return nil, fmt.Errorf("invalid outline index: %d", index)
|
||||
}
|
||||
node = nodes[index]
|
||||
nodes = node.OutlineElem
|
||||
}
|
||||
return r.sourceLinks([]actionSource{{Actions: node.Actions}}, "")
|
||||
}
|
||||
|
||||
// sourceLinks 解析动作目标,保留动作顺序及独立目标数据
|
||||
// 入参: sources 动作来源, event 事件类型,空值表示目录激活动作
|
||||
// 返回: []PageLink 动作目标, error 区域或文档解析错误
|
||||
func (r *Renderer) sourceLinks(sources []actionSource, event string) ([]PageLink, error) {
|
||||
var links []PageLink
|
||||
var bookmarks map[string]Dest
|
||||
for sourceIndex, source := range sources {
|
||||
start := len(links)
|
||||
for _, action := range source.Actions {
|
||||
if action.Event != "CLICK" {
|
||||
if event != "" && action.Event != event {
|
||||
continue
|
||||
}
|
||||
var link PageLink
|
||||
if event == "CLICK" {
|
||||
box, path, err := r.actionLinkRegion(source, action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -69,7 +116,8 @@ func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) {
|
||||
if box.W <= 0 || box.H <= 0 {
|
||||
continue
|
||||
}
|
||||
link := PageLink{Box: box, Path: path}
|
||||
link.Box, link.Path = box, path
|
||||
}
|
||||
if action.Goto != nil {
|
||||
if bookmarks == nil {
|
||||
doc, err := r.Reader.Doc()
|
||||
@@ -111,6 +159,10 @@ func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) {
|
||||
value.Operator = "Play"
|
||||
}
|
||||
link.Movie = &value
|
||||
if source.Graphic {
|
||||
playback := source.Box
|
||||
link.PlaybackBox = &playback
|
||||
}
|
||||
links = append(links, link)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ package ofdgo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
@@ -488,15 +489,17 @@ func actionRegionPath(region *Region, matrix Matrix) *canvas.Path {
|
||||
angle, err := strconv.ParseFloat(command.RotationAngle, 64)
|
||||
large, largeErr := strconv.ParseBool(command.LargeArc)
|
||||
sweep, sweepErr := strconv.ParseBool(command.SweepDirection)
|
||||
if len(end) == 2 && err == nil && largeErr == nil && sweepErr == nil {
|
||||
if len(end) == 2 && finite(end[0]) && finite(end[1]) && err == nil && finite(angle) && largeErr == nil && sweepErr == nil {
|
||||
rx, ry := 0.0, 0.0
|
||||
if len(size) > 0 {
|
||||
rx, ry = size[0], size[0]
|
||||
rx, ry = math.Abs(size[0]), math.Abs(size[0])
|
||||
}
|
||||
if len(size) > 1 {
|
||||
ry = size[1]
|
||||
rx, ry = rx/2, math.Abs(size[1])/2
|
||||
}
|
||||
if finite(rx) && finite(ry) {
|
||||
part.ArcTo(rx, ry, math.Mod(angle, 360), large, sweep, end[0], end[1])
|
||||
}
|
||||
part.ArcTo(rx, ry, angle, large, sweep, end[0], end[1])
|
||||
}
|
||||
case "Close":
|
||||
part.Close()
|
||||
|
||||
@@ -127,7 +127,7 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
|
||||
}
|
||||
start := buf.Len()
|
||||
p := pdf.New(buf, pages[0].Box.W, pages[0].Box.H, nil)
|
||||
renderer := &pdfRenderer{PDF: p, glyphPaths: make(map[*canvas.Path]*canvas.Path), images: make([][]image.Image, 1), navigation: navigation}
|
||||
renderer := &pdfRenderer{PDF: p, glyphPaths: make(map[*canvas.Path]*canvas.Path), images: make([][]image.Image, 1), text: make([]pdfPageText, 1), navigation: navigation}
|
||||
defer func() {
|
||||
if renderer.imageError != nil {
|
||||
r.canvasState().images = renderCache[*EncodedImage, image.Image]{limit: imageCacheLimit}
|
||||
@@ -142,6 +142,7 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
|
||||
if i > 0 {
|
||||
p.NewPage(page.Box.W, page.Box.H)
|
||||
renderer.images = append(renderer.images, nil)
|
||||
renderer.text = append(renderer.text, pdfPageText{})
|
||||
}
|
||||
navigation.apply(p, i)
|
||||
if err := r.renderCanvasPageToContext(canvas.NewContext(renderer), page.Content, !r.TransparentBackground); err != nil {
|
||||
@@ -172,7 +173,7 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
|
||||
}
|
||||
}
|
||||
if direct {
|
||||
if !renderer.exactImages && !navigation.exactLinks {
|
||||
if !renderer.exactImages && !renderer.exactText && !navigation.nativeWrite {
|
||||
if err := r.outputContext().Err(); err != nil {
|
||||
buf.Truncate(start)
|
||||
return err
|
||||
|
||||
@@ -42,13 +42,6 @@ func (n *pdfNavigation) addActions(renderer *Renderer, page int, source actionSo
|
||||
if action.Event != "CLICK" {
|
||||
continue
|
||||
}
|
||||
value, err := pdfNavigationTarget(action, bookmarks, pageIndex, pages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
box, outline, err := renderer.actionLinkRegion(source, action)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -56,6 +49,13 @@ func (n *pdfNavigation) addActions(renderer *Renderer, page int, source actionSo
|
||||
if box.W <= 0 || box.H <= 0 {
|
||||
continue
|
||||
}
|
||||
value, err := n.actionTarget(action, page, source, bookmarks, pageIndex, pages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
path := geometryRectangle(box)
|
||||
if outline != "" {
|
||||
parsed, err := canvas.ParseSVGPath(outline)
|
||||
@@ -99,7 +99,7 @@ func (n *pdfNavigation) addActions(renderer *Renderer, page int, source actionSo
|
||||
continue
|
||||
}
|
||||
n.Link[page] = append(n.Link[page], pdfLink{Rect: pdfSourceRect(box, pages[page].Box.H), Region: region, Actions: area.Actions})
|
||||
n.exactLinks = true
|
||||
n.nativeWrite = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -203,7 +203,11 @@ func combinePDFActionRegion(ctx context.Context, geometry GeometryBackend, left,
|
||||
func pdfNavigationValues(actions []pdfNavigationAction, pages []pdfgo.Reference) []pdfgo.NavigationAction {
|
||||
values := make([]pdfgo.NavigationAction, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
value := pdfgo.NavigationAction{URI: action.URI}
|
||||
if action.Movie != nil {
|
||||
values = append(values, action.Movie.values()...)
|
||||
continue
|
||||
}
|
||||
value := pdfgo.NavigationAction{URI: action.URI, Attachment: action.Attachment, Sound: action.Sound}
|
||||
if action.Target != nil {
|
||||
dest := action.Target.Destination
|
||||
dest.Page = pages[action.Target.Page]
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// writeAttachments 在页面绘制后读取附件,保留文件字节,不自动添加个人信息和时间
|
||||
// 入参: ctx 取消上下文, options PDF写出配置
|
||||
// 返回: error 附件读取或取消错误
|
||||
func (n *pdfNavigation) writeAttachments(ctx context.Context, options *pdfgo.RewriteOptions) error {
|
||||
if len(n.attachments) == 0 {
|
||||
return nil
|
||||
}
|
||||
options.Attachments = make(map[string]*pdfgo.EmbeddedFile, len(n.attachments))
|
||||
for _, id := range slices.Sorted(maps.Keys(n.attachments)) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
attachment := n.attachments[id]
|
||||
input, err := n.source.openFile(n.source.ResPath(attachment.FileLoc))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read attachment %s: %w", id, err)
|
||||
}
|
||||
data, err := io.ReadAll(imageInput{ReadCloser: input, context: ctx})
|
||||
closeErr := input.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read attachment %s: %w", id, err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
options.Attachments[id] = &pdfgo.EmbeddedFile{Name: attachment.FileName(), Data: data}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
+104
-11
@@ -31,17 +31,33 @@ import (
|
||||
type pdfRenderer struct {
|
||||
*pdf.PDF
|
||||
glyphPaths map[*canvas.Path]*canvas.Path
|
||||
fonts map[*canvas.Font]*canvas.Font
|
||||
images [][]image.Image
|
||||
exactImages bool
|
||||
imageError error
|
||||
navigation *pdfNavigation
|
||||
text []pdfPageText
|
||||
exactText bool
|
||||
}
|
||||
|
||||
// pdfPageText 记录后端文字对象数量及OFD原文所属区段
|
||||
type pdfPageText struct {
|
||||
count int
|
||||
replacements []pdfgo.TextReplacement
|
||||
}
|
||||
|
||||
// pdfNavigation PDF导航信息
|
||||
type pdfNavigation struct {
|
||||
Link map[int][]pdfLink
|
||||
Outline []pdfOutline
|
||||
exactLinks bool
|
||||
Open []pdfNavigationAction
|
||||
PageOpen map[int][]pdfNavigationAction
|
||||
attachments map[string]Attachment
|
||||
audio map[string]*pdfgo.FileSpecification
|
||||
video map[string]*pdfVideoResource
|
||||
mediaFiles map[string]*pdfgo.FileSpecification
|
||||
source *Reader
|
||||
nativeWrite bool
|
||||
}
|
||||
|
||||
// pdfLink PDF链接
|
||||
@@ -64,10 +80,13 @@ type pdfOutline struct {
|
||||
Actions []pdfNavigationAction
|
||||
}
|
||||
|
||||
// pdfNavigationAction 保存链接或目录的导航动作,输出时关联实际页面引用
|
||||
// pdfNavigationAction 保存待写出的导航动作,输出时关联实际页面引用
|
||||
type pdfNavigationAction struct {
|
||||
URI string
|
||||
Target *pdfTarget
|
||||
Attachment *pdfgo.AttachmentAction
|
||||
Sound *pdfgo.SoundAction
|
||||
Movie *pdfMovieAction
|
||||
}
|
||||
|
||||
// RenderImage 校验惰性图片并同步不透明画笔,保留解码错误及后端透明度状态
|
||||
@@ -136,6 +155,38 @@ func (r *pdfRenderer) glyphPath(path *canvas.Path, matrix canvas.Matrix) *canvas
|
||||
func newPDFNavigation(renderer *Renderer, doc *Document, pages []RenderDocumentPage) (*pdfNavigation, error) {
|
||||
navigation := &pdfNavigation{
|
||||
Link: make(map[int][]pdfLink),
|
||||
PageOpen: make(map[int][]pdfNavigationAction),
|
||||
source: renderer.Reader,
|
||||
}
|
||||
if doc != nil && (doc.Attachments.Path != "" || len(doc.Attachments.Attachment) != 0) {
|
||||
attachments, err := renderer.Reader.Attachments()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
navigation.attachments = make(map[string]Attachment, len(attachments))
|
||||
for _, attachment := range attachments {
|
||||
if attachment.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := navigation.attachments[attachment.ID]; exists {
|
||||
return nil, fmt.Errorf("duplicate attachment identifier: %s", attachment.ID)
|
||||
}
|
||||
navigation.attachments[attachment.ID] = attachment
|
||||
}
|
||||
for i, attachment := range attachments {
|
||||
if attachment.ID != "" {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("attachment-%d", i+1)
|
||||
for {
|
||||
if _, exists := navigation.attachments[key]; !exists {
|
||||
break
|
||||
}
|
||||
key += "-"
|
||||
}
|
||||
navigation.attachments[key] = attachment
|
||||
}
|
||||
navigation.nativeWrite = len(attachments) != 0
|
||||
}
|
||||
pageIndex := make(map[string]int, len(pages))
|
||||
for i, page := range pages {
|
||||
@@ -146,9 +197,17 @@ func newPDFNavigation(renderer *Renderer, doc *Document, pages []RenderDocumentP
|
||||
for _, bookmark := range doc.Bookmarks.Bookmark {
|
||||
bookmarks[bookmark.Name] = bookmark.Dest
|
||||
}
|
||||
if err := navigation.addLifecycleActions(renderer.outputContext(), actionSource{Actions: doc.Actions}, -1, bookmarks, pageIndex, pages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for i, page := range pages {
|
||||
sources := renderer.pageActionSources(page.Content, page.Box)
|
||||
for _, source := range renderer.pageOpenActionSources(page.Content) {
|
||||
if err := navigation.addLifecycleActions(renderer.outputContext(), source, i, bookmarks, pageIndex, pages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sources := renderer.pageActionSources(page.Content)
|
||||
if renderer.RenderAnnotations {
|
||||
sources = append(sources, renderer.annotationActionSources(renderer.Reader.Annots[page.Content.ID])...)
|
||||
}
|
||||
@@ -166,10 +225,10 @@ func newPDFNavigation(renderer *Renderer, doc *Document, pages []RenderDocumentP
|
||||
return navigation, nil
|
||||
}
|
||||
|
||||
// pdfNavigationTarget 转换导航动作,选页未包含的目标不写入
|
||||
// 入参: action 动作, bookmarks 书签, pageIndex 页面索引表, pages 页面数据
|
||||
// actionTarget 转换动作及资源引用,选页未包含的目标不写入
|
||||
// 入参: action 动作, page 来源页索引,负值表示文档, source 动作来源, bookmarks 书签, pageIndex 页面索引表, pages 页面数据
|
||||
// 返回: *pdfNavigationAction 导航动作,nil表示无输出动作, error 目标参数错误
|
||||
func pdfNavigationTarget(action Action, bookmarks map[string]Dest, pageIndex map[string]int, pages []RenderDocumentPage) (*pdfNavigationAction, error) {
|
||||
func (n *pdfNavigation) actionTarget(action Action, page int, source actionSource, bookmarks map[string]Dest, pageIndex map[string]int, pages []RenderDocumentPage) (*pdfNavigationAction, error) {
|
||||
if action.Goto != nil {
|
||||
dest := gotoDest(action.Goto, bookmarks)
|
||||
if dest == nil {
|
||||
@@ -188,6 +247,22 @@ func pdfNavigationTarget(action Action, bookmarks map[string]Dest, pageIndex map
|
||||
if action.URI != nil && action.URI.URI != "" {
|
||||
return &pdfNavigationAction{URI: resolveActionURI(*action.URI)}, nil
|
||||
}
|
||||
if action.GotoA != nil {
|
||||
if attachment, exists := n.attachments[action.GotoA.AttachID]; !exists || attachment.ID != action.GotoA.AttachID {
|
||||
return nil, fmt.Errorf("attachment not found: %s", action.GotoA.AttachID)
|
||||
}
|
||||
window := true
|
||||
if action.GotoA.NewWindow != nil {
|
||||
window = *action.GotoA.NewWindow
|
||||
}
|
||||
return &pdfNavigationAction{Attachment: &pdfgo.AttachmentAction{Key: action.GotoA.AttachID, NewWindow: &window}}, nil
|
||||
}
|
||||
if action.Sound != nil {
|
||||
return n.soundAction(*action.Sound)
|
||||
}
|
||||
if action.Movie != nil {
|
||||
return n.movieAction(*action.Movie, page, source, pages)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -202,7 +277,7 @@ func (n *pdfNavigation) addOutlines(outlines []OutlineElem, level int, bookmarks
|
||||
if action.Goto == nil {
|
||||
onlyMissingTargets = false
|
||||
}
|
||||
value, err := pdfNavigationTarget(action, bookmarks, pageIndex, pages)
|
||||
value, err := n.actionTarget(action, -1, actionSource{}, bookmarks, pageIndex, pages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -219,7 +294,7 @@ func (n *pdfNavigation) addOutlines(outlines []OutlineElem, level int, bookmarks
|
||||
n.Outline = n.Outline[:index]
|
||||
}
|
||||
}
|
||||
n.exactLinks = n.exactLinks || len(n.Outline) != 0
|
||||
n.nativeWrite = n.nativeWrite || len(n.Outline) != 0
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -236,10 +311,28 @@ func (n *pdfNavigation) apply(renderer *pdf.PDF, page int) {
|
||||
}
|
||||
}
|
||||
|
||||
// destinations 将输出页引用关联到原有链接和先序大纲,不按目标页重新排序
|
||||
// prepareRewrite 关联输出页引用并准备附件、事件、链接及先序大纲的替换
|
||||
// 入参: ctx 取消上下文, reader 输出PDF, pages 输出页引用, options 替换配置
|
||||
// 返回: error 目录结构或取消错误
|
||||
func (n *pdfNavigation) destinations(ctx context.Context, reader *pdfgo.Reader, pages []pdfgo.Reference, options *pdfgo.RewriteOptions) error {
|
||||
// 返回: error 资源、目录结构或取消错误
|
||||
func (n *pdfNavigation) prepareRewrite(ctx context.Context, reader *pdfgo.Reader, pages []pdfgo.Reference, options *pdfgo.RewriteOptions) error {
|
||||
if err := n.writeAttachments(ctx, options); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := n.loadAudio(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := n.writeVideo(ctx, pages, options); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(n.Open) != 0 {
|
||||
options.OpenActions = pdfNavigationValues(n.Open, pages)
|
||||
}
|
||||
if len(n.PageOpen) != 0 {
|
||||
options.PageOpenActions = make(map[pdfgo.Reference][]pdfgo.NavigationAction, len(n.PageOpen))
|
||||
for page, actions := range n.PageOpen {
|
||||
options.PageOpenActions[pages[page]] = pdfNavigationValues(actions, pages)
|
||||
}
|
||||
}
|
||||
options.LinkDestinations = make(map[pdfgo.AnnotationLocation]pdfgo.Destination)
|
||||
options.LinkActions = make(map[pdfgo.AnnotationLocation][]pdfgo.NavigationAction)
|
||||
for page, links := range n.Link {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import "context"
|
||||
|
||||
// addLifecycleActions 按事件归属收集打开动作,不受点击区域限制
|
||||
// 入参: ctx 取消上下文, source 动作来源, page 页面索引,负值表示文档, bookmarks 书签, pageIndex 页面索引表, pages 页面数据
|
||||
// 返回: error 目标或取消错误
|
||||
func (n *pdfNavigation) addLifecycleActions(ctx context.Context, source actionSource, page int, bookmarks map[string]Dest, pageIndex map[string]int, pages []RenderDocumentPage) error {
|
||||
event := "PO"
|
||||
if page < 0 {
|
||||
event = "DO"
|
||||
}
|
||||
for _, action := range source.Actions {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if action.Event != event || source.Graphic {
|
||||
continue
|
||||
}
|
||||
value, err := n.actionTarget(action, page, source, bookmarks, pageIndex, pages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if action.Event == "DO" {
|
||||
n.Open = append(n.Open, *value)
|
||||
} else {
|
||||
n.PageOpen[page] = append(n.PageOpen[page], *value)
|
||||
}
|
||||
n.nativeWrite = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// mediaFile 复用同一包内文件的输出说明,不合并播放资源及窗口
|
||||
// 入参: media 音视频资源
|
||||
// 返回: *pdfgo.FileSpecification 待读取的共享文件
|
||||
func (n *pdfNavigation) mediaFile(media MultiMedia) *pdfgo.FileSpecification {
|
||||
name := n.source.ResPath(media.MediaFile)
|
||||
if file := n.mediaFiles[name]; file != nil {
|
||||
return file
|
||||
}
|
||||
file := &pdfgo.FileSpecification{Name: path.Base(name)}
|
||||
if n.mediaFiles == nil {
|
||||
n.mediaFiles = make(map[string]*pdfgo.FileSpecification)
|
||||
}
|
||||
n.mediaFiles[name] = file
|
||||
return file
|
||||
}
|
||||
|
||||
// loadMediaFiles 按需读取原始媒体,独立播放资源共用的文件只读取一次
|
||||
// 入参: ctx 取消上下文, files 媒体资源及输出文件
|
||||
// 返回: error 资源读取或取消错误
|
||||
func (n *pdfNavigation) loadMediaFiles(ctx context.Context, files map[string]*pdfgo.FileSpecification) error {
|
||||
for _, id := range slices.Sorted(maps.Keys(files)) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if files[id].Embedded != nil {
|
||||
continue
|
||||
}
|
||||
input, err := n.source.OpenMedia(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := io.ReadAll(imageInput{ReadCloser: input, context: ctx})
|
||||
closeErr := input.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
files[id].Embedded = &pdfgo.Stream{Data: data}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfVideoResource 共享视频内容及各播放区域,控制动作按资源关联
|
||||
type pdfVideoResource struct {
|
||||
Clip *pdfgo.MediaClip
|
||||
Screens []*pdfVideoScreen
|
||||
}
|
||||
|
||||
// pdfVideoScreen 保存播放区域的页面归属及呈现参数
|
||||
type pdfVideoScreen struct {
|
||||
Page int
|
||||
Screen *pdfgo.ScreenAnnotation
|
||||
Rendition *pdfgo.Rendition
|
||||
}
|
||||
|
||||
// pdfMovieAction 保存视频控制及可选播放区域,待全部来源收集后关联
|
||||
type pdfMovieAction struct {
|
||||
Resource *pdfVideoResource
|
||||
Target *pdfVideoScreen
|
||||
Operation int
|
||||
}
|
||||
|
||||
// movieAction 按OFD第12章选择内嵌区域或浮窗,控制动作不另建播放器
|
||||
// 入参: action 视频动作, page 来源页索引,负值表示文档, source 动作来源, pages 输出页面
|
||||
// 返回: *pdfNavigationAction 待关联视频动作, error 资源或参数错误
|
||||
func (n *pdfNavigation) movieAction(action Movie, page int, source actionSource, pages []RenderDocumentPage) (*pdfNavigationAction, error) {
|
||||
operation := 0
|
||||
switch action.Operator {
|
||||
case "", "Play":
|
||||
case "Stop":
|
||||
operation = 1
|
||||
case "Pause":
|
||||
operation = 2
|
||||
case "Resume":
|
||||
operation = 3
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid movie operator: %s", action.Operator)
|
||||
}
|
||||
resource := n.video[action.ResourceID]
|
||||
if resource == nil {
|
||||
media, err := n.source.Media(action.ResourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if media.Type != "Video" {
|
||||
return nil, fmt.Errorf("movie resource is not video: %s", action.ResourceID)
|
||||
}
|
||||
resource = &pdfVideoResource{Clip: &pdfgo.MediaClip{
|
||||
Subtype: "MCD", ContentType: pdfVideoContentType(media),
|
||||
File: n.mediaFile(media),
|
||||
}}
|
||||
if n.video == nil {
|
||||
n.video = make(map[string]*pdfVideoResource)
|
||||
}
|
||||
n.video[action.ResourceID] = resource
|
||||
}
|
||||
value := &pdfMovieAction{Resource: resource, Operation: operation}
|
||||
if operation != 0 {
|
||||
return &pdfNavigationAction{Movie: value}, nil
|
||||
}
|
||||
if len(pages) == 0 || page >= len(pages) {
|
||||
return nil, fmt.Errorf("missing movie playback page")
|
||||
}
|
||||
var rect pdfgo.Rectangle
|
||||
if source.Graphic {
|
||||
if page < 0 || source.Box.W <= 0 || source.Box.H <= 0 {
|
||||
return nil, fmt.Errorf("invalid movie playback region")
|
||||
}
|
||||
const scale = 72.0 / 25.4
|
||||
box := source.Box
|
||||
rect = pdfgo.Rectangle{XMin: box.X * scale, YMin: (pages[page].Box.H - box.Y - box.H) * scale, XMax: (box.X + box.W) * scale, YMax: (pages[page].Box.H - box.Y) * scale}
|
||||
} else {
|
||||
page = 0
|
||||
}
|
||||
for _, screen := range resource.Screens {
|
||||
if screen.Page == page && screen.Screen.Rect == rect {
|
||||
value.Target = screen
|
||||
return &pdfNavigationAction{Movie: value}, nil
|
||||
}
|
||||
}
|
||||
screen := &pdfVideoScreen{Page: page, Screen: &pdfgo.ScreenAnnotation{Rect: rect, Hidden: true}, Rendition: &pdfgo.Rendition{Subtype: "MR", Clip: resource.Clip}}
|
||||
if !source.Graphic {
|
||||
screen.Rendition.Screen = pdfgo.Dictionary{"MH": pdfgo.Dictionary{"W": pdfgo.Integer(0)}, "BE": pdfgo.Dictionary{"F": pdfgo.Dictionary{"D": pdfgo.Array{pdfgo.Integer(640), pdfgo.Integer(360)}, "R": pdfgo.Integer(1)}}}
|
||||
}
|
||||
resource.Screens = append(resource.Screens, screen)
|
||||
value.Target = screen
|
||||
return &pdfNavigationAction{Movie: value}, nil
|
||||
}
|
||||
|
||||
// values 将资源控制关联到全部播放区域,重新播放前停止同一资源的其他实例
|
||||
// 返回: []pdfgo.NavigationAction 有序呈现动作
|
||||
func (a *pdfMovieAction) values() []pdfgo.NavigationAction {
|
||||
values := make([]pdfgo.NavigationAction, 0, len(a.Resource.Screens))
|
||||
for _, screen := range a.Resource.Screens {
|
||||
if screen == a.Target {
|
||||
continue
|
||||
}
|
||||
operation := a.Operation
|
||||
if operation == 0 {
|
||||
operation = 1
|
||||
}
|
||||
values = append(values, pdfgo.NavigationAction{Rendition: &pdfgo.RenditionAction{Target: screen.Screen, Operation: operation}})
|
||||
}
|
||||
if a.Target != nil {
|
||||
values = append(values, pdfgo.NavigationAction{Rendition: &pdfgo.RenditionAction{Target: a.Target.Screen, Rendition: a.Target.Rendition}})
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// writeVideo 在绘制后读取被播放的视频,原样嵌入并关联输出页面
|
||||
// 入参: ctx 取消上下文, pages 输出页引用, options 替换配置
|
||||
// 返回: error 资源读取或取消错误
|
||||
func (n *pdfNavigation) writeVideo(ctx context.Context, pages []pdfgo.Reference, options *pdfgo.RewriteOptions) error {
|
||||
files := make(map[string]*pdfgo.FileSpecification)
|
||||
for _, id := range slices.Sorted(maps.Keys(n.video)) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
resource := n.video[id]
|
||||
for _, screen := range resource.Screens {
|
||||
if screen.Page < 0 || screen.Page >= len(pages) {
|
||||
return fmt.Errorf("missing movie playback page")
|
||||
}
|
||||
if options.ScreenAnnotations == nil {
|
||||
options.ScreenAnnotations = make(map[pdfgo.Reference][]*pdfgo.ScreenAnnotation)
|
||||
}
|
||||
ref := pages[screen.Page]
|
||||
options.ScreenAnnotations[ref] = append(options.ScreenAnnotations[ref], screen.Screen)
|
||||
files[id] = resource.Clip.File
|
||||
}
|
||||
}
|
||||
if err := n.loadMediaFiles(ctx, files); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range slices.Sorted(maps.Keys(files)) {
|
||||
clip := n.video[id].Clip
|
||||
if clip.ContentType == "" {
|
||||
kind := http.DetectContentType(clip.File.Embedded.Data)
|
||||
if !strings.HasPrefix(kind, "video/") && kind != "application/ogg" {
|
||||
return fmt.Errorf("cannot identify video content type: %s", id)
|
||||
}
|
||||
clip.ContentType = kind
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// pdfVideoContentType 转换明确声明的视频格式,未知格式留待检查文件头
|
||||
// 入参: media 视频资源
|
||||
// 返回: string 媒体类型,无法确定时为空
|
||||
func pdfVideoContentType(media MultiMedia) string {
|
||||
format := strings.ToLower(strings.TrimSpace(media.Format))
|
||||
if format == "" {
|
||||
format = strings.TrimPrefix(strings.ToLower(path.Ext(media.MediaFile)), ".")
|
||||
}
|
||||
switch format {
|
||||
case "mp4", "m4v":
|
||||
return "video/mp4"
|
||||
case "mpeg", "mpg":
|
||||
return "video/mpeg"
|
||||
case "mov", "qt":
|
||||
return "video/quicktime"
|
||||
case "webm":
|
||||
return "video/webm"
|
||||
case "ogv", "ogg":
|
||||
return "video/ogg"
|
||||
case "avi":
|
||||
return "video/x-msvideo"
|
||||
case "wmv":
|
||||
return "video/x-ms-wmv"
|
||||
case "3gp":
|
||||
return "video/3gpp"
|
||||
case "3g2":
|
||||
return "video/3gpp2"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// soundAction 保留音量、循环及等待语义,PDF同步播放同时限制交互
|
||||
// 入参: action OFD音频动作
|
||||
// 返回: *pdfNavigationAction PDF音频动作, error 资源或参数错误
|
||||
func (n *pdfNavigation) soundAction(action Sound) (*pdfNavigationAction, error) {
|
||||
volume := 100
|
||||
if action.Volume != nil {
|
||||
volume = *action.Volume
|
||||
}
|
||||
if volume < 0 || volume > 100 {
|
||||
return nil, fmt.Errorf("invalid sound volume")
|
||||
}
|
||||
file := n.audio[action.ResourceID]
|
||||
if file == nil {
|
||||
media, err := n.source.Media(action.ResourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if media.Type != "Audio" {
|
||||
return nil, fmt.Errorf("sound resource is not audio: %s", action.ResourceID)
|
||||
}
|
||||
if n.audio == nil {
|
||||
n.audio = make(map[string]*pdfgo.FileSpecification)
|
||||
}
|
||||
file = n.mediaFile(media)
|
||||
n.audio[action.ResourceID] = file
|
||||
}
|
||||
return &pdfNavigationAction{Sound: &pdfgo.SoundAction{
|
||||
Sound: pdfgo.Sound{File: file}, Volume: float64(volume) / 100,
|
||||
Repeat: action.Repeat, Synchronous: action.Synchronous && !action.Repeat,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// loadAudio 在绘制后读取自描述音频文件,原始内容不作采样转换
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 资源读取或取消错误
|
||||
func (n *pdfNavigation) loadAudio(ctx context.Context) error {
|
||||
return n.loadMediaFiles(ctx, n.audio)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"github.com/tdewolff/canvas"
|
||||
canvastext "github.com/tdewolff/canvas/text"
|
||||
canvasfont "github.com/tdewolff/font"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// RenderText 记录后端文字区段,写出时校验数量并关联原文
|
||||
// 入参: text 画布文字, matrix 文字变换
|
||||
func (r *pdfRenderer) RenderText(text *canvas.Text, matrix canvas.Matrix) {
|
||||
if len(r.text) == 0 {
|
||||
r.text = append(r.text, pdfPageText{})
|
||||
}
|
||||
page := &r.text[len(r.text)-1]
|
||||
text.WalkSpans(func(_, _ float64, span canvas.TextSpan) {
|
||||
if span.IsText() {
|
||||
page.count++
|
||||
}
|
||||
})
|
||||
r.PDF.RenderText(text, matrix)
|
||||
}
|
||||
|
||||
// preserveText 将一个OFD文字对象关联到完整PDF文字区段
|
||||
// 入参: first 起始文字对象序号, text 原文
|
||||
func (r *pdfRenderer) preserveText(first int, text string) {
|
||||
page := &r.text[len(r.text)-1]
|
||||
if page.count > first {
|
||||
page.replacements = append(page.replacements, pdfgo.TextReplacement{First: first, Count: page.count - first, Text: text})
|
||||
r.exactText = true
|
||||
}
|
||||
}
|
||||
|
||||
// glyphText 使用独立输出字体和既定字形建立PDF文字,不改变字体缓存及定位
|
||||
// 入参: face 字体, glyph 字形
|
||||
// 返回: *canvas.Text 画布文字, error 字体解析错误
|
||||
func (r *pdfRenderer) glyphText(face *canvas.FontFace, glyph textGlyph) (*canvas.Text, error) {
|
||||
font := r.fonts[face.Font]
|
||||
if font == nil {
|
||||
sfnt, err := canvasfont.ParseSFNT(fontSFNTData(face.Font.SFNT), 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy := *face.Font
|
||||
copy.SFNT = sfnt
|
||||
font = ©
|
||||
if r.fonts == nil {
|
||||
r.fonts = make(map[*canvas.Font]*canvas.Font)
|
||||
}
|
||||
r.fonts[face.Font] = font
|
||||
}
|
||||
outputFace := *face
|
||||
outputFace.Font = font
|
||||
face = &outputFace
|
||||
if glyph.GlyphID < 0 || glyph.GlyphID > 0xffff {
|
||||
return canvas.NewTextLine(face, glyph.Text, canvas.Left), nil
|
||||
}
|
||||
id := uint16(glyph.GlyphID)
|
||||
text := canvas.NewTextLine(face, " ", canvas.Left)
|
||||
text.WalkLines(func(_ float64, spans []canvas.TextSpan) {
|
||||
for i := range spans {
|
||||
spans[i].Text = glyph.Text
|
||||
spans[i].Glyphs = []canvastext.Glyph{{SFNT: face.Font.SFNT, Size: face.Size, ID: id, XAdvance: int32(face.Font.GlyphAdvance(id)), Text: glyph.Text}}
|
||||
}
|
||||
})
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// pdfTextSupported 核对原生文字的字形和未裁剪范围,复用实际绘制的字形缓存
|
||||
// 入参: object 文字对象, fontID 字体标识, face 实际字体, transforms 字形映射, clip 裁剪路径, height 页面高度, matrix 对象变换, glyphMatrix 字形变换
|
||||
// 返回: bool 是否能完整保留字形外观
|
||||
func (r *Renderer) pdfTextSupported(object TextObject, fontID string, face *canvas.FontFace, transforms map[int]textGlyphTransform, clip *canvas.Path, height float64, matrix Matrix, glyphMatrix canvas.Matrix) bool {
|
||||
if face.Font.IsCFF && face.Font.CFF == nil {
|
||||
return false
|
||||
}
|
||||
var rect canvas.Rect
|
||||
if clip != nil {
|
||||
var ok bool
|
||||
rect, ok = rectangularPath(clip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
const tolerance = 1e-9
|
||||
rect = canvas.Rect{X0: rect.X0 - tolerance, Y0: rect.Y0 - tolerance, X1: rect.X1 + tolerance, Y1: rect.Y1 + tolerance}
|
||||
}
|
||||
scale := object.HScale
|
||||
if scale == 0 {
|
||||
scale = 1
|
||||
}
|
||||
position := 0
|
||||
for _, code := range object.TextCode {
|
||||
runes := textCodeRunes(code.Value)
|
||||
glyphs := textCodeGlyphs(runes, transforms, position)
|
||||
if code.Index != "" {
|
||||
runes = r.parseIndexRunes(code.Index, fontID)
|
||||
glyphs = textRuneGlyphs(runes)
|
||||
}
|
||||
positioner := NewTextPositioner(code)
|
||||
for _, glyph := range glyphs {
|
||||
if glyph.GlyphID == 0 || glyph.GlyphID >= int(face.Font.NumGlyphs()) {
|
||||
return false
|
||||
}
|
||||
if glyph.GlyphID < 0 {
|
||||
for _, char := range glyph.Text {
|
||||
if face.Font.GlyphIndex(char) == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
origin := positioner.Next()
|
||||
if clip == nil {
|
||||
continue
|
||||
}
|
||||
path, _ := r.cachedTextGlyphPath(face, glyph)
|
||||
if path == nil || r.renderError != nil {
|
||||
return false
|
||||
}
|
||||
if path.Empty() {
|
||||
continue
|
||||
}
|
||||
x, y := matrix.Transform(origin.X, origin.Y)
|
||||
view := canvas.Identity.Translate(x, height-y).Mul(glyphMatrix).Scale(scale, 1)
|
||||
if !rect.Contains(path.Copy().Transform(view).Bounds()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
position += len(runes)
|
||||
}
|
||||
return true
|
||||
}
|
||||
+26
-10
@@ -24,15 +24,15 @@ import (
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// writePDF 合并图像、导航修正及压缩,直接写出最终PDF
|
||||
// writePDF 合并图像、文字、导航修正及压缩,直接写出最终PDF
|
||||
// 入参: ctx 取消上下文, data 后端PDF数据, writer 输出流, optimization 优化配置
|
||||
// 返回: error 资源、压缩或写入错误
|
||||
func (r *pdfRenderer) writePDF(ctx context.Context, data []byte, writer io.Writer, optimization pdfgo.OptimizeOptions) error {
|
||||
if r.imageError != nil {
|
||||
return r.imageError
|
||||
}
|
||||
exactLinks := r.navigation != nil && r.navigation.exactLinks
|
||||
if !r.exactImages && !exactLinks && optimization.Compression.Mode == CompressionUnchanged {
|
||||
nativeWrite := r.navigation != nil && r.navigation.nativeWrite
|
||||
if !r.exactImages && !r.exactText && !nativeWrite && optimization.Compression.Mode == CompressionUnchanged {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -47,12 +47,13 @@ func (r *pdfRenderer) writePDF(ctx context.Context, data []byte, writer io.Write
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
if !r.exactImages && !exactLinks {
|
||||
if !r.exactImages && !r.exactText && !nativeWrite {
|
||||
_, err := reader.OptimizeTo(ctx, writer, optimization)
|
||||
return err
|
||||
}
|
||||
images := make(map[pdfgo.Reference]image.Image)
|
||||
links := make(map[pdfgo.AnnotationLocation]pdfgo.LinkRegion)
|
||||
texts := make(map[pdfgo.Reference][]pdfgo.TextReplacement)
|
||||
references := make([]pdfgo.Reference, len(r.images))
|
||||
pages := 0
|
||||
err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error {
|
||||
@@ -61,7 +62,7 @@ func (r *pdfRenderer) writePDF(ctx context.Context, data []byte, writer io.Write
|
||||
}
|
||||
pages++
|
||||
references[index] = page.Reference
|
||||
if exactLinks {
|
||||
if nativeWrite {
|
||||
annotations, err := page.AnnotationsContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -79,7 +80,7 @@ func (r *pdfRenderer) writePDF(ctx context.Context, data []byte, writer io.Write
|
||||
}
|
||||
}
|
||||
}
|
||||
if !r.exactImages {
|
||||
if !r.exactImages && !r.exactText {
|
||||
return nil
|
||||
}
|
||||
value, err := reader.Resolve(page.Resources["XObject"])
|
||||
@@ -92,7 +93,14 @@ func (r *pdfRenderer) writePDF(ctx context.Context, data []byte, writer io.Write
|
||||
return err
|
||||
}
|
||||
position := 0
|
||||
textCount := 0
|
||||
if err := pdfgo.WalkOperations(ctx, content.Bytes(), func(operation pdfgo.Operation) error {
|
||||
if operation.Operator == "BT" {
|
||||
textCount++
|
||||
}
|
||||
if !r.exactImages {
|
||||
return nil
|
||||
}
|
||||
if operation.Operator != "Do" {
|
||||
return nil
|
||||
}
|
||||
@@ -127,9 +135,17 @@ func (r *pdfRenderer) writePDF(ctx context.Context, data []byte, writer io.Write
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if position != len(r.images[index]) {
|
||||
if r.exactImages && position != len(r.images[index]) {
|
||||
return fmt.Errorf("PDF output image missing")
|
||||
}
|
||||
if r.exactText {
|
||||
if index >= len(r.text) || textCount != r.text[index].count {
|
||||
return fmt.Errorf("PDF output text count differs")
|
||||
}
|
||||
if len(r.text[index].replacements) != 0 {
|
||||
texts[page.Reference] = r.text[index].replacements
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -138,9 +154,9 @@ func (r *pdfRenderer) writePDF(ctx context.Context, data []byte, writer io.Write
|
||||
if pages != len(r.images) {
|
||||
return fmt.Errorf("PDF output page missing")
|
||||
}
|
||||
options := pdfgo.RewriteOptions{Images: images, ImageOptions: pdfgo.ImageWriteOptions{PreblendBinary: true}, LinkRegions: links, Optimization: optimization}
|
||||
if exactLinks {
|
||||
if err := r.navigation.destinations(ctx, reader, references, &options); err != nil {
|
||||
options := pdfgo.RewriteOptions{Images: images, ImageOptions: pdfgo.ImageWriteOptions{PreblendBinary: true}, LinkRegions: links, TextReplacements: texts, Optimization: optimization}
|
||||
if nativeWrite {
|
||||
if err := r.navigation.prepareRewrite(ctx, reader, references, &options); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,21 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
||||
glyphTransforms := r.textObjectGlyphTransforms(fontID, obj)
|
||||
hasUnderline := strings.Contains(obj.Decoration, "Underline")
|
||||
_, shadedFill := fillPaint.(canvas.Gradient)
|
||||
pdfOutput, _ := ctx.Renderer.(*pdfRenderer)
|
||||
pdfText := pdfOutput != nil && !r.textOnly && shouldFill && !shouldStroke && fillPattern == nil && !shadedFill && face.FauxBold == 0 && face.FauxItalic == 0 && !(hasUnderline && fillClip != nil)
|
||||
if pdfText {
|
||||
view := canvas.Identity
|
||||
if useTextMatrix {
|
||||
view = glyphMatrix
|
||||
}
|
||||
pdfText = r.pdfTextSupported(obj, fontID, face, glyphTransforms, fillClip, pageH, objectCTM, view)
|
||||
}
|
||||
if pdfText {
|
||||
if len(pdfOutput.text) == 0 {
|
||||
pdfOutput.text = append(pdfOutput.text, pdfPageText{})
|
||||
}
|
||||
defer pdfOutput.preserveText(pdfOutput.text[len(pdfOutput.text)-1].count, obj.Text())
|
||||
}
|
||||
codePos := 0
|
||||
textPos := 0
|
||||
for index, tc := range obj.TextCode {
|
||||
@@ -238,7 +253,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
||||
}
|
||||
var glyphPath *canvas.Path
|
||||
var glyphWidth float64
|
||||
if drawAsGlyphPath {
|
||||
if drawAsGlyphPath && !pdfText {
|
||||
glyphPath, glyphWidth = r.cachedTextGlyphPath(face, glyph)
|
||||
} else {
|
||||
glyphWidth = textGlyphWidth(face, glyph)
|
||||
@@ -325,7 +340,10 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
||||
ctx.SetFill(fillPaint)
|
||||
ctx.SetStrokeColor(canvas.Transparent)
|
||||
glyphClip := fillClip
|
||||
if fillClip != nil && !embeddedFont && face.FauxBold == 0 && !textCodePositioned(tc, xs, ys) && !shadedFill && fillPattern == nil && glyph.GlyphID < 0 && !hasUnderline {
|
||||
if pdfText {
|
||||
glyphClip = nil
|
||||
}
|
||||
if !pdfText && fillClip != nil && !embeddedFont && face.FauxBold == 0 && !textCodePositioned(tc, xs, ys) && !shadedFill && fillPattern == nil && glyph.GlyphID < 0 && !hasUnderline {
|
||||
if rect, ok := rectangularPath(fillClip); ok {
|
||||
transform := canvas.Identity.Translate(canvasX, canvasY)
|
||||
if useTextMatrix {
|
||||
@@ -374,7 +392,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
||||
continue
|
||||
}
|
||||
drawGlyph := func(x, y float64) {
|
||||
if drawAsGlyphPath {
|
||||
if drawAsGlyphPath && !pdfText {
|
||||
scaleX := hScale
|
||||
textWidth = glyphWidth * scaleX
|
||||
if face.FauxBold != 0 {
|
||||
@@ -399,7 +417,20 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
||||
ctx.Scale(hScale, 1)
|
||||
x, y = 0, 0
|
||||
}
|
||||
text := canvas.NewTextLine(face, str, canvas.Left)
|
||||
var text *canvas.Text
|
||||
if pdfOutput != nil {
|
||||
var err error
|
||||
text, err = pdfOutput.glyphText(face, glyph)
|
||||
if err != nil {
|
||||
r.renderError = err
|
||||
if scaled {
|
||||
ctx.Pop()
|
||||
}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
text = canvas.NewTextLine(face, str, canvas.Left)
|
||||
}
|
||||
ctx.DrawText(x, y, text)
|
||||
if scaled {
|
||||
ctx.Pop()
|
||||
|
||||
+19
-2
@@ -419,14 +419,16 @@ func (e *Editor) writePartsWithPages(write func(string, []byte, bool) error, pro
|
||||
return err
|
||||
}
|
||||
}
|
||||
written := make(map[string]bool)
|
||||
for _, resources := range [][]editorResource{fonts, images} {
|
||||
for _, resource := range resources {
|
||||
if resource.name == "" {
|
||||
if resource.name == "" || written[resource.name] {
|
||||
continue
|
||||
}
|
||||
if err := write(resource.name, resource.data, resource.image != nil); err != nil {
|
||||
return err
|
||||
}
|
||||
written[resource.name] = true
|
||||
}
|
||||
}
|
||||
for _, resource := range e.resources {
|
||||
@@ -445,6 +447,13 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color
|
||||
used := make(map[string]bool)
|
||||
promoted := make(map[string]bool)
|
||||
files := make(map[string]bool)
|
||||
retainAll := false
|
||||
if len(e.outlines) != 0 {
|
||||
refs := editorResourceRefs{ids: used, files: make(map[string]bool)}
|
||||
if _, safe := refs.scan(bytes.NewReader(e.outlines), e.packageName("Document.xml")); !safe {
|
||||
retainAll = true
|
||||
}
|
||||
}
|
||||
if e.source != nil {
|
||||
collectActionReferences(e.source.document.Actions, used)
|
||||
for _, name := range e.annotationFiles() {
|
||||
@@ -454,6 +463,11 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color
|
||||
}
|
||||
refs := editorResourceRefs{ids: used, files: make(map[string]bool)}
|
||||
if _, safe := refs.scan(bytes.NewReader(data), name); !safe {
|
||||
retainAll = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if retainAll {
|
||||
for _, resource := range e.resources {
|
||||
if resource.font != nil {
|
||||
used[resource.font.ID] = true
|
||||
@@ -467,7 +481,7 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color
|
||||
used[resource.definition()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.source != nil {
|
||||
maps.Copy(promoted, used)
|
||||
}
|
||||
for _, page := range e.pages {
|
||||
@@ -1188,6 +1202,9 @@ func (x *ofdXML) actions(actions []Action) {
|
||||
return
|
||||
}
|
||||
var attrs ofdAttrs
|
||||
if command.Type == "Arc" && command.EllipseSize == "" {
|
||||
attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "EllipseSize"}})
|
||||
}
|
||||
for _, pair := range [][2]string{{"Point1", command.Point1}, {"Point2", command.Point2}, {"Point3", command.Point3}, {"EllipseSize", command.EllipseSize}, {"RotationAngle", command.RotationAngle}, {"LargeArc", command.LargeArc}, {"SweepDirection", command.SweepDirection}, {"EndPoint", command.EndPoint}} {
|
||||
attrs.add(pair[0], pair[1])
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user