feat(能力完善): 完善转换保真,修复交互缺陷
build.yaml / build (push) Successful in 2m26s

This commit is contained in:
xiaoqidun committed 2026-10-10 09:48:05 +08:00
1 parent 0ad34ee2b1
commit 0ad2bda1f7
47 files changed
+2849 -522

No files matched your search

+34
View File
@@ -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;
}
+242 -66
View File
@@ -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();
setStatus(fontsRestored ? STATUS.ready : "字体读取失败");
setBusy(false);
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) {
setStatus("引擎加载失败");
setEmpty(String(err.message || err));
setBusy(false);
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,22 +6043,95 @@ function mountPageSVG(index, page, openSeq = state.openSeq) {
function releaseMediaPlayers(surface) {
for (const [id, entry] of state.mediaPlayers || []) {
if (surface && entry.surface !== surface) continue;
for (const [requestID, request] of wasmRequests) {
if (request.mediaID === id) wasmWorker?.postMessage({type: "cancel", id: requestID});
}
entry.finish?.();
if (entry.element) {
entry.element.pause();
entry.element.removeAttribute("src");
entry.element.load();
entry.element.remove();
}
if (entry.url) URL.revokeObjectURL(entry.url);
state.mediaPlayers.delete(id);
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");
entry.element.load();
entry.element.remove();
}
if (entry.url) URL.revokeObjectURL(entry.url);
}
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) {
const action = link.sound || link.movie;
const id = action?.resourceID;
@@ -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 (operator === "Stop") {
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;
}
if (outline.page) renderPage(outline.page - 1);
activateOutline(current);
});
}
row.append(link);
+8
View File
@@ -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 => {
+7 -7
View File
@@ -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
+14 -14
View File
@@ -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=
+117 -46
View File
@@ -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,52 +833,9 @@ 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()
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
links, err := linkResults(page.Links)
if err != nil {
return nil, err
}
fonts := make([]any, len(page.Fonts))
for i, font := range page.Fonts {
@@ -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 错误信息
+46 -22
View File
@@ -66,21 +66,24 @@ 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
Dest *Dest
Attachment string
NewWindow *bool
Path string
Group int
Sound *Sound
Movie *Movie
URI string
Target string
Box Box
PlaybackBox *Box
Dest *Dest
Attachment string
NewWindow *bool
Path string
Group int
Sound *Sound
Movie *Movie
}
// GotoA 附件动作
@@ -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
View File
@@ -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
+6 -5
View File
@@ -167,10 +167,11 @@ type OutlineElem struct {
// OutlineInfo 目录节点信息,Page从1开始,0表示没有有效的文档内目标页
type OutlineInfo struct {
Title string `json:"title"`
Expanded bool `json:"expanded"`
Page int `json:"page,omitempty"`
Children []OutlineInfo `json:"children,omitempty"`
Title string `json:"title"`
Expanded bool `json:"expanded"`
Page int `json:"page,omitempty"`
ActionCount int `json:"actionCount,omitempty"`
Children []OutlineInfo `json:"children,omitempty"`
}
// Permissions 权限声明
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 基本对象
+34
View File
@@ -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
}
+46
View File
@@ -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 错误信息
+64
View File
@@ -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")
}
+9
View File
@@ -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
View File
@@ -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
}
+12 -8
View File
@@ -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", &currentPage)
actions, err := p.eventActions(ctx, object, "DO", pdfgo.Annotation{}, &currentPage)
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
})
+34 -43
View File
@@ -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)
if err != nil {
return err
}
number, ok := resolved.(pdfgo.Integer)
if !ok {
return fmt.Errorf("invalid PDF annotation flags")
}
flags = int64(number)
flags, err := p.reader.ReadAnnotationFlags(annotation)
if err != nil {
return err
}
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
View File
@@ -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, &currentPage)
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, &currentPage)
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()
}
+49
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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, &currentPage)
converted, err := p.linkActions(current, strict, &currentPage)
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
View File
@@ -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()
}
+221
View File
@@ -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
}
+3
View File
@@ -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
+8 -5
View File
@@ -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, &currentPage)
converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, &currentPage)
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
}
+155 -50
View File
@@ -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}
}
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF screen appearance retained; " + reason})
delete(appearance.Dictionary, "AA")
flags, err := p.reader.ReadAnnotationFlags(annotation)
if err != nil {
return err
}
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, &currentPage)
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
}
operation, err := p.reader.Resolve(action["OP"])
if err != nil {
return 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 target, ok := action["AN"].(pdfgo.Reference); !ok || target != annotation.Reference || annotation.Dictionary["P"] != page.Reference {
return fallback("screen action target is unavailable")
}
rendition, err := p.reader.ReadRendition(ctx, action["R"])
if err != nil {
if ctx.Err() != nil {
return ctx.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
}
return fallback("media rendition unavailable: " + err.Error())
return nil, err
}
selected, err := rendition.Select(ctx, func(candidate *pdfgo.Rendition) (bool, error) {
if playback.Operation == nil || *playback.Operation < 0 || *playback.Operation >= 4 {
return nil, p.renditionActionLoss("conditional rendition playback or script", strict)
}
if _, ok := p.pages[playback.Page]; !ok {
return nil, p.renditionActionLoss("screen page is outside the document", strict)
}
if *playback.Operation != 0 {
return p.renditionControls(playback.Annotation.Reference, *playback.Operation, "", strict)
}
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 err != nil {
return err
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 nil, err
}
window := pdfgo.Integer(3)
if value, ok := parameters["W"].(pdfgo.Integer); ok {
window = value
}
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
}
}
}
if !matches {
if err := p.renditionActionLoss("media playback window differs in OFD", strict); err != nil {
return nil, err
}
}
}
if !available {
return p.appearanceAnnotation(ctx, page, appearance)
id, err := p.renditionResource(selected, kind, playback)
if err != nil || id == "" {
return nil, err
}
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.editor.AddMedia(kind, format, data)
if err != nil {
return 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 条件字典或引用错误
+213
View File
@@ -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()
}
+301
View File
@@ -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, " ")}}
}
+66 -14
View File
@@ -14,6 +14,8 @@
package ofdgo
import "fmt"
// RenderPage 编译页面为与绘图库无关的只读绘制数据
// 入参: page 页面内容
// 返回: *RasterPage 绘制页面, error 错误信息
@@ -42,34 +44,80 @@ 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
}
box, path, err := r.actionLinkRegion(source, action)
if err != nil {
return nil, err
var link PageLink
if event == "CLICK" {
box, path, err := r.actionLinkRegion(source, action)
if err != nil {
return nil, err
}
if box.W <= 0 || box.H <= 0 {
continue
}
link.Box, link.Path = box, path
}
if box.W <= 0 || box.H <= 0 {
continue
}
link := PageLink{Box: box, Path: 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)
}
}
+7 -4
View File
@@ -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()
+3 -2
View File
@@ -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
+13 -9
View File
@@ -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]
+55
View File
@@ -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()
}
+109 -16
View File
@@ -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
Link map[int][]pdfLink
Outline []pdfOutline
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
URI string
Target *pdfTarget
Attachment *pdfgo.AttachmentAction
Sound *pdfgo.SoundAction
Movie *pdfMovieAction
}
// RenderImage 校验惰性图片并同步不透明画笔,保留解码错误及后端透明度状态
@@ -135,7 +154,39 @@ func (r *pdfRenderer) glyphPath(path *canvas.Path, matrix canvas.Matrix) *canvas
// 返回: *pdfNavigation PDF导航信息, error 区域或目标错误
func newPDFNavigation(renderer *Renderer, doc *Document, pages []RenderDocumentPage) (*pdfNavigation, error) {
navigation := &pdfNavigation{
Link: make(map[int][]pdfLink),
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 {
+49
View File
@@ -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
}
+69
View File
@@ -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()
}
+203
View File
@@ -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 ""
}
+61
View File
@@ -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)
}
+145
View File
@@ -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 = &copy
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
View File
@@ -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
}
}
+35 -4
View File
@@ -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()
+30 -13
View File
@@ -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,20 +463,25 @@ 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 {
for _, resource := range e.resources {
if resource.font != nil {
used[resource.font.ID] = true
}
if resource.image != nil {
used[resource.image.ID] = true
}
if resource.space != nil {
used[resource.space.ID] = true
}
used[resource.definition()] = true
}
retainAll = true
}
}
}
if retainAll {
for _, resource := range e.resources {
if resource.font != nil {
used[resource.font.ID] = true
}
if resource.image != nil {
used[resource.image.ID] = true
}
if resource.space != nil {
used[resource.space.ID] = true
}
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])
}