feat(文件归并): 归并PDF适配与解析逻辑
build.yaml / build (push) Successful in 48s

This commit is contained in:
xiaoqidun committed 2026-10-14 09:22:47 +08:00
1 parent b3c4bbc844
commit 961bc52038
43 files changed
+2283 -2594

No files matched your search

+218
View File
@@ -16,14 +16,37 @@ package ofdgo
import (
"context"
"fmt"
"io"
"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
}
// mediaFile 复用同一包内文件的输出说明,不合并播放资源及窗口
// 入参: media 音视频资源
// 返回: *pdfgo.FileSpecification 待读取的共享文件
@@ -67,3 +90,198 @@ func (n *pdfNavigation) loadMediaFiles(ctx context.Context, files map[string]*pd
}
return ctx.Err()
}
// 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)
}
// 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 ""
}