mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 08:46:55 +08:00
1 parent
91aa711dfc
commit
c5867b0948
20 files changed
+541
-54
No files matched your search
@@ -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);
|
||||
|
||||
+115
-3
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-1
@@ -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 附件动作
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 错误信息
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+36
-30
@@ -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"}
|
||||
|
||||
+1
-1
@@ -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 ""
|
||||
|
||||
+17
-2
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+29
-6
@@ -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
|
||||
|
||||
+149
-5
@@ -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 不可表示的参数或损坏的采样数据
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+16
-1
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user