From 73105646aa84d69a630e8c7570c7ab56dbd50a24 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Mon, 28 Sep 2026 19:15:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=BD=AC=E6=8D=A2=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=93=BE=E6=8E=A5=E4=B8=8E=E5=9B=BE=E5=B1=82?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- go.sum | 4 +- ofdgo_pdf.go | 3 + ofdgo_pdf_link.go | 364 ++++++++++++++++++++++++++-------------------- 4 files changed, 214 insertions(+), 159 deletions(-) diff --git a/go.mod b/go.mod index 21138ec..02c08fe 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-20260928103444-c1e05032dc3a + github.com/xiaoqidun/pdfgo v0.0.0-20260928111239-49265df4c04c 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 d65b1e7..4dbc436 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-20260928103444-c1e05032dc3a h1:ROTJKhSKiF4LHufA5UjBWwGQimvWQV8hU9SxsrkeTWE= -github.com/xiaoqidun/pdfgo v0.0.0-20260928103444-c1e05032dc3a/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +github.com/xiaoqidun/pdfgo v0.0.0-20260928111239-49265df4c04c h1:AkN9yw2b5LXinOBkWy/Wppdu4AtqQ2j6M4AYxlThL/A= +github.com/xiaoqidun/pdfgo v0.0.0-20260928111239-49265df4c04c/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.go b/ofdgo_pdf.go index f47e7d0..61a4d7b 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -75,6 +75,7 @@ type pdfImporter struct { 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 @@ -128,6 +129,7 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI editor.fontDirs, editor.fontFS = renderer.FontSources() 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) 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"} @@ -152,6 +154,7 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI } importer.pages[page.Reference] = page importer.pageIDs[page.Reference] = editor.pages[index].ID + importer.pageIndexes[page.Reference] = index if options.OnProgress != nil { return options.OnProgress("pages", index+1, 0) } diff --git a/ofdgo_pdf_link.go b/ofdgo_pdf_link.go index 3830d01..6c1e251 100644 --- a/ofdgo_pdf_link.go +++ b/ofdgo_pdf_link.go @@ -65,6 +65,22 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict continue } dict := annotation.Dictionary + if dict["OC"] != nil { + if strict { + return &pdfgo.UnsupportedError{Feature: "annotation optional content conversion"} + } + visible, err := p.reader.OptionalContentVisible(dict["OC"]) + if err != nil { + return err + } + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF annotation optional content default visibility applied; layer switching not transferred"}) + if !visible { + continue + } + dict = maps.Clone(dict) + delete(dict, "OC") + annotation.Dictionary = dict + } if annotation.Subtype == "Text" { value, err := p.reader.Resolve(dict["Open"]) if err != nil { @@ -169,178 +185,50 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict if annotation.Subtype != "Link" { return &pdfgo.UnsupportedError{Feature: "annotation " + string(annotation.Subtype)} } - for _, key := range []pdfgo.Name{"AA", "OC"} { - if dict[key] != nil { - return &pdfgo.UnsupportedError{Feature: fmt.Sprintf("link annotation field %q", key)} - } + if dict["AA"] != nil { + return &pdfgo.UnsupportedError{Feature: "link annotation field \"AA\""} } - action := Action{Event: "CLICK"} - target := dict["Dest"] - if dict["A"] != nil { - value, err := p.reader.Resolve(dict["A"]) + var actions []Action + currentPage := p.page + appendAction := func(dict pdfgo.Dictionary) error { + current := annotation + current.Dictionary = dict + action, err := p.linkAction(current, strict, ¤tPage) if err != nil { return err } - a, ok := value.(pdfgo.Dictionary) - if !ok { - return fmt.Errorf("invalid PDF link action") - } - if a["Next"] != nil { - return &pdfgo.UnsupportedError{Feature: "chained link actions"} - } - switch a["S"] { - case pdfgo.Name("GoTo"): - target = a["D"] - case pdfgo.Name("Named"): - value, err := p.reader.Resolve(a["N"]) - if err != nil { - return err - } - index := p.page - switch value { - case pdfgo.Name("FirstPage"): - index = 0 - case pdfgo.Name("LastPage"): - index = len(p.editor.pages) - 1 - case pdfgo.Name("NextPage"): - index = min(index+1, len(p.editor.pages)-1) - case pdfgo.Name("PrevPage"): - index = max(index-1, 0) - default: - return &pdfgo.UnsupportedError{Feature: "named link action"} - } - action.Goto = &Goto{Dest: &Dest{Type: "XYZ", PageID: p.editor.pages[index].ID, OmitLeft: true, OmitTop: true, OmitZoom: true}} - case pdfgo.Name("URI"): - value, err := p.reader.Resolve(a["URI"]) - if err != nil { - return err - } - uri, ok := value.(pdfgo.String) - if !ok { - return fmt.Errorf("invalid PDF URI") - } - isMap, err := p.reader.Resolve(a["IsMap"]) - if err != nil { - return err - } - if isMap != nil && isMap != pdfgo.Boolean(false) && isMap != pdfgo.Boolean(true) { - return fmt.Errorf("invalid PDF URI image map flag") - } - if isMap == pdfgo.Boolean(true) { - return &pdfgo.UnsupportedError{Feature: "URI image map"} - } - base, err := p.reader.BaseURI() - if err != nil { - return err - } - action.URI = &URI{URI: string(uri), Base: base} - default: - return &pdfgo.UnsupportedError{Feature: "link action"} + if action != nil { + actions = append(actions, *action) } + return nil } - if action.URI == nil && action.Goto == nil { - destination, err := p.reader.ReadDestination(target) - if err != nil { - if !strict && errors.Is(err, pdfgo.ErrDestinationNotFound) { - p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: fmt.Sprintf("%v; link omitted", err)}) - if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { - return err - } - continue - } + if dict["A"] == nil { + if err := appendAction(dict); err != nil { return err } - targetPage := p.pages[destination.Page] - if targetPage == nil { - if !strict { - p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF link targets a page outside the document page tree; link omitted"}) - if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { - return err - } - continue - } - return fmt.Errorf("PDF destination page missing") - } - matrix, _, _ := pdfPageMatrix(targetPage) - dest := &Dest{Type: string(destination.Mode), PageID: p.pageIDs[destination.Page]} - if destination.Mode == "XYZ" { - values := [3]float64{} - retained := [3]bool{} - for n, v := range destination.Parameters { - switch v := v.(type) { - case pdfgo.Integer: - values[n] = float64(v) - case pdfgo.Real: - values[n] = float64(v) - case nil: - retained[n] = true - } - } - point := matrix.Apply(pdfgo.Point{X: values[0], Y: values[1]}) - dest.Left, dest.Top, dest.Zoom = point.X, point.Y, values[2] - dest.OmitLeft, dest.OmitTop = retained[0], retained[1] - if targetPage.Rotation == 90 || targetPage.Rotation == 270 { - dest.OmitLeft, dest.OmitTop = retained[1], retained[0] - } - dest.OmitZoom = retained[2] || values[2] == 0 - } else if destination.Mode == "FitH" || destination.Mode == "FitV" { - var coordinate float64 - switch value := destination.Parameters[0].(type) { - case pdfgo.Integer: - coordinate = float64(value) - case pdfgo.Real: - coordinate = float64(value) - } - point := pdfgo.Point{X: coordinate} - if destination.Mode == "FitH" { - point = pdfgo.Point{Y: coordinate} - } - point = matrix.Apply(point) - if targetPage.Rotation == 90 || targetPage.Rotation == 270 { - if dest.Type == "FitH" { - dest.Type = "FitV" - } else { - dest.Type = "FitH" - } - } - if dest.Type == "FitH" { - dest.Top, dest.OmitTop = point.Y, destination.Parameters[0] == nil - } else { - dest.Left, dest.OmitLeft = point.X, destination.Parameters[0] == nil - } - } else if destination.Mode == "FitR" { - values := [4]float64{} - for n, v := range destination.Parameters { - switch v := v.(type) { - case pdfgo.Integer: - values[n] = float64(v) - case pdfgo.Real: - values[n] = float64(v) - default: - return fmt.Errorf("invalid PDF FitR destination coordinate") - } - } - bounds := pdfBounds([]pdfgo.Point{ - matrix.Apply(pdfgo.Point{X: values[0], Y: values[1]}), - matrix.Apply(pdfgo.Point{X: values[2], Y: values[3]}), - }) - dest.Left, dest.Top = bounds.X, bounds.Y - dest.Right, dest.Bottom = bounds.X+bounds.W, bounds.Y+bounds.H - } else if destination.Mode != "Fit" { - return &pdfgo.UnsupportedError{Feature: "destination mode " + string(destination.Mode)} - } - action.Goto = &Goto{Dest: dest} + } else if err := p.reader.WalkActions(ctx, dict["A"], func(info pdfgo.ActionInfo) error { + current := maps.Clone(dict) + current["A"] = info.Dictionary + return appendAction(current) + }); err != nil { + return err } + b := annotation.Rect box := pdfBounds([]pdfgo.Point{p.matrix.Apply(pdfgo.Point{X: b.XMin, Y: b.YMin}), p.matrix.Apply(pdfgo.Point{X: b.XMax, Y: b.YMax})}) - action.Region, err = p.linkRegion(annotation, box) + region, err := p.linkRegion(annotation, box) if err != nil { return err } - if err := p.appearanceAnnotation(ctx, page, annotation, action); err != nil { + for i := range actions { + actions[i].Region = region + } + if err := p.appearanceAnnotation(ctx, page, annotation, actions...); err != nil { return err } - p.report.Links++ + if len(actions) != 0 { + p.report.Links++ + } } return nil } @@ -390,3 +278,167 @@ func (p *pdfImporter) linkRegion(annotation pdfgo.Annotation, box Box) (*Region, } return region, nil } + +// linkAction 将单个链接动作转换为OFD动作,失效目标按导入策略报告 +// 入参: annotation 链接注解, strict 严格检查开关, currentPage 动作链当前页 +// 返回: *Action 动作,失效目标为空, error 转换错误 +func (p *pdfImporter) linkAction(annotation pdfgo.Annotation, strict bool, currentPage *int) (*Action, error) { + dict := annotation.Dictionary + action := Action{Event: "CLICK"} + target, err := p.reader.Resolve(dict["Dest"]) + if err != nil { + return nil, err + } + if target == nil && dict["A"] == nil { + return nil, nil + } + if dict["A"] != nil { + value, err := p.reader.Resolve(dict["A"]) + if err != nil { + return nil, err + } + a, ok := value.(pdfgo.Dictionary) + if !ok { + return nil, fmt.Errorf("invalid PDF link action") + } + switch a["S"] { + case pdfgo.Name("GoTo"): + target = a["D"] + case pdfgo.Name("Named"): + value, err := p.reader.Resolve(a["N"]) + if err != nil { + return nil, err + } + index := *currentPage + switch value { + case pdfgo.Name("FirstPage"): + index = 0 + case pdfgo.Name("LastPage"): + index = len(p.editor.pages) - 1 + case pdfgo.Name("NextPage"): + index = min(index+1, len(p.editor.pages)-1) + case pdfgo.Name("PrevPage"): + index = max(index-1, 0) + default: + return nil, &pdfgo.UnsupportedError{Feature: "named link action"} + } + action.Goto = &Goto{Dest: &Dest{Type: "XYZ", PageID: p.editor.pages[index].ID, OmitLeft: true, OmitTop: true, OmitZoom: true}} + *currentPage = index + case pdfgo.Name("URI"): + value, err := p.reader.Resolve(a["URI"]) + if err != nil { + return nil, err + } + uri, ok := value.(pdfgo.String) + if !ok { + return nil, fmt.Errorf("invalid PDF URI") + } + isMap, err := p.reader.Resolve(a["IsMap"]) + if err != nil { + return nil, err + } + if isMap != nil && isMap != pdfgo.Boolean(false) && isMap != pdfgo.Boolean(true) { + return nil, fmt.Errorf("invalid PDF URI image map flag") + } + if isMap == pdfgo.Boolean(true) { + return nil, &pdfgo.UnsupportedError{Feature: "URI image map"} + } + base, err := p.reader.BaseURI() + if err != nil { + return nil, err + } + action.URI = &URI{URI: string(uri), Base: base} + default: + return nil, &pdfgo.UnsupportedError{Feature: "link action"} + } + } + if action.URI == nil && action.Goto == nil { + destination, err := p.reader.ReadDestination(target) + if err != nil { + if !strict && errors.Is(err, pdfgo.ErrDestinationNotFound) { + p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: fmt.Sprintf("%v; link omitted", err)}) + return nil, nil + } + return nil, err + } + targetPage := p.pages[destination.Page] + if targetPage == nil { + if !strict { + p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF link targets a page outside the document page tree; link omitted"}) + return nil, nil + } + return nil, fmt.Errorf("PDF destination page missing") + } + matrix, _, _ := pdfPageMatrix(targetPage) + dest := &Dest{Type: string(destination.Mode), PageID: p.pageIDs[destination.Page]} + if destination.Mode == "XYZ" { + values := [3]float64{} + retained := [3]bool{} + for n, v := range destination.Parameters { + switch v := v.(type) { + case pdfgo.Integer: + values[n] = float64(v) + case pdfgo.Real: + values[n] = float64(v) + case nil: + retained[n] = true + } + } + point := matrix.Apply(pdfgo.Point{X: values[0], Y: values[1]}) + dest.Left, dest.Top, dest.Zoom = point.X, point.Y, values[2] + dest.OmitLeft, dest.OmitTop = retained[0], retained[1] + if targetPage.Rotation == 90 || targetPage.Rotation == 270 { + dest.OmitLeft, dest.OmitTop = retained[1], retained[0] + } + dest.OmitZoom = retained[2] || values[2] == 0 + } else if destination.Mode == "FitH" || destination.Mode == "FitV" { + var coordinate float64 + switch value := destination.Parameters[0].(type) { + case pdfgo.Integer: + coordinate = float64(value) + case pdfgo.Real: + coordinate = float64(value) + } + point := pdfgo.Point{X: coordinate} + if destination.Mode == "FitH" { + point = pdfgo.Point{Y: coordinate} + } + point = matrix.Apply(point) + if targetPage.Rotation == 90 || targetPage.Rotation == 270 { + if dest.Type == "FitH" { + dest.Type = "FitV" + } else { + dest.Type = "FitH" + } + } + if dest.Type == "FitH" { + dest.Top, dest.OmitTop = point.Y, destination.Parameters[0] == nil + } else { + dest.Left, dest.OmitLeft = point.X, destination.Parameters[0] == nil + } + } else if destination.Mode == "FitR" { + values := [4]float64{} + for n, v := range destination.Parameters { + switch v := v.(type) { + case pdfgo.Integer: + values[n] = float64(v) + case pdfgo.Real: + values[n] = float64(v) + default: + return nil, fmt.Errorf("invalid PDF FitR destination coordinate") + } + } + bounds := pdfBounds([]pdfgo.Point{ + matrix.Apply(pdfgo.Point{X: values[0], Y: values[1]}), + matrix.Apply(pdfgo.Point{X: values[2], Y: values[3]}), + }) + dest.Left, dest.Top = bounds.X, bounds.Y + dest.Right, dest.Bottom = bounds.X+bounds.W, bounds.Y+bounds.H + } else if destination.Mode != "Fit" { + return nil, &pdfgo.UnsupportedError{Feature: "destination mode " + string(destination.Mode)} + } + action.Goto = &Goto{Dest: dest} + *currentPage = p.pageIndexes[destination.Page] + } + return &action, nil +}