diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 43bf632..42e378c 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -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; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 3987474..f2067bb 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -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); diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index 4f0d9ac..3e2763a 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -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 => { diff --git a/go.mod b/go.mod index 8742344..4f5d5be 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index f197c22..c56030e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 00a5ef9..dd37271 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -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 错误信息 diff --git a/ofdgo_action.go b/ofdgo_action.go index b69255c..beec843 100644 --- a/ofdgo_action.go +++ b/ofdgo_action.go @@ -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 } diff --git a/ofdgo_convert.go b/ofdgo_convert.go index 7558b4d..166a055 100644 --- a/ofdgo_convert.go +++ b/ofdgo_convert.go @@ -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 diff --git a/ofdgo_doc.go b/ofdgo_doc.go index b52af76..2243a04 100644 --- a/ofdgo_doc.go +++ b/ofdgo_doc.go @@ -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 { diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 279f775..9e65174 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -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 { diff --git a/ofdgo_editor_action.go b/ofdgo_editor_action.go index f2c1958..093bee5 100644 --- a/ofdgo_editor_action.go +++ b/ofdgo_editor_action.go @@ -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 复用动作编码并补齐独立片段的命名空间,空列表不输出节点 diff --git a/ofdgo_editor_link.go b/ofdgo_editor_link.go index f588181..f51876c 100644 --- a/ofdgo_editor_link.go +++ b/ofdgo_editor_link.go @@ -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 基本对象 diff --git a/ofdgo_editor_media.go b/ofdgo_editor_media.go index 940a519..a4b053a 100644 --- a/ofdgo_editor_media.go +++ b/ofdgo_editor_media.go @@ -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 +} diff --git a/ofdgo_editor_outline.go b/ofdgo_editor_outline.go index 21edc67..39303df 100644 --- a/ofdgo_editor_outline.go +++ b/ofdgo_editor_outline.go @@ -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 错误信息 diff --git a/ofdgo_format.go b/ofdgo_format.go new file mode 100644 index 0000000..730b223 --- /dev/null +++ b/ofdgo_format.go @@ -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") +} diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index dde31a2..7dcd5a1 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -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) } diff --git a/ofdgo_pdf_3d.go b/ofdgo_pdf_3d.go index 6971630..6ebae92 100644 --- a/ofdgo_pdf_3d.go +++ b/ofdgo_pdf_3d.go @@ -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 } diff --git a/ofdgo_pdf_action_lifecycle.go b/ofdgo_pdf_action_lifecycle.go index 8fc5f04..fd3fa91 100644 --- a/ofdgo_pdf_action_lifecycle.go +++ b/ofdgo_pdf_action_lifecycle.go @@ -42,7 +42,7 @@ func (p *pdfImporter) documentOpenActions(ctx context.Context) error { object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": array} } currentPage := 0 - actions, err := p.eventActions(ctx, object, "DO", ¤tPage) + actions, err := p.eventActions(ctx, object, "DO", pdfgo.Annotation{}, ¤tPage) if err != nil { return err } @@ -80,7 +80,7 @@ func (p *pdfImporter) pageOpenActions(ctx context.Context, page *pdfgo.Page) err } continue } - actions, err := p.eventActions(ctx, trigger.Action, "PO", &p.pageActionPage) + actions, err := p.eventActions(ctx, trigger.Action, "PO", pdfgo.Annotation{}, &p.pageActionPage) if err != nil { return err } @@ -92,14 +92,18 @@ func (p *pdfImporter) pageOpenActions(ctx context.Context, page *pdfgo.Page) err } // eventActions 按原顺序转换动作链,保留事件类型与链内跳转页状态 -// 入参: ctx 取消上下文, object 动作对象, event OFD事件, currentPage 当前页面 +// 入参: ctx 取消上下文, object 动作对象, event OFD事件, source 触发注解,零值表示非图元动作, currentPage 当前页面 // 返回: []Action 转换动作, error 动作或取消错误 -func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, event string, currentPage *int) ([]Action, error) { +func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, event string, source pdfgo.Annotation, currentPage *int) ([]Action, error) { var actions []Action err := p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error { - annotation := pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}} - action, err := p.linkAction(annotation, p.warning == nil, currentPage) + annotation := source + annotation.Dictionary = pdfgo.Dictionary{"A": info.Dictionary} + converted, err := p.linkActions(annotation, p.warning == nil, currentPage) if err != nil { + if errors.Is(err, errPDFMediaRead) { + return err + } var unsupported *pdfgo.UnsupportedError if event != "CLICK" && p.warning != nil && errors.Is(err, pdfgo.ErrInvalidAction) { p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF %s action ignored: %v", event, err)}) @@ -115,9 +119,9 @@ func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, eve } return err } - if action != nil { + for _, action := range converted { action.Event = event - actions = append(actions, *action) + actions = append(actions, action) } return nil }) diff --git a/ofdgo_pdf_annot.go b/ofdgo_pdf_annot.go index ea320e0..af36a52 100644 --- a/ofdgo_pdf_annot.go +++ b/ofdgo_pdf_annot.go @@ -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 } diff --git a/ofdgo_pdf_annot_trigger.go b/ofdgo_pdf_annot_trigger.go index 815fa94..9ce5c51 100644 --- a/ofdgo_pdf_annot_trigger.go +++ b/ofdgo_pdf_annot_trigger.go @@ -21,55 +21,64 @@ import ( "github.com/xiaoqidun/pdfgo" ) -// annotationTriggerActions 将释放点击和页面打开事件转换为OFD,不改变其他事件的触发条件 -// 入参: ctx 取消上下文, annotation PDF注解 -// 返回: []Action 点击动作, []Action 页面打开动作, error 事件、动作或取消错误 -func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation pdfgo.Annotation) ([]Action, []Action, error) { - if annotation.Subtype == "Widget" { - return nil, nil, nil +// annotationTriggerActions 按事件转换注解动作,页面打开动作独立于注解外观 +// 入参: ctx 取消上下文, annotation PDF注解, event OFD事件 +// 返回: []Action 动作序列, error 事件、动作或取消错误 +func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation pdfgo.Annotation, event string) ([]Action, error) { + if annotation.Subtype == "Widget" && event == "CLICK" { + return nil, ctx.Err() } triggers, err := p.reader.ReadAnnotationTriggers(ctx, annotation) if err != nil { - return nil, nil, err + return nil, err } if len(triggers) == 0 { - return nil, nil, nil + return nil, ctx.Err() } flags, err := p.reader.ReadAnnotationFlags(annotation) if err != nil { - return nil, nil, err + return nil, err } - var clicks, opened []Action + var converted []Action for _, trigger := range triggers { + if trigger.Event == "PC" || trigger.Event == "PV" || trigger.Event == "PI" { + if event == "PO" { + if err := p.unmappedTrigger(ctx, "annotation", trigger); err != nil { + return nil, err + } + } + continue + } + if (trigger.Event == "PO") != (event == "PO") { + continue + } if flags&64 != 0 && (trigger.Event == "E" || trigger.Event == "X" || trigger.Event == "D" || trigger.Event == "U") { continue } - event := "" switch trigger.Event { - case "U": - event = "CLICK" - case "PO": - event = "PO" + case "U", "PO": default: - return nil, nil, &pdfgo.UnsupportedError{Feature: "annotation trigger " + string(trigger.Event)} + return nil, &pdfgo.UnsupportedError{Feature: "annotation trigger " + string(trigger.Event)} } currentPage := p.page if event == "PO" { currentPage = p.pageActionPage } - actions, err := p.eventActions(ctx, trigger.Action, event, ¤tPage) - if err != nil { - return nil, nil, err - } + var source pdfgo.Annotation if event == "CLICK" { - clicks = append(clicks, actions...) - } else { + source = annotation + } + actions, err := p.eventActions(ctx, trigger.Action, event, source, ¤tPage) + if err != nil { + return nil, err + } + converted = append(converted, actions...) + if event == "PO" { p.pageActionPage = currentPage - opened = append(opened, actions...) } } - if len(opened) != 0 && (p.page < 0 || p.page >= len(p.editor.pages)) { - return nil, nil, fmt.Errorf("invalid annotation trigger page") + if event == "PO" && len(converted) != 0 && (p.page < 0 || p.page >= len(p.editor.pages)) { + return nil, fmt.Errorf("invalid annotation trigger page") } - return clicks, opened, ctx.Err() + return converted, ctx.Err() } diff --git a/ofdgo_pdf_attachment.go b/ofdgo_pdf_attachment.go new file mode 100644 index 0000000..c90592d --- /dev/null +++ b/ofdgo_pdf_attachment.go @@ -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 + }) +} diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go index b2a0fbd..ef0e39a 100644 --- a/ofdgo_pdf_composite.go +++ b/ofdgo_pdf_composite.go @@ -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 } diff --git a/ofdgo_pdf_composite_draw.go b/ofdgo_pdf_composite_draw.go index b47a44a..f8e88f2 100644 --- a/ofdgo_pdf_composite_draw.go +++ b/ofdgo_pdf_composite_draw.go @@ -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 } diff --git a/ofdgo_pdf_composite_pattern.go b/ofdgo_pdf_composite_pattern.go index e415ccf..0330de9 100644 --- a/ofdgo_pdf_composite_pattern.go +++ b/ofdgo_pdf_composite_pattern.go @@ -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 } diff --git a/ofdgo_pdf_composite_shading.go b/ofdgo_pdf_composite_shading.go index 8f7ee0b..956455c 100644 --- a/ofdgo_pdf_composite_shading.go +++ b/ofdgo_pdf_composite_shading.go @@ -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 } diff --git a/ofdgo_pdf_link.go b/ofdgo_pdf_link.go index 5757973..133359e 100644 --- a/ofdgo_pdf_link.go +++ b/ofdgo_pdf_link.go @@ -27,6 +27,7 @@ import ( // 入参: ctx 取消上下文, page PDF页面, strict 严格检查开关 // 返回: error 错误信息 func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict bool) error { + p.movieBounds = nil annotations, err := page.AnnotationsContext(ctx) if err != nil { return err @@ -41,6 +42,13 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict if err := ctx.Err(); err != nil { return err } + opened, err := p.annotationTriggerActions(ctx, annotation, "PO") + if err != nil { + return err + } + if len(opened) != 0 { + p.editor.pages[p.page].Actions = append(p.editor.pages[p.page].Actions, opened...) + } if annotation.Reference != (pdfgo.Reference{}) && annotation.Subtype != "Popup" { parents[annotation.Reference] = i } @@ -167,7 +175,7 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict continue } if annotation.Subtype == "Movie" { - if err := p.movieAnnotation(ctx, page, annotation); err != nil { + if err := p.movieAnnotation(ctx, page, annotation, strict); err != nil { return err } continue @@ -265,13 +273,11 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict appendAction := func(dict pdfgo.Dictionary) error { current := annotation current.Dictionary = dict - action, err := p.linkAction(current, strict, ¤tPage) + converted, err := p.linkActions(current, strict, ¤tPage) if err != nil { return err } - if action != nil { - actions = append(actions, *action) - } + actions = append(actions, converted...) return nil } if primary == nil { @@ -348,7 +354,31 @@ func (p *pdfImporter) linkRegion(annotation pdfgo.Annotation, box Box) (*Region, return region, nil } -// linkAction 将单个链接动作转换为OFD动作,失效目标按导入策略报告 +// linkActions 将单个PDF动作转换为有序OFD动作,保留动态媒体控制的目标范围 +// 入参: annotation 触发注解, strict 严格检查开关, currentPage 动作链当前页 +// 返回: []Action 动作序列,空切片表示无操作,nil表示未转换, error 转换错误 +func (p *pdfImporter) linkActions(annotation pdfgo.Annotation, strict bool, currentPage *int) ([]Action, error) { + value, err := p.reader.Resolve(annotation.Dictionary["A"]) + if err != nil { + return nil, err + } + if dict, ok := value.(pdfgo.Dictionary); ok { + kind, err := p.reader.Resolve(dict["S"]) + if err != nil { + return nil, err + } + if kind == pdfgo.Name("Rendition") { + return p.renditionLinkActions(dict, annotation, strict, *currentPage) + } + } + action, err := p.linkAction(annotation, strict, currentPage) + if err != nil || action == nil { + return nil, err + } + return []Action{*action}, nil +} + +// linkAction 将单个非呈现动作转换为OFD动作,失效目标按导入策略报告 // 入参: annotation 链接注解, strict 严格检查开关, currentPage 动作链当前页 // 返回: *Action 动作,失效目标为空, error 转换错误 func (p *pdfImporter) linkAction(annotation pdfgo.Annotation, strict bool, currentPage *int) (*Action, error) { @@ -383,7 +413,9 @@ func (p *pdfImporter) linkAction(annotation pdfgo.Annotation, strict bool, curre return p.launchLinkAction(a, strict) case pdfgo.Name("Sound"): return p.soundLinkAction(a, strict) - case pdfgo.Name("JavaScript"), pdfgo.Name("ResetForm"), pdfgo.Name("ImportData"), pdfgo.Name("Hide"), pdfgo.Name("Movie"): + case pdfgo.Name("Movie"): + return p.movieLinkAction(a, annotation, strict, *currentPage) + case pdfgo.Name("JavaScript"), pdfgo.Name("ResetForm"), pdfgo.Name("ImportData"), pdfgo.Name("Hide"): return p.staticLinkAction(a, kind.(pdfgo.Name), strict) case pdfgo.Name("GoToE"): converted, local, err := p.embeddedLinkAction(a, strict) @@ -528,8 +560,6 @@ func (p *pdfImporter) staticLinkAction(object pdfgo.Object, kind pdfgo.Name, str _, err = p.reader.ReadImportDataAction(p.ctx, object) case "Hide": _, err = p.reader.ReadHideAction(p.ctx, object) - case "Movie": - _, err = p.reader.ReadMovieAction(p.ctx, object) } if err != nil { return nil, err diff --git a/ofdgo_pdf_media.go b/ofdgo_pdf_media.go index 1855116..f9efe3d 100644 --- a/ofdgo_pdf_media.go +++ b/ofdgo_pdf_media.go @@ -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() } diff --git a/ofdgo_pdf_movie.go b/ofdgo_pdf_movie.go new file mode 100644 index 0000000..2530bd4 --- /dev/null +++ b/ofdgo_pdf_movie.go @@ -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 +} diff --git a/ofdgo_pdf_object.go b/ofdgo_pdf_object.go index df61c81..f695abf 100644 --- a/ofdgo_pdf_object.go +++ b/ofdgo_pdf_object.go @@ -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 diff --git a/ofdgo_pdf_outline.go b/ofdgo_pdf_outline.go index f6eb96c..6748804 100644 --- a/ofdgo_pdf_outline.go +++ b/ofdgo_pdf_outline.go @@ -36,7 +36,7 @@ func (p *pdfImporter) outlines(ctx context.Context) error { err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error { actions, err := p.outlineActions(ctx, item) if err != nil { - if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) { return err } p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline %q actions not transferred to OFD: %v", item.Title, err)}) @@ -68,7 +68,7 @@ func (p *pdfImporter) outlines(ctx context.Context) error { if ctx.Err() != nil { return ctx.Err() } - if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) { return err } p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outlines not transferred to OFD: %v", err)}) @@ -101,8 +101,11 @@ func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem currentPage := -1 var actions []Action err = p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error { - action, err := p.linkAction(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage) + converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage) if err != nil { + if errors.Is(err, errPDFMediaRead) { + return err + } var unsupported *pdfgo.UnsupportedError if p.warning != nil && (errors.As(err, &unsupported) || errors.Is(err, pdfgo.ErrInvalidDestination) || errors.Is(err, pdfgo.ErrInvalidAction)) { p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline action not transferred to OFD: %v", err)}) @@ -111,8 +114,8 @@ func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem } return err } - if action != nil { - actions = append(actions, *action) + if converted != nil { + actions = append(actions, converted...) } else { currentPage = -1 } diff --git a/ofdgo_pdf_rendition.go b/ofdgo_pdf_rendition.go index 8532f85..c9aa892 100644 --- a/ofdgo_pdf_rendition.go +++ b/ofdgo_pdf_rendition.go @@ -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, ¤tPage) + actions = append(actions, converted...) + return err + }); err != nil { return err } - action, ok := value.(pdfgo.Dictionary) - if !ok { - return fmt.Errorf("invalid screen action") - } - subtype, err := p.reader.Resolve(action["S"]) + return p.appearanceAnnotation(ctx, page, appearance, actions...) +} + +// renditionLinkActions 按屏幕关联转换播放及控制,切换视频时先停止其他候选 +// 入参: object PDF媒体动作, source 触发注解,零值表示非图元动作, strict 是否禁止语义损失, currentPage 动作链当前页 +// 返回: []Action 有序OFD动作,无法转换时为空, error 参数、资源或严格模式错误 +func (p *pdfImporter) renditionLinkActions(object pdfgo.Object, source pdfgo.Annotation, strict bool, currentPage int) ([]Action, error) { + playback, err := p.reader.ReadRenditionPlayback(p.ctx, object) if err != nil { - return err - } - 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 条件字典或引用错误 diff --git a/ofdgo_pdf_rendition_control.go b/ofdgo_pdf_rendition_control.go new file mode 100644 index 0000000..ad3d162 --- /dev/null +++ b/ofdgo_pdf_rendition_control.go @@ -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() +} diff --git a/ofdgo_pdf_text_replacement.go b/ofdgo_pdf_text_replacement.go new file mode 100644 index 0000000..f6ef2b7 --- /dev/null +++ b/ofdgo_pdf_text_replacement.go @@ -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, " ")}} +} diff --git a/ofdgo_render.go b/ofdgo_render.go index b68c664..8f61e6c 100644 --- a/ofdgo_render.go +++ b/ofdgo_render.go @@ -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) } } diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go index 66aa8aa..290c6b0 100644 --- a/ofdgo_renderer_geom_canvas.go +++ b/ofdgo_renderer_geom_canvas.go @@ -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() diff --git a/ofdgo_renderer_output_canvas.go b/ofdgo_renderer_output_canvas.go index b78ee12..bc7e1c8 100644 --- a/ofdgo_renderer_output_canvas.go +++ b/ofdgo_renderer_output_canvas.go @@ -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 diff --git a/ofdgo_renderer_pdf_action.go b/ofdgo_renderer_pdf_action.go index 3169ef6..d3da017 100644 --- a/ofdgo_renderer_pdf_action.go +++ b/ofdgo_renderer_pdf_action.go @@ -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] diff --git a/ofdgo_renderer_pdf_attachment.go b/ofdgo_renderer_pdf_attachment.go new file mode 100644 index 0000000..4094bbe --- /dev/null +++ b/ofdgo_renderer_pdf_attachment.go @@ -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() +} diff --git a/ofdgo_renderer_pdf_canvas.go b/ofdgo_renderer_pdf_canvas.go index 5bb6120..4438396 100644 --- a/ofdgo_renderer_pdf_canvas.go +++ b/ofdgo_renderer_pdf_canvas.go @@ -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 { diff --git a/ofdgo_renderer_pdf_lifecycle.go b/ofdgo_renderer_pdf_lifecycle.go new file mode 100644 index 0000000..9090c53 --- /dev/null +++ b/ofdgo_renderer_pdf_lifecycle.go @@ -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 +} diff --git a/ofdgo_renderer_pdf_media.go b/ofdgo_renderer_pdf_media.go new file mode 100644 index 0000000..3d9c9f9 --- /dev/null +++ b/ofdgo_renderer_pdf_media.go @@ -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() +} diff --git a/ofdgo_renderer_pdf_movie.go b/ofdgo_renderer_pdf_movie.go new file mode 100644 index 0000000..2cb9eb2 --- /dev/null +++ b/ofdgo_renderer_pdf_movie.go @@ -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 "" +} diff --git a/ofdgo_renderer_pdf_sound.go b/ofdgo_renderer_pdf_sound.go new file mode 100644 index 0000000..e983de1 --- /dev/null +++ b/ofdgo_renderer_pdf_sound.go @@ -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) +} diff --git a/ofdgo_renderer_pdf_text.go b/ofdgo_renderer_pdf_text.go new file mode 100644 index 0000000..5a3903b --- /dev/null +++ b/ofdgo_renderer_pdf_text.go @@ -0,0 +1,145 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "github.com/tdewolff/canvas" + canvastext "github.com/tdewolff/canvas/text" + canvasfont "github.com/tdewolff/font" + "github.com/xiaoqidun/pdfgo" +) + +// RenderText 记录后端文字区段,写出时校验数量并关联原文 +// 入参: text 画布文字, matrix 文字变换 +func (r *pdfRenderer) RenderText(text *canvas.Text, matrix canvas.Matrix) { + if len(r.text) == 0 { + r.text = append(r.text, pdfPageText{}) + } + page := &r.text[len(r.text)-1] + text.WalkSpans(func(_, _ float64, span canvas.TextSpan) { + if span.IsText() { + page.count++ + } + }) + r.PDF.RenderText(text, matrix) +} + +// preserveText 将一个OFD文字对象关联到完整PDF文字区段 +// 入参: first 起始文字对象序号, text 原文 +func (r *pdfRenderer) preserveText(first int, text string) { + page := &r.text[len(r.text)-1] + if page.count > first { + page.replacements = append(page.replacements, pdfgo.TextReplacement{First: first, Count: page.count - first, Text: text}) + r.exactText = true + } +} + +// glyphText 使用独立输出字体和既定字形建立PDF文字,不改变字体缓存及定位 +// 入参: face 字体, glyph 字形 +// 返回: *canvas.Text 画布文字, error 字体解析错误 +func (r *pdfRenderer) glyphText(face *canvas.FontFace, glyph textGlyph) (*canvas.Text, error) { + font := r.fonts[face.Font] + if font == nil { + sfnt, err := canvasfont.ParseSFNT(fontSFNTData(face.Font.SFNT), 0) + if err != nil { + return nil, err + } + copy := *face.Font + copy.SFNT = sfnt + font = © + if r.fonts == nil { + r.fonts = make(map[*canvas.Font]*canvas.Font) + } + r.fonts[face.Font] = font + } + outputFace := *face + outputFace.Font = font + face = &outputFace + if glyph.GlyphID < 0 || glyph.GlyphID > 0xffff { + return canvas.NewTextLine(face, glyph.Text, canvas.Left), nil + } + id := uint16(glyph.GlyphID) + text := canvas.NewTextLine(face, " ", canvas.Left) + text.WalkLines(func(_ float64, spans []canvas.TextSpan) { + for i := range spans { + spans[i].Text = glyph.Text + spans[i].Glyphs = []canvastext.Glyph{{SFNT: face.Font.SFNT, Size: face.Size, ID: id, XAdvance: int32(face.Font.GlyphAdvance(id)), Text: glyph.Text}} + } + }) + return text, nil +} + +// pdfTextSupported 核对原生文字的字形和未裁剪范围,复用实际绘制的字形缓存 +// 入参: object 文字对象, fontID 字体标识, face 实际字体, transforms 字形映射, clip 裁剪路径, height 页面高度, matrix 对象变换, glyphMatrix 字形变换 +// 返回: bool 是否能完整保留字形外观 +func (r *Renderer) pdfTextSupported(object TextObject, fontID string, face *canvas.FontFace, transforms map[int]textGlyphTransform, clip *canvas.Path, height float64, matrix Matrix, glyphMatrix canvas.Matrix) bool { + if face.Font.IsCFF && face.Font.CFF == nil { + return false + } + var rect canvas.Rect + if clip != nil { + var ok bool + rect, ok = rectangularPath(clip) + if !ok { + return false + } + const tolerance = 1e-9 + rect = canvas.Rect{X0: rect.X0 - tolerance, Y0: rect.Y0 - tolerance, X1: rect.X1 + tolerance, Y1: rect.Y1 + tolerance} + } + scale := object.HScale + if scale == 0 { + scale = 1 + } + position := 0 + for _, code := range object.TextCode { + runes := textCodeRunes(code.Value) + glyphs := textCodeGlyphs(runes, transforms, position) + if code.Index != "" { + runes = r.parseIndexRunes(code.Index, fontID) + glyphs = textRuneGlyphs(runes) + } + positioner := NewTextPositioner(code) + for _, glyph := range glyphs { + if glyph.GlyphID == 0 || glyph.GlyphID >= int(face.Font.NumGlyphs()) { + return false + } + if glyph.GlyphID < 0 { + for _, char := range glyph.Text { + if face.Font.GlyphIndex(char) == 0 { + return false + } + } + } + origin := positioner.Next() + if clip == nil { + continue + } + path, _ := r.cachedTextGlyphPath(face, glyph) + if path == nil || r.renderError != nil { + return false + } + if path.Empty() { + continue + } + x, y := matrix.Transform(origin.X, origin.Y) + view := canvas.Identity.Translate(x, height-y).Mul(glyphMatrix).Scale(scale, 1) + if !rect.Contains(path.Copy().Transform(view).Bounds()) { + return false + } + } + position += len(runes) + } + return true +} diff --git a/ofdgo_renderer_pdf_write.go b/ofdgo_renderer_pdf_write.go index 37564e2..eec3fae 100644 --- a/ofdgo_renderer_pdf_write.go +++ b/ofdgo_renderer_pdf_write.go @@ -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 } } diff --git a/ofdgo_renderer_text_canvas.go b/ofdgo_renderer_text_canvas.go index c417224..15e4c07 100644 --- a/ofdgo_renderer_text_canvas.go +++ b/ofdgo_renderer_text_canvas.go @@ -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() diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 017a3ba..22f29b7 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -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]) }