diff --git a/go.mod b/go.mod index 5d30998..3d9251e 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/xiaoqidun/guigo v0.0.0-20261020075840-622fd8853d72 github.com/xiaoqidun/j2kgo v1.0.2 github.com/xiaoqidun/jbig2 v1.1.8 - github.com/xiaoqidun/pdfgo v0.0.0-20261018110224-069b0fe96ea8 + github.com/xiaoqidun/pdfgo v0.0.0-20261021023132-6452ac6abaef golang.org/x/crypto v0.58.0 golang.org/x/image v0.47.0 golang.org/x/text v0.43.0 diff --git a/go.sum b/go.sum index 7d30ff1..dbcdddc 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo= github.com/xiaoqidun/jbig2 v1.1.8/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA= -github.com/xiaoqidun/pdfgo v0.0.0-20261018110224-069b0fe96ea8 h1:vEcUgzXL2Qbk8iXTYEauKGFgyqfv2Ns9ZSy7UMBh3tk= -github.com/xiaoqidun/pdfgo v0.0.0-20261018110224-069b0fe96ea8/go.mod h1:AaabzsObZ8Z76AcaWbo6swRROrJsblyIGB7HbieeJA4= +github.com/xiaoqidun/pdfgo v0.0.0-20261021023132-6452ac6abaef h1:q0QmZrpkiTtTtNghOY7t+G30YrYu7V3/FgOi14NbdGc= +github.com/xiaoqidun/pdfgo v0.0.0-20261021023132-6452ac6abaef/go.mod h1:AaabzsObZ8Z76AcaWbo6swRROrJsblyIGB7HbieeJA4= 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 0d90095..1989d62 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -18,6 +18,7 @@ import ( "context" "fmt" "io" + "maps" "slices" "sort" @@ -245,13 +246,14 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed editor.SetRenderBackends(renderer.Backends()) editor.fontDirs, editor.fontFS = renderer.FontSources() editor.fontSourcesCache = renderer.fontSourcesCache - importer := pdfImporter{resources: options.Resources, ctx: ctx, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}, structureBindings: &pdfStructureBindings{}} + importer := pdfImporter{resources: options.Resources, ctx: ctx, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, page: -1, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}, structureBindings: &pdfStructureBindings{}} readOptions := pdfgo.ReaderOptions{Password: options.Password, PasswordUTF8: options.PasswordUTF8, ShapeText: importer.shapePDFText} - var diagnostics []pdfgo.Diagnostic - var readWarning func(pdfgo.Diagnostic) if !options.Strict { - readWarning = func(diagnostic pdfgo.Diagnostic) { diagnostics = append(diagnostics, diagnostic) } - readOptions.Warning = func(diagnostic pdfgo.Diagnostic) { readWarning(diagnostic) } + importer.warning = func(warning pdfgo.Diagnostic) { + warning.Page = importer.page + 1 + importer.report.Warnings = append(importer.report.Warnings, warning) + } + readOptions.Warning = importer.warning } reader, err := pdfgo.NewReaderWithOptions(convertReader{ctx: ctx, input: input}, input.Size(), readOptions) if err != nil { @@ -266,12 +268,11 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed if err != nil { return nil, PDFImportReport{}, fmt.Errorf("import PDF output intents: %w", err) } - importer.report.Warnings = diagnostics if len(outputIntents) != 0 { if options.Strict { return nil, PDFImportReport{}, &pdfgo.UnsupportedError{Feature: "PDF output intent conversion"} } - importer.report.Warnings = append(importer.report.Warnings, pdfgo.Diagnostic{Message: "PDF output intents not transferred to OFD"}) + importer.report.Warnings = append(importer.report.Warnings, pdfgo.Diagnostic{Message: "PDF output intents preserved as metadata; not applied to rendering"}) } importer.pageIndexes = make(map[pdfgo.Reference]int) importer.resolveFile = options.ResolveFile @@ -281,13 +282,6 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed importer.references = &codecpdf.ReferenceCache{} defer importer.references.Close() importer.halftoneWarnings = make(map[*pdfgo.Halftone]bool) - if !options.Strict { - importer.warning = func(warning pdfgo.Diagnostic) { - warning.Page = importer.page + 1 - importer.report.Warnings = append(importer.report.Warnings, warning) - } - readWarning = importer.warning - } if err := progress.report("pages", 0, 0); err != nil { return nil, PDFImportReport{}, err } @@ -420,6 +414,67 @@ func (p *pdfImporter) referencePage(ctx context.Context, source *pdfgo.Reader, r return p.references.Resolve(ctx, source, reference, p.resolveFile, p.resolveReference, p.warning) } +// pageAnnotations 保留不可显示的源注解声明,其他注解仍按原顺序读取 +// 入参: ctx 取消上下文, page PDF页面 +// 返回: []pdfgo.Annotation 可显示的注解, error 输入、取消或严格校验错误 +func (p *pdfImporter) pageAnnotations(ctx context.Context, page *pdfgo.Page) ([]pdfgo.Annotation, error) { + annotations, err := page.AnnotationsContext(ctx) + if err == nil || p.warning == nil || !pdfAuxiliaryError(err) { + return annotations, err + } + object := page.Dictionary["Annots"] + if err := p.recoverPDFObject(ctx, "Page.Annots", object, err); err != nil { + return nil, err + } + value, err := p.reader.Resolve(object) + if err != nil { + return nil, p.recoverPDFObject(ctx, "Page.Annots", object, err) + } + array, ok := value.(pdfgo.Array) + if !ok { + return nil, ctx.Err() + } + for index, object := range array { + if err := ctx.Err(); err != nil { + return nil, err + } + candidate := *page + candidate.Dictionary = maps.Clone(page.Dictionary) + candidate.Dictionary["Annots"] = pdfgo.Array{object} + items, err := candidate.AnnotationsContext(ctx) + if err != nil && pdfAuxiliaryError(err) { + value, resolveErr := p.reader.Resolve(object) + if resolveErr != nil && !pdfAuxiliaryError(resolveErr) { + return nil, resolveErr + } + if dict, ok := value.(pdfgo.Dictionary); ok && dict["AP"] != nil { + subtype, resolveErr := p.reader.Resolve(dict["Subtype"]) + if resolveErr != nil && !pdfAuxiliaryError(resolveErr) { + return nil, resolveErr + } + if _, named := subtype.(pdfgo.Name); !named && resolveErr == nil { + dict = maps.Clone(dict) + dict["Subtype"] = pdfgo.Name("Stamp") + candidate.Dictionary["Annots"] = pdfgo.Array{dict} + items, err = candidate.AnnotationsContext(ctx) + } + } + } + if err != nil { + if !pdfAuxiliaryError(err) { + return nil, err + } + p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF annotation %d retained without appearance: %v", index+1, err)}) + continue + } + if len(items) == 1 && items[0].Reference == (pdfgo.Reference{}) { + items[0].Reference, _ = object.(pdfgo.Reference) + } + annotations = append(annotations, items...) + } + return annotations, ctx.Err() +} + // objectValidation 在单次导入中共享不可变资源的校验结果,隔离局部编辑器 // 返回: *editorValidation 当前编辑器的构建校验会话 func (p *pdfImporter) objectValidation() *editorValidation { diff --git a/ofdgo_pdf_action.go b/ofdgo_pdf_action.go index d0f2c1e..0ba3e00 100644 --- a/ofdgo_pdf_action.go +++ b/ofdgo_pdf_action.go @@ -18,7 +18,6 @@ import ( "bytes" "context" "encoding/xml" - "errors" "fmt" "math" "strconv" @@ -36,12 +35,15 @@ type pdfDestinationFontBackend struct { // 入参: ctx 取消上下文 // 返回: error 动作、编码或取消错误 func (p *pdfImporter) documentOpenActions(ctx context.Context) error { + raw, err := p.pdfCatalogObject("OpenAction") + if err != nil { + return err + } opened, err := p.reader.ReadOpenAction(ctx) if err != nil { - if p.warning == nil || !errors.Is(err, pdfgo.ErrInvalidDestination) { + if err := p.recoverPDFObject(ctx, "OpenAction", raw, err); err != nil { return err } - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF initial destination ignored: %v", err)}) } var object pdfgo.Object if opened.Action != nil { @@ -52,7 +54,7 @@ func (p *pdfImporter) documentOpenActions(ctx context.Context) error { object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": array} } currentPage := 0 - actions, err := p.eventActions(ctx, object, "DO", pdfgo.Annotation{}, ¤tPage) + actions, err := p.eventActions(ctx, object, "DO", pdfgo.Annotation{}, ¤tPage, raw) if err != nil { return err } @@ -61,11 +63,18 @@ func (p *pdfImporter) documentOpenActions(ctx context.Context) error { } triggers, err := p.reader.ReadDocumentTriggers(ctx) if err != nil { - return err + object, readErr := p.pdfCatalogObject("AA") + if readErr != nil { + return readErr + } + return p.recoverPDFObject(ctx, "Document.AA", object, err) } for _, trigger := range triggers { if _, err := p.reader.ReadJavaScriptAction(ctx, trigger.Action); err != nil { - return err + if err := p.recoverPDFObject(ctx, "Document.AA."+string(trigger.Event), trigger.Action, err); err != nil { + return err + } + continue } if err := p.unmappedTrigger(ctx, "document", trigger); err != nil { return err @@ -81,7 +90,7 @@ func (p *pdfImporter) pageOpenActions(ctx context.Context, page *pdfgo.Page) err p.pageActionPage = p.page triggers, err := p.reader.ReadPageTriggers(ctx, page) if err != nil { - return err + return p.recoverPDFObject(ctx, "Page.AA", page.Dictionary["AA"], err) } for _, trigger := range triggers { if trigger.Event != "O" { @@ -102,32 +111,59 @@ func (p *pdfImporter) pageOpenActions(ctx context.Context, page *pdfgo.Page) err } // eventActions 按原顺序转换动作链,保留事件类型与链内跳转页状态 -// 入参: ctx 取消上下文, object 动作对象, event OFD事件, source 触发注解,零值表示非图元动作, currentPage 当前页面 +// 入参: ctx 取消上下文, object 动作对象, event OFD事件, source 触发注解,零值表示非图元动作, currentPage 当前页面, original 可选原始来源 // 返回: []Action 转换动作, error 动作或取消错误 -func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, event string, source pdfgo.Annotation, currentPage *int) ([]Action, error) { +func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, event string, source pdfgo.Annotation, currentPage *int, original ...pdfgo.Object) ([]Action, error) { + raw := object + if len(original) != 0 { + raw = original[0] + } + retained, warned := false, false + warning := p.warning + if warning != nil { + p.warning = func(diagnostic pdfgo.Diagnostic) { + warned = true + warning(diagnostic) + } + defer func() { p.warning = warning }() + } + retain := func(cause error) error { + if cause != nil && (p.warning == nil || !pdfAuxiliaryError(cause)) { + return cause + } + if retained { + if cause != nil { + p.warning(pdfgo.Diagnostic{Message: "PDF Action." + event + " retained as metadata: " + cause.Error()}) + } + return ctx.Err() + } + var err error + if cause != nil { + err = p.recoverPDFObject(ctx, "Action."+event, raw, cause) + } else if p.warning != nil { + err = p.preservePDFObject(ctx, "Action."+event, raw) + } + retained = err == nil + return err + } var actions []Action + var conversionErr error err := p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error { annotation := source annotation.Dictionary = pdfgo.Dictionary{"A": info.Dictionary} - converted, err := p.linkActions(annotation, p.warning == nil, currentPage) + warned = false + warnings := len(p.report.Warnings) + converted, err := p.convertLinkActions(annotation, p.warning == nil, currentPage) if err != nil { - if errors.Is(err, errPDFMediaRead) { + conversionErr = retain(err) + *currentPage = -1 + return conversionErr + } + if p.warning != nil && (converted == nil || warned || len(p.report.Warnings) != warnings) { + if err := retain(nil); err != nil { + conversionErr = err return err } - var unsupported *pdfgo.UnsupportedError - if event != "CLICK" && p.warning != nil && errors.Is(err, pdfgo.ErrInvalidAction) { - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF %s action ignored: %v", event, err)}) - return nil - } - if event != "CLICK" && p.warning != nil && errors.Is(err, pdfgo.ErrInvalidDestination) { - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF %s destination ignored: %v", event, err)}) - return nil - } - if event != "CLICK" && p.warning != nil && errors.As(err, &unsupported) { - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF %s action not transferred to OFD: %s", event, unsupported.Feature)}) - return nil - } - return err } for _, action := range converted { action.Event = event @@ -135,8 +171,14 @@ func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, eve } return nil }) + if conversionErr != nil { + return nil, conversionErr + } if err != nil { - return nil, err + if err := retain(err); err != nil { + return nil, err + } + *currentPage = -1 } return actions, ctx.Err() } @@ -146,14 +188,10 @@ func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, eve // 返回: error 动作链、取消或严格模式错误 func (p *pdfImporter) unmappedTrigger(ctx context.Context, owner string, trigger pdfgo.ActionTrigger) error { if err := p.reader.WalkActions(ctx, trigger.Action, func(pdfgo.ActionInfo) error { return nil }); err != nil { - return err + return p.recoverPDFObject(ctx, owner+".AA."+string(trigger.Event), trigger.Action, err) } feature := owner + " trigger " + string(trigger.Event) - if p.warning == nil { - return &pdfgo.UnsupportedError{Feature: feature} - } - p.warning(pdfgo.Diagnostic{Message: "PDF " + feature + " not transferred to OFD"}) - return nil + return p.recoverPDFObject(ctx, owner+".AA."+string(trigger.Event), trigger.Action, &pdfgo.UnsupportedError{Feature: feature}) } // annotationTriggerActions 按事件转换注解动作,页面打开动作独立于注解外观 @@ -165,7 +203,7 @@ func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation p } triggers, err := p.reader.ReadAnnotationTriggers(ctx, annotation) if err != nil { - return nil, err + return nil, p.recoverPDFObject(ctx, "Annotation.AA", annotation.Dictionary["AA"], err) } if len(triggers) == 0 { return nil, ctx.Err() @@ -193,7 +231,10 @@ func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation p switch trigger.Event { case "U", "PO": default: - return nil, &pdfgo.UnsupportedError{Feature: "annotation trigger " + string(trigger.Event)} + if err := p.unmappedTrigger(ctx, "annotation", trigger); err != nil { + return nil, err + } + continue } currentPage := p.page if event == "PO" { @@ -258,7 +299,8 @@ func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) { local := pdfImporter{ resources: p.resources, ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, - matrix: matrix, pageBox: page.CropBox, pageWidth: width, pageHeight: height, + warning: p.warning, + matrix: matrix, pageBox: page.CropBox, pageWidth: width, pageHeight: height, rasterDPI: p.rasterDPI, fontIDs: make(map[*pdfgo.Font]string), resolveFile: p.resolveFile, resolveReference: p.resolveReference, references: &codecpdf.ReferenceCache{}, @@ -320,17 +362,15 @@ func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) { // 返回: error 严格模式转换错误或取消错误 func (p *pdfImporter) outlines(ctx context.Context) error { count := 0 + var conversionErr error data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) { x.root("Outlines", nil) depth := 0 err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error { actions, err := p.outlineActions(ctx, item) if err != nil { - if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) { - return err - } - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline %q actions not transferred to OFD: %v", item.Title, err)}) - actions = nil + conversionErr = err + return err } for depth > level { x.end("OutlineElem") @@ -354,15 +394,21 @@ func (p *pdfImporter) outlines(ctx context.Context) error { } x.end("Outlines") }) + if conversionErr != nil { + return conversionErr + } if err != nil { if ctx.Err() != nil { return ctx.Err() } - if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) { + if p.warning == nil || !pdfAuxiliaryError(err) { return err } - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outlines not transferred to OFD: %v", err)}) - return nil + object, readErr := p.pdfCatalogObject("Outlines") + if readErr != nil { + return readErr + } + return p.recoverPDFObject(ctx, "Outlines", object, err) } if count > 0 { p.editor.outlines = bytes.TrimPrefix(data, []byte(xml.Header)) @@ -374,42 +420,22 @@ func (p *pdfImporter) outlines(ctx context.Context) error { // 入参: ctx 取消上下文, item PDF目录节点 // 返回: []Action OFD动作序列, error 动作或取消错误 func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem) ([]Action, error) { + raw := item.Action object, err := p.reader.Resolve(item.Action) if err != nil { - return nil, err + return nil, p.recoverPDFObject(ctx, "Outline.A", item.Action, err) } dest, err := p.reader.Resolve(item.Destination) if err != nil { - return nil, err + return nil, p.recoverPDFObject(ctx, "Outline.Dest", item.Destination, err) } if dest != nil { if object != nil { - return nil, fmt.Errorf("PDF outline has both destination and action") + return nil, p.recoverPDFObject(ctx, "Outline", pdfgo.Dictionary{"A": item.Action, "Dest": item.Destination}, fmt.Errorf("PDF outline has both destination and action")) } object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": dest} + raw = item.Destination } currentPage := -1 - var actions []Action - err = p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error { - converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage) - if err != nil { - if errors.Is(err, errPDFMediaRead) { - return err - } - var unsupported *pdfgo.UnsupportedError - if p.warning != nil && (errors.As(err, &unsupported) || errors.Is(err, pdfgo.ErrInvalidDestination) || errors.Is(err, pdfgo.ErrInvalidAction)) { - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline action not transferred to OFD: %v", err)}) - currentPage = -1 - return nil - } - return err - } - if converted != nil { - actions = append(actions, converted...) - } else { - currentPage = -1 - } - return nil - }) - return actions, err + return p.eventActions(ctx, object, "CLICK", pdfgo.Annotation{}, ¤tPage, raw) } diff --git a/ofdgo_pdf_annot.go b/ofdgo_pdf_annot.go index d7e53ea..9d27596 100644 --- a/ofdgo_pdf_annot.go +++ b/ofdgo_pdf_annot.go @@ -27,6 +27,95 @@ import ( "github.com/xiaoqidun/pdfgo" ) +// recoverAnnotationAuxiliary 仅在存在有效正常外观时保留辅助错误,外观仍由调用方实际绘制 +// 入参: ctx 取消上下文, annotation 原始注解, name 辅助字段名, object 原始对象, cause 辅助错误 +// 返回: error 严格模式、缺少有效外观或不可恢复错误 +func (p *pdfImporter) recoverAnnotationAuxiliary(ctx context.Context, annotation pdfgo.Annotation, name string, object pdfgo.Object, cause error) error { + if err := ctx.Err(); err != nil { + return err + } + if p.warning == nil || !pdfAuxiliaryError(cause) { + return cause + } + appearance, err := p.reader.ReadAnnotationAppearance(annotation) + if err != nil { + return err + } + if appearance == nil { + return cause + } + return p.recoverPDFObject(ctx, name, object, cause) +} + +// popupAnnotation 单独恢复弹窗的打开状态和父引用,不忽略外观或几何错误 +// 入参: ctx 取消上下文, object 原始弹窗对象 +// 返回: pdfgo.PopupAnnotation 弹窗信息, error 读取或不可恢复错误 +func (p *pdfImporter) popupAnnotation(ctx context.Context, object pdfgo.Object) (pdfgo.PopupAnnotation, error) { + value, err := p.reader.Resolve(object) + if err != nil { + return pdfgo.PopupAnnotation{}, p.recoverPDFObject(ctx, "Annotation.Popup", object, err) + } + if value == nil { + return pdfgo.PopupAnnotation{}, nil + } + dict, ok := value.(pdfgo.Dictionary) + if !ok { + return pdfgo.PopupAnnotation{}, p.recoverPDFObject(ctx, "Annotation.Popup", object, fmt.Errorf("invalid popup annotation")) + } + dict = maps.Clone(dict) + open, err := p.reader.Resolve(dict["Open"]) + if err == nil && open != nil && open != pdfgo.Boolean(false) && open != pdfgo.Boolean(true) { + err = fmt.Errorf("invalid popup open state") + } + if err != nil { + if err := p.recoverPDFObject(ctx, "Annotation.Popup.Open", dict["Open"], err); err != nil { + return pdfgo.PopupAnnotation{}, err + } + delete(dict, "Open") + } + if object := dict["Parent"]; object != nil { + parent, reference := object.(pdfgo.Reference) + value, err := p.reader.Resolve(object) + if err == nil && value != nil { + parentDict, ok := value.(pdfgo.Dictionary) + if !reference || !ok { + err = fmt.Errorf("invalid popup parent annotation") + } else { + subtype, resolveErr := p.reader.Resolve(parentDict["Subtype"]) + err = resolveErr + if kind, ok := subtype.(pdfgo.Name); err == nil && (!ok || kind == "Popup") { + err = fmt.Errorf("invalid popup parent subtype") + } + } + } else if err == nil && !reference { + err = fmt.Errorf("invalid popup parent reference") + } + if err != nil { + if err := p.recoverPDFObject(ctx, "Annotation.Popup.Parent", object, err); err != nil { + return pdfgo.PopupAnnotation{}, err + } + delete(dict, "Parent") + } else { + dict["Parent"] = parent + } + } + return p.reader.ReadPopupAnnotation(dict) +} + +// annotationOpen 读取文本注解初始打开状态,单独保留无效辅助字段 +// 入参: ctx 取消上下文, dict 注解字典 +// 返回: pdfgo.Object 打开状态, error 不可恢复错误 +func (p *pdfImporter) annotationOpen(ctx context.Context, dict pdfgo.Dictionary) (pdfgo.Object, error) { + value, err := p.reader.Resolve(dict["Open"]) + if err == nil && value != nil && value != pdfgo.Boolean(false) && value != pdfgo.Boolean(true) { + err = fmt.Errorf("invalid PDF text annotation open state") + } + if err != nil { + return nil, p.recoverPDFObject(ctx, "Annotation.Open", dict["Open"], err) + } + return value, nil +} + // appearanceAnnotation 将PDF静态外观转换为对应类型的OFD注解 // 入参: ctx 取消上下文, page PDF页面, annotation PDF注解, actions 外观区域的动作 // 返回: error 外观或转换错误 @@ -38,6 +127,21 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page // 入参: ctx 取消上下文, page PDF页面, annotation PDF注解, paint 绘制回调,空值使用原外观, actions 外观区域的动作 // 返回: error 外观或转换错误 func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, paint func(*pdfImporter) error, actions ...Action) error { + if annotation.Subtype == "Projection" { + if _, err := p.reader.ReadProjectionAnnotation(annotation); err != nil { + if p.warning == nil || !pdfAuxiliaryError(err) { + return err + } + if _, err := p.reader.ReadAnnotationAppearance(annotation); err != nil { + return err + } + if err := p.recoverPDFObject(ctx, "Annotation.Projection.ExData", annotation.Dictionary["ExData"], err); err != nil { + return err + } + annotation.Dictionary = maps.Clone(annotation.Dictionary) + delete(annotation.Dictionary, "ExData") + } + } clicks, err := p.annotationTriggerActions(ctx, annotation, "CLICK") if err != nil { return err @@ -50,11 +154,13 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a continue } value, err := p.reader.Resolve(annotation.Dictionary[key]) - if err != nil { - return err - } - if value != nil { - return &pdfgo.UnsupportedError{Feature: fmt.Sprintf("annotation field %q", key)} + if value != nil || err != nil { + if err == nil { + err = &pdfgo.UnsupportedError{Feature: fmt.Sprintf("annotation field %q", key)} + } + if err := p.recoverPDFObject(ctx, "Annotation."+string(key), annotation.Dictionary[key], err); err != nil { + return err + } } } remark, err := p.reader.ReadAnnotationTextContext(ctx, annotation, "Contents", p.warning) @@ -74,7 +180,9 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a } invisible := flags&1 != 0 && !annotation.IsStandard() if flags&256 != 0 { - return &pdfgo.UnsupportedError{Feature: "annotation ToggleNoView flag"} + if err := p.recoverPDFObject(ctx, "Annotation.F", annotation.Dictionary["F"], &pdfgo.UnsupportedError{Feature: "annotation ToggleNoView flag"}); err != nil { + return err + } } box := pdfBounds([]pdfgo.Point{ p.matrix.Apply(pdfgo.Point{X: annotation.Rect.XMin, Y: annotation.Rect.YMin}), @@ -85,7 +193,9 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a if len(clicks) != 0 && annotation.Subtype == "Link" { region, err := p.linkRegion(annotation, box) if err != nil { - return err + if err := p.recoverPDFObject(ctx, "Annotation.QuadPoints", annotation.Dictionary["QuadPoints"], err); err != nil { + return err + } } for i := range clicks { clicks[i].Region = region @@ -151,21 +261,18 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a converted.Parameters = &AnnotationParameters{Parameter: []AnnotationParameter{{Name: "PDF.Redact.Pending", Value: "true"}}} } if annotation.Subtype == "Text" { - value, err := p.reader.Resolve(annotation.Dictionary["Open"]) + value, err := p.annotationOpen(ctx, annotation.Dictionary) if err != nil { return err } converted.Parameters = &AnnotationParameters{Parameter: []AnnotationParameter{{Name: "PDF.Text.Open", Value: strconv.FormatBool(value == pdfgo.Boolean(true))}}} } - popupObject, err := p.reader.Resolve(annotation.Dictionary["Popup"]) - if err != nil { - return err - } + popupObject := annotation.Dictionary["Popup"] if annotation.Subtype == "Popup" { popupObject = annotation.Dictionary } if popupObject != nil { - popup, err := p.reader.ReadPopupAnnotation(popupObject) + popup, err := p.popupAnnotation(ctx, popupObject) if err != nil { return err } diff --git a/ofdgo_pdf_geometry.go b/ofdgo_pdf_geometry.go index 6321cd9..a57ec70 100644 --- a/ofdgo_pdf_geometry.go +++ b/ofdgo_pdf_geometry.go @@ -54,6 +54,9 @@ func pdfGeometryPath(ctx context.Context, source pdfgo.Path, matrix pdfgo.Matrix // 入参: node 图元, stroke 是否描边 // 返回: *RasterPage 覆盖场景, Box 页面区域, error 编译错误 func (p *pdfImporter) compileCoverage(node pdfCompositeNode, stroke bool) (*RasterPage, Box, error) { + if node.path != nil && pdfPaintedPathEmpty(node.path.Path) { + return nil, Box{}, p.emptyPaintedPath() + } fallback := func() (*RasterPage, Box, error) { return p.compileGroup(func(local *pdfImporter) error { return node.coverage(local, stroke) }) } @@ -95,7 +98,7 @@ func (p *pdfImporter) compileCoverage(node pdfCompositeNode, stroke bool) (*Rast return nil, Box{}, err } if len(path) == 0 { - return nil, Box{}, fmt.Errorf("empty PDF painted path") + return nil, Box{}, p.emptyPaintedPath() } path = GeometryPath(basegeom.Close(basegeom.Path(path))) clip, err := p.coverageClip(style.Clips, geometry) @@ -298,6 +301,12 @@ func (b *pdfPointBounds) box() Box { // 入参: mark PDF路径绘制信息 // 返回: error 错误信息 func (p *pdfImporter) path(mark pdfgo.PathMark) error { + if err := p.ctx.Err(); err != nil { + return err + } + if pdfPaintedPathEmpty(mark.Path) { + return p.emptyPaintedPath() + } if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" { return &pdfgo.UnsupportedError{Feature: "path blend mode " + string(mark.Style.BlendMode)} } @@ -308,6 +317,9 @@ func (p *pdfImporter) path(mark pdfgo.PathMark) error { // 入参: mark PDF路径绘制信息 // 返回: error 错误信息 func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { + if pdfPaintedPathEmpty(mark.Path) { + return p.emptyPaintedPath() + } var err error mark.Style, err = p.maskStyle(mark.Style) if err != nil { @@ -331,9 +343,6 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { if err != nil { return err } - if box == (Box{}) { - return fmt.Errorf("empty PDF painted path") - } scale := math.Hypot(p.matrix[0], p.matrix[1]) drawing, origin, ctm := *p, box, "" marginScale := scale @@ -410,6 +419,31 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { return nil } +// pdfPaintedPathEmpty 判断无坐标路径,不排除具有坐标的退化描边 +// 入参: path PDF路径 +// 返回: bool 是否没有坐标 +func pdfPaintedPathEmpty(path pdfgo.Path) bool { + for _, segment := range path.Segments { + if segment.Operator != "C" || len(segment.Points) != 0 { + return false + } + } + return true +} + +// emptyPaintedPath 按诊断策略忽略不绘制内容的空路径,严格模式仍拒绝 +// 返回: error 严格模式或取消错误 +func (p *pdfImporter) emptyPaintedPath() error { + if err := p.ctx.Err(); err != nil { + return err + } + if p.warning == nil { + return fmt.Errorf("empty PDF painted path") + } + p.warning(pdfgo.Diagnostic{Message: "empty PDF painted path ignored"}) + return p.ctx.Err() +} + // clipPath 复用页面内重复路径编码,按估算字节预算淘汰且不缓存错误 // 入参: path 裁剪路径 // 返回: pdfClipPath 变换后的边界及编码, error 取消错误 diff --git a/ofdgo_pdf_link.go b/ofdgo_pdf_link.go index 9686c73..3fa2477 100644 --- a/ofdgo_pdf_link.go +++ b/ofdgo_pdf_link.go @@ -37,7 +37,7 @@ type pdfEmbeddedFile struct { // 返回: error 错误信息 func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict bool) error { p.movieBounds = nil - annotations, err := page.AnnotationsContext(ctx) + annotations, err := p.pageAnnotations(ctx, page) if err != nil { return err } @@ -70,7 +70,7 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict if annotation.Subtype != "Popup" { continue } - popup, err := p.reader.ReadPopupAnnotation(annotation.Dictionary) + popup, err := p.popupAnnotation(ctx, annotation.Dictionary) if err != nil { return err } @@ -78,11 +78,15 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict dict := maps.Clone(annotations[parent].Dictionary) previous, err := p.reader.Resolve(dict["Popup"]) if err != nil { - return err + if err := p.recoverPDFObject(ctx, "Annotation.Popup", dict["Popup"], err); err != nil { + return err + } } if previous != nil { if reference, ok := dict["Popup"].(pdfgo.Reference); !ok || reference != annotation.Reference { - return fmt.Errorf("inconsistent PDF popup parent") + if err := p.recoverPDFObject(ctx, "Annotation.Popup", dict["Popup"], fmt.Errorf("inconsistent PDF popup parent")); err != nil { + return err + } } } dict["Popup"] = annotation.Dictionary @@ -127,13 +131,10 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict annotation.Dictionary = dict } if annotation.Subtype == "Text" { - value, err := p.reader.Resolve(dict["Open"]) + value, err := p.annotationOpen(ctx, dict) if err != nil { return err } - if value != nil && value != pdfgo.Boolean(false) && value != pdfgo.Boolean(true) { - return fmt.Errorf("invalid PDF text annotation open state") - } if value == pdfgo.Boolean(true) { if strict { return &pdfgo.UnsupportedError{Feature: "initially open text annotation"} @@ -141,20 +142,19 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF text annotation open state retained as annotation parameter"}) } } - popupObject, err := p.reader.Resolve(dict["Popup"]) - if err != nil { - return err - } + popupObject := dict["Popup"] if annotation.Subtype == "Popup" { popupObject = dict } if popupObject != nil { - popup, err := p.reader.ReadPopupAnnotation(popupObject) + popup, err := p.popupAnnotation(ctx, popupObject) if err != nil { return err } if annotation.Subtype != "Popup" && popup.Parent != (pdfgo.Reference{}) && popup.Parent != annotation.Reference { - return fmt.Errorf("inconsistent PDF popup parent") + if err := p.recoverPDFObject(ctx, "Annotation.Popup.Parent", popupObject, fmt.Errorf("inconsistent PDF popup parent")); err != nil { + return err + } } if popup.Open { if strict { @@ -166,11 +166,20 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict if annotation.Subtype == "Sound" { sound, err := p.reader.ReadSound(dict["Sound"]) if err != nil { - return err + if err := p.recoverPDFObject(ctx, "Annotation.Sound", dict["Sound"], err); err != nil { + return err + } + if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { + return err + } + continue } id, err := p.soundResource(ctx, sound) if err != nil { - return err + if err := p.recoverPDFObject(ctx, "Annotation.Sound", dict["Sound"], err); err != nil { + return err + } + id = "" } if id == "" { if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { @@ -228,7 +237,13 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict if annotation.Subtype == "Widget" { field, err := p.reader.ReadField(annotation) if err != nil { - return err + if err := p.recoverAnnotationAuxiliary(ctx, annotation, "Annotation.Field", annotation.Dictionary, err); err != nil { + return err + } + if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { + return err + } + continue } annotation.Dictionary = field if field["FT"] == pdfgo.Name("Sig") && field["V"] != nil { @@ -273,10 +288,6 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict } continue } - primary, err := p.reader.Resolve(dict["A"]) - if err != nil { - return err - } var actions []Action currentPage := p.page appendAction := func(dict pdfgo.Dictionary) error { @@ -289,23 +300,29 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict actions = append(actions, converted...) return nil } - if primary == nil { + primary, err := p.reader.Resolve(dict["A"]) + if err != nil { + if err := p.recoverPDFObject(ctx, "Annotation.A", dict["A"], err); err != nil { + return err + } + } else if primary == nil { if err := appendAction(dict); err != nil { return err } - } else if err := p.reader.WalkActions(ctx, primary, func(info pdfgo.ActionInfo) error { - current := maps.Clone(dict) - current["A"] = info.Dictionary - return appendAction(current) - }); err != nil { - return err + } else { + actions, err = p.eventActions(ctx, dict["A"], "CLICK", annotation, ¤tPage) + if 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})}) region, err := p.linkRegion(annotation, box) if err != nil { - return err + if err := p.recoverPDFObject(ctx, "Annotation.QuadPoints", dict["QuadPoints"], err); err != nil { + return err + } } for i := range actions { actions[i].Region = region @@ -366,7 +383,32 @@ func (p *pdfImporter) linkRegion(annotation pdfgo.Annotation, box Box) (*Region, // linkActions 将单个PDF动作转换为有序OFD动作,保留动态媒体控制的目标范围 // 入参: annotation 触发注解, strict 严格检查开关, currentPage 动作链当前页 // 返回: []Action 动作序列,空切片表示无操作,nil表示未转换, error 转换错误 -func (p *pdfImporter) linkActions(annotation pdfgo.Annotation, strict bool, currentPage *int) ([]Action, error) { +func (p *pdfImporter) linkActions(annotation pdfgo.Annotation, strict bool, currentPage *int) (actions []Action, err error) { + object := pdfgo.Dictionary{} + for _, key := range []pdfgo.Name{"A", "Dest"} { + if value, ok := annotation.Dictionary[key]; ok { + object[key] = value + } + } + warnings := len(p.report.Warnings) + defer func() { + if err != nil && pdfAuxiliaryError(err) { + err = p.recoverPDFObject(p.ctx, "Link", object, err) + if err == nil { + actions = nil + *currentPage = -1 + } + } else if err == nil && (actions == nil || len(p.report.Warnings) != warnings) && len(object) != 0 && p.warning != nil { + err = p.preservePDFObject(p.ctx, "Link", object) + } + }() + return p.convertLinkActions(annotation, strict, currentPage) +} + +// convertLinkActions 转换单个动作,由调用方在辅助失败时保留原始来源 +// 入参: annotation 触发注解, strict 严格检查开关, currentPage 动作链当前页 +// 返回: []Action 转换动作, error 动作或读取错误 +func (p *pdfImporter) convertLinkActions(annotation pdfgo.Annotation, strict bool, currentPage *int) ([]Action, error) { value, err := p.reader.Resolve(annotation.Dictionary["A"]) if err != nil { return nil, err @@ -521,6 +563,9 @@ func (p *pdfImporter) linkAction(annotation pdfgo.Annotation, strict bool, curre if destination.Mode == "FitB" { box, err := p.destinationBounds(targetPage) if err != nil { + if !pdfAuxiliaryError(err) { + return nil, err + } if canceled := p.ctx.Err(); canceled != nil { return nil, canceled } @@ -641,7 +686,7 @@ func (p *pdfImporter) remoteLinkAction(object pdfgo.Object, strict bool) (*Actio } data, err := p.reader.ReadFileData(p.ctx, *action.File, p.resolveFile) if err != nil { - return nil, err + return nil, fmt.Errorf("%w: %w", errPDFMediaRead, err) } id, err := p.attachmentFile(action.File.Name, data) if err != nil { diff --git a/ofdgo_pdf_media.go b/ofdgo_pdf_media.go index b67a04a..a9ca59f 100644 --- a/ofdgo_pdf_media.go +++ b/ofdgo_pdf_media.go @@ -19,6 +19,7 @@ import ( "crypto/sha256" "errors" "fmt" + "maps" "math" "path" "reflect" @@ -336,12 +337,39 @@ func (p *pdfImporter) movieAnnotation(ctx context.Context, page *pdfgo.Page, ann return err } movie, err := p.reader.ReadMovie(annotation.Dictionary["Movie"]) + if err != nil && p.warning != nil && pdfAuxiliaryError(err) { + value, resolveErr := p.reader.Resolve(annotation.Dictionary["Movie"]) + if resolveErr != nil { + return resolveErr + } + if dict, ok := value.(pdfgo.Dictionary); ok && dict["Aspect"] != nil { + corrected := maps.Clone(dict) + delete(corrected, "Aspect") + retry, readErr := p.reader.ReadMovie(corrected) + if readErr == nil { + if err := p.recoverPDFObject(ctx, "Annotation.Movie", annotation.Dictionary["Movie"], err); err != nil { + return err + } + annotation.Dictionary = maps.Clone(annotation.Dictionary) + annotation.Dictionary["Movie"] = corrected + movie, err = retry, nil + } else if !pdfAuxiliaryError(readErr) { + return readErr + } + } + } if err != nil { - return err + if err := p.recoverAnnotationAuxiliary(ctx, annotation, "Annotation.Movie", annotation.Dictionary["Movie"], err); err != nil { + return err + } + return p.appearanceAnnotation(ctx, page, annotation) } activation, err := p.reader.Resolve(annotation.Dictionary["A"]) if err != nil { - return err + if err := p.recoverAnnotationAuxiliary(ctx, annotation, "Annotation.Movie.A", annotation.Dictionary["A"], err); err != nil { + return err + } + return p.appearanceAnnotation(ctx, page, annotation) } if activation == pdfgo.Boolean(false) { return p.appearanceAnnotation(ctx, page, annotation) @@ -351,7 +379,10 @@ func (p *pdfImporter) movieAnnotation(ctx context.Context, page *pdfgo.Page, ann } parameters, err := p.reader.ReadMovieActivation(ctx, activation) if err != nil { - return err + if err := p.recoverAnnotationAuxiliary(ctx, annotation, "Annotation.Movie.A", annotation.Dictionary["A"], err); err != nil { + return err + } + return p.appearanceAnnotation(ctx, page, annotation) } if !pdfMoviePlaybackSupported(movie, parameters) { if err := p.moviePlaybackLoss(ctx, movie.File, strict); err != nil { @@ -523,7 +554,28 @@ func (p *pdfImporter) movieActionLoss(feature string, strict, retained bool) err // 入参: ctx 取消上下文 // 返回: error 名称树、附件读取或写入错误 func (p *pdfImporter) documentAttachments(ctx context.Context) error { - return p.reader.WalkEmbeddedFiles(ctx, func(_ string, file pdfgo.FileSpecification) error { + var original pdfgo.Object + if p.warning != nil { + names, err := p.pdfCatalogObject("Names") + if err != nil { + return err + } + value, err := p.reader.Resolve(names) + if err != nil { + return p.recoverPDFObject(ctx, "Names", names, err) + } + if dictionary, ok := value.(pdfgo.Dictionary); ok { + original = dictionary["EmbeddedFiles"] + if original != nil { + if err := p.preservePDFObject(ctx, "EmbeddedFiles", original); err != nil { + return err + } + } + } else if value != nil { + return p.recoverPDFObject(ctx, "Names", names, fmt.Errorf("invalid names dictionary")) + } + } + err := p.reader.WalkEmbeddedFiles(ctx, func(_ string, file pdfgo.FileSpecification) error { if file.Embedded == nil { if p.warning == nil { return fmt.Errorf("PDF embedded file has no embedded data") @@ -533,16 +585,23 @@ func (p *pdfImporter) documentAttachments(ctx context.Context) error { } data, err := file.Embedded.DecodeContext(ctx) if err != nil { - var unsupported *pdfgo.UnsupportedError - if p.warning != nil && errors.As(err, &unsupported) { - p.warning(pdfgo.Diagnostic{Message: "PDF document attachment not transferred to OFD: " + err.Error()}) + if p.warning != nil && pdfAuxiliaryError(err) { + p.warning(pdfgo.Diagnostic{Message: "PDF document attachment retained as metadata: " + err.Error()}) return nil } return err } _, err = p.attachmentFile(file.Name, data) + if err != nil && p.warning != nil && pdfAuxiliaryError(err) { + p.warning(pdfgo.Diagnostic{Message: "PDF document attachment retained as metadata: " + err.Error()}) + return nil + } return err }) + if err != nil { + return p.recoverPDFObject(ctx, "EmbeddedFiles", original, err) + } + return ctx.Err() } // attachmentFile 按原名及数据复用附件,兼容末尾NUL,缺名使用固定名称 diff --git a/ofdgo_pdf_structure.go b/ofdgo_pdf_structure.go index b2aa3f8..b6f1c80 100644 --- a/ofdgo_pdf_structure.go +++ b/ofdgo_pdf_structure.go @@ -18,9 +18,11 @@ import ( "context" "encoding/base64" "encoding/xml" + "errors" "fmt" "maps" "math" + "os" "slices" "strconv" "strings" @@ -49,12 +51,14 @@ const ( pdfMetadataTagType = "OFDGo.PDFMetadata" pdfInformationTagType = "OFDGo.PDFInformation" pdfInformationNamespace = "https://ofdgo.aite.me/ns/pdf/information/1" + pdfAuxiliaryTagType = "OFDGo.PDFAuxiliary" ) // pdfStructureBindings 保存来源区段与最终页面图元的关联,不记录临时渲染对象 type pdfStructureBindings struct { parents map[*pdfgo.Reader]map[[2]int64]pdfgo.Reference prepared map[*pdfgo.Reader]bool + invalid map[*pdfgo.Reader]bool readers []*pdfgo.Reader marked map[pdfStructureContent]bool sequences map[*pdfgo.ContentSequence][]StructureReference @@ -104,11 +108,12 @@ type pdfStructureBinding struct { // pdfStructureValues 保存属性值类型及间接对象共享关系 type pdfStructureValues struct { - ctx context.Context - reader *pdfgo.Reader - seen map[pdfgo.Reference]bool - elements map[pdfgo.Reference]bool - objects []StructureNode + ctx context.Context + reader *pdfgo.Reader + seen map[pdfgo.Reference]bool + elements map[pdfgo.Reference]bool + objects []StructureNode + auxiliary bool } // pdfMetadataValue 保留字段是否声明及其来源,空文本不作为缺省值 @@ -117,10 +122,39 @@ type pdfMetadataValue struct { xmp bool } -// prepareStructure 校验结构内容关联并登记引用区段,不提前写入标引 +// prepareStructure 校验结构内容关联,无法映射时保留原始结构 // 入参: ctx 取消上下文 -// 返回: error 结构或内容容器错误 +// 返回: error 读取、严格校验或取消错误 func (p *pdfImporter) prepareStructure(ctx context.Context) error { + err := p.readStructureBindings(ctx) + if err == nil { + return nil + } + if p.warning == nil || !pdfAuxiliaryError(err) { + return err + } + object, readErr := p.pdfCatalogObject("StructTreeRoot") + if readErr != nil { + return readErr + } + if err := p.recoverPDFObject(ctx, "Structure", object, err); err != nil { + return err + } + b := p.structureBindings + if b.prepared == nil { + b.prepared = make(map[*pdfgo.Reader]bool) + } + if b.invalid == nil { + b.invalid = make(map[*pdfgo.Reader]bool) + } + b.prepared[p.reader], b.invalid[p.reader] = true, true + return ctx.Err() +} + +// readStructureBindings 校验结构内容关联并准备有效图元绑定 +// 入参: ctx 取消上下文 +// 返回: error 结构、内容容器或取消错误 +func (p *pdfImporter) readStructureBindings(ctx context.Context) error { if err := ctx.Err(); err != nil { return err } @@ -233,10 +267,34 @@ func (p *pdfImporter) documentStructures(ctx context.Context) error { return ctx.Err() } -// documentStructure 写出逻辑结构层级、文本属性及已提交的内容引用 +// documentStructure 写出逻辑结构及已提交的图元引用,异常结构以类型化标引保留 // 入参: ctx 取消上下文 // 返回: error 解析、未支持的结构属性或提交错误 func (p *pdfImporter) documentStructure(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if p.structureBindings.invalid[p.reader] { + return ctx.Err() + } + err := p.projectStructure(ctx) + if err == nil { + return nil + } + if p.warning == nil || !pdfAuxiliaryError(err) { + return err + } + object, readErr := p.pdfCatalogObject("StructTreeRoot") + if readErr != nil { + return readErr + } + return p.recoverPDFObject(ctx, "Structure", object, err) +} + +// projectStructure 将有效逻辑结构投影为OFD层级及实际图元引用 +// 入参: ctx 取消上下文 +// 返回: error 结构、绑定或提交错误 +func (p *pdfImporter) projectStructure(ctx context.Context) error { if err := ctx.Err(); err != nil { return err } @@ -484,7 +542,7 @@ func (v *pdfStructureValues) encode(object pdfgo.Object, depth int) (StructureNo if err := v.ctx.Err(); err != nil { return node, err } - if depth > 128 { + if depth > 128 && !v.auxiliary { return node, fmt.Errorf("PDF structure attribute limit exceeded") } switch value := object.(type) { @@ -516,8 +574,17 @@ func (v *pdfStructureValues) encode(object pdfgo.Object, depth int) (StructureNo v.seen[value] = true resolved, err := v.reader.Resolve(value) if err != nil { + if v.auxiliary && pdfAuxiliaryError(err) { + node.Attributes = append(node.Attributes, pdfStructureAttribute("Error", err.Error())...) + return node, nil + } return node, err } + if v.auxiliary { + if dict, ok := resolved.(pdfgo.Dictionary); ok && (dict["Type"] == pdfgo.Name("Page") || dict["Type"] == pdfgo.Name("Pages") || dict["Type"] == pdfgo.Name("Catalog")) { + return node, nil + } + } child, err := v.encode(resolved, depth+1) if err != nil { return node, err @@ -548,17 +615,83 @@ func (v *pdfStructureValues) encode(object pdfgo.Object, depth int) (StructureNo if err != nil { return node, err } - data, err := value.DecodeContext(v.ctx) + data := value.Data + dataName := "RawData" + if !v.auxiliary { + dataName = "DecodedData" + data, err = value.DecodeContext(v.ctx) + } if err != nil { return node, err } - node.Children = []StructureNode{dictionary, {Name: "DecodedData", Namespace: pdfStructureNamespace, Attributes: []StructureAttribute{{Name: "Encoding", Value: "base64"}}, Text: base64.StdEncoding.EncodeToString(data)}} + node.Children = []StructureNode{dictionary, {Name: dataName, Namespace: pdfStructureNamespace, Attributes: []StructureAttribute{{Name: "Encoding", Value: "base64"}}, Text: base64.StdEncoding.EncodeToString(data)}} default: return node, fmt.Errorf("invalid PDF structure attribute value") } return node, v.ctx.Err() } +// pdfCatalogObject 获取文档级原始字段,不预先解析字段值 +// 入参: name 目录字段名 +// 返回: pdfgo.Object 原始字段, error 目录读取错误 +func (p *pdfImporter) pdfCatalogObject(name pdfgo.Name) (pdfgo.Object, error) { + value, err := p.reader.Resolve(p.reader.Trailer["Root"]) + if err != nil { + return nil, err + } + dict, ok := value.(pdfgo.Dictionary) + if !ok { + return nil, fmt.Errorf("invalid PDF catalog") + } + return dict[name], nil +} + +// pdfAuxiliaryError 区分辅助格式错误与必须传递的输入、关闭和取消错误 +// 入参: err 待检查错误 +// 返回: bool 是否可以保留原文后继续 +func pdfAuxiliaryError(err error) bool { + var read *pdfgo.ReadError + return err != nil && !errors.As(err, &read) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, os.ErrClosed) && !errors.Is(err, errPDFMediaRead) +} + +// preservePDFObject 保存辅助对象的原始类型、引用和流数据,不展开页面及目录 +// 入参: ctx 取消上下文, name 来源字段, object 原始对象 +// 返回: error 读取、编码或提交错误 +func (p *pdfImporter) preservePDFObject(ctx context.Context, name string, object pdfgo.Object) error { + values := pdfStructureValues{ctx: ctx, reader: p.reader, seen: make(map[pdfgo.Reference]bool), auxiliary: true} + node, err := values.encode(object, 0) + if err != nil { + return err + } + root := StructureNode{Name: "Auxiliary", Namespace: pdfStructureNamespace, Attributes: pdfStructureAttribute("Name", name), Children: []StructureNode{node}} + if len(values.objects) != 0 { + root.Children = append(root.Children, StructureNode{Name: "Objects", Namespace: pdfStructureNamespace, Children: values.objects}) + } + data, err := encodeStructureNode(root) + if err != nil { + return err + } + _, err = p.editor.addStructureXML(ctx, CustomTag{TypeID: pdfAuxiliaryTagType, NameSpace: pdfStructureNamespace}, data) + return err +} + +// recoverPDFObject 按导入策略诊断并保留辅助对象,读取和取消错误不恢复 +// 入参: ctx 取消上下文, name 来源字段, object 原始对象, cause 辅助错误 +// 返回: error 严格模式、读取、取消或提交错误 +func (p *pdfImporter) recoverPDFObject(ctx context.Context, name string, object pdfgo.Object, cause error) error { + if err := ctx.Err(); err != nil { + return err + } + if p.warning == nil || !pdfAuxiliaryError(cause) { + return cause + } + if err := p.preservePDFObject(ctx, name, object); err != nil { + return err + } + p.warning(pdfgo.Diagnostic{Message: "PDF " + name + " retained as metadata: " + cause.Error()}) + return nil +} + // pdfStructureAttribute 保留XML不可表达的源字段,不替换原始字符 // 入参: name 属性名, value 属性值 // 返回: []StructureAttribute 属性及可选的编码声明 @@ -924,7 +1057,12 @@ func (p *pdfImporter) documentMetadata(ctx context.Context, strict bool) error { }{{"CreationDate", &info.CreationDate}, {"ModDate", &info.ModDate}} { text, err := codecpdf.NormalizeDate(*field.text, strict) if err != nil { - return fmt.Errorf("PDF metadata %s: %w", field.name, err) + if strict || !pdfAuxiliaryError(err) { + return fmt.Errorf("PDF metadata %s: %w", field.name, err) + } + p.warning(pdfgo.Diagnostic{Message: "PDF metadata " + field.name + " retained without date projection: " + err.Error()}) + *field.text = "" + continue } if text != *field.text { p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Message: "PDF metadata " + field.name + ": date normalized"}) @@ -937,22 +1075,51 @@ func (p *pdfImporter) documentMetadata(ctx context.Context, strict bool) error { } stream, err := p.reader.Metadata() if err != nil { - return err + object, readErr := p.pdfCatalogObject("Metadata") + if readErr != nil { + return readErr + } + if err := p.recoverPDFObject(ctx, "Metadata", object, err); err != nil { + return err + } + stream = nil } var packet []byte var root *StructureNode if stream != nil { packet, err = stream.DecodeContext(ctx) if err != nil { - return err - } - root, err = parseStructure(ctx, packet, CustomTag{}) - if err != nil { - return err + if err := p.recoverPDFObject(ctx, "Metadata", stream, err); err != nil { + return err + } + } else { + root, err = parseStructure(ctx, packet, CustomTag{}) + if err != nil { + if err := p.recoverPDFObject(ctx, "Metadata", stream, err); err != nil { + return err + } + root = nil + } } + } + if root != nil { xmp, err := pdfMetadataFields(ctx, root) if err != nil { - return err + if err := p.recoverPDFObject(ctx, "Metadata", stream, err); err != nil { + return err + } + xmp = nil + } + for _, key := range []string{"CreationDate", "ModDate", "MetadataDate"} { + if value, ok := xmp[key]; ok { + if _, _, _, err := codecpdf.MetadataDate(value.text, true); err != nil { + if strict || !pdfAuxiliaryError(err) { + return fmt.Errorf("PDF metadata %s: %w", key, err) + } + p.warning(pdfgo.Diagnostic{Message: "PDF metadata " + key + " retained without date projection: " + err.Error()}) + delete(xmp, key) + } + } } newer := false if stamp, ok := xmp["MetadataDate"]; ok && stamp.text != "" && info.ModDate != "" { @@ -1013,25 +1180,38 @@ func (p *pdfImporter) documentMetadata(ctx context.Context, strict bool) error { if err != nil { return err } - _, err = p.editor.addStructureXML(ctx, CustomTag{TypeID: pdfInformationTagType, NameSpace: pdfInformationNamespace}, data) - return err + if _, err = p.editor.addStructureXML(ctx, CustomTag{TypeID: pdfInformationTagType, NameSpace: pdfInformationNamespace}, data); err != nil { + return err + } + } + if !strict { + object, err := p.pdfCatalogObject("OutputIntents") + if err != nil { + return err + } + if object != nil { + return p.preservePDFObject(ctx, "OutputIntents", object) + } } return ctx.Err() } -// informationMetadata 收集已声明字段并保存文本,日期使用校验结果,XML无法表示的字符使用Base64保留 +// informationMetadata 收集已声明字段,日期分别保留原文及投影值,XML无法表示的文本使用Base64保留 // 入参: ctx 取消上下文, info 已解码的信息字典 // 返回: map[string]pdfMetadataValue 可映射字段, StructureNode 原始字段, error 读取或取消错误 func (p *pdfImporter) informationMetadata(ctx context.Context, info pdfgo.DocumentInfo) (map[string]pdfMetadataValue, StructureNode, error) { root := StructureNode{Name: "Information", Namespace: pdfInformationNamespace} values := make(map[string]pdfMetadataValue) object, err := p.reader.Resolve(p.reader.Trailer["Info"]) - if err != nil || object == nil { - return values, root, err + if err != nil { + return values, root, p.recoverPDFObject(ctx, "Information", p.reader.Trailer["Info"], err) + } + if object == nil { + return values, root, nil } dict, ok := object.(pdfgo.Dictionary) if !ok { - return nil, root, fmt.Errorf("invalid PDF information dictionary") + return values, root, p.recoverPDFObject(ctx, "Information", object, fmt.Errorf("invalid PDF information dictionary")) } known := map[pdfgo.Name]string{ "Title": info.Title, "Author": info.Author, "Subject": info.Subject, "Keywords": info.Keywords, @@ -1044,12 +1224,40 @@ func (p *pdfImporter) informationMetadata(ctx context.Context, info pdfgo.Docume } value, err := p.reader.Resolve(dict[key]) if err != nil { - return nil, root, err + if err := p.recoverPDFObject(ctx, "Information."+string(key), dict[key], err); err != nil { + return nil, root, err + } + continue } if value == nil { continue } + if text, ok := value.(pdfgo.String); ok { + if _, err := pdfgo.DecodeTextString(text); err != nil { + if err := p.recoverPDFObject(ctx, "Information."+string(key), dict[key], err); err != nil { + return nil, root, err + } + if key == "CreationDate" || key == "ModDate" { + values[string(key)] = pdfMetadataValue{} + } + continue + } + } text, standard := known[key] + _, stringValue := value.(pdfgo.String) + valid := stringValue + if key == "Trapped" { + valid = value == pdfgo.Name("True") || value == pdfgo.Name("False") || value == pdfgo.Name("Unknown") + } + if !valid { + if err := p.preservePDFObject(ctx, "Information."+string(key), dict[key]); err != nil { + return nil, root, err + } + if key == "CreationDate" || key == "ModDate" { + values[string(key)] = pdfMetadataValue{} + } + continue + } if !standard { text = info.Custom[key] } else { @@ -1069,6 +1277,20 @@ func (p *pdfImporter) informationMetadata(ctx context.Context, info pdfgo.Docume text = base64.StdEncoding.EncodeToString([]byte(text)) field.Attributes = append(field.Attributes, StructureAttribute{Name: "Encoding", Value: "base64"}) } + if key == "CreationDate" || key == "ModDate" { + original, err := pdfgo.DecodeTextString(value.(pdfgo.String)) + if err != nil { + if err := p.recoverPDFObject(ctx, "Information."+string(key), dict[key], err); err != nil { + return nil, root, err + } + continue + } + text = original + if !structureXMLText(text) { + text = base64.StdEncoding.EncodeToString([]byte(text)) + field.Attributes = append(field.Attributes, StructureAttribute{Name: "Encoding", Value: "base64"}) + } + } field.Text = text root.Children = append(root.Children, field) } diff --git a/ofdgo_renderer_canvas.go b/ofdgo_renderer_canvas.go index 1ce8ba0..704ecaf 100644 --- a/ofdgo_renderer_canvas.go +++ b/ofdgo_renderer_canvas.go @@ -16,7 +16,6 @@ package ofdgo import ( "fmt" - basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" "image" "image/color" "image/draw" @@ -24,6 +23,7 @@ import ( "github.com/tdewolff/canvas" "github.com/tdewolff/canvas/renderers/rasterizer" + basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" ) @@ -176,7 +176,26 @@ func (b CanvasBackend) renderImage(page *RasterPage, img *image.RGBA) (image.Ima style.Dashes[i] = dash / options.Width } } - if slices.Contains(options.Dashes, 0) { + var pointPath GeometryPath + pointStroke := false + if options.Cap == "Round" { + pointPath, pointStroke = canvasPointPath(geometryFromRaster(cmd.Path)) + } + if options.Cap == "Round" && (p.Empty() || pointStroke) { + path, err := pointPath.Transform(MatrixFromValues([6]float64(m))) + if err != nil { + return nil, err + } + path, err = path.Stroke(options) + if err != nil { + return nil, err + } + p, err = canvasObjectPath(path) + if err != nil { + return nil, err + } + style = canvas.Style{Fill: paint, FillRule: canvas.NonZero} + } else if slices.Contains(options.Dashes, 0) { var err error p, err = canvasStrokePath(b, p, options) if err != nil { diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go index 096ca2c..5c94589 100644 --- a/ofdgo_renderer_geom_canvas.go +++ b/ofdgo_renderer_geom_canvas.go @@ -196,10 +196,18 @@ func (CanvasBackend) Stroke(path GeometryPath, options StrokeOptions) (result Ge if slices.Contains(options.Dashes, 0) { return path.Stroke(options) } + if options.Cap == "Round" { + if pointPath, found := canvasPointPath(path); found { + return pointPath.Stroke(options) + } + } p, err := geometryToCanvasPath(&path) if err != nil { return nil, err } + if p.Empty() && options.Cap == "Round" { + return path.Stroke(options) + } tolerance := options.Tolerance if tolerance == 0 { tolerance = canvas.Tolerance @@ -222,6 +230,45 @@ func (CanvasBackend) Stroke(path GeometryPath, options StrokeOptions) (result Ge return *geometryFromCanvasPath(p), nil } +// canvasPointPath 保留普通闭合轮廓,将闭合单点改为同位置绘制段 +// 入参: path 原始几何路径 +// 返回: GeometryPath 只在需要时复制的路径, bool 是否含实际绘制的单点子路径 +func canvasPointPath(path GeometryPath) (GeometryPath, bool) { + result := path + var start Point + point, drawn, active, found, copied := false, false, false, false, false + for i, segment := range path { + switch segment.Verb { + case GeometryMove: + found = found || point && drawn + start = segment.End + point, drawn, active = true, false, true + case GeometryClose: + if point && active { + if !copied { + result = slices.Clone(path) + copied = true + } + result[i] = GeometrySegment{Verb: GeometryLine, End: start} + found = true + } + point, drawn, active = true, false, false + case GeometryLine: + point = point && segment.End == start + drawn, active = true, true + case GeometryQuad: + point = point && segment.End == start && segment.Control1 == start + drawn, active = true, true + case GeometryCubic: + point = point && segment.End == start && segment.Control1 == start && segment.Control2 == start + drawn, active = true, true + default: + point, drawn, active = false, true, true + } + } + return result, found || point && drawn +} + // Clip 解析对象裁剪并与父裁剪相交,保留未裁剪与完全裁去的区别 // 入参: r 渲染器, clips OFD裁剪, matrix 裁剪坐标到页面的变换, parent 父裁剪 // 返回: *GeometryPath 页面裁剪, error 无效父路径错误 @@ -389,7 +436,11 @@ func geometryToCanvasPath(path *GeometryPath) (*canvas.Path, error) { case GeometryArc: p.ArcTo(s.RadiusX, s.RadiusY, -s.Rotation, s.Large, !s.Sweep, s.End.X, -s.End.Y) case GeometryClose: - p.Close() + if data := p.Data(); len(data) != 0 && data[len(data)-1] == canvas.MoveToCmd { + p = canvas.NewPathFromData(append(data, canvas.CloseCmd, data[len(data)-3], data[len(data)-2], canvas.CloseCmd)) + } else { + p.Close() + } default: return nil, fmt.Errorf("unsupported geometry command %d", s.Verb) } @@ -419,7 +470,11 @@ func canvasObjectPath(path GeometryPath) (*canvas.Path, error) { data = p.Data() case GeometryClose: p := canvas.NewPathFromData(data) - p.Close() + if len(data) != 0 && data[len(data)-1] == canvas.MoveToCmd { + p = canvas.NewPathFromData(append(data, canvas.CloseCmd, data[len(data)-3], data[len(data)-2], canvas.CloseCmd)) + } else { + p.Close() + } data = p.Data() default: return nil, fmt.Errorf("unsupported geometry command %d", s.Verb)