From e5993aed24435e328069a4b9ccb0592e12d26912 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 30 Sep 2026 23:46:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=BD=AC=E6=8D=A2=E6=9C=8D=E5=8A=A1):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=82=E5=B8=B8=E6=B3=A8=E8=A7=A3=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=E4=B8=AD=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- go.sum | 4 +- ofdgo_pdf_annot.go | 91 ++++--------- ofdgo_pdf_halftone.go | 2 +- ofdgo_pdf_link.go | 6 + ofdgo_pdf_rendition.go | 292 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 325 insertions(+), 72 deletions(-) create mode 100644 ofdgo_pdf_rendition.go diff --git a/go.mod b/go.mod index ca89bc8..edede07 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-20260930125501-bec2763edcef + github.com/xiaoqidun/pdfgo v0.0.0-20260930154408-27ca213a4f2a 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 667f21e..1020b82 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-20260930125501-bec2763edcef h1:GpFAltFp5bxvK0TjTnSe185hw3qQQBjx1GaJg0RFDhI= -github.com/xiaoqidun/pdfgo v0.0.0-20260930125501-bec2763edcef/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +github.com/xiaoqidun/pdfgo v0.0.0-20260930154408-27ca213a4f2a h1:uoc2uT3bVXxEIzFUj8sLO1FloPi3JTcqLHpNztQB+qs= +github.com/xiaoqidun/pdfgo v0.0.0-20260930154408-27ca213a4f2a/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/ofdgo_pdf_annot.go b/ofdgo_pdf_annot.go index b640c47..1b37347 100644 --- a/ofdgo_pdf_annot.go +++ b/ofdgo_pdf_annot.go @@ -17,9 +17,8 @@ package ofdgo import ( "context" "fmt" + "maps" "strconv" - "strings" - "time" "github.com/xiaoqidun/pdfgo" ) @@ -39,6 +38,14 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page return &pdfgo.UnsupportedError{Feature: fmt.Sprintf("annotation field %q", key)} } } + remark, err := p.reader.ReadAnnotationText(annotation, "Contents", p.warning) + if err != nil { + return err + } + if annotation.Subtype == "FreeText" { + annotation.Dictionary = maps.Clone(annotation.Dictionary) + annotation.Dictionary["Contents"] = pdfgo.String("\xef\xbb\xbf" + remark) + } flags := int64(0) if value := annotation.Dictionary["F"]; value != nil { resolved, err := p.reader.Resolve(value) @@ -95,7 +102,7 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page if flags&(2|32) == 0 { p.compositeNodes = stamp.compositeNodes } - converted := Annotation{Type: pdfAnnotationType(annotation.Subtype), Subtype: string(annotation.Subtype), Appearance: Appearance{Boundary: pdfBoundary(box), Objects: stamp.objects}} + converted := Annotation{Type: pdfAnnotationType(annotation.Subtype), Subtype: string(annotation.Subtype), Remark: remark, Appearance: Appearance{Boundary: pdfBoundary(box), Objects: stamp.objects}} if annotation.Subtype == "Text" { value, err := p.reader.Resolve(annotation.Dictionary["Open"]) if err != nil { @@ -129,80 +136,28 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page region.Fill, region.Stroke, region.Actions = &no, &no, actions converted.Appearance.Objects = append(converted.Appearance.Objects, GraphicObject{Type: "PathObject", PathObject: region}) } - if value, err := p.reader.Resolve(annotation.Dictionary["Contents"]); err != nil { - return err - } else if value != nil { - contents, ok := value.(pdfgo.String) - if !ok { - return fmt.Errorf("invalid PDF annotation contents") - } - converted.Remark, err = pdfgo.DecodeTextString(contents) - if err != nil { - return err - } - } if flags&(2|32) != 0 { visible := false converted.Visible = &visible } converted.NoZoom = flags&8 != 0 || annotation.Subtype == "Text" converted.NoRotate = flags&16 != 0 || annotation.Subtype == "Text" - if value := annotation.Dictionary["T"]; value != nil { - resolved, err := p.reader.Resolve(value) - if err != nil { - return err - } - creator, ok := resolved.(pdfgo.String) - if !ok { - return fmt.Errorf("invalid PDF annotation creator") - } - converted.Creator, err = pdfgo.DecodeTextString(creator) - if err != nil { - return err - } + converted.Creator, err = p.reader.ReadAnnotationText(annotation, "T", p.warning) + if err != nil { + return err } - if value := annotation.Dictionary["M"]; value != nil { - resolved, err := p.reader.Resolve(value) - if err != nil { - return err + modified, err := p.reader.ReadAnnotationText(annotation, "M", p.warning) + if err != nil { + return err + } + if modified != "" { + if date, err := pdfgo.ParseDate(modified); err == nil && date.Year() > 0 { + converted.LastModDate = date.Format("2006-01-02") } - encoded, ok := resolved.(pdfgo.String) - if !ok { - return fmt.Errorf("invalid PDF annotation modification date") - } - date, err := pdfgo.DecodeTextString(encoded) - if err != nil { - return err - } - date = strings.TrimPrefix(date, "D:") - if len(date) < 4 { - return fmt.Errorf("invalid PDF annotation modification date") - } - year, err := strconv.Atoi(date[:4]) - if err != nil { - return fmt.Errorf("invalid PDF annotation modification date: %w", err) - } - month, day := 1, 1 - if len(date) >= 6 { - month, err = strconv.Atoi(date[4:6]) - if err != nil { - return fmt.Errorf("invalid PDF annotation modification month: %w", err) - } - } - if len(date) >= 8 { - day, err = strconv.Atoi(date[6:8]) - if err != nil { - return fmt.Errorf("invalid PDF annotation modification day: %w", err) - } - } - parsed := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) - if parsed.Year() != year || int(parsed.Month()) != month || parsed.Day() != day { - return fmt.Errorf("invalid PDF annotation modification date") - } - converted.LastModDate = parsed.Format("2006-01-02") - if len(date) > 8 { - p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF annotation modification time cannot fit OFD annotation date"}) + if converted.Parameters == nil { + converted.Parameters = &AnnotationParameters{} } + converted.Parameters.Parameter = append(converted.Parameters.Parameter, AnnotationParameter{Name: "PDF.M", Value: modified}) } if _, err := p.editor.AddAnnotation(p.page, converted); err != nil { return fmt.Errorf("PDF annotation: %w", err) diff --git a/ofdgo_pdf_halftone.go b/ofdgo_pdf_halftone.go index 83fc919..30a273b 100644 --- a/ofdgo_pdf_halftone.go +++ b/ofdgo_pdf_halftone.go @@ -21,7 +21,7 @@ import "github.com/xiaoqidun/pdfgo" // 返回: error 严格模式或非恒等传递函数无法表达 func (p *pdfImporter) halftone(style pdfgo.Style) error { h := style.Halftone - if h == nil { + if h == nil || h.Type == 0 { return nil } identity, err := h.IdentityTransfer(p.reader) diff --git a/ofdgo_pdf_link.go b/ofdgo_pdf_link.go index f45338a..d8b3509 100644 --- a/ofdgo_pdf_link.go +++ b/ofdgo_pdf_link.go @@ -153,6 +153,12 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict } continue } + if annotation.Subtype == "Screen" { + if err := p.screenAnnotation(ctx, page, annotation, strict); err != nil { + return err + } + continue + } if annotation.Subtype == "3D" || annotation.Subtype == "RichMedia" { if err := p.interactiveAnnotation(ctx, page, annotation, strict); err != nil { return err diff --git a/ofdgo_pdf_rendition.go b/ofdgo_pdf_rendition.go new file mode 100644 index 0000000..8532f85 --- /dev/null +++ b/ofdgo_pdf_rendition.go @@ -0,0 +1,292 @@ +// 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" + "mime" + "strings" + + "github.com/xiaoqidun/pdfgo" +) + +// screenAnnotation 转换屏幕注解的音视频呈现,不忽略必须遵守的播放器约束 +// 入参: ctx 取消上下文, page PDF页面, annotation 屏幕注解, strict 严格检查开关 +// 返回: error 动作、媒体或外观错误 +func (p *pdfImporter) screenAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, strict bool) error { + if annotation.Dictionary["A"] == nil { + return p.appearanceAnnotation(ctx, page, annotation) + } + appearance := annotation + appearance.Dictionary = maps.Clone(annotation.Dictionary) + delete(appearance.Dictionary, "A") + 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") + return p.appearanceAnnotation(ctx, page, appearance) + } + value, err := p.reader.Resolve(annotation.Dictionary["A"]) + if err != nil { + return err + } + action, ok := value.(pdfgo.Dictionary) + if !ok { + return fmt.Errorf("invalid screen action") + } + subtype, err := p.reader.Resolve(action["S"]) + 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() + } + return fallback("media rendition unavailable: " + err.Error()) + } + selected, err := rendition.Select(ctx, func(candidate *pdfgo.Rendition) (bool, error) { + _, _, viable, err := p.nativeRendition(candidate) + return viable, err + }) + if err != nil { + return err + } + if selected == nil { + return fallback("no equivalent media rendition") + } + converted, kind, _, err := p.nativeRendition(selected) + if err != nil { + return err + } + data, available, err := p.mediaData(ctx, *selected.Clip.File) + if err != nil { + return err + } + if !available { + return p.appearanceAnnotation(ctx, page, appearance) + } + 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 + } + if kind == "Audio" { + converted.Sound.ResourceID = id + } else { + converted.Movie.ResourceID = id + } + 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) +} + +// nativeRendition 检查可等价转换的媒体分支,视频不接受无法表达的音量及循环 +// 入参: rendition 媒体呈现或选择器 +// 返回: Action 播放动作, string 媒体类别, bool 是否可用, error 参数错误 +func (p *pdfImporter) nativeRendition(rendition *pdfgo.Rendition) (Action, string, bool, error) { + result := Action{Event: "CLICK"} + viable, err := p.renditionCriteria(rendition) + if err != nil || !viable { + return result, "", false, err + } + if rendition.Subtype == "SR" { + return result, "", true, nil + } + clip := rendition.Clip + if clip == nil || clip.Subtype != "MCD" || clip.File == nil || len(clip.MustHonor) != 0 || len(clip.Players) != 0 { + return result, "", false, nil + } + if clip.File.Embedded == nil && p.resolveFile == nil { + return result, "", false, nil + } + for key, object := range clip.Permissions { + value, err := p.reader.Resolve(object) + if err != nil { + return result, "", false, err + } + if key == "Type" && value == pdfgo.Name("MediaPermissions") { + continue + } + if key != "TF" { + return result, "", false, nil + } + if _, ok := value.(pdfgo.String); !ok { + return result, "", false, fmt.Errorf("invalid media temporary-file permission") + } + } + mediaType, _, err := mime.ParseMediaType(clip.ContentType) + if err != nil || !strings.HasPrefix(mediaType, "audio/") && !strings.HasPrefix(mediaType, "video/") { + return result, "", false, nil + } + audio := strings.HasPrefix(mediaType, "audio/") + play, viable, err := p.renditionParameters(rendition.Play, map[pdfgo.Name]func(pdfgo.Object) bool{ + "V": func(value pdfgo.Object) bool { + number, ok := value.(pdfgo.Integer) + return ok && number >= 0 && number <= 100 && (audio || number == 100) + }, + "RC": func(value pdfgo.Object) bool { + return value == pdfgo.Integer(1) || value == pdfgo.Real(1) || audio && (value == pdfgo.Integer(0) || value == pdfgo.Real(0)) + }, + "A": func(value pdfgo.Object) bool { return value == pdfgo.Boolean(true) }, + "F": func(value pdfgo.Object) bool { return value == pdfgo.Integer(0) || value == pdfgo.Integer(5) }, + }) + 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) }, + }) + if err != nil || !viable { + return result, "", false, err + } + volume := 100 + if value := play["V"]; value != nil { + volume = int(value.(pdfgo.Integer)) + } + repeat := false + if value := play["RC"]; value != nil && value != pdfgo.Integer(1) && value != pdfgo.Real(1) { + repeat = true + } + if audio { + result.Sound = &Sound{Volume: &volume, Repeat: repeat} + return result, "Audio", true, nil + } + result.Movie = &Movie{Operator: "Play"} + return result, "Video", true, nil +} + +// renditionCriteria 检查媒体环境条件,未知尽力处理字段不影响候选可用性 +// 入参: rendition 媒体呈现或选择器 +// 返回: bool 是否可用, error 条件字典或引用错误 +func (p *pdfImporter) renditionCriteria(rendition *pdfgo.Rendition) (bool, error) { + for _, constraints := range []struct { + dict pdfgo.Dictionary + required bool + }{{rendition.MustHonor, true}, {rendition.BestEffort, false}} { + for key, object := range constraints.dict { + if key != "C" { + if constraints.required { + return false, nil + } + continue + } + value, err := p.reader.Resolve(object) + if err != nil { + return false, err + } + criteria, ok := value.(pdfgo.Dictionary) + if !ok { + return false, fmt.Errorf("invalid media criteria") + } + for name, object := range criteria { + value, err := p.reader.Resolve(object) + if err != nil { + return false, err + } + if name == "Type" && value == pdfgo.Name("MediaCriteria") { + continue + } + switch name { + case "Type", "A", "C", "O", "S", "R", "D", "Z", "V", "P", "L": + return false, nil + default: + if constraints.required { + return false, nil + } + } + } + } + } + return true, nil +} + +// renditionParameters 合并可等价表达的播放参数,不支持的偏好不阻断播放 +// 入参: dict 参数字典, supported 字段及可表达值的检查 +// 返回: pdfgo.Dictionary 合并后的值, bool 是否可用, error 字典或引用错误 +func (p *pdfImporter) renditionParameters(dict pdfgo.Dictionary, supported map[pdfgo.Name]func(pdfgo.Object) bool) (pdfgo.Dictionary, bool, error) { + values := make(pdfgo.Dictionary) + for key := range dict { + if key != "Type" && key != "MH" && key != "BE" { + return nil, false, nil + } + } + value, err := p.reader.Resolve(dict["MH"]) + if err != nil { + return nil, false, err + } + mandatory, _ := value.(pdfgo.Dictionary) + for _, key := range []pdfgo.Name{"BE", "MH"} { + value, err := p.reader.Resolve(dict[key]) + if err != nil { + return nil, false, err + } + if value == nil { + continue + } + parameters, ok := value.(pdfgo.Dictionary) + if !ok { + return nil, false, fmt.Errorf("invalid media playback constraints") + } + for name, object := range parameters { + if _, overridden := mandatory[name]; key == "BE" && overridden { + continue + } + check := supported[name] + if check == nil { + if key == "MH" { + return nil, false, nil + } + continue + } + resolved, err := p.reader.Resolve(object) + if err != nil { + return nil, false, err + } + if !check(resolved) { + if key == "MH" { + return nil, false, nil + } + continue + } + values[name] = resolved + } + } + return values, true, nil +}