diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index cd7aa8b..f758863 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -2392,6 +2392,20 @@ button.outline-link:hover { position: absolute; } +.page-media { + position: absolute; + z-index: 1; + object-fit: contain; +} + +audio.page-media { + height: 32px; +} + +.editing .page-media { + display: none; +} + .page-link:hover, .page-link:focus-visible { background: rgba(15, 123, 104, 0.08); diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index fb903d4..32e4e60 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -98,6 +98,7 @@ const state = { renderMode: "svg", renderDPI: DEFAULT_IMAGE_DPI, pageCache: new Map(), + mediaPlayers: new Map(), svgFonts: new Map(), svgImages: new Map(), selectedPages: new Set(), @@ -1960,6 +1961,7 @@ async function toggleEditor() { canvasEditor.clear(); canvasEditor.setTool(""); if (state.editing) { + releaseMediaPlayers(); state.selectObjects = true; setPan(false); } @@ -1992,6 +1994,7 @@ async function toggleEditor() { state.editorViews.clear(); setEditorInfo(doc); state.editing = true; + releaseMediaPlayers(); state.composite = null; state.selectObjects = true; state.ofdBytes = null; @@ -5484,6 +5487,7 @@ function resetPageFlow(keepCache = false) { } function resetPageLoading() { + releaseMediaPlayers(); for (const task of state.pageInFlight.values()) { task.resolve(null); } @@ -5549,6 +5553,7 @@ function unmountPage(index) { state.selectedPages.add(index); return; } + releaseMediaPlayers(shell.querySelector(".page-surface")); shell.querySelector(".page-surface").replaceChildren(); shell.classList.remove("rendered"); state.selectedPages.delete(index); @@ -5784,7 +5789,113 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { } } -function pageLinkTarget(link) { +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); + } +} + +async function playMediaAction(link, page, surface) { + const action = link.sound || link.movie; + const id = action?.resourceID; + if (!id || !surface || !page) return; + const operator = link.sound ? "Play" : action.operator || "Play"; + if (!["Play", "Stop", "Pause", "Resume"].includes(operator)) return; + const players = state.mediaPlayers ||= new Map(); + 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?.(); + } + } + return; + } + if (operator === "Resume" && !entry) 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; + const element = document.createElement(data.kind === "Video" ? "video" : "audio"); + entry.kind = data.kind; + entry.element = element; + entry.url = URL.createObjectURL(data.blob); + element.src = entry.url; + element.controls = true; + element.className = "page-media"; + element.addEventListener("error", () => { + entry.finish?.(); + if (seq === state.openSeq) setStatus("媒体无法播放"); + }); + entry.surface.append(element); + }); + } + if (operator === "Play") entry.surface = surface; + 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; + const element = entry.element; + if (link.sound) { + 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; + } + let ended; + if (link.sound && action.synchronous && !action.repeat) { + ended = new Promise(resolve => { + entry.finish = event => { + element.removeEventListener("ended", entry.finish); + entry.finish = null; + resolve(event?.type === "ended"); + }; + element.addEventListener("ended", entry.finish); + }); + } + try { await element.play(); } catch (err) { entry.finish?.(); throw err; } + if (ended) return await ended; + } catch (err) { + if (players.get(id) === entry && seq === state.openSeq) { + if (!entry?.element) players.delete(id); + setStatus(err.message || "媒体无法播放"); + } + return false; + } +} + +function pageLinkTarget(link, page, surface) { + 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) }; @@ -5805,7 +5916,7 @@ function mountPageLinks(page, surface) { const groups = new Map(); const mounted = new Map(); for (const [index, link] of page.links.entries()) { - const target = pageLinkTarget(link); + const target = pageLinkTarget(link, page, surface); if (!target) continue; target.order = index; const key = link.group ? JSON.stringify([link.group, link.x, link.y, link.width, link.height, link.path]) : index; @@ -5837,7 +5948,7 @@ function mountPageLinks(page, surface) { } for (const target of actions) { if (openSeq !== state.openSeq) break; - await target.activate(); + if (await target.activate() === false) break; } }); } @@ -8253,6 +8364,7 @@ async function callWASM(name, ...args) { return new Promise((resolve, reject) => { const id = ++wasmRequestID; wasmRequests.set(id, { resolve, reject, openSeq: state.openSeq }); + if (name === "ofdgoExportMedia") wasmRequests.get(id).mediaID = args[0]; if (name === "ofdgoConvertPDF") { const request = wasmRequests.get(id); request.localFonts = fontManager.localEntries(); diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index 01d7498..4f0d9ac 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -29,7 +29,7 @@ self.onmessage = ({ data }) => { operations.get(data.id)?.abort(); return; } - if (data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { + if (data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoExportMedia" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { operations.set(data.id, new AbortController()); } pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data)); @@ -246,7 +246,7 @@ async function handleMessage({ id, name, args }) { if (signal && !importing && !converting && result.ok) { operations.delete(id); result.data.size = size; - self.postMessage({ id, type: "export", stage: "save" }); + if (name !== "ofdgoExportMedia") self.postMessage({ id, type: "export", stage: "save" }); if (output) { await output.close(); } else { diff --git a/go.mod b/go.mod index 494187f..ca89bc8 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 github.com/xiaoqidun/jbig2 v1.1.1 - github.com/xiaoqidun/pdfgo v0.0.0-20260930103756-809bab53bc35 + github.com/xiaoqidun/pdfgo v0.0.0-20260930125501-bec2763edcef golang.org/x/crypto v0.57.0 golang.org/x/image v0.46.0 golang.org/x/text v0.42.0 diff --git a/go.sum b/go.sum index 1282159..667f21e 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI= github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ= -github.com/xiaoqidun/pdfgo v0.0.0-20260930103756-809bab53bc35 h1:mJNER0HyJkHnIBg8v8UAdbAxRwEla4RfNTJVYLZU4tw= -github.com/xiaoqidun/pdfgo v0.0.0-20260930103756-809bab53bc35/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +github.com/xiaoqidun/pdfgo v0.0.0-20260930125501-bec2763edcef h1:GpFAltFp5bxvK0TjTnSe185hw3qQQBjx1GaJg0RFDhI= +github.com/xiaoqidun/pdfgo v0.0.0-20260930125501-bec2763edcef/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= 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= diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 8f011bf..d2d388b 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -201,6 +201,7 @@ func RunWASM() { registerAsyncCallback("ofdgoExportPage", exportPage) registerAsyncCallback("ofdgoExportDocument", exportDocument) registerAsyncCallback("ofdgoExportAttachment", exportAttachment) + registerAsyncCallback("ofdgoExportMedia", exportMedia) registerCallback("ofdgoImages", documentImages) registerAsyncCallback("ofdgoExportImages", exportImages) registerCallback("ofdgoMatchFontFiles", matchFontFiles) @@ -729,6 +730,32 @@ func exportAttachment(args []js.Value) (any, error) { }), nil } +// exportMedia 分块读取文档音视频,播放由浏览器负责 +// 入参: args 资源标识和分块接收函数 +// 返回: any 媒体格式, error 资源或读取错误 +func exportMedia(args []js.Value) (any, error) { + if currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + media, err := currentSession.Reader.Media(args[0].String()) + if err != nil { + return nil, err + } + stream, err := currentSession.Reader.OpenMedia(media.ID) + if err != nil { + return nil, err + } + defer stream.Close() + writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) + if _, err := io.Copy(writer, stream); err != nil { + return nil, err + } + if err := writer.Flush(); err != nil { + return nil, err + } + return successResult(map[string]any{"format": media.Format, "kind": media.Type}), nil +} + // svgFontData 读取SVG字体资源 // 入参: args 浏览器参数 // 返回: any 字体数据, error 错误信息 @@ -818,6 +845,16 @@ func renderPage(args []js.Value) (any, error) { 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, diff --git a/ofdgo_action.go b/ofdgo_action.go index 5992ddc..401e014 100644 --- a/ofdgo_action.go +++ b/ofdgo_action.go @@ -62,7 +62,7 @@ type URI struct { Base string `xml:"Base,attr"` } -// PageLink 页面链接,URI、Dest和Attachment分别表示外链、文档内跳转和附件 +// PageLink 页面点击动作,包含外链、跳转、附件和音视频播放 // Box使用页面毫米坐标,Path为可选的页面坐标SVG路径,存在时作为精确点击区域 // 同一来源的多个动作共享非零Group,组内按返回顺序执行 type PageLink struct { @@ -72,6 +72,8 @@ type PageLink struct { Attachment string Path string Group int + Sound *Sound + Movie *Movie } // GotoA 附件动作 diff --git a/ofdgo_editor_color.go b/ofdgo_editor_color.go index 5625978..d276d94 100644 --- a/ofdgo_editor_color.go +++ b/ofdgo_editor_color.go @@ -318,26 +318,32 @@ func collectPaintReferences(paint *FillColor, used map[string]bool) { // 入参: object 图形对象, used 引用集合 func collectObjectReferences(object GraphicObject, used map[string]bool) { var clips *Clips + var actions []Action switch object.Type { case "TextObject", "Text": used[object.TextObject.Font], used[object.TextObject.DrawParam] = true, true collectPaintReferences(object.TextObject.FillColor, used) collectPaintReferences((*FillColor)(object.TextObject.StrokeColor), used) clips = object.TextObject.Clips + actions = object.TextObject.Actions case "PathObject", "Path": used[object.PathObject.DrawParam] = true collectPaintReferences(object.PathObject.FillColor, used) collectPaintReferences((*FillColor)(object.PathObject.StrokeColor), used) clips = object.PathObject.Clips + actions = object.PathObject.Actions case "ImageObject": used[object.ImageObject.ResourceID], used[object.ImageObject.ImageMask] = true, true if object.ImageObject.Border != nil { collectPaintReferences((*FillColor)(object.ImageObject.Border.BorderColor), used) } clips = object.ImageObject.Clips + actions = object.ImageObject.Actions case "CompositeObject", "CompositeGraphicUnit": collectCompositeReferences(object.CompositeGraphicUnit, used) + actions = object.CompositeGraphicUnit.Actions } + collectActionReferences(actions, used) if clips == nil { return } diff --git a/ofdgo_editor_link.go b/ofdgo_editor_link.go index 50486e0..8b260f6 100644 --- a/ofdgo_editor_link.go +++ b/ofdgo_editor_link.go @@ -83,6 +83,19 @@ func (e *Editor) SetObjectLink(page int, ids []string, link *AnnotationLink) err return e.updateObjects(page, objects, true) } +// collectActionReferences 收集播放动作引用的音视频资源 +// 入参: actions 动作列表, used 引用集合 +func collectActionReferences(actions []Action, used map[string]bool) { + for _, action := range actions { + if action.Sound != nil { + used[action.Sound.ResourceID] = true + } + if action.Movie != nil { + used[action.Movie.ResourceID] = true + } + } +} + // SetObjectActions 原子替换普通对象的动作,空列表移除动作 // 入参: page 页面索引, ids 对象标识, actions 动作及已注册的资源引用 // 返回: error 错误信息 diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 1c7f343..1dedf4f 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -219,6 +219,7 @@ func cloneEditorReader(reader *Reader) *Reader { next.doc = &doc next.files = maps.Clone(reader.files) next.ResMap = maps.Clone(reader.ResMap) + next.mediaCache = maps.Clone(reader.mediaCache) next.resourcesRead = maps.Clone(reader.resourcesRead) next.resourceFiles = maps.Clone(reader.resourceFiles) next.fontCache = cloneEditorMap(reader.fontCache) diff --git a/ofdgo_media.go b/ofdgo_media.go new file mode 100644 index 0000000..ccf669f --- /dev/null +++ b/ofdgo_media.go @@ -0,0 +1,45 @@ +// 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 ( + "fmt" + "io" +) + +// Media 获取已加载的音视频资源,页面资源须先读取所属页面 +// 入参: id 资源标识 +// 返回: MultiMedia 资源信息, error 资源缺失或类型错误 +func (r *Reader) Media(id string) (MultiMedia, error) { + if _, err := r.Doc(); err != nil { + return MultiMedia{}, err + } + media, ok := r.mediaCache[id] + if !ok || media.Type != "Audio" && media.Type != "Video" { + return MultiMedia{}, fmt.Errorf("audio or video resource %q is unavailable", id) + } + return media, nil +} + +// OpenMedia 打开文档内音视频原始数据,不解码或执行播放 +// 入参: id 资源标识 +// 返回: io.ReadCloser 数据流,由调用方关闭, error 资源或文件读取错误 +func (r *Reader) OpenMedia(id string) (io.ReadCloser, error) { + media, err := r.Media(id) + if err != nil { + return nil, err + } + return r.openFile(media.MediaFile) +} diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 65750b0..87d3903 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -35,6 +35,7 @@ var ErrPDFPassword = pdfgo.ErrPassword // RendererOptions复用渲染器字体来源和后端配置,Backends优先于其中的后端设置 // RasterDPI指定局部透明效果合成精度,0沿用渲染器DPI,默认300dpi,Strict禁用局部合成 // OnProgress按open、pages、convert及write.*阶段报告进度,total为0表示总量未知 +// ResolveFile读取PDF引用的外部媒体和附件,默认不访问网络或本地路径 type PDFImportOptions struct { Password []byte PasswordUTF8 bool @@ -44,6 +45,7 @@ type PDFImportOptions struct { OnProgress func(stage string, completed, total int) error Strict bool RasterDPI float64 + ResolveFile pdfgo.FileResolver } // PDFImportReport 汇总转换页数、对象数、链接数和转换警告 @@ -58,36 +60,38 @@ type PDFImportReport struct { // pdfImporter 保存单次转换的对象与资源状态 type pdfImporter struct { - ctx context.Context - reader *pdfgo.Reader - editor *Editor - renderer *Renderer - report PDFImportReport - matrix pdfgo.Matrix - page int - fontIDs map[*pdfgo.Font]string - fontMetrics map[string]FontMetrics - fontRepairLimit map[*pdfgo.Font]uint16 - fontWarnings map[string]bool - type1Glyphs map[*pdfgo.Font]map[string]uint16 - clipTexts map[*pdfgo.TextClip]TextObject - cmykSpace string - objects []GraphicObject - pages map[pdfgo.Reference]*pdfgo.Page - pageIDs map[pdfgo.Reference]string - pageIndexes map[pdfgo.Reference]int - imageIDs map[pdfImageKey]string - maskClips map[*pdfgo.SoftMask]pdfgo.Path - pageBox pdfgo.Rectangle - pageWidth float64 - pageHeight float64 - pendingPath *pdfgo.PathMark - warning func(pdfgo.Diagnostic) - rasterDPI float64 - rasterWarned bool - compositeCache *pdfCompositeCache - compositeNodes []pdfCompositeNode - compositeSpace *pdfgo.ColorSpace + ctx context.Context + reader *pdfgo.Reader + editor *Editor + renderer *Renderer + report PDFImportReport + matrix pdfgo.Matrix + page int + fontIDs map[*pdfgo.Font]string + fontMetrics map[string]FontMetrics + fontRepairLimit map[*pdfgo.Font]uint16 + fontWarnings map[string]bool + type1Glyphs map[*pdfgo.Font]map[string]uint16 + clipTexts map[*pdfgo.TextClip]TextObject + cmykSpace string + objects []GraphicObject + pages map[pdfgo.Reference]*pdfgo.Page + pageIDs map[pdfgo.Reference]string + pageIndexes map[pdfgo.Reference]int + imageIDs map[pdfImageKey]string + maskClips map[*pdfgo.SoftMask]pdfgo.Path + pageBox pdfgo.Rectangle + pageWidth float64 + pageHeight float64 + pendingPath *pdfgo.PathMark + warning func(pdfgo.Diagnostic) + rasterDPI float64 + rasterWarned bool + compositeCache *pdfCompositeCache + compositeNodes []pdfCompositeNode + compositeSpace *pdfgo.ColorSpace + resolveFile pdfgo.FileResolver + halftoneWarnings map[*pdfgo.Halftone]bool } // ImportPDF 将PDF内容转换为独立OFD编辑文档,失败时不返回部分结果 @@ -131,6 +135,8 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI importer := pdfImporter{ctx: ctx, reader: reader, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, fontIDs: map[*pdfgo.Font]string{}, fontMetrics: map[string]FontMetrics{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}} importer.report.Warnings = diagnostics importer.pageIndexes = make(map[pdfgo.Reference]int) + importer.resolveFile = options.ResolveFile + importer.halftoneWarnings = make(map[*pdfgo.Halftone]bool) if security := reader.Encryption(); security != nil && !security.Owner && security.Permissions&0xf3c != 0xf3c { if options.Strict { return nil, PDFImportReport{}, &pdfgo.UnsupportedError{Feature: "PDF access permission conversion"} diff --git a/ofdgo_pdf_annot.go b/ofdgo_pdf_annot.go index 83263c0..b640c47 100644 --- a/ofdgo_pdf_annot.go +++ b/ofdgo_pdf_annot.go @@ -228,7 +228,7 @@ func pdfAnnotationType(subtype pdfgo.Name) string { return "Watermark" case "Highlight", "Underline", "Squiggly", "StrikeOut": return "Highlight" - case "Text", "FreeText", "Line", "Square", "Circle", "Polygon", "PolyLine", "Caret", "Ink", "FileAttachment", "Sound", "Movie", "Screen", "Link", "Popup", "Widget", "PrinterMark", "TrapNet": + case "Text", "FreeText", "Line", "Square", "Circle", "Polygon", "PolyLine", "Caret", "Ink", "FileAttachment", "Sound", "Movie", "Screen", "Link", "Popup", "Widget", "PrinterMark", "TrapNet", "3D", "RichMedia": return "Path" } return "" diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go index 74278d7..b1b3b1f 100644 --- a/ofdgo_pdf_composite.go +++ b/ofdgo_pdf_composite.go @@ -154,8 +154,17 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p collect = func(walk func(pdfgo.Visitor) error) ([]pdfCompositeNode, error) { var nodes []pdfCompositeNode v := pdfgo.Visitor{Warning: p.warning} - v.Path = func(mark pdfgo.PathMark) error { nodes = append(nodes, pdfCompositeNode{path: &mark}); return nil } + v.Path = func(mark pdfgo.PathMark) error { + if err := p.halftone(mark.Style); err != nil { + return err + } + nodes = append(nodes, pdfCompositeNode{path: &mark}) + return nil + } v.Text = func(mark pdfgo.TextMark) error { + if err := p.halftone(mark.Style); err != nil { + return err + } if mark.Font.Subtype != "Type3" { nodes = append(nodes, pdfCompositeNode{text: &mark}) return nil @@ -181,7 +190,13 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p } return nil } - v.Image = func(mark pdfgo.ImageMark) error { nodes = append(nodes, pdfCompositeNode{image: &mark}); return nil } + v.Image = func(mark pdfgo.ImageMark) error { + if err := p.halftone(mark.Style); err != nil { + return err + } + nodes = append(nodes, pdfCompositeNode{image: &mark}) + return nil + } v.Group = func(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { children, err := collect(walk) if err != nil { diff --git a/ofdgo_pdf_halftone.go b/ofdgo_pdf_halftone.go new file mode 100644 index 0000000..83fc919 --- /dev/null +++ b/ofdgo_pdf_halftone.go @@ -0,0 +1,45 @@ +// 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/xiaoqidun/pdfgo" + +// halftone 检查连续色调转换及不可忽略的传递函数,避免将打印网屏误用于屏幕显示 +// 入参: style 图形状态 +// 返回: error 严格模式或非恒等传递函数无法表达 +func (p *pdfImporter) halftone(style pdfgo.Style) error { + h := style.Halftone + if h == nil { + return nil + } + identity, err := h.IdentityTransfer(p.reader) + if err != nil { + return err + } + if !identity { + return &pdfgo.UnsupportedError{Feature: "halftone transfer function conversion"} + } + if p.warning == nil { + return &pdfgo.UnsupportedError{Feature: "device halftone conversion"} + } + if p.halftoneWarnings == nil { + p.halftoneWarnings = make(map[*pdfgo.Halftone]bool) + } + if !p.halftoneWarnings[h] { + p.halftoneWarnings[h] = true + p.warning(pdfgo.Diagnostic{Message: "PDF continuous-tone appearance retained; device halftone not transferred to OFD"}) + } + return nil +} diff --git a/ofdgo_pdf_link.go b/ofdgo_pdf_link.go index 6c1e251..f45338a 100644 --- a/ofdgo_pdf_link.go +++ b/ofdgo_pdf_link.go @@ -120,11 +120,25 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict if err != nil { return err } - data, err := pdfSoundWAV(sound) + var data []byte + format := "WAV" + if sound.File != nil { + var available bool + data, available, err = p.mediaData(ctx, *sound.File) + if err == nil && !available { + if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { + return err + } + continue + } + format = pdfMediaFormat(*sound.File) + } else { + data, err = pdfSoundWAV(sound) + } if err != nil { return err } - id, err := p.editor.AddMedia("Audio", "WAV", data) + id, err := p.editor.AddMedia("Audio", format, data) if err != nil { return err } @@ -139,18 +153,27 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict } continue } + if annotation.Subtype == "3D" || annotation.Subtype == "RichMedia" { + if err := p.interactiveAnnotation(ctx, page, annotation, strict); err != nil { + return err + } + continue + } if annotation.Subtype == "FileAttachment" { file, err := p.reader.ReadFileSpecification(dict["FS"]) if err != nil { return err } - if file.Embedded == nil { - return &pdfgo.UnsupportedError{Feature: "external file attachment"} - } - data, err := file.Embedded.Decode() + data, available, err := p.mediaData(ctx, file) if err != nil { return err } + if !available { + if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { + return err + } + continue + } id, err := p.editor.AddAttachment(file.Name, data) if err != nil { return err diff --git a/ofdgo_pdf_media.go b/ofdgo_pdf_media.go index 1c42049..0ed7be3 100644 --- a/ofdgo_pdf_media.go +++ b/ofdgo_pdf_media.go @@ -20,6 +20,7 @@ import ( "encoding/binary" "fmt" "math" + "net/url" "path" "strings" @@ -73,20 +74,163 @@ func (p *pdfImporter) movieAnnotation(ctx context.Context, page *pdfgo.Page, ann if movie.Rotate != 0 { return &pdfgo.UnsupportedError{Feature: "rotated movie playback"} } - if movie.File.Embedded == nil { - return fmt.Errorf("movie media is not embedded: %s", movie.File.Name) - } - data, err := movie.File.Embedded.Decode() + data, available, err := p.mediaData(ctx, movie.File) if err != nil { return err } - id, err := p.editor.AddMedia("Video", strings.TrimPrefix(path.Ext(movie.File.Name), "."), data) + 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 外观、资源或不可等价转换错误 +func (p *pdfImporter) interactiveAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, strict bool) error { + if strict { + return &pdfgo.UnsupportedError{Feature: "interactive " + string(annotation.Subtype) + " conversion"} + } + var actions []Action + if annotation.Subtype == "3D" { + model, err := p.reader.ReadThreeD(annotation) + if err != nil { + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF 3D metadata unavailable; static appearance retained: " + err.Error()}) + return p.appearanceAnnotation(ctx, page, annotation) + } + data, err := model.Stream.Decode() + if err != nil { + return err + } + id, err := p.editor.AddAttachment(fmt.Sprintf("Model_%d.%s", p.page+1, strings.ToLower(string(model.Format))), data) + if err != nil { + return err + } + actions = append(actions, Action{Event: "CLICK", GotoA: &GotoA{AttachID: id}}) + } else { + media, err := p.reader.ReadRichMedia(ctx, annotation) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF rich media metadata unavailable; static appearance retained: " + err.Error()}) + return p.appearanceAnnotation(ctx, page, annotation) + } + config := media.Configurations[media.ActiveConfiguration] + var playing *pdfgo.FileSpecification + if (config.Subtype == "Sound" || config.Subtype == "Video") && len(config.Instances) == 1 && config.Instances[0].Subtype == config.Subtype { + condition, err := p.reader.Resolve(media.Activation["Condition"]) + if err != nil { + return err + } + style, err := p.reader.Resolve(media.Presentation["Style"]) + if err != nil { + return err + } + if (condition == nil || condition == pdfgo.Name("XA")) && (style == nil || style == pdfgo.Name("Embedded")) && media.Activation["Scripts"] == nil { + file := config.Instances[0].Asset + data, available, err := p.mediaData(ctx, file) + if err != nil { + return err + } + if available { + kind := "Video" + if config.Subtype == "Sound" { + kind = "Audio" + } + id, err := p.editor.AddMedia(kind, pdfMediaFormat(file), data) + if err != nil { + return err + } + if kind == "Audio" { + actions = append(actions, Action{Event: "CLICK", Sound: &Sound{ResourceID: id}}) + } else { + actions = append(actions, Action{Event: "CLICK", Movie: &Movie{ResourceID: id, Operator: "Play"}}) + } + } + playing = &file + } + } + files := append([]pdfgo.MediaAsset(nil), media.Assets...) + seen := make(map[*pdfgo.Stream]bool) + external := make(map[string]bool) + if playing != nil { + if playing.Embedded != nil { + seen[playing.Embedded] = true + } else { + external[string(playing.FileSystem)+"\x00"+playing.Name] = true + } + } + for _, config := range media.Configurations { + for _, instance := range config.Instances { + files = append(files, pdfgo.MediaAsset{File: instance.Asset}) + } + } + for index, asset := range files { + key := string(asset.File.FileSystem) + "\x00" + asset.File.Name + if asset.File.Embedded != nil && seen[asset.File.Embedded] || asset.File.Embedded == nil && external[key] { + continue + } + data, available, err := p.mediaData(ctx, asset.File) + if err != nil { + return err + } + if available { + name := path.Base(strings.ReplaceAll(asset.File.Name, "\\", "/")) + if name == "" || name == "." || name == "/" { + name = fmt.Sprintf("Media_%d_%d", p.page+1, index+1) + } + _, err = p.editor.AddAttachment(name, data) + if err != nil { + return err + } + } + if asset.File.Embedded != nil { + seen[asset.File.Embedded] = true + } else { + external[key] = true + } + } + } + if err := p.appearanceAnnotation(ctx, page, annotation, actions...); err != nil { + return err + } + p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF " + string(annotation.Subtype) + " appearance and available primary assets retained; advanced interaction not transferred to OFD"}) + return nil +} + +// mediaData 读取媒体或附件,宽松模式缺少外部读取器时报告资源不可用 +// 入参: ctx 取消上下文, file PDF文件说明 +// 返回: []byte 原始文件, bool 是否可用, error 解码、外部读取或严格模式错误 +func (p *pdfImporter) mediaData(ctx context.Context, file pdfgo.FileSpecification) ([]byte, bool, error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + if file.Embedded == nil && p.resolveFile == nil && p.warning != nil { + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF external file unavailable: " + file.Name + "; appearance retained"}) + return nil, false, nil + } + data, err := p.reader.ReadFileData(ctx, file, p.resolveFile) + return data, err == nil, err +} + +// pdfMediaFormat 获取媒体文件扩展名,URL参数和片段不参与格式判断 +// 入参: file PDF文件说明 +// 返回: string 媒体格式,未声明扩展名时为空 +func pdfMediaFormat(file pdfgo.FileSpecification) string { + name := strings.ReplaceAll(file.Name, "\\", "/") + if file.FileSystem == "URL" { + if location, err := url.Parse(name); err == nil { + name = location.Path + } + } + return strings.TrimPrefix(path.Ext(name), ".") +} + // pdfSoundWAV 封装PDF采样音频,保持采样率、声道及数值,超过RIFF容量时使用RF64 // 入参: sound 音频流及参数 // 返回: []byte WAVE文件, error 不可表示的参数或损坏的采样数据 diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 00b91c5..7267d63 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -37,6 +37,7 @@ type Reader struct { resourcesRead map[string]bool resourceFiles map[string]string imageCatalog []ImageInfo + mediaCache map[string]MultiMedia fontCache map[string]*Font fontResourcesRead bool fontFaces map[string][]*FontFace @@ -387,6 +388,7 @@ func (r *Reader) initRoot() error { r.OFD = &ofd r.ResMap = make(map[string]string) r.resourcesRead = make(map[string]bool) + r.mediaCache = make(map[string]MultiMedia) r.resourceFiles = make(map[string]string) r.fontCache = make(map[string]*Font) r.fontFaces = make(map[string][]*FontFace) @@ -546,6 +548,11 @@ func (r *Reader) loadRes(resPath string) { for _, mm := range res.MultiMedias.MultiMedia { if mm.MediaFile != "" { if finalPath := resolveResourcePath(resPath, baseLoc, mm.MediaFile); finalPath != "" { + mm.MediaFile = finalPath + if r.mediaCache == nil { + r.mediaCache = make(map[string]MultiMedia) + } + r.mediaCache[mm.ID] = mm r.ResMap[mm.ID] = finalPath r.resourceFiles[mm.ID] = fullPath } diff --git a/ofdgo_render.go b/ofdgo_render.go index c89a85e..e432989 100644 --- a/ofdgo_render.go +++ b/ofdgo_render.go @@ -42,7 +42,7 @@ func (r *Renderer) GetPageBox(page *PageContent) (Box, error) { return ParseBox(boxStr) } -// PageLinks 获取页面、模板和可见注释的点击链接,包含复杂区域、组合图元和附件动作 +// PageLinks 获取页面、模板和可见注释的点击动作,保留复杂区域和组合图元 // 入参: page 页面内容 // 返回: []PageLink 页面链接, error 错误信息 func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) { @@ -92,6 +92,21 @@ func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) { } else if action.GotoA != nil { link.Attachment = action.GotoA.AttachID links = append(links, link) + } else if action.Sound != nil { + value := *action.Sound + if value.Volume != nil { + volume := *value.Volume + value.Volume = &volume + } + link.Sound = &value + links = append(links, link) + } else if action.Movie != nil { + value := *action.Movie + if value.Operator == "" { + value.Operator = "Play" + } + link.Movie = &value + links = append(links, link) } } if len(links)-start > 1 { diff --git a/ofdgo_writer.go b/ofdgo_writer.go index a8b7516..2568b32 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -432,6 +432,7 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color maps.Copy(promoted, used) } for _, page := range e.pages { + collectActionReferences(page.Actions, used) var original map[string]GraphicObject if e.source != nil { original = make(map[string]GraphicObject) @@ -506,6 +507,7 @@ func (x *ofdXML) page(page PageContent) { x.layer(layer) } x.end("Content") + x.actions(page.Actions) x.end("Page") }